@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.
- package/GUIDE-CLI.md +2 -2
- package/GUIDE-PROMPTS.md +4 -4
- package/README.md +8 -5
- package/commands/chat.js +1 -1
- package/lib/credentials.js +1 -1
- package/package.json +1 -4
- package/src/api.ts +3 -31
- package/src/dispatch.ts +28 -11
- package/src/invoke.ts +1 -1
- package/src/onboarding.ts +15 -0
- package/src/resolve.ts +1 -1
- package/src/web/dashboard.ts +1 -1
- package/src/web/layout.ts +3 -3
- package/src/web/stubs.ts +1 -1
- package/template/CROSSTALK.md +2 -2
- package/template/auth/README.md +17 -0
- package/template/data/crosstalk.yaml +17 -0
- package/src/web/chat-page.ts +0 -636
- package/src/web/chat-pty.ts +0 -254
package/src/web/chat-pty.ts
DELETED
|
@@ -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
|
-
}
|