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
+75
View File
@@ -0,0 +1,75 @@
// Builds dist/index.html: one self-contained page (ttyd --index serves a single
// file and nothing else), with CSS, JS, fonts and the favicon all inlined.
import { build, transform } from 'esbuild';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const at = (...parts) => join(root, ...parts);
const read = (...parts) => readFile(at(...parts), 'utf8');
// Version: $DEVDESK_VERSION (CI) or the top entry of debian/changelog.
async function version() {
if (process.env.DEVDESK_VERSION) return process.env.DEVDESK_VERSION;
const changelog = await read('debian', 'changelog');
return /^devdesk \(([^)]+)\)/.exec(changelog)?.[1] ?? 'dev';
}
// JetBrains Mono (OFL-1.1) from @fontsource: keep the latin + latin-ext faces
// of the given weights and inline their woff2 files.
async function fontFaces(weights) {
const pkg = at('node_modules', '@fontsource', 'jetbrains-mono');
const faces = [];
for (const weight of weights) {
const css = await readFile(join(pkg, `${weight}.css`), 'utf8');
for (const block of css.match(/\/\* jetbrains-mono-latin(-ext)?-\d+-normal \*\/\s*@font-face\s*{[^}]*}/g) ?? []) {
const file = /url\(\.\/files\/([^)]+\.woff2)\)/.exec(block)[1];
const data = (await readFile(join(pkg, 'files', file))).toString('base64');
faces.push(block.replace(/src:[^;]*;/, `src: url(data:font/woff2;base64,${data}) format('woff2');`));
}
}
if (!faces.length) throw new Error('no JetBrains Mono faces found');
return faces.join('\n');
}
async function main() {
const ver = await version();
const js = await build({
entryPoints: [at('web', 'src', 'main.js')],
bundle: true,
minify: true,
format: 'iife',
target: ['es2020'],
write: false,
legalComments: 'eof',
define: { __DEVDESK_VERSION__: JSON.stringify(ver) },
});
const css = await transform([
await read('vendor', 'web_template', 'base.css'),
await read('node_modules', '@xterm', 'xterm', 'css', 'xterm.css'),
await fontFaces([400, 700]),
await read('web', 'styles.css'),
].join('\n'), { loader: 'css', minify: true, legalComments: 'eof' });
const { FAVICON: favicon } = await import('./src/icon.js');
const script = js.outputFiles[0].text.replace(/<\/script/gi, '<\\/script');
const style = css.code.replace(/<\/style/gi, '<\\/style');
const template = await read('web', 'index.template.html');
const html = template
.replace('__FAVICON__', () => favicon)
.replace('<!--STYLES-->', () => `<style>${style}</style>`)
.replace('<!--SCRIPT-->', () => `<script>${script}</script>`);
await mkdir(at('dist'), { recursive: true });
await writeFile(at('dist', 'index.html'), html);
console.log(`dist/index.html ${(html.length / 1024).toFixed(0)} KiB (devdesk ${ver})`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});