@cordfuse/crosstalk 8.2.0 → 8.3.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.
@@ -1,254 +0,0 @@
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 on a channel (the dispatcher replies).
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
- }