feat: DevDesk v0.1.0, a persistent browser terminal for Claude Code
ttyd serves a single-file xterm.js UI styled with the LotusGuild TDS (web_template base.css). Sessions live in a dedicated tmux server unit (devdesk-tmux.service), so restarting or upgrading the web unit never stops Claude. Each named session maps to a project directory. A Claude SessionStart hook records the conversation ID, so after a reboot the session resumes that exact conversation. - bin/devdesk: attach / new / ls / end / pick, with an fzf picker - ttyd requires a credential that NPM injects; origin check enabled - debian/: dh 13 with --no-stop-on-upgrade for the tmux unit, bundled sha256-verified ttyd 1.7.7, lintian overrides, man page - CI: lint, test, build, smoke-test, artifact; v* tags publish to the Gitea apt registry and create a release Refs: tinker ticket 887531976 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit,
|
||||
encodeInput, encodeResize, parseListOutput, tokenUrl, wsUrl, FlowControl,
|
||||
} from './protocol.js';
|
||||
|
||||
export class AuthExpiredError extends Error {}
|
||||
export class UnauthorizedError extends Error {}
|
||||
|
||||
// GET /token. Through NPM this also exercises the Authelia gate, so it doubles
|
||||
// as the session probe: a redirect means the SSO session expired.
|
||||
export async function fetchToken() {
|
||||
const res = await fetch(tokenUrl(window.location), {
|
||||
cache: 'no-store', credentials: 'same-origin', redirect: 'manual',
|
||||
});
|
||||
if (res.type === 'opaqueredirect') throw new AuthExpiredError('SSO session expired');
|
||||
if (res.status === 401 || res.status === 403 || res.status === 407) {
|
||||
throw new UnauthorizedError(`HTTP ${res.status}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`token request failed: HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
return body.token || '';
|
||||
}
|
||||
|
||||
// One WebSocket to ttyd, i.e. one `devdesk attach <arg>` process on the server.
|
||||
export class TtydConnection {
|
||||
constructor({ arg, size, onOpen, onOutput, onClose, onPreferences }) {
|
||||
Object.assign(this, { arg, size, onOpen, onOutput, onClose, onPreferences });
|
||||
this.flow = new FlowControl();
|
||||
this.socket = null;
|
||||
this.disposed = false;
|
||||
}
|
||||
|
||||
async open() {
|
||||
const token = await fetchToken();
|
||||
if (this.disposed) return;
|
||||
const socket = new WebSocket(wsUrl(window.location, this.arg), ['tty']);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
const { cols, rows } = this.size();
|
||||
socket.send(encodeInit(token, cols, rows));
|
||||
this.onOpen?.();
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
const { type, payload } = decodeServer(event.data);
|
||||
if (type === Server.OUTPUT) this.onOutput?.(payload);
|
||||
else if (type === Server.SET_PREFERENCES) this.onPreferences?.(payload);
|
||||
// SET_WINDOW_TITLE only carries ttyd's "<command> (<host>)"; real titles come via OSC.
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
if (this.socket === socket) this.socket = null;
|
||||
if (!this.disposed) this.onClose?.(event.code, event.reason);
|
||||
};
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return this.socket?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
send(frame) {
|
||||
if (this.isOpen) this.socket.send(frame);
|
||||
}
|
||||
|
||||
sendInput(text) {
|
||||
this.send(encodeInput(text));
|
||||
}
|
||||
|
||||
sendBinary(binary) {
|
||||
this.send(encodeBinaryInput(binary));
|
||||
}
|
||||
|
||||
resize(cols, rows) {
|
||||
this.send(encodeResize(cols, rows));
|
||||
}
|
||||
|
||||
// Called with each output chunk; returns a write-callback when tracking is needed.
|
||||
flowWrite(length) {
|
||||
const { command, track } = this.flow.onData(length);
|
||||
if (command) this.send(encodeCommand(command));
|
||||
if (!track) return undefined;
|
||||
return () => {
|
||||
const resume = this.flow.release();
|
||||
if (resume) this.send(encodeCommand(resume));
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
if (this.socket) {
|
||||
this.socket.onclose = null;
|
||||
this.socket.close(1000);
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ask the server for sessions + projects: a short-lived `devdesk attach --list`.
|
||||
export async function fetchSessionList(timeoutMs = 8000) {
|
||||
const token = await fetchToken();
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(wsUrl(window.location, LIST_ARG), ['tty']);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
const chunks = [];
|
||||
const timer = setTimeout(() => {
|
||||
socket.close();
|
||||
reject(new Error('session list timed out'));
|
||||
}, timeoutMs);
|
||||
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
|
||||
socket.onmessage = (event) => {
|
||||
const { type, payload } = decodeServer(event.data);
|
||||
if (type === Server.OUTPUT) chunks.push(payload.slice());
|
||||
};
|
||||
socket.onerror = () => {};
|
||||
socket.onclose = () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const text = new TextDecoder().decode(concat(chunks));
|
||||
resolve(parseListOutput(text));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function concat(chunks) {
|
||||
const total = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user