feat: DevDesk v0.1.0, a persistent browser terminal for Claude Code
build-deb / Lint, test, package (push) Canceled after 1m13s
build-deb / Notify on failure (push) Canceled after 0s

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:
2026-10-06 23:11:07 -04:00
co-authored by Claude Opus 5.5
commit 07a41bd625
52 changed files with 10769 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env bats
# Exercises bin/devdesk and the libexec helpers against a throwaway tmux server.
REPO=$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)
setup() {
TMP=$(mktemp -d /tmp/devdesk-test.XXXXXX) # short path: unix socket limit
export DEVDESK_ENV=/dev/null
export DEVDESK_PROJECTS_ROOT="$TMP/projects"
export DEVDESK_STATE_DIR="$TMP/state"
export DEVDESK_SOCKET="$TMP/tmux.sock"
export DEVDESK_LIBEXEC="$REPO/libexec"
export DEVDESK_SHARE="$REPO/share"
export DEVDESK_NO_SYSTEMD=1
export FAKE_LOG="$TMP/claude-args"
export CLAUDE_BIN="$TMP/fake-claude"
export PATH="$REPO/bin:$PATH"
mkdir -p "$DEVDESK_PROJECTS_ROOT/alpha" "$DEVDESK_PROJECTS_ROOT/test code" "$TMP/outside" "$FAKE_LOG"
ln -s "$TMP/outside" "$DEVDESK_PROJECTS_ROOT/escape"
cat >"$CLAUDE_BIN" <<'EOF'
#!/bin/bash
printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION"
exec sleep 300
EOF
chmod +x "$CLAUDE_BIN"
}
teardown() {
tmux -S "$DEVDESK_SOCKET" kill-server 2>/dev/null || true
rm -rf "$TMP"
}
start_server() {
tmux -u -D -S "$DEVDESK_SOCKET" -f "$REPO/share/tmux.conf" &
"$REPO/libexec/devdesk-tmux-ready"
}
# Wait for the fake claude in SESSION to record its arguments.
claude_args() {
for _ in $(seq 1 50); do
[ -s "$FAKE_LOG/$1" ] && { tail -n1 "$FAKE_LOG/$1"; return 0; }
sleep 0.1
done
return 1
}
@test "rejects invalid session names" {
start_server
for bad in ../etc "a b" -x .hidden "$(printf 'x%.0s' {1..70})"; do
run devdesk attach "$bad"
[ "$status" -ne 0 ]
[[ $output == *"invalid session name"* ]]
done
run devdesk attach one two
[ "$status" -ne 0 ]
}
@test "fails clearly when the tmux server is down" {
run devdesk new alpha
[ "$status" -ne 0 ]
[[ $output == *"tmux server is not running"* ]]
}
@test "new session gets claude and shell windows in the project dir" {
start_server
run devdesk new alpha
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" list-windows -t =alpha -F '#{window_name}:#{pane_current_path}'
[ "${lines[0]}" = "claude:$DEVDESK_PROJECTS_ROOT/alpha" ]
[ "${lines[1]}" = "shell:$DEVDESK_PROJECTS_ROOT/alpha" ]
run claude_args alpha
[ "$output" = "-n alpha --settings $REPO/share/claude-settings.json" ]
run jq -r '.sessions.alpha.dir' "$DEVDESK_STATE_DIR/registry.json"
[ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha" ]
}
@test "creating an existing session is a no-op" {
start_server
devdesk new alpha
run devdesk new alpha
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" list-sessions -F '#{session_name}'
[ "$output" = "alpha" ]
}
@test "default session maps to the projects root" {
start_server
devdesk new main
run tmux -S "$DEVDESK_SOCKET" display -p -t =main:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT" ]
}
@test "directories with spaces are reachable by slug" {
start_server
run devdesk new test-code
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =test-code:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT/test code" ]
}
@test "symlinks may not escape the projects root" {
start_server
run devdesk new escape
[ "$status" -ne 0 ]
[[ $output == *"no project directory"* ]]
run devdesk new nonexistent
[ "$status" -ne 0 ]
}
@test "session hook records the Claude session and the next start resumes it" {
start_server
echo '{"session_id":"abc-123","source":"startup"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
run jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json"
[ "$output" = "abc-123" ]
devdesk new alpha
run claude_args alpha
[ "$output" = "--resume abc-123 --settings $REPO/share/claude-settings.json" ]
}
@test "session hook ignores sessions it does not own" {
echo '{"session_id":"zzz"}' | DEVDESK_SESSION= "$REPO/libexec/devdesk-session-hook"
echo '{"session_id":"zzz"}' | DEVDESK_SESSION='../x' "$REPO/libexec/devdesk-session-hook"
[ ! -e "$DEVDESK_STATE_DIR/registry.json" ]
}
@test "--list reports running, dormant and project entries" {
start_server
devdesk new alpha
echo '{"session_id":"old-1","source":"resume"}' | DEVDESK_SESSION=gone "$REPO/libexec/devdesk-session-hook"
run devdesk attach --list
[ "$status" -eq 0 ]
[ "$(jq -r '.default' <<<"$output")" = main ]
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .running' <<<"$output")" = true ]
[ "$(jq -r '.sessions[] | select(.name=="gone") | .running' <<<"$output")" = false ]
[ "$(jq -r '.sessions[] | select(.name=="gone") | .claude_session_id' <<<"$output")" = old-1 ]
[ "$(jq -r '[.projects[].name] | sort | join(",")' <<<"$output")" = "alpha,main,test-code" ]
}
@test "--list works with the server down" {
run devdesk attach --list
[ "$status" -eq 0 ]
[ "$(jq -r '.sessions | length' <<<"$output")" = 0 ]
}
@test "end kills the session and forgets it" {
start_server
devdesk new alpha
run devdesk end alpha
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" has-session -t =alpha
[ "$status" -ne 0 ]
run jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json"
[ "$output" = false ]
}
@test "remote control flag is passed when enabled" {
start_server
tmux -S "$DEVDESK_SOCKET" set-environment -g DEVDESK_REMOTE_CONTROL 1
devdesk new alpha
run claude_args alpha
[ "$output" = "-n alpha --settings $REPO/share/claude-settings.json --remote-control devdesk-alpha" ]
}
+69
View File
@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize,
parseListOutput, parseTitle, sessionFromSearch, tokenUrl, wsUrl,
} from '../web/src/protocol.js';
const text = (bytes) => new TextDecoder().decode(bytes);
test('init message is bare JSON with the auth token and size', () => {
assert.deepEqual(JSON.parse(text(encodeInit('dG9r', 120, 40))), { AuthToken: 'dG9r', columns: 120, rows: 40 });
});
test('input frames are prefixed with "0" and UTF-8 encoded', () => {
assert.equal(text(encodeInput('héllo\n')), '0héllo\n');
assert.deepEqual([...encodeBinaryInput('\x1b\xff')], [0x30, 0x1b, 0xff]);
});
test('resize frames are "1" + JSON', () => {
assert.equal(text(encodeResize(80, 24)), '1{"columns":80,"rows":24}');
});
test('server frames split into command and payload', () => {
const frame = new TextEncoder().encode('0output').buffer;
const { type, payload } = decodeServer(frame);
assert.equal(type, '0');
assert.equal(text(payload), 'output');
});
test('URLs follow the page location, including a path prefix', () => {
const loc = { protocol: 'https:', host: 'dev.lotusguild.org', pathname: '/' };
assert.equal(wsUrl(loc, 'main'), 'wss://dev.lotusguild.org/ws?arg=main');
assert.equal(wsUrl(loc, null), 'wss://dev.lotusguild.org/ws');
assert.equal(tokenUrl(loc), 'https://dev.lotusguild.org/token');
const sub = { protocol: 'http:', host: 'localhost:7681', pathname: '/desk/index.html' };
assert.equal(wsUrl(sub, '--list'), 'ws://localhost:7681/desk/ws?arg=--list');
});
test('only valid session names are taken from the URL', () => {
assert.equal(sessionFromSearch('?arg=tinker_tickets'), 'tinker_tickets');
assert.equal(sessionFromSearch('?arg=../etc'), null);
assert.equal(sessionFromSearch('?arg=--list'), null);
assert.equal(sessionFromSearch(''), null);
});
test('tmux titles carry the session and the pane title', () => {
assert.deepEqual(parseTitle('devdesk:main|✳ Refactor | parser'), { session: 'main', pane: '✳ Refactor | parser' });
assert.deepEqual(parseTitle('devdesk:picker|Pick a session'), { session: 'picker', pane: 'Pick a session' });
assert.equal(parseTitle('bash'), null);
});
test('session list JSON survives pty line endings', () => {
const out = '\r\n{"default":"main","sessions":[],\r\n"projects":[]}\r\n';
assert.deepEqual(parseListOutput(out), { default: 'main', sessions: [], projects: [] });
const colored = '\x1b[1;39m{\x1b[0m\x1b[34;1m"default"\x1b[0m:\x1b[0;32m"main"\x1b[0m}';
assert.deepEqual(parseListOutput(colored), { default: 'main' });
assert.throws(() => parseListOutput('devdesk: tmux server is not running'));
});
test('flow control pauses after highWater pending writes and resumes below lowWater', () => {
const flow = new FlowControl({ limit: 10, highWater: 2, lowWater: 1 });
assert.deepEqual(flow.onData(5), { command: null, track: false });
assert.deepEqual(flow.onData(10), { command: null, track: true }); // pending 1
assert.deepEqual(flow.onData(11), { command: null, track: true }); // pending 2
assert.deepEqual(flow.onData(11), { command: Client.PAUSE, track: true }); // pending 3
assert.equal(flow.release(), null); // 2
assert.equal(flow.release(), null); // 1
assert.equal(flow.release(), Client.RESUME); // 0
});