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,106 @@
|
||||
// ttyd 1.7.x WebSocket protocol (see ttyd src/server.h and src/protocol.c).
|
||||
//
|
||||
// Frames are binary. The first message is a bare JSON object; every later
|
||||
// message starts with a one-character command.
|
||||
|
||||
export const Client = Object.freeze({ INPUT: '0', RESIZE: '1', PAUSE: '2', RESUME: '3' });
|
||||
export const Server = Object.freeze({ OUTPUT: '0', SET_WINDOW_TITLE: '1', SET_PREFERENCES: '2' });
|
||||
|
||||
export const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
||||
export const LIST_ARG = '--list';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function encodeInit(token, columns, rows) {
|
||||
return encoder.encode(JSON.stringify({ AuthToken: token, columns, rows }));
|
||||
}
|
||||
|
||||
export function encodeInput(text) {
|
||||
const body = encoder.encode(text);
|
||||
const frame = new Uint8Array(body.length + 1);
|
||||
frame[0] = Client.INPUT.charCodeAt(0);
|
||||
frame.set(body, 1);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// xterm.js onBinary delivers a "binary string" (one char per byte).
|
||||
export function encodeBinaryInput(binary) {
|
||||
const frame = new Uint8Array(binary.length + 1);
|
||||
frame[0] = Client.INPUT.charCodeAt(0);
|
||||
for (let i = 0; i < binary.length; i++) frame[i + 1] = binary.charCodeAt(i) & 0xff;
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function encodeResize(columns, rows) {
|
||||
return encoder.encode(Client.RESIZE + JSON.stringify({ columns, rows }));
|
||||
}
|
||||
|
||||
export function encodeCommand(command) {
|
||||
return encoder.encode(command);
|
||||
}
|
||||
|
||||
export function decodeServer(data) {
|
||||
const bytes = new Uint8Array(data);
|
||||
return { type: String.fromCharCode(bytes[0]), payload: bytes.subarray(1) };
|
||||
}
|
||||
|
||||
// URLs are relative to the page so DevDesk also works under a path prefix.
|
||||
function basePath(location) {
|
||||
return location.pathname.replace(/[^/]*$/, '');
|
||||
}
|
||||
|
||||
export function tokenUrl(location) {
|
||||
return `${location.protocol}//${location.host}${basePath(location)}token`;
|
||||
}
|
||||
|
||||
export function wsUrl(location, arg) {
|
||||
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const query = arg ? `?arg=${encodeURIComponent(arg)}` : '';
|
||||
return `${scheme}//${location.host}${basePath(location)}ws${query}`;
|
||||
}
|
||||
|
||||
export function sessionFromSearch(search) {
|
||||
const value = new URLSearchParams(search).get('arg');
|
||||
return value && SESSION_RE.test(value) ? value : null;
|
||||
}
|
||||
|
||||
// tmux set-titles-string is "devdesk:#S|#T"; the picker prints "devdesk:picker|…".
|
||||
export function parseTitle(title) {
|
||||
const match = /^devdesk:([^|]*)\|([\s\S]*)$/.exec(title || '');
|
||||
if (!match) return null;
|
||||
return { session: match[1], pane: match[2] };
|
||||
}
|
||||
|
||||
// Output of `devdesk attach --list` arrives through a pty: CRLF line endings and
|
||||
// possibly split across frames. Pull the JSON object out of the joined text.
|
||||
export function parseListOutput(raw) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const text = raw.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ''); // tools may colorize on a tty
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start < 0 || end < start) throw new Error('no session list in output');
|
||||
return JSON.parse(text.slice(start, end + 1));
|
||||
}
|
||||
|
||||
// Same back-pressure scheme as ttyd's own client: pause the pty when xterm.js
|
||||
// falls behind on rendering, resume once it catches up.
|
||||
export class FlowControl {
|
||||
constructor({ limit = 100000, highWater = 10, lowWater = 4 } = {}) {
|
||||
Object.assign(this, { limit, highWater, lowWater, written: 0, pending: 0 });
|
||||
}
|
||||
|
||||
// Returns { command, track }: command is PAUSE when the writer should stop;
|
||||
// track says whether to pass a callback to term.write (and call release()).
|
||||
onData(length) {
|
||||
this.written += length;
|
||||
if (this.written <= this.limit) return { command: null, track: false };
|
||||
this.written = 0;
|
||||
this.pending += 1;
|
||||
return { command: this.pending > this.highWater ? Client.PAUSE : null, track: true };
|
||||
}
|
||||
|
||||
release() {
|
||||
this.pending = Math.max(this.pending - 1, 0);
|
||||
return this.pending < this.lowWater ? Client.RESUME : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user