@cordfuse/crosstalk 7.0.0-alpha.9 → 7.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/GUIDE-CLI.md +322 -0
  2. package/GUIDE-PROMPTS.md +118 -0
  3. package/LICENSE +21 -0
  4. package/README.md +402 -61
  5. package/bin/crosstalk.js +88 -54
  6. package/commands/agent.js +69 -0
  7. package/commands/auth.js +273 -0
  8. package/commands/channel.js +54 -44
  9. package/commands/chat.js +107 -71
  10. package/commands/daemon.js +120 -0
  11. package/commands/logs.js +108 -19
  12. package/commands/message.js +125 -0
  13. package/commands/server.js +153 -0
  14. package/commands/settings.js +49 -0
  15. package/commands/status.js +37 -13
  16. package/commands/token.js +136 -0
  17. package/commands/transport.js +270 -0
  18. package/commands/version.js +3 -3
  19. package/commands/workflow.js +234 -0
  20. package/deploy/crosstalk@.service +62 -0
  21. package/deploy/install.sh +82 -0
  22. package/lib/api-client.js +77 -22
  23. package/lib/credentials.js +207 -0
  24. package/lib/nativeServer.js +173 -0
  25. package/lib/resolve.js +101 -34
  26. package/package.json +27 -4
  27. package/src/activation.ts +104 -0
  28. package/src/api.ts +1716 -0
  29. package/src/auth/enforce.ts +68 -0
  30. package/src/auth/handlers.ts +266 -0
  31. package/src/auth/middleware.ts +132 -0
  32. package/src/auth/setup.ts +263 -0
  33. package/src/auth/tokens.ts +285 -0
  34. package/src/auth/users.ts +267 -0
  35. package/src/dispatch.ts +492 -0
  36. package/src/dispatchers.ts +91 -0
  37. package/src/filenames.ts +28 -0
  38. package/src/frontmatter.ts +26 -0
  39. package/src/init.ts +116 -0
  40. package/src/invoke.ts +201 -0
  41. package/src/log-buffer.ts +67 -0
  42. package/src/models.ts +283 -0
  43. package/src/resolve.ts +100 -0
  44. package/src/state.ts +190 -0
  45. package/src/stop.ts +37 -0
  46. package/src/transport.ts +243 -0
  47. package/src/web/auth-pages.ts +160 -0
  48. package/src/web/channels.ts +395 -0
  49. package/src/web/chat-page.ts +636 -0
  50. package/src/web/chat-pty.ts +254 -0
  51. package/src/web/dashboard.ts +129 -0
  52. package/src/web/layout.ts +237 -0
  53. package/src/web/stubs.ts +510 -0
  54. package/src/web/workflows.ts +490 -0
  55. package/src/workflow.ts +470 -0
  56. package/template/CLAUDE.md +10 -0
  57. package/template/CROSSTALK-VERSION +1 -0
  58. package/template/CROSSTALK.md +262 -0
  59. package/template/PROTOCOL.md +70 -0
  60. package/template/README.md +64 -0
  61. package/template/auth/.gitkeep +0 -0
  62. package/template/auth/README.md +224 -0
  63. package/template/data/crosstalk.yaml +196 -0
  64. package/template/gitignore +4 -0
  65. package/commands/down.js +0 -40
  66. package/commands/init.js +0 -243
  67. package/commands/pull.js +0 -22
  68. package/commands/replies.js +0 -40
  69. package/commands/restart.js +0 -29
  70. package/commands/rm.js +0 -109
  71. package/commands/run.js +0 -115
  72. package/commands/up.js +0 -135
@@ -0,0 +1,254 @@
1
+ // chat-pty.ts — WebSocket bridge from xterm.js in the browser to a pty
2
+ // running an agent CLI (or $SHELL) on the host.
3
+ //
4
+ // Lifecycle contract (Steve's directive: 'when the user exits, kill
5
+ // the interactive agent'):
6
+ //
7
+ // client connects WS /ws/chat?agent=<name>
8
+ // ↓
9
+ // server spawns a pty running the named agent (or 'bash' for --shell)
10
+ // ↓
11
+ // pty stdout → WS text frames (browser xterm.js writes them in)
12
+ // ↑ ↓
13
+ // pty stdin ← WS text frames operator types
14
+ // ↓ ↓
15
+ // when client disconnects (tab close, /exit typed by operator):
16
+ // pty.kill(SIGTERM) → SIGKILL 1s later if still alive
17
+ //
18
+ // Async-by-design contract: NO persistent session. Each WS attach
19
+ // spawns a fresh agent. There's no detach + reattach. If you want
20
+ // durable state, send messages through the orch bus.
21
+
22
+ import type { Server as HttpServer } from 'http';
23
+ import type { Duplex } from 'stream';
24
+ import { WebSocketServer, type WebSocket } from 'ws';
25
+ import { spawn as ptySpawn, type IPty } from 'node-pty';
26
+ import { spawnSync } from 'child_process';
27
+ import { hostname } from 'os';
28
+ import { statSync } from 'fs';
29
+
30
+ // node-pty's native bindings load under bun, but tmux/SIGHUP semantics
31
+ // break at runtime (parallels the llmuxd 'must launch under node' issue).
32
+ // Production is safe — commands/daemon.js spawns the dispatch loop with
33
+ // process.execPath = node. This guard fires loudly if someone tries to
34
+ // load the chat-pty module under bun (e.g. via `bun test` importing
35
+ // src/api.ts, or `bun bin/crosstalk.js daemon dispatch` directly), so
36
+ // the failure mode is a clear message instead of a mysterious tmux hang.
37
+ declare const Bun: unknown;
38
+ if (typeof Bun !== 'undefined') {
39
+ throw new Error(
40
+ 'src/web/chat-pty.ts cannot load under bun — node-pty pty operations ' +
41
+ 'fail at runtime (tmux/SIGHUP). Run under node instead. ' +
42
+ '(Production daemon already does — this guard catches test/dev mis-invocations.)',
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Set of agent binary names crosstalk knows how to PTY-wrap. Matches
48
+ * the static list in chat.js — keep them in sync if a new agent is
49
+ * added to the catalog.
50
+ */
51
+ const KNOWN_AGENT_BINARIES = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
52
+
53
+ interface AttachOptions {
54
+ agent: string; // 'bash' (for --shell) or an entry in KNOWN_AGENT_BINARIES
55
+ cwd?: string;
56
+ cols?: number;
57
+ rows?: number;
58
+ }
59
+
60
+ function isOnPath(bin: string): boolean {
61
+ const r = spawnSync('which', [bin], { encoding: 'utf-8' });
62
+ return r.status === 0;
63
+ }
64
+
65
+ function resolveBinary(agent: string): string | null {
66
+ if (agent === 'bash') {
67
+ // Use $SHELL when present, fall back to bash. Matches the CLI
68
+ // chat --shell behavior we added in v8-native.
69
+ const shell = process.env['SHELL'];
70
+ return shell || 'bash';
71
+ }
72
+ if (!KNOWN_AGENT_BINARIES.includes(agent)) return null;
73
+ return isOnPath(agent) ? agent : null;
74
+ }
75
+
76
+ function safeKill(pty: IPty): void {
77
+ try { pty.kill('SIGTERM'); } catch { /* already gone */ }
78
+ setTimeout(() => {
79
+ try { pty.kill('SIGKILL'); } catch { /* expected if SIGTERM took */ }
80
+ }, 1000);
81
+ }
82
+
83
+ /**
84
+ * Mount the chat WebSocket onto an existing http server. Wires
85
+ * /ws/chat?agent=<name>&cols=<n>&rows=<n> to a pty spawn.
86
+ *
87
+ * Trust model: same as the rest of the API — engine binds 127.0.0.1,
88
+ * so anyone reaching the WS is on the host (or coming via the
89
+ * operator's tailscale-serve front). v8 auth: the upgrade handshake
90
+ * checks the crosstalk_session cookie via the auth middleware so a
91
+ * browser without a valid session can't open a chat panel even via
92
+ * the loopback bind.
93
+ */
94
+ export interface MountChatPtyOptions {
95
+ /** Validates the upgrade request's auth (cookie). Returns true to
96
+ * allow, false to reject (results in 401 close). When undefined,
97
+ * the WS is open — caller must ensure the bind address is the
98
+ * security boundary. */
99
+ authorize?: (req: import('http').IncomingMessage) => Promise<boolean>;
100
+ }
101
+
102
+ export function mountChatPty(server: HttpServer, opts: MountChatPtyOptions = {}): WebSocketServer {
103
+ // `noServer: true` so we drive the upgrade handshake ourselves and
104
+ // can write a proper auth rejection. With `server: server`, ws would
105
+ // accept the upgrade unconditionally.
106
+ const wss = new WebSocketServer({ noServer: true });
107
+
108
+ server.on('upgrade', async (req, socket, head) => {
109
+ try {
110
+ const url = new URL(req.url ?? '/', 'http://localhost');
111
+ if (url.pathname !== '/ws/chat') {
112
+ // Not our route — let the default 404 fall through.
113
+ rejectUpgrade(socket, 404, 'not found');
114
+ return;
115
+ }
116
+ if (opts.authorize) {
117
+ const ok = await opts.authorize(req);
118
+ if (!ok) {
119
+ rejectUpgrade(socket, 401, 'unauthorized');
120
+ return;
121
+ }
122
+ }
123
+ wss.handleUpgrade(req, socket, head, (ws) => {
124
+ const agent = url.searchParams.get('agent') ?? 'bash';
125
+ const cols = clamp(Number(url.searchParams.get('cols') ?? 80), 20, 500);
126
+ const rows = clamp(Number(url.searchParams.get('rows') ?? 24), 5, 200);
127
+ const cwd = url.searchParams.get('cwd') ?? undefined;
128
+ bridgePty(ws, { agent, cols, rows, cwd: cwd ?? undefined });
129
+ });
130
+ } catch (err) {
131
+ rejectUpgrade(socket, 500, 'upgrade failure');
132
+ void err;
133
+ }
134
+ });
135
+
136
+ return wss;
137
+ }
138
+
139
+ function rejectUpgrade(socket: Duplex, status: number, reason: string): void {
140
+ try {
141
+ socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`);
142
+ socket.destroy();
143
+ } catch { /* socket already gone */ }
144
+ }
145
+
146
+ function clamp(n: number, lo: number, hi: number): number {
147
+ if (!Number.isFinite(n)) return lo;
148
+ return Math.max(lo, Math.min(hi, Math.floor(n)));
149
+ }
150
+
151
+ function bridgePty(ws: WebSocket, opts: AttachOptions): void {
152
+ const bin = resolveBinary(opts.agent);
153
+ if (!bin) {
154
+ ws.send(`\r\n\x1b[31mcrosstalk: agent '${opts.agent}' not on PATH or not supported.\x1b[0m\r\n`);
155
+ ws.close(1003, 'agent unavailable');
156
+ return;
157
+ }
158
+
159
+ const env: Record<string, string> = {};
160
+ for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
161
+ env.TERM = 'xterm-256color';
162
+
163
+ // Resolve cwd. Operator can pass any path via the cwd= query param,
164
+ // but the engine refuses to spawn into a non-existent or non-dir
165
+ // path — falls back to $HOME with a warning line so the session
166
+ // still starts. The engine already runs as the operator's user, so
167
+ // there's no privilege boundary to enforce beyond fs existence.
168
+ const home = env['HOME'] ?? '/';
169
+ let cwd = opts.cwd && opts.cwd.length > 0 ? opts.cwd : home;
170
+ let cwdWarning: string | null = null;
171
+ if (cwd !== home) {
172
+ try {
173
+ const st = statSync(cwd);
174
+ if (!st.isDirectory()) {
175
+ cwdWarning = `crosstalk: '${cwd}' is not a directory, falling back to $HOME (${home})`;
176
+ cwd = home;
177
+ }
178
+ } catch {
179
+ cwdWarning = `crosstalk: '${cwd}' not accessible, falling back to $HOME (${home})`;
180
+ cwd = home;
181
+ }
182
+ }
183
+
184
+ let pty: IPty;
185
+ try {
186
+ pty = ptySpawn(bin, [], {
187
+ name: 'xterm-256color',
188
+ cols: opts.cols ?? 80,
189
+ rows: opts.rows ?? 24,
190
+ cwd,
191
+ env,
192
+ });
193
+ } catch (err) {
194
+ ws.send(`\r\n\x1b[31mcrosstalk: spawn failed: ${(err as Error).message}\x1b[0m\r\n`);
195
+ ws.close(1011, 'spawn failed');
196
+ return;
197
+ }
198
+
199
+ if (cwdWarning) {
200
+ ws.send(`\r\n\x1b[33m${cwdWarning}\x1b[0m\r\n`);
201
+ }
202
+
203
+ // Greeting so the operator sees something even before the agent's
204
+ // own TUI renders. Sky-blue branded line + cwd + small hint.
205
+ const host = (() => { try { return hostname(); } catch { return 'host'; } })();
206
+ ws.send(`\r\n\x1b[36m── crosstalk chat: ${opts.agent} @ ${host} ────────────────\x1b[0m\r\n`);
207
+ ws.send(`\x1b[90m cwd: ${cwd}\r\n Type /exit to disconnect (or close the tab).\r\n When you exit, this agent process is terminated.\x1b[0m\r\n\r\n`);
208
+
209
+ // pty → ws (output stream)
210
+ pty.onData((data) => {
211
+ try { ws.send(data); } catch { /* socket closing */ }
212
+ });
213
+
214
+ // pty exit → close ws
215
+ pty.onExit(({ exitCode, signal }) => {
216
+ try {
217
+ ws.send(`\r\n\x1b[36m── agent exited (code=${exitCode}${signal ? `, signal=${signal}` : ''}) ──\x1b[0m\r\n`);
218
+ ws.close(1000, 'agent exited');
219
+ } catch { /* already closed */ }
220
+ });
221
+
222
+ // ws → pty (input)
223
+ // Two message kinds:
224
+ // strings — interpreted as terminal input (typed characters)
225
+ // JSON { type: 'resize', cols, rows } — pty resize from the browser
226
+ // JSON { type: 'exit' } — explicit operator '/exit' command
227
+ ws.on('message', (raw) => {
228
+ const text = typeof raw === 'string' ? raw : raw.toString('utf-8');
229
+ if (text.startsWith('{')) {
230
+ try {
231
+ const msg = JSON.parse(text) as { type?: string; cols?: number; rows?: number };
232
+ if (msg.type === 'resize' && typeof msg.cols === 'number' && typeof msg.rows === 'number') {
233
+ try { pty.resize(clamp(msg.cols, 20, 500), clamp(msg.rows, 5, 200)); } catch {}
234
+ return;
235
+ }
236
+ // Two terminations:
237
+ // exit — graceful: SIGTERM, then SIGKILL after 1s if the
238
+ // process didn't honor the TERM (matches /exit typed
239
+ // in the terminal).
240
+ // kill — immediate SIGKILL. No grace window. For when an
241
+ // agent CLI is wedged and you want it gone NOW.
242
+ if (msg.type === 'exit') { safeKill(pty); return; }
243
+ if (msg.type === 'kill') {
244
+ try { pty.kill('SIGKILL'); } catch { /* already gone */ }
245
+ return;
246
+ }
247
+ } catch { /* fall through to treat as raw input */ }
248
+ }
249
+ try { pty.write(text); } catch { /* pty closed */ }
250
+ });
251
+
252
+ ws.on('close', () => safeKill(pty));
253
+ ws.on('error', () => safeKill(pty));
254
+ }
@@ -0,0 +1,129 @@
1
+ // dashboard.ts — / page: operator's at-a-glance state of this crosstalk
2
+ // engine instance. Heartbeat freshness, claimed model registry, channel count,
3
+ // recent activity, error count. Auto-refreshes every 5s.
4
+
5
+ import { page, escapeHtml, relativeTime, TOAST_HELPER } from './layout.js';
6
+
7
+ export interface DashboardData {
8
+ host: string;
9
+ alias: string;
10
+ version: string;
11
+ transportRoot: string;
12
+ heartbeat: { ts: string; pid: number; version: string; alias: string | undefined } | null;
13
+ cursor: string | null;
14
+ claimedModels: string[];
15
+ channels: { uuid: string; name: string | null; parent: string | null }[];
16
+ errorsLogged: number;
17
+ /** Pending work — unreplied messages grouped by recipient. Cap to top 10. */
18
+ pendingWork: { to: string; count: number; channelLabels: string[] }[];
19
+ }
20
+
21
+ export function dashboardPage(d: DashboardData): string {
22
+ const hbAge = d.heartbeat ? Date.now() - new Date(d.heartbeat.ts).getTime() : null;
23
+ const hbClass = hbAge === null
24
+ ? 'err'
25
+ : hbAge < 60_000 ? 'ok' : hbAge < 300_000 ? 'warn' : 'err';
26
+ const hbLabel = d.heartbeat ? relativeTime(d.heartbeat.ts) : 'no heartbeat yet';
27
+
28
+ const namedChannels = d.channels.filter(c => c.name).map(c => c.name as string);
29
+ const cursorShort = d.cursor ? d.cursor.slice(0, 8) + '…' : '—';
30
+
31
+ const body = `
32
+ <div class="grid cols-2">
33
+ <section class="card">
34
+ <h3>Engine</h3>
35
+ <div class="kv"><span class="key">alias</span><span class="val brand">${escapeHtml(d.alias)}</span></div>
36
+ <div class="kv"><span class="key">version</span><span class="val">${escapeHtml(d.version)}</span></div>
37
+ <div class="kv"><span class="key">transport</span><span class="val dim">${escapeHtml(d.transportRoot)}</span></div>
38
+ <div class="kv"><span class="key">cursor</span><span class="val">${escapeHtml(cursorShort)}</span></div>
39
+ </section>
40
+
41
+ <section class="card">
42
+ <h3>Dispatcher</h3>
43
+ <div class="kv"><span class="key">heartbeat</span><span class="val ${hbClass}">${escapeHtml(hbLabel)}</span></div>
44
+ <div class="kv"><span class="key">pid</span><span class="val">${d.heartbeat ? d.heartbeat.pid : '—'}</span></div>
45
+ <div class="kv"><span class="key">claimed</span><span class="val">${d.claimedModels.length} model${d.claimedModels.length === 1 ? '' : 's'}</span></div>
46
+ <div class="kv"><span class="key">errors logged</span><span class="val ${d.errorsLogged > 0 ? 'warn' : 'dim'}">${d.errorsLogged}</span></div>
47
+ </section>
48
+ </div>
49
+
50
+ <section class="card">
51
+ <h3>Pending work <span style="float:right;font-size:10px;color:#7a7f87;letter-spacing:.06em">${d.pendingWork.reduce((sum, p) => sum + p.count, 0)} TOTAL</span></h3>
52
+ <p class="sub">Unreplied messages on the bus, grouped by recipient. A claimed model with pending work is what the dispatcher will pick up on its next tick.</p>
53
+ ${d.pendingWork.length === 0
54
+ ? '<p class="empty">No pending work — every message on the bus has a reply.</p>'
55
+ : d.pendingWork.slice(0, 10).map(p => {
56
+ const isClaimedHere = d.claimedModels.some(m => m === p.to || p.to.startsWith(m + '@'));
57
+ const badge = isClaimedHere ? '<span class="chip brand" title="claimed by this dispatcher">us</span>' : '';
58
+ return `<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid #1f2329">
59
+ <div style="min-width:0;flex:1 1 auto"><span class="chip">${escapeHtml(p.to)}</span> ${badge}<div style="font-size:10px;color:#7a7f87;margin-top:4px">in ${p.channelLabels.map(escapeHtml).join(', ')}</div></div>
60
+ <div style="font-weight:600;color:${isClaimedHere ? '#7ee787' : '#d29922'};font-size:18px">${p.count}</div>
61
+ </div>`;
62
+ }).join('')}
63
+ </section>
64
+
65
+ <section class="card">
66
+ <h3>Claimed models</h3>
67
+ ${d.claimedModels.length === 0
68
+ ? '<p class="empty">No models claimed on this dispatcher. Check <code>data/crosstalk.yaml</code> + that the CLI binaries are on PATH.</p>'
69
+ : d.claimedModels.map(m => `<span class="chip brand">${escapeHtml(m)}</span>`).join('')}
70
+ </section>
71
+
72
+ <section class="card">
73
+ <h3>Channels (${d.channels.length})</h3>
74
+ <p class="sub">Click a channel to open the threaded message view + send form.</p>
75
+ ${d.channels.length === 0
76
+ ? '<p class="empty">No channels yet. Create one with <code>crosstalk channel &lt;name&gt;</code>.</p>'
77
+ : d.channels.map(c => {
78
+ const handle = c.name ?? c.uuid;
79
+ const display = c.name ? escapeHtml(c.name) : `<span style="color:#7a7f87">${escapeHtml(c.uuid.slice(0,8))}…</span>`;
80
+ return `<a href="/c/${encodeURIComponent(handle)}" style="display:block;padding:8px 0;border-bottom:1px solid #1f2329"><span class="chip brand">${display}</span> <span style="color:#7a7f87;font-size:11px;margin-left:8px">${escapeHtml(c.uuid)}</span></a>`;
81
+ }).join('')}
82
+ </section>
83
+ `;
84
+
85
+ return page({
86
+ title: `crosstalk on ${d.host} · Dashboard`,
87
+ host: d.host,
88
+ alias: d.alias,
89
+ activeNav: 'dashboard',
90
+ body,
91
+ inlineScript: `${TOAST_HELPER}
92
+ // SSE subscription — engine pushes 'snapshot' (initial) + 'update'
93
+ // (whenever cursor or heartbeat changes). When we see an update, reload
94
+ // the page so the cards re-render with the new values. Falls back to
95
+ // 5s polling if SSE isn't available (e.g., behind a buffering proxy).
96
+ let lastCursor = ${JSON.stringify(d.cursor)};
97
+ let lastHb = ${JSON.stringify(d.heartbeat?.ts ?? null)};
98
+ let sseConnected = false;
99
+ try {
100
+ const es = new EventSource('/events');
101
+ es.addEventListener('open', () => { sseConnected = true; });
102
+ es.addEventListener('update', (e) => {
103
+ try {
104
+ const data = JSON.parse(e.data);
105
+ const curCursor = data.cursor ?? null;
106
+ const curHb = data.heartbeat?.ts ?? null;
107
+ if (curCursor !== lastCursor || curHb !== lastHb) {
108
+ location.reload();
109
+ }
110
+ } catch (_) {}
111
+ });
112
+ es.addEventListener('error', () => { /* browser will retry; we don't fall back unless sseConnected stayed false for > 10s */ });
113
+ } catch (_) {}
114
+ // Poll fallback — fires only if SSE never connected.
115
+ setTimeout(() => {
116
+ if (sseConnected) return;
117
+ setInterval(async () => {
118
+ try {
119
+ const r = await fetch('/status');
120
+ if (!r.ok) return;
121
+ const s = await r.json();
122
+ const curCursor = s.cursor ?? null;
123
+ const curHb = s.heartbeat?.ts ?? null;
124
+ if (curCursor !== lastCursor || curHb !== lastHb) location.reload();
125
+ } catch (_) {}
126
+ }, 5000);
127
+ }, 10000);`,
128
+ });
129
+ }
@@ -0,0 +1,237 @@
1
+ // layout.ts — shared HTML/CSS chrome for the crosstalk web UI.
2
+ //
3
+ // Design language matches the @cordfuse/llmux picker (dark navy bg,
4
+ // sky-blue accents, monospace) so a single Cordfuse operator sees a
5
+ // consistent visual family across tools. Layout is async-by-design:
6
+ // dashboards, channel browser, threaded message view, dispatcher
7
+ // status. The chat panel (interactive agent attach) is Phase 2 — pty
8
+ // over WebSocket + xterm.js, fresh agent per attach, killed on close.
9
+
10
+ const BRAND_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="90" ry="90" fill="#0b0c10"/><rect x="1.5" y="1.5" width="509" height="509" rx="89" ry="89" fill="none" stroke="#7cc4ff" stroke-width="1.5" stroke-opacity="0.22"/><text x="256" y="236" text-anchor="middle" dominant-baseline="central" font-family="'Noto Sans Mono', 'Courier New', monospace" font-size="155" font-weight="700" fill="#7cc4ff" letter-spacing="-3">{Ct}</text></svg>`;
11
+ const FAVICON_DATA_URL = `data:image/svg+xml,${encodeURIComponent(BRAND_SVG)}`;
12
+
13
+ export function escapeHtml(s: string | null | undefined): string {
14
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
15
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
16
+ }[c]!));
17
+ }
18
+
19
+ export function relativeTime(iso: string | null | undefined): string {
20
+ if (!iso) return '—';
21
+ const ms = Date.now() - new Date(iso).getTime();
22
+ if (Number.isNaN(ms)) return iso;
23
+ if (ms < 0) return 'in the future';
24
+ if (ms < 5_000) return 'just now';
25
+ if (ms < 60_000) return Math.floor(ms / 1000) + 's ago';
26
+ if (ms < 3_600_000) return Math.floor(ms / 60_000) + 'm ago';
27
+ if (ms < 86_400_000) return Math.floor(ms / 3_600_000) + 'h ago';
28
+ return Math.floor(ms / 86_400_000) + 'd ago';
29
+ }
30
+
31
+ interface NavItem {
32
+ id: string;
33
+ label: string;
34
+ icon: string;
35
+ href: string;
36
+ }
37
+
38
+ const NAV: NavItem[] = [
39
+ { id: 'dashboard', label: 'Dashboard', icon: '▦', href: '/' },
40
+ { id: 'chat', label: 'Chat', icon: '◉', href: '/chat' },
41
+ { id: 'channels', label: 'Channels', icon: '⇄', href: '/c' },
42
+ { id: 'workflows', label: 'Workflows', icon: '⎇', href: '/w' },
43
+ { id: 'tokens', label: 'Tokens', icon: '⚿', href: '/tokens' },
44
+ { id: 'agents', label: 'Agents', icon: '⌬', href: '/agents' },
45
+ { id: 'logs', label: 'Logs', icon: '▤', href: '/logs' },
46
+ { id: 'settings', label: 'Settings', icon: '⚙', href: '/settings' },
47
+ { id: 'account', label: 'Account', icon: '◐', href: '/account' },
48
+ { id: 'users', label: 'Users', icon: '☷', href: '/users' },
49
+ { id: 'about', label: 'About', icon: 'ⓘ', href: '/about' },
50
+ ];
51
+
52
+ function navDrawer(opts: { host: string; alias: string; activeId?: string }): string {
53
+ const links = NAV.map((it) => {
54
+ const active = it.id === opts.activeId ? ' class="active"' : '';
55
+ return ` <a href="${escapeHtml(it.href)}"${active}><span class="nav-icon">${it.icon}</span>${escapeHtml(it.label)}</a>`;
56
+ }).join('\n');
57
+ return `<div id="nav-backdrop" aria-hidden="true"></div>
58
+ <aside id="nav-drawer" aria-hidden="true">
59
+ <div class="nav-header">
60
+ <span class="nav-brand">CROSSTALK</span>
61
+ <span class="nav-host">${escapeHtml(opts.host)}</span>
62
+ <span class="nav-alias">alias: ${escapeHtml(opts.alias)}</span>
63
+ </div>
64
+ <nav>
65
+ ${links}
66
+ </nav>
67
+ </aside>`;
68
+ }
69
+
70
+ function commonStyles(): string {
71
+ return `
72
+ :root{color-scheme:dark}
73
+ html,body{margin:0;background:#0b0c10;color:#e6e8eb;font-family:ui-monospace,monospace;font-size:14px;overflow-x:hidden}
74
+ body{padding:18px 16px 80px;max-width:980px;margin:0 auto;box-sizing:border-box}
75
+ a{color:#7cc4ff;text-decoration:none}
76
+ a:hover{text-decoration:underline}
77
+ header{display:flex;flex-direction:column;align-items:stretch;gap:8px;margin-bottom:18px}
78
+ header .header-controls{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
79
+ h1{font-size:18px;margin:0}
80
+ h1 .brand{color:#7cc4ff;letter-spacing:.08em;font-weight:600}
81
+ h1 .host{color:#a371f7;font-weight:500;margin-left:8px}
82
+ h1 .page-title{color:#a371f7;font-weight:500;margin-left:8px}
83
+ #meta{color:#7a7f87;font-size:11px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
84
+ #meta .dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#7ee787}
85
+ #meta .dot.stale{background:#d29922}
86
+ #meta .dot.dead{background:#f85149}
87
+ #nav-toggle{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;font-size:18px;line-height:1;margin-right:10px;flex:0 0 auto;transition:background 150ms ease,border-color 150ms ease}
88
+ #nav-toggle:hover{background:#252b34;border-color:#3a414b}
89
+ #nav-toggle:active{transform:scale(.94)}
90
+ #nav-drawer{position:fixed;top:0;left:-300px;width:280px;height:100dvh;background:#0e1116;border-right:1px solid #1f2329;transition:left 220ms ease;z-index:55;padding:18px 0;box-sizing:border-box;display:flex;flex-direction:column}
91
+ #nav-drawer.open{left:0}
92
+ #nav-backdrop{position:fixed;inset:0;background:rgba(11,12,16,.55);z-index:54;opacity:0;visibility:hidden;transition:opacity 180ms ease,visibility 0s 180ms}
93
+ #nav-backdrop.show{opacity:1;visibility:visible;transition:opacity 180ms ease}
94
+ #nav-drawer .nav-header{padding:0 20px 16px;border-bottom:1px solid #1f2329;display:flex;flex-direction:column;gap:4px}
95
+ #nav-drawer .nav-brand{color:#7cc4ff;font-weight:600;letter-spacing:.08em;font-size:15px}
96
+ #nav-drawer .nav-host{color:#a371f7;font-size:12px}
97
+ #nav-drawer .nav-alias{color:#7a7f87;font-size:11px;margin-top:2px}
98
+ #nav-drawer nav{flex:1;display:flex;flex-direction:column;padding:8px 0;overflow-y:auto}
99
+ #nav-drawer a{display:flex;align-items:center;gap:10px;padding:12px 20px;color:#c9d1d9;text-decoration:none;font-size:14px;border-left:3px solid transparent;cursor:pointer}
100
+ #nav-drawer a:hover{background:#11141a;text-decoration:none}
101
+ #nav-drawer a.active{border-left-color:#7cc4ff;color:#7cc4ff;background:#11141a}
102
+ #nav-drawer a .nav-icon{font-size:16px;width:20px;text-align:center;color:inherit}
103
+ .card{background:#11141a;border:1px solid #1f2329;border-radius:8px;padding:14px 16px;margin-bottom:14px}
104
+ .card h3{margin:0 0 10px;font-size:13px;color:#7cc4ff;font-weight:600;letter-spacing:.05em;text-transform:uppercase}
105
+ .card .sub{margin:-6px 0 10px;font-size:11px;color:#9aa0a6;line-height:1.5}
106
+ .kv{display:flex;justify-content:space-between;gap:14px;padding:6px 0;border-bottom:1px solid #1f2329;font-size:13px}
107
+ .kv:last-child{border-bottom:none}
108
+ .kv .key{color:#9aa0a6}
109
+ .kv .val{color:#e6e8eb;font-family:ui-monospace,monospace;text-align:right;word-break:break-word}
110
+ .kv .val.dim{color:#7a7f87}
111
+ .kv .val.ok{color:#7ee787}
112
+ .kv .val.warn{color:#d29922}
113
+ .kv .val.err{color:#f85149}
114
+ .kv .val.brand{color:#7cc4ff}
115
+ .grid{display:grid;grid-template-columns:1fr;gap:14px;margin-bottom:14px}
116
+ @media (min-width:601px){.grid.cols-2{grid-template-columns:1fr 1fr}}
117
+ .chip{display:inline-block;background:#1c2128;color:#c9d1d9;border:1px solid #262c34;border-radius:12px;padding:3px 10px;font-size:11px;margin:2px 4px 2px 0}
118
+ .chip.brand{color:#7cc4ff;border-color:#2d4a66}
119
+ .empty{color:#7a7f87;font-style:italic;text-align:center;padding:24px 0}
120
+ /* Threaded message view (mirrors @cordfuse/llmux's /orch pattern). */
121
+ .thread{margin-bottom:18px;border:1px solid #262c34;border-left:4px solid #2d4a66;border-radius:8px;padding:12px;background:#0d0f14}
122
+ .thread .msg{margin-bottom:8px}
123
+ .thread .msg:last-of-type{margin-bottom:0}
124
+ .thread-summary{font-size:10px;color:#7cc4ff;text-transform:uppercase;letter-spacing:.08em;padding:0 0 8px 4px;font-weight:600}
125
+ .msg{background:#11141a;border:1px solid #1f2329;border-radius:8px;padding:12px 14px}
126
+ .msg.reply{margin-left:36px;background:#161a22;border-left:3px solid #2d4a66;position:relative}
127
+ .msg.reply::before{content:"";position:absolute;left:-22px;top:18px;width:18px;height:2px;background:#2d4a66}
128
+ @media (max-width:600px){
129
+ .msg.reply{margin-left:20px}
130
+ .msg.reply::before{left:-14px;width:12px}
131
+ }
132
+ .msg-head{display:flex;justify-content:space-between;gap:12px;font-size:12px;color:#9aa0a6;margin-bottom:8px;align-items:baseline;flex-wrap:wrap}
133
+ .msg-route{display:inline-flex;gap:6px;align-items:baseline;flex-wrap:wrap}
134
+ .msg-from{color:#7cc4ff;font-weight:600}
135
+ .msg-arrow{color:#7a7f87}
136
+ .msg-to{color:#7ee787}
137
+ .msg-re{color:#f0883e;font-size:11px;margin-left:6px}
138
+ .msg-id{font-family:ui-monospace,monospace;font-size:11px;color:#7a7f87}
139
+ .msg-body{white-space:pre-wrap;font:12px ui-monospace,monospace;background:#0b0c10;padding:10px 12px;border-radius:6px;color:#e6e8eb;max-height:400px;overflow:auto;border:1px solid #1f2329}
140
+ .msg-failed{margin-top:6px;color:#f85149;font-size:11px}
141
+ /* Forms (send-message, channel-create, etc.) */
142
+ .field{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}
143
+ .field label{font-size:11px;color:#9aa0a6;text-transform:uppercase;letter-spacing:.05em}
144
+ .field input,.field textarea,.field select{background:#0b0c10;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 10px;font:13px ui-monospace,monospace;outline:none;width:100%;box-sizing:border-box}
145
+ .field input:focus,.field textarea:focus,.field select:focus{border-color:#2d4a66}
146
+ .field textarea{min-height:90px;resize:vertical}
147
+ .actions{display:flex;gap:8px;justify-content:flex-end;align-items:center}
148
+ button{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 14px;font:13px ui-monospace,monospace;cursor:pointer;transition:background 150ms ease,border-color 150ms ease}
149
+ button:hover:not(:disabled){background:#252b34;border-color:#3a414b}
150
+ button.primary{color:#7cc4ff;border-color:#2d4a66}
151
+ button.primary:hover:not(:disabled){background:#11141a}
152
+ button:disabled{opacity:.4;cursor:not-allowed}
153
+ .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(80px);background:#11141a;border:1px solid #1f2329;border-radius:8px;padding:10px 16px;font-size:13px;z-index:80;opacity:0;transition:opacity 180ms ease,transform 220ms ease;pointer-events:none;max-width:90vw;text-align:center}
154
+ .toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
155
+ .toast.ok{color:#7ee787;border-color:#1f4528}
156
+ .toast.err{color:#f85149;border-color:#4a2329}
157
+ `;
158
+ }
159
+
160
+ export const TOAST_HELPER = `
161
+ window.showToast = function(msg, kind){
162
+ const t = document.getElementById('toast');
163
+ if (!t) return;
164
+ t.textContent = msg;
165
+ t.className = 'toast show ' + (kind || 'ok');
166
+ clearTimeout(window._toastTimer);
167
+ window._toastTimer = setTimeout(() => { t.className = 'toast ' + (kind || 'ok'); }, 3000);
168
+ };
169
+ `;
170
+
171
+ const NAV_TOGGLE_SCRIPT = `<script>
172
+ (() => {
173
+ const drawer = document.getElementById('nav-drawer');
174
+ const backdrop = document.getElementById('nav-backdrop');
175
+ const toggle = document.getElementById('nav-toggle');
176
+ if (!drawer || !backdrop || !toggle) return;
177
+ const open = () => { drawer.classList.add('open'); backdrop.classList.add('show'); drawer.setAttribute('aria-hidden','false'); };
178
+ const close = () => { drawer.classList.remove('open'); backdrop.classList.remove('show'); drawer.setAttribute('aria-hidden','true'); };
179
+ toggle.addEventListener('click', () => drawer.classList.contains('open') ? close() : open());
180
+ backdrop.addEventListener('click', close);
181
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
182
+ })();
183
+ </script>`;
184
+
185
+ export interface PageOpts {
186
+ title: string;
187
+ host: string;
188
+ alias: string;
189
+ /** Marks the active nav item. Use one of: 'dashboard'|'channels'|'agents'. */
190
+ activeNav?: string;
191
+ /** Short page subtitle shown in the header next to host. */
192
+ pageTitle?: string;
193
+ /** Extra CSS rules appended after commonStyles(). */
194
+ extraCss?: string;
195
+ /** Body HTML. */
196
+ body: string;
197
+ /** Inline JS to run after the body. */
198
+ inlineScript?: string;
199
+ /** Override the viewport meta. Chat page passes
200
+ * `interactive-widget=resizes-content` so the soft keyboard
201
+ * resizes the viewport (pushing the bottom bar up) instead of
202
+ * overlaying it. */
203
+ viewport?: string;
204
+ }
205
+
206
+ export function page(opts: PageOpts): string {
207
+ const css = commonStyles() + (opts.extraCss ?? '');
208
+ const titleSuffix = opts.pageTitle ? ` <span class="page-title">· ${escapeHtml(opts.pageTitle)}</span>` : '';
209
+ const viewport = opts.viewport ?? 'width=device-width,initial-scale=1';
210
+ return `<!doctype html><html lang="en"><head>
211
+ <meta charset="utf-8"><meta name="viewport" content="${escapeHtml(viewport)}">
212
+ <title>${escapeHtml(opts.title)}</title>
213
+ <link rel="icon" href="${FAVICON_DATA_URL}">
214
+ <link rel="apple-touch-icon" href="${FAVICON_DATA_URL}">
215
+ <meta name="theme-color" content="#0b0c10">
216
+ <style>${css}</style>
217
+ </head><body>
218
+ ${navDrawer({ host: opts.host, alias: opts.alias, ...(opts.activeNav ? { activeId: opts.activeNav } : {}) })}
219
+ <header>
220
+ <div class="header-controls">
221
+ <h1><button id="nav-toggle" aria-label="open navigation" type="button">≡</button><span class="brand">CROSSTALK</span> <span class="host">${escapeHtml(opts.host)}</span>${titleSuffix}</h1>
222
+ <div id="meta">
223
+ <span class="dot" id="liveness-dot"></span>
224
+ <span id="liveness-label">live · 5s</span>
225
+ <span>·</span>
226
+ <span>alias ${escapeHtml(opts.alias)}</span>
227
+ </div>
228
+ </div>
229
+ </header>
230
+ <main>
231
+ ${opts.body}
232
+ </main>
233
+ <div class="toast" id="toast" role="status" aria-live="polite"></div>
234
+ ${NAV_TOGGLE_SCRIPT}
235
+ ${opts.inlineScript ? '<script>' + opts.inlineScript + '</script>' : ''}
236
+ </body></html>`;
237
+ }