@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
package/src/api.ts ADDED
@@ -0,0 +1,1716 @@
1
+ // api.ts — local HTTP API the host-side client (@cordfuse/crosstalk)
2
+ // connects to. Binds 127.0.0.1 only; never exposes to the network.
3
+ //
4
+ // Design choices (see conversation 2026-06-12):
5
+ // - No auth. Localhost dev tool, same model as ollama / postgres-on-
6
+ // localhost / jupyter-without-token. If you're on the host, you're
7
+ // trusted. The actual security boundary is the bind address.
8
+ // - Bind hardcoded to 127.0.0.1. Operators can't accidentally expose
9
+ // to the network even by misconfiguring docker-compose port mapping.
10
+ // - Plain Node `http` module, no Express/Fastify dep. v7 ships with
11
+ // two runtime deps (tsx + yaml); HTTP framework would break that.
12
+ // - JSON in, JSON out. Error responses carry { error: <message> }.
13
+ //
14
+ // Endpoint surface (v7.0.0-alpha.1):
15
+ // GET /healthz — liveness probe (no body needed)
16
+ // GET /version — engine version + alias
17
+ // GET /status — status summary
18
+ // GET /channels — channel listing (name → uuid)
19
+ // POST /channels — create a channel
20
+ // POST /messages — write primitive or workflow marker
21
+ // GET /messages/:relPath/replies — reply status for given relPath(s)
22
+ //
23
+ // Deferred to follow-up: SSE streaming endpoint for `replies --watch`,
24
+ // /transport/template (export template tarball for `crosstalk transport init`),
25
+ // /shutdown (graceful engine stop).
26
+
27
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http';
28
+ import { randomUUID } from 'crypto';
29
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
30
+ import { hostname, homedir } from 'os';
31
+ import { join } from 'path';
32
+ import { now, messageFilename } from './filenames.js';
33
+ import { parseFrontmatter, serializeFrontmatter } from './frontmatter.js';
34
+ import {
35
+ discoverChannels,
36
+ gitCommitAndPush,
37
+ listChannelMessages,
38
+ } from './transport.js';
39
+ import { sendWakeSignal, readHeartbeat, readCursor, countErrors } from './state.js';
40
+ import { reList } from './activation.js';
41
+ import { isOnPath, type ModelEntry, type ModelsRegistry } from './models.js';
42
+ import { resolveToField } from './resolve.js';
43
+ import { dashboardPage } from './web/dashboard.js';
44
+ import { channelListPage, channelViewPage, agentsPage, type BusMessage } from './web/channels.js';
45
+ import { workflowsListPage, workflowDetailPage, type WorkflowSummary } from './web/workflows.js';
46
+ import { setupPage as authSetupPage, loginPage as authLoginPage } from './web/auth-pages.js';
47
+ import { chatPage } from './web/chat-page.js';
48
+ import { mountChatPty } from './web/chat-pty.js';
49
+ import { accountPage, usersPage, tokensPage, logsPage, settingsPage, aboutPage } from './web/stubs.js';
50
+ import { logBuffer, type LogEntry } from './log-buffer.js';
51
+ import { findOpenWorkflows, validatePlan, COMPILE_PROMPT, extractPlanFromOutput, type WorkflowPlan } from './workflow.js';
52
+ import { invokeModelCli } from './invoke.js';
53
+ import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
54
+ import { FileUserStore, type UserStore } from './auth/users.js';
55
+ import { FileTokenStore, type TokenStore } from './auth/tokens.js';
56
+ import { SetupState, handleSetupSubmit, setupGate } from './auth/setup.js';
57
+ import {
58
+ authenticateRequest, sessionCookieFor, clearSessionCookie,
59
+ type AuthSuccess,
60
+ } from './auth/middleware.js';
61
+ import { handleLogin, handleLogout } from './auth/handlers.js';
62
+
63
+ export interface ApiContext {
64
+ transportRoot: string;
65
+ alias: string;
66
+ version: string;
67
+ registry: ModelsRegistry;
68
+ claimed: Map<string, ModelEntry>; // shorthand for registry.claimed
69
+ /** Auth stores; v8-native. Optional — engine still runs without auth
70
+ * if the operator doesn't want it (just the web UI gating turns off). */
71
+ userStore?: UserStore;
72
+ tokenStore?: TokenStore;
73
+ setupState?: SetupState;
74
+ }
75
+
76
+ interface JsonRes {
77
+ status: number;
78
+ body: unknown;
79
+ }
80
+
81
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
82
+
83
+ function readBody(req: IncomingMessage): Promise<string> {
84
+ return new Promise((resolveBody, reject) => {
85
+ const chunks: Buffer[] = [];
86
+ req.on('data', (c) => chunks.push(Buffer.from(c)));
87
+ req.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf-8')));
88
+ req.on('error', reject);
89
+ });
90
+ }
91
+
92
+ async function readJson<T = unknown>(req: IncomingMessage): Promise<T> {
93
+ const raw = await readBody(req);
94
+ if (raw.length === 0) return {} as T;
95
+ try {
96
+ return JSON.parse(raw) as T;
97
+ } catch {
98
+ throw new HttpError(400, 'invalid JSON body');
99
+ }
100
+ }
101
+
102
+ class HttpError extends Error {
103
+ constructor(public status: number, message: string) {
104
+ super(message);
105
+ }
106
+ }
107
+
108
+ function writeJson(res: ServerResponse, status: number, body: unknown, engineVersion?: string): void {
109
+ const json = JSON.stringify(body);
110
+ const headers: Record<string, string> = {
111
+ 'Content-Type': 'application/json; charset=utf-8',
112
+ 'Content-Length': Buffer.byteLength(json).toString(),
113
+ };
114
+ // Advertise our version on every response so the client can detect
115
+ // version skew passively — no extra round-trip needed. Client writes
116
+ // a one-shot warning when its own version doesn't match.
117
+ if (engineVersion) headers['X-Crosstalk-Engine-Version'] = engineVersion;
118
+ res.writeHead(status, headers);
119
+ res.end(json);
120
+ }
121
+
122
+ function writeHtml(res: ServerResponse, status: number, html: string, engineVersion?: string): void {
123
+ const headers: Record<string, string> = {
124
+ 'Content-Type': 'text/html; charset=utf-8',
125
+ 'Content-Length': Buffer.byteLength(html, 'utf-8').toString(),
126
+ // HTML pages always reflect server-side state (channels, workflows,
127
+ // tokens, etc.) — never serve a stale cached copy. Defeats both
128
+ // intermediary proxy caches and Chrome's bfcache restoring a list
129
+ // page that still shows a just-deleted entity.
130
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
131
+ 'Pragma': 'no-cache',
132
+ };
133
+ if (engineVersion) headers['X-Crosstalk-Engine-Version'] = engineVersion;
134
+ res.writeHead(status, headers);
135
+ res.end(html);
136
+ }
137
+
138
+ function writeRedirect(res: ServerResponse, to: string): void {
139
+ res.writeHead(302, { 'Location': to });
140
+ res.end();
141
+ }
142
+
143
+ // ── channel discovery helpers ────────────────────────────────────────
144
+
145
+ interface ChannelMeta {
146
+ uuid: string;
147
+ name: string | null;
148
+ parent: string | null;
149
+ }
150
+
151
+ function readChannelMeta(transportRoot: string, uuid: string): ChannelMeta {
152
+ const chPath = join(transportRoot, 'data', 'channels', uuid, 'CHANNEL.md');
153
+ if (!existsSync(chPath)) return { uuid, name: null, parent: null };
154
+ const raw = readFileSync(chPath, 'utf-8');
155
+ const { data } = parseFrontmatter<{ name?: unknown; parent?: unknown }>(raw);
156
+ return {
157
+ uuid,
158
+ name: typeof data.name === 'string' ? data.name : null,
159
+ parent: typeof data.parent === 'string' ? data.parent : null,
160
+ };
161
+ }
162
+
163
+ function allChannelMeta(transportRoot: string): ChannelMeta[] {
164
+ return discoverChannels(transportRoot).map((u) => readChannelMeta(transportRoot, u));
165
+ }
166
+
167
+ function resolveChannelHandle(transportRoot: string, handle: string): ChannelMeta | null {
168
+ if (UUID_RE.test(handle)) {
169
+ const path = join(transportRoot, 'data', 'channels', handle);
170
+ if (!existsSync(path)) return null;
171
+ return readChannelMeta(transportRoot, handle);
172
+ }
173
+ // Prefer CHANNEL.md name match.
174
+ for (const meta of allChannelMeta(transportRoot)) {
175
+ if (meta.name === handle) return meta;
176
+ }
177
+ // Fallback: bare directory name. Handles legacy / orphan channels
178
+ // whose dir name is not a UUID AND whose CHANNEL.md has no name —
179
+ // they'd be otherwise unreachable via the API. Caught 2026-06-22
180
+ // when an old "mctest" dir couldn't be deleted from the web UI.
181
+ const directPath = join(transportRoot, 'data', 'channels', handle);
182
+ if (existsSync(directPath)) return readChannelMeta(transportRoot, handle);
183
+ return null;
184
+ }
185
+
186
+ // ── route handlers ───────────────────────────────────────────────────
187
+
188
+ function handleHealthz(ctx: ApiContext): JsonRes {
189
+ return { status: 200, body: { ok: true, alias: ctx.alias, version: ctx.version } };
190
+ }
191
+
192
+ function handleVersion(ctx: ApiContext): JsonRes {
193
+ return {
194
+ status: 200,
195
+ body: { engine: 'crosstalk', version: ctx.version, alias: ctx.alias },
196
+ };
197
+ }
198
+
199
+ function handleStatus(ctx: ApiContext): JsonRes {
200
+ const { transportRoot } = ctx;
201
+ const hb = readHeartbeat(transportRoot);
202
+ const cursor = readCursor(transportRoot);
203
+ const channels = allChannelMeta(transportRoot);
204
+ return {
205
+ status: 200,
206
+ body: {
207
+ transport_root: transportRoot,
208
+ alias: ctx.alias,
209
+ version: ctx.version,
210
+ claimed_models: [...ctx.claimed.keys()],
211
+ heartbeat: hb
212
+ ? { ts: hb.ts, pid: hb.pid, version: hb.version, alias: hb.alias }
213
+ : null,
214
+ cursor: cursor ?? null,
215
+ channels: channels.map((c) => ({ uuid: c.uuid, name: c.name, parent: c.parent })),
216
+ errors_logged: countErrors(transportRoot),
217
+ },
218
+ };
219
+ }
220
+
221
+ function handleListChannels(ctx: ApiContext): JsonRes {
222
+ return {
223
+ status: 200,
224
+ body: { channels: allChannelMeta(ctx.transportRoot) },
225
+ };
226
+ }
227
+
228
+ // Canonical agent CLI binaries crosstalk supports. The set the host
229
+ // client's `chat` command knows how to PTY-wrap. The engine checks PATH
230
+ // inside the container for each — what's actually installed.
231
+ //
232
+ // This is distinct from `claimed_models` on /status: that lists
233
+ // data/crosstalk.yaml entries whose CLI is on PATH (e.g. "sonnet", "haiku"
234
+ // — model names). /agents/installed lists raw CLI binaries (e.g.
235
+ // "claude" — the interactive entry point operators talk to).
236
+ const KNOWN_AGENT_BINARIES = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
237
+
238
+ function handleAgentsInstalled(ctx: ApiContext): JsonRes {
239
+ const installed = KNOWN_AGENT_BINARIES.filter((bin) => isOnPath(bin));
240
+ // The set of CLI binaries referenced by entries in data/crosstalk.yaml
241
+ // (whether their CLI is currently claimed or not). Lets the client
242
+ // differentiate "yaml has them but they're not claimed → restart" from
243
+ // "yaml has no entry referencing them → add one". M2 fix (alpha.10):
244
+ // alpha.9 status hint conflated those two cases.
245
+ const yamlReferenced = [...new Set(
246
+ [...ctx.registry.all.values()].map((entry) => entry.cli),
247
+ )];
248
+ // The set of CLI binaries with at least one currently-claimed model.
249
+ // Distinct from yaml_referenced: yaml may reference `claude` but the
250
+ // dispatcher may not yet have claimed it (CLI installed post-startup,
251
+ // PATH change, etc.). N1 (alpha.11): lets the client surface the
252
+ // "needs restart" hint in mixed states where some models ARE claimed.
253
+ const claimedClis = [...new Set(
254
+ [...ctx.claimed.values()].map((entry) => entry.cli),
255
+ )];
256
+ return {
257
+ status: 200,
258
+ body: {
259
+ installed,
260
+ known: KNOWN_AGENT_BINARIES,
261
+ yaml_referenced: yamlReferenced,
262
+ claimed: claimedClis,
263
+ },
264
+ };
265
+ }
266
+
267
+ interface CreateChannelReq { name: string }
268
+
269
+ function handleCreateChannel(ctx: ApiContext, body: CreateChannelReq): JsonRes {
270
+ const { transportRoot } = ctx;
271
+ if (typeof body.name !== 'string' || body.name.length === 0) {
272
+ throw new HttpError(400, 'channel name is required');
273
+ }
274
+ if (UUID_RE.test(body.name)) {
275
+ throw new HttpError(400, 'channel name cannot be UUID-shaped');
276
+ }
277
+ if (allChannelMeta(transportRoot).some((c) => c.name === body.name)) {
278
+ throw new HttpError(409, `a channel named '${body.name}' already exists`);
279
+ }
280
+ const uuid = randomUUID();
281
+ const dir = join(transportRoot, 'data', 'channels', uuid);
282
+ mkdirSync(dir, { recursive: true });
283
+ writeFileSync(join(dir, 'CHANNEL.md'), serializeFrontmatter({ name: body.name }, ''));
284
+ const push = gitCommitAndPush(transportRoot, `channel(create): ${body.name} (${uuid.slice(0, 8)})`);
285
+ if (!push.committed && push.error) {
286
+ // Commit itself failed (e.g. file system / git config issue).
287
+ throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
288
+ }
289
+ // push.committed=true but push.pushed=false → local commit landed,
290
+ // remote push failed (missing ssh key, no internet, conflicting
291
+ // origin). Don't fail the operation — operators on single-machine
292
+ // setups should be able to use crosstalk locally without git
293
+ // credentials. The engine log records the push error for visibility.
294
+ return { status: 201, body: { uuid, name: body.name } };
295
+ }
296
+
297
+ interface RenameChannelReq { name: string }
298
+
299
+ function handleRenameChannel(ctx: ApiContext, handle: string, body: RenameChannelReq): JsonRes {
300
+ const { transportRoot } = ctx;
301
+ if (typeof body.name !== 'string' || body.name.length === 0) {
302
+ throw new HttpError(400, 'new name is required');
303
+ }
304
+ if (UUID_RE.test(body.name)) {
305
+ throw new HttpError(400, 'channel name cannot be UUID-shaped');
306
+ }
307
+ const meta = resolveChannelHandle(transportRoot, handle);
308
+ if (!meta) throw new HttpError(404, `channel '${handle}' not found`);
309
+ if (meta.name === body.name) {
310
+ return { status: 200, body: { uuid: meta.uuid, name: meta.name, noop: true } };
311
+ }
312
+ if (allChannelMeta(transportRoot).some((c) => c.name === body.name)) {
313
+ throw new HttpError(409, `a channel named '${body.name}' already exists`);
314
+ }
315
+ const chPath = join(transportRoot, 'data', 'channels', meta.uuid, 'CHANNEL.md');
316
+ const fm: Record<string, unknown> = { name: body.name };
317
+ if (meta.parent) fm['parent'] = meta.parent;
318
+ writeFileSync(chPath, serializeFrontmatter(fm, ''));
319
+ const push = gitCommitAndPush(
320
+ transportRoot,
321
+ `channel(rename): ${meta.name ?? '(unnamed)'} -> ${body.name} (${meta.uuid.slice(0, 8)})`,
322
+ );
323
+ if (!push.committed && push.error) {
324
+ // Commit itself failed (e.g. file system / git config issue).
325
+ throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
326
+ }
327
+ // push.committed=true but push.pushed=false → local commit landed,
328
+ // remote push failed (missing ssh key, no internet, conflicting
329
+ // origin). Don't fail the operation — operators on single-machine
330
+ // setups should be able to use crosstalk locally without git
331
+ // credentials. The engine log records the push error for visibility.
332
+ return { status: 200, body: { uuid: meta.uuid, name: body.name } };
333
+ }
334
+
335
+ function handleDeleteChannel(ctx: ApiContext, handle: string, confirm: string | null): JsonRes {
336
+ const { transportRoot } = ctx;
337
+ const meta = resolveChannelHandle(transportRoot, handle);
338
+ if (!meta) throw new HttpError(404, `channel '${handle}' not found`);
339
+ // The confirmation string must match the channel's name (or UUID if
340
+ // unnamed). Prevents accidental deletes — operator + client both
341
+ // typed the same name out, gives a moment for second thoughts.
342
+ const expected = meta.name ?? meta.uuid;
343
+ if (confirm !== expected) {
344
+ throw new HttpError(
345
+ 400,
346
+ `confirmation mismatch — pass ?confirm=${expected} to delete this channel`,
347
+ );
348
+ }
349
+ const dir = join(transportRoot, 'data', 'channels', meta.uuid);
350
+ rmSync(dir, { recursive: true, force: true });
351
+ const push = gitCommitAndPush(
352
+ transportRoot,
353
+ `channel(delete): ${meta.name ?? '(unnamed)'} (${meta.uuid.slice(0, 8)})`,
354
+ );
355
+ if (!push.committed && push.error) {
356
+ // Commit itself failed (e.g. file system / git config issue).
357
+ throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
358
+ }
359
+ // push.committed=true but push.pushed=false → local commit landed,
360
+ // remote push failed (missing ssh key, no internet, conflicting
361
+ // origin). Don't fail the operation — operators on single-machine
362
+ // setups should be able to use crosstalk locally without git
363
+ // credentials. The engine log records the push error for visibility.
364
+ return { status: 200, body: { uuid: meta.uuid, name: meta.name, deleted: true } };
365
+ }
366
+
367
+ interface PostMessageReq {
368
+ type: 'primitive' | 'workflow';
369
+ channel?: string; // name or UUID; defaults to single channel if exactly one exists
370
+ from?: string;
371
+ to?: string; // required for primitive
372
+ as?: string; // optional persona for primitive
373
+ fanout?: number; // optional, default 1
374
+ body: string;
375
+ re?: string[]; // optional explicit re: targets
376
+ }
377
+
378
+ function resolveChannelUuid(transportRoot: string, handle?: string): string {
379
+ if (handle) {
380
+ const meta = resolveChannelHandle(transportRoot, handle);
381
+ if (!meta) throw new HttpError(404, `channel '${handle}' not found`);
382
+ return meta.uuid;
383
+ }
384
+ const all = discoverChannels(transportRoot);
385
+ if (all.length === 1) return all[0]!;
386
+ if (all.length === 0) {
387
+ throw new HttpError(409, "no channels exist — create one via POST /channels first");
388
+ }
389
+ throw new HttpError(409, 'multiple channels exist — specify "channel" in request body');
390
+ }
391
+
392
+ function writeMessageFile(opts: {
393
+ transportRoot: string;
394
+ channelUuid: string;
395
+ from: string;
396
+ to: string;
397
+ as?: string;
398
+ body: string;
399
+ re: string[];
400
+ extraFrontmatter?: Record<string, unknown>;
401
+ workflow?: boolean;
402
+ }): string {
403
+ const ts = now();
404
+ const dir = join(opts.transportRoot, 'data', 'channels', opts.channelUuid, ts.pathDate);
405
+ mkdirSync(dir, { recursive: true });
406
+ const fm: Record<string, unknown> = {
407
+ from: opts.from,
408
+ to: opts.to,
409
+ timestamp: ts.iso,
410
+ };
411
+ if (opts.as) fm['as'] = opts.as;
412
+ if (opts.workflow) fm['type'] = 'workflow';
413
+ if (opts.re.length === 1) fm['re'] = opts.re[0];
414
+ else if (opts.re.length > 1) fm['re'] = opts.re;
415
+ if (opts.extraFrontmatter) Object.assign(fm, opts.extraFrontmatter);
416
+ const filename = messageFilename(ts);
417
+ writeFileSync(join(dir, filename), serializeFrontmatter(fm, opts.body));
418
+ return join(ts.pathDate, filename);
419
+ }
420
+
421
+ function handlePostMessage(ctx: ApiContext, body: PostMessageReq): JsonRes {
422
+ const { transportRoot } = ctx;
423
+ if (typeof body.body !== 'string') {
424
+ throw new HttpError(400, 'message body (`body` field) is required and must be a string');
425
+ }
426
+ const re = Array.isArray(body.re) ? body.re.filter((s) => typeof s === 'string') : [];
427
+ const from = typeof body.from === 'string' && body.from.length > 0 ? body.from : 'operator';
428
+
429
+ if (body.type === 'primitive') {
430
+ if (typeof body.to !== 'string' || body.to.length === 0) {
431
+ throw new HttpError(400, 'primitive requires `to` (model name)');
432
+ }
433
+ // Normalize `to:` to qualified `<provider>/<model>[@host]` via the
434
+ // addressing layer. Bare names auto-disambig against the claimed
435
+ // registry; unknown/ambiguous fails fast with a pick-list 400.
436
+ const resolved = resolveToField(body.to, ctx.registry);
437
+ if (resolved.kind !== 'ok' && resolved.kind !== 'reserved') {
438
+ throw new HttpError(400, resolved.error!);
439
+ }
440
+ const toQualified = resolved.qualified!;
441
+ const channelUuid = resolveChannelUuid(transportRoot, body.channel);
442
+ const fanout = Math.max(1, Math.min(50, body.fanout ?? 1));
443
+ const relPaths: string[] = [];
444
+ for (let i = 0; i < fanout; i++) {
445
+ relPaths.push(
446
+ writeMessageFile({
447
+ transportRoot,
448
+ channelUuid,
449
+ from,
450
+ to: toQualified,
451
+ as: body.as,
452
+ body: body.body,
453
+ re,
454
+ }),
455
+ );
456
+ }
457
+ const commitMsg =
458
+ fanout === 1
459
+ ? `run: ${from} -> ${toQualified} in ${channelUuid.slice(0, 8)}`
460
+ : `run: ${from} -> ${toQualified} x${fanout} in ${channelUuid.slice(0, 8)}`;
461
+ const push = gitCommitAndPush(transportRoot, commitMsg);
462
+ sendWakeSignal(transportRoot);
463
+ if (!push.committed && push.error) {
464
+ throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
465
+ }
466
+ // push.committed=true with push.pushed=false → local-only, OK.
467
+ return { status: 201, body: { relPaths, channel: channelUuid, type: 'primitive' } };
468
+ }
469
+
470
+ if (body.type === 'workflow') {
471
+ const parsed = parseFrontmatter<Record<string, unknown>>(body.body);
472
+ if (parsed.data['type'] !== 'workflow') {
473
+ throw new HttpError(400, "workflow body must declare 'type: workflow' in frontmatter");
474
+ }
475
+ const parentUuid = resolveChannelUuid(transportRoot, body.channel);
476
+ const childUuid = randomUUID();
477
+ const childDir = join(transportRoot, 'data', 'channels', childUuid);
478
+ mkdirSync(childDir, { recursive: true });
479
+ writeFileSync(
480
+ join(childDir, 'CHANNEL.md'),
481
+ serializeFrontmatter({ name: `workflow-${Date.now()}`, parent: parentUuid }, ''),
482
+ );
483
+ const heartbeat = readHeartbeat(transportRoot);
484
+ const dispatchHost = heartbeat?.alias;
485
+ const extraFrontmatter: Record<string, unknown> = { child_channel: childUuid };
486
+ if (dispatchHost) extraFrontmatter['dispatch_host'] = dispatchHost;
487
+ const relPath = writeMessageFile({
488
+ transportRoot,
489
+ channelUuid: parentUuid,
490
+ from,
491
+ to: 'workflow',
492
+ body: parsed.body,
493
+ re,
494
+ extraFrontmatter,
495
+ workflow: true,
496
+ });
497
+ const push = gitCommitAndPush(
498
+ transportRoot,
499
+ `run(workflow): ${from} child ${childUuid.slice(0, 8)}`,
500
+ );
501
+ sendWakeSignal(transportRoot);
502
+ if (!push.committed && push.error) {
503
+ throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
504
+ }
505
+ // push.committed=true with push.pushed=false → local-only, OK.
506
+ return {
507
+ status: 201,
508
+ body: { relPath, channel: parentUuid, childChannel: childUuid, type: 'workflow' },
509
+ };
510
+ }
511
+
512
+ throw new HttpError(400, "`type` must be 'primitive' or 'workflow'");
513
+ }
514
+
515
+ function handleReplies(ctx: ApiContext, relPathQuery: string): JsonRes {
516
+ const targets = relPathQuery.split(',').map((s) => s.trim()).filter(Boolean);
517
+ if (targets.length === 0) {
518
+ throw new HttpError(400, 'at least one relPath query argument required');
519
+ }
520
+ const found = new Map<string, { from: string; relPath: string; failed: boolean }>();
521
+ for (const channel of discoverChannels(ctx.transportRoot)) {
522
+ for (const msg of listChannelMessages(ctx.transportRoot, channel)) {
523
+ for (const entry of reList(msg.data['re'])) {
524
+ if (targets.includes(entry) && !found.has(entry)) {
525
+ found.set(entry, {
526
+ from: String(msg.data['from']),
527
+ relPath: msg.relPath,
528
+ failed: msg.data['failed'] === true,
529
+ });
530
+ }
531
+ }
532
+ }
533
+ }
534
+ const results = targets.map((t) => {
535
+ const reply = found.get(t);
536
+ if (!reply) return { target: t, status: 'PENDING' };
537
+ return {
538
+ target: t,
539
+ status: reply.failed ? 'FAILED' : 'REPLIED',
540
+ from: reply.from,
541
+ replyRelPath: reply.relPath,
542
+ };
543
+ });
544
+ return { status: 200, body: { replies: results } };
545
+ }
546
+
547
+ // ── HTML page rendering ──────────────────────────────────────────────
548
+ //
549
+ // The web UI lives at separate paths from the JSON API to avoid Accept-
550
+ // header ambiguity and to keep the JSON surface untouched. Paths:
551
+ // GET / → dashboard
552
+ // GET /c → channel list
553
+ // GET /c/<handle> → threaded message view (handle =
554
+ // channel name or uuid)
555
+ // GET /c/<handle>/messages.json → poll endpoint for the threaded view
556
+ // GET /agents → installed/yaml-referenced/claimed agents
557
+ //
558
+ // All HTML routes are synchronous reads of the transport state — no
559
+ // auth (engine binds 127.0.0.1; tailscale-serve front handles tailnet
560
+ // exposure if the operator wants it).
561
+
562
+ function hostString(): string {
563
+ try { return hostname() || 'localhost'; } catch { return 'localhost'; }
564
+ }
565
+
566
+ /**
567
+ * v8 auth gate. Web pages (HTML routes) AND auth-API routes (POST
568
+ * /api/setup, POST /api/auth/login, POST /api/auth/logout) are gated by
569
+ * the user/token stores. JSON API routes (everything else under /api/*)
570
+ * stay open on 127.0.0.1 — the engine binds loopback only, so the
571
+ * security boundary is the bind address, not app-level auth. This
572
+ * matches the v7 model + only adds gating for browser/web access.
573
+ *
574
+ * Returns: 'allow' = caller may proceed; 'handled' = setup/login flow
575
+ * was served (HTML or JSON); 'redirect' = unauthed; caller wrote 302.
576
+ */
577
+ async function tryWebAuthFlow(ctx: ApiContext, req: IncomingMessage, res: ServerResponse): Promise<'handled' | 'allow' | 'redirect'> {
578
+ if (!ctx.userStore || !ctx.tokenStore || !ctx.setupState) return 'allow';
579
+ const { method = 'GET', url = '/' } = req;
580
+ const u = new URL(url, 'http://localhost');
581
+ const path = u.pathname;
582
+
583
+ // POST /api/setup — open during first-run only.
584
+ if (method === 'POST' && path === '/api/setup') {
585
+ const body = JSON.parse(await readBody(req) || '{}') as { setupToken?: string; name?: string; username?: string; passphrase?: string };
586
+ const r = await handleSetupSubmit({
587
+ setupToken: body.setupToken ?? '',
588
+ name: body.name ?? '',
589
+ username: body.username ?? '',
590
+ passphrase: body.passphrase ?? '',
591
+ }, ctx.userStore, ctx.tokenStore, ctx.setupState);
592
+ if (r.ok && r.bootstrapToken) {
593
+ // Set the session cookie alongside the JSON body so the operator
594
+ // is auto-signed-in after setup completes.
595
+ const json = JSON.stringify(r);
596
+ res.writeHead(200, {
597
+ 'Content-Type': 'application/json',
598
+ 'Content-Length': Buffer.byteLength(json).toString(),
599
+ 'X-Crosstalk-Engine-Version': ctx.version,
600
+ 'Set-Cookie': sessionCookieFor(r.bootstrapToken.plaintextSecret),
601
+ });
602
+ res.end(json);
603
+ } else {
604
+ writeJson(res, r.ok ? 200 : 400, r, ctx.version);
605
+ }
606
+ return 'handled';
607
+ }
608
+
609
+ // POST /api/auth/login — open. Returns wire token + sets session cookie.
610
+ if (method === 'POST' && path === '/api/auth/login') {
611
+ const body = JSON.parse(await readBody(req) || '{}');
612
+ const r = await handleLogin(body, ctx.userStore, ctx.tokenStore);
613
+ if (r.status === 200 && 'token' in r.body) {
614
+ const json = JSON.stringify(r.body);
615
+ res.writeHead(200, {
616
+ 'Content-Type': 'application/json',
617
+ 'Content-Length': Buffer.byteLength(json).toString(),
618
+ 'X-Crosstalk-Engine-Version': ctx.version,
619
+ 'Set-Cookie': sessionCookieFor(r.body.token),
620
+ });
621
+ res.end(json);
622
+ } else {
623
+ writeJson(res, r.status, r.body, ctx.version);
624
+ }
625
+ return 'handled';
626
+ }
627
+
628
+ // POST /api/auth/logout — requires v2 cookie; clears session.
629
+ if (method === 'POST' && path === '/api/auth/logout') {
630
+ const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
631
+ if (!auth.ok) { writeJson(res, auth.status, auth.body, ctx.version); return 'handled'; }
632
+ const r = await handleLogout(auth.token, ctx.tokenStore);
633
+ const json = JSON.stringify(r.body);
634
+ res.writeHead(r.status, {
635
+ 'Content-Type': 'application/json',
636
+ 'Content-Length': Buffer.byteLength(json).toString(),
637
+ 'X-Crosstalk-Engine-Version': ctx.version,
638
+ 'Set-Cookie': clearSessionCookie(),
639
+ });
640
+ res.end(json);
641
+ return 'handled';
642
+ }
643
+
644
+ // GET /setup — render the wizard. Only honored when no users exist;
645
+ // post-setup it 302s to /.
646
+ if (method === 'GET' && path === '/setup') {
647
+ if (!(await ctx.userStore.isEmpty())) { writeRedirect(res, '/'); return 'handled'; }
648
+ const token = u.searchParams.get('token') ?? '';
649
+ writeHtml(res, 200, authSetupPage({ setupToken: token, host: hostString(), alias: ctx.alias }), ctx.version);
650
+ return 'handled';
651
+ }
652
+
653
+ // GET /login — render the form. Already-authed users redirect to /.
654
+ if (method === 'GET' && path === '/login') {
655
+ const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
656
+ if (auth.ok) { writeRedirect(res, '/'); return 'handled'; }
657
+ const returnTo = u.searchParams.get('returnTo') ?? '/';
658
+ writeHtml(res, 200, authLoginPage({ host: hostString(), alias: ctx.alias, returnTo }), ctx.version);
659
+ return 'handled';
660
+ }
661
+
662
+ // Setup gate first-run: when no users exist AND the request isn't a
663
+ // setup/api-setup path, redirect to /setup so the first browser visit
664
+ // bootstraps the admin account.
665
+ const HTML_GATED_PATHS = new Set([
666
+ '/', '/agents', '/chat',
667
+ '/tokens', '/logs', '/settings', '/account', '/users', '/about',
668
+ ]);
669
+ if (method === 'GET' && (HTML_GATED_PATHS.has(path) || path.startsWith('/c') || path.startsWith('/w'))) {
670
+ const gate = await setupGate(ctx.userStore, path);
671
+ if (!gate.allow) {
672
+ // Mint a setup token if none active; either way, include it in
673
+ // the redirect URL so the operator doesn't have to fish it out
674
+ // of the daemon log.
675
+ const token = ctx.setupState.currentToken
676
+ ?? ctx.setupState.mint().token;
677
+ writeRedirect(res, `/setup?token=${encodeURIComponent(token)}`);
678
+ return 'redirect';
679
+ }
680
+ // Users exist — require a valid v2 cookie/bearer for HTML routes.
681
+ const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
682
+ if (!auth.ok) {
683
+ writeRedirect(res, `/login?returnTo=${encodeURIComponent(u.pathname + u.search)}`);
684
+ return 'redirect';
685
+ }
686
+ }
687
+
688
+ return 'allow';
689
+ }
690
+
691
+ /**
692
+ * Auth-aware JSON API routes for the web UI:
693
+ * POST /api/tokens — mint a token for current user
694
+ * DELETE /api/tokens/:tokenId — revoke a token (must own it)
695
+ * PATCH /api/tokens/:tokenId — rename a token (must own it)
696
+ * POST /api/account/passphrase — change own passphrase
697
+ * POST /api/users — admin: create user
698
+ * DELETE /api/users/:username — admin: delete user (not self)
699
+ * PATCH /api/users/:username — admin: toggle admin (not self)
700
+ *
701
+ * All return JSON. Auth required on every endpoint; admin endpoints
702
+ * additionally check the caller's admin flag. Returns true if the
703
+ * request was handled (response written), false to fall through.
704
+ */
705
+ async function tryWebApiRoute(ctx: ApiContext, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
706
+ if (!ctx.userStore || !ctx.tokenStore) return false;
707
+ const { method = 'GET', url = '/' } = req;
708
+ const u = new URL(url, 'http://localhost');
709
+ const path = u.pathname;
710
+
711
+ const isApiPath =
712
+ path === '/api/tokens' || path.startsWith('/api/tokens/') ||
713
+ path === '/api/account' || path === '/api/account/passphrase' ||
714
+ path === '/api/users' || path.startsWith('/api/users/') ||
715
+ path === '/api/workflows' || path.startsWith('/api/workflows/') ||
716
+ path === '/api/logs' || path === '/api/logs/stream' ||
717
+ path === '/api/settings';
718
+ if (!isApiPath) return false;
719
+
720
+ const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
721
+ if (!auth.ok) { writeJson(res, auth.status, auth.body, ctx.version); return true; }
722
+ const me = auth.user;
723
+
724
+ // ── Account read ─────────────────────────────────────────────────
725
+ // GET /api/account → who am I (used by `crosstalk auth whoami` + UI).
726
+ if (method === 'GET' && path === '/api/account') {
727
+ // Don't leak passphraseHash.
728
+ const { passphraseHash: _ph, ...safe } = me;
729
+ void _ph;
730
+ writeJson(res, 200, safe, ctx.version);
731
+ return true;
732
+ }
733
+
734
+ // ── Tokens ────────────────────────────────────────────────────────
735
+ // GET /api/tokens → list current user's tokens (CLI + UI).
736
+ if (method === 'GET' && path === '/api/tokens') {
737
+ const tokens = await ctx.tokenStore.list(me.username);
738
+ writeJson(res, 200, { tokens }, ctx.version);
739
+ return true;
740
+ }
741
+ if (method === 'POST' && path === '/api/tokens') {
742
+ const body = await readJson<{ name?: string }>(req);
743
+ if (typeof body.name !== 'string' || body.name.trim().length === 0) {
744
+ writeJson(res, 400, { error: 'token name required' }, ctx.version);
745
+ return true;
746
+ }
747
+ const r = await ctx.tokenStore.mint({ username: me.username, name: body.name.trim() });
748
+ writeJson(res, 201, { tokenId: r.token.tokenId, plaintextSecret: r.plaintextSecret, createdAt: r.token.createdAt }, ctx.version);
749
+ return true;
750
+ }
751
+ {
752
+ const m = path.match(/^\/api\/tokens\/([^/]+)$/);
753
+ if (m && method === 'DELETE') {
754
+ const tokenId = decodeURIComponent(m[1]!);
755
+ // Operators can only revoke tokens they own. Admin override
756
+ // isn't necessary here — admin can use the CLI / edit the json
757
+ // if they really need to nuke another user's token.
758
+ const tok = await ctx.tokenStore.get(tokenId);
759
+ if (!tok) { writeJson(res, 404, { error: 'token not found' }, ctx.version); return true; }
760
+ if (tok.username !== me.username) {
761
+ writeJson(res, 403, { error: 'cannot revoke a token you do not own' }, ctx.version);
762
+ return true;
763
+ }
764
+ await ctx.tokenStore.revoke(tokenId);
765
+ writeJson(res, 200, { ok: true }, ctx.version);
766
+ return true;
767
+ }
768
+ if (m && method === 'PATCH') {
769
+ const tokenId = decodeURIComponent(m[1]!);
770
+ const tok = await ctx.tokenStore.get(tokenId);
771
+ if (!tok) { writeJson(res, 404, { error: 'token not found' }, ctx.version); return true; }
772
+ if (tok.username !== me.username) {
773
+ writeJson(res, 403, { error: 'cannot rename a token you do not own' }, ctx.version);
774
+ return true;
775
+ }
776
+ const body = await readJson<{ name?: string }>(req);
777
+ if (typeof body.name !== 'string' || body.name.trim().length === 0) {
778
+ writeJson(res, 400, { error: 'token name required' }, ctx.version);
779
+ return true;
780
+ }
781
+ const updated = await ctx.tokenStore.rename(tokenId, body.name.trim());
782
+ writeJson(res, 200, { token: updated }, ctx.version);
783
+ return true;
784
+ }
785
+ }
786
+
787
+ // ── Account: change passphrase ───────────────────────────────────
788
+ if (method === 'POST' && path === '/api/account/passphrase') {
789
+ const body = await readJson<{ oldPassphrase?: string; newPassphrase?: string }>(req);
790
+ if (!body.oldPassphrase || !body.newPassphrase) {
791
+ writeJson(res, 400, { error: 'oldPassphrase + newPassphrase required' }, ctx.version);
792
+ return true;
793
+ }
794
+ const ok = await ctx.userStore.verifyPassphrase(me.username, body.oldPassphrase);
795
+ if (!ok) { writeJson(res, 401, { error: 'current passphrase incorrect' }, ctx.version); return true; }
796
+ try {
797
+ await ctx.userStore.setPassphrase(me.username, body.newPassphrase);
798
+ writeJson(res, 200, { ok: true }, ctx.version);
799
+ } catch (err) {
800
+ writeJson(res, 400, { error: (err as Error).message }, ctx.version);
801
+ }
802
+ return true;
803
+ }
804
+
805
+ // ── Users: admin-gated ────────────────────────────────────────────
806
+ if (path.startsWith('/api/users')) {
807
+ if (!me.admin) { writeJson(res, 403, { error: 'admin only' }, ctx.version); return true; }
808
+
809
+ if (method === 'POST' && path === '/api/users') {
810
+ const body = await readJson<{ username?: string; name?: string; passphrase?: string; admin?: boolean }>(req);
811
+ if (!body.username || !body.name || !body.passphrase) {
812
+ writeJson(res, 400, { error: 'username + name + passphrase required' }, ctx.version);
813
+ return true;
814
+ }
815
+ try {
816
+ const created = await ctx.userStore.createUser({
817
+ username: body.username, name: body.name, passphrase: body.passphrase, admin: body.admin === true,
818
+ });
819
+ // Don't echo the passphrase hash back.
820
+ const { passphraseHash: _ph, ...safe } = created;
821
+ void _ph;
822
+ writeJson(res, 201, safe, ctx.version);
823
+ } catch (err) {
824
+ writeJson(res, 400, { error: (err as Error).message }, ctx.version);
825
+ }
826
+ return true;
827
+ }
828
+
829
+ const m = path.match(/^\/api\/users\/([^/]+)$/);
830
+ if (m) {
831
+ const targetUsername = decodeURIComponent(m[1]!);
832
+ // Self-protection: admins can't delete or demote themselves —
833
+ // prevents the "last admin" footgun where deleting yourself
834
+ // locks the operator pool down with no path back without
835
+ // editing users.json by hand.
836
+ if (targetUsername === me.username) {
837
+ writeJson(res, 400, { error: 'cannot modify your own admin record via this endpoint' }, ctx.version);
838
+ return true;
839
+ }
840
+ if (method === 'DELETE') {
841
+ try {
842
+ await ctx.userStore.deleteUser(targetUsername);
843
+ // Revoke all tokens for the deleted user.
844
+ await ctx.tokenStore.revokeAllForUser(targetUsername);
845
+ writeJson(res, 200, { ok: true }, ctx.version);
846
+ } catch (err) {
847
+ writeJson(res, 404, { error: (err as Error).message }, ctx.version);
848
+ }
849
+ return true;
850
+ }
851
+ if (method === 'PATCH') {
852
+ const body = await readJson<{ admin?: boolean }>(req);
853
+ if (typeof body.admin !== 'boolean') {
854
+ writeJson(res, 400, { error: 'admin (boolean) required' }, ctx.version);
855
+ return true;
856
+ }
857
+ try {
858
+ await ctx.userStore.setAdmin(targetUsername, body.admin);
859
+ writeJson(res, 200, { ok: true }, ctx.version);
860
+ } catch (err) {
861
+ writeJson(res, 404, { error: (err as Error).message }, ctx.version);
862
+ }
863
+ return true;
864
+ }
865
+ }
866
+ }
867
+
868
+ // ── Settings snapshot (JSON, for CLI `settings show`) ───────────
869
+ if (method === 'GET' && path === '/api/settings') {
870
+ const apiPort = Number(process.env['CROSSTALK_API_PORT']) || DEFAULT_API_PORT;
871
+ writeJson(res, 200, {
872
+ alias: ctx.alias,
873
+ version: ctx.version,
874
+ transportRoot: ctx.transportRoot,
875
+ claimedModels: [...ctx.claimed.keys()],
876
+ pollSeconds: null,
877
+ apiPort,
878
+ }, ctx.version);
879
+ return true;
880
+ }
881
+
882
+ // ── Workflow list / detail (JSON, for CLI `workflow status`) ────
883
+ if (method === 'GET' && path === '/api/workflows') {
884
+ writeJson(res, 200, {
885
+ workflows: collectWorkflowSummaries(ctx.transportRoot),
886
+ }, ctx.version);
887
+ return true;
888
+ }
889
+ {
890
+ const m = path.match(/^\/api\/workflows\/([^/]+)$/);
891
+ // The compose / submit endpoints use the same prefix but with
892
+ // literal verbs (compose, submit). Detail lookups use a UUID-shaped
893
+ // child channel id; reject the literal-verb cases so we fall
894
+ // through to the POST handlers below.
895
+ if (m && method === 'GET' && m[1] !== 'compose' && m[1] !== 'submit') {
896
+ const childUuid = decodeURIComponent(m[1]!);
897
+ const detail = collectWorkflowDetail(ctx.transportRoot, childUuid);
898
+ if (!detail) {
899
+ writeJson(res, 404, { error: 'workflow not found' }, ctx.version);
900
+ return true;
901
+ }
902
+ writeJson(res, 200, detail, ctx.version);
903
+ return true;
904
+ }
905
+ }
906
+
907
+ // ── Workflow compose ─────────────────────────────────────────────
908
+ //
909
+ // Operator types a plain-language prompt; engine invokes the compiler
910
+ // agent (defaults to first claimed model) with COMPILE_PROMPT + the
911
+ // prompt as the document. Compiler returns JSON matching WorkflowPlan;
912
+ // we YAML-stringify it for the preview UI. Operator reviews + edits
913
+ // the YAML, then submits it back to /api/workflows/submit which
914
+ // creates the workflow message + writes PLAN.json pre-compiled so the
915
+ // dispatcher skips its own compile step.
916
+ if (method === 'POST' && path === '/api/workflows/compose') {
917
+ const body = await readJson<{ prompt?: string; compilerAgent?: string }>(req);
918
+ if (typeof body.prompt !== 'string' || body.prompt.trim().length === 0) {
919
+ writeJson(res, 400, { error: 'prompt required' }, ctx.version);
920
+ return true;
921
+ }
922
+ // Resolve compiler agent. body.compilerAgent is the qualified model
923
+ // name (<provider>/<model>) as it appears in the claimed map.
924
+ let compiler: ModelEntry | undefined;
925
+ if (body.compilerAgent && ctx.claimed.has(body.compilerAgent)) {
926
+ compiler = ctx.claimed.get(body.compilerAgent);
927
+ } else {
928
+ compiler = ctx.claimed.values().next().value as ModelEntry | undefined;
929
+ }
930
+ if (!compiler) {
931
+ writeJson(res, 409, { error: 'no claimed model available to compile — check /agents' }, ctx.version);
932
+ return true;
933
+ }
934
+ const result = await invokeModelCli(compiler, COMPILE_PROMPT, body.prompt, {});
935
+ if (result.status !== 0) {
936
+ writeJson(res, 502, {
937
+ error: `compiler exit ${result.status}: ${result.stderr.slice(0, 500)}`,
938
+ stdout: result.stdout.slice(0, 800),
939
+ }, ctx.version);
940
+ return true;
941
+ }
942
+ const plan = extractPlanFromOutput(result.stdout);
943
+ if (!plan) {
944
+ writeJson(res, 422, {
945
+ error: 'compiler output did not parse as a workflow plan',
946
+ rawOutput: result.stdout.slice(0, 1500),
947
+ }, ctx.version);
948
+ return true;
949
+ }
950
+ const yaml = yamlStringify(plan, { indent: 2 });
951
+ writeJson(res, 200, { plan, yaml, compiler: compiler.name }, ctx.version);
952
+ return true;
953
+ }
954
+ if (method === 'POST' && path === '/api/workflows/submit') {
955
+ const body = await readJson<{ yaml?: string; channel?: string; prompt?: string }>(req);
956
+ if (typeof body.yaml !== 'string' || body.yaml.length === 0) {
957
+ writeJson(res, 400, { error: 'yaml required' }, ctx.version);
958
+ return true;
959
+ }
960
+ let parsed: unknown;
961
+ try {
962
+ parsed = yamlParse(body.yaml);
963
+ } catch (err) {
964
+ writeJson(res, 400, { error: `yaml parse error: ${(err as Error).message}` }, ctx.version);
965
+ return true;
966
+ }
967
+ if (!validatePlan(parsed)) {
968
+ writeJson(res, 400, { error: 'yaml does not match workflow plan schema (fanout.to/count/body + synthesize.to/body)' }, ctx.version);
969
+ return true;
970
+ }
971
+ const plan = parsed as WorkflowPlan;
972
+ let parentUuid: string;
973
+ try {
974
+ parentUuid = resolveChannelUuid(ctx.transportRoot, body.channel);
975
+ } catch (err) {
976
+ writeJson(res, (err as HttpError).status ?? 400, { error: (err as Error).message }, ctx.version);
977
+ return true;
978
+ }
979
+ // Create child channel + pre-write PLAN.json so the dispatcher skips
980
+ // its own compile (loadPlan returns non-null).
981
+ const childUuid = randomUUID();
982
+ const childDir = join(ctx.transportRoot, 'data', 'channels', childUuid);
983
+ mkdirSync(childDir, { recursive: true });
984
+ writeFileSync(
985
+ join(childDir, 'CHANNEL.md'),
986
+ serializeFrontmatter({ name: `workflow-${Date.now()}`, parent: parentUuid }, ''),
987
+ );
988
+ writeFileSync(
989
+ join(childDir, 'PLAN.json'),
990
+ JSON.stringify(plan, null, 2) + '\n',
991
+ );
992
+ // Marker message body = operator's original prompt (for traceability).
993
+ const markerBody = typeof body.prompt === 'string' && body.prompt.length > 0
994
+ ? body.prompt
995
+ : '(composed via web UI)';
996
+ const heartbeat = readHeartbeat(ctx.transportRoot);
997
+ const dispatchHost = heartbeat?.alias;
998
+ const extraFrontmatter: Record<string, unknown> = { child_channel: childUuid };
999
+ if (dispatchHost) extraFrontmatter['dispatch_host'] = dispatchHost;
1000
+ const relPath = writeMessageFile({
1001
+ transportRoot: ctx.transportRoot,
1002
+ channelUuid: parentUuid,
1003
+ from: me.username,
1004
+ to: 'workflow',
1005
+ body: markerBody,
1006
+ re: [],
1007
+ extraFrontmatter,
1008
+ workflow: true,
1009
+ });
1010
+ const push = gitCommitAndPush(
1011
+ ctx.transportRoot,
1012
+ `run(workflow): ${me.username} child ${childUuid.slice(0, 8)} (composed)`,
1013
+ );
1014
+ sendWakeSignal(ctx.transportRoot);
1015
+ if (!push.committed && push.error) {
1016
+ // Commit itself failed — must surface as 500 to caller.
1017
+ writeJson(res, 500, { error: `commit failed: ${push.error.slice(0, 300)}` }, ctx.version);
1018
+ return true;
1019
+ }
1020
+ // Push failed but commit landed locally — proceed; commits sync
1021
+ // up the next time the remote becomes reachable.
1022
+ writeJson(res, 201, { childUuid, parentUuid, relPath, plan }, ctx.version);
1023
+ return true;
1024
+ }
1025
+
1026
+ // ── Logs ─────────────────────────────────────────────────────────
1027
+ //
1028
+ // GET /api/logs?since=<seq> → JSON snapshot of entries with seq > since
1029
+ // GET /api/logs/stream → SSE stream of new entries as they happen
1030
+ //
1031
+ // Both auth-gated like the rest of /api/*. Engine emits structured
1032
+ // events via dispatch.ts's log() function which tees into the
1033
+ // in-memory ring buffer (log-buffer.ts).
1034
+ if (method === 'GET' && path === '/api/logs') {
1035
+ const since = Number(u.searchParams.get('since') ?? 0);
1036
+ const entries = logBuffer.recent(Number.isFinite(since) ? since : 0);
1037
+ writeJson(res, 200, { entries, currentSeq: logBuffer.currentSeq }, ctx.version);
1038
+ return true;
1039
+ }
1040
+ if (method === 'GET' && path === '/api/logs/stream') {
1041
+ // SSE: keep the connection open + push every new log entry as a
1042
+ // `data: <json>\n\n` frame. Cache-Control: no-cache is critical —
1043
+ // intermediaries that buffer text/event-stream would coalesce
1044
+ // frames and break live tail.
1045
+ res.writeHead(200, {
1046
+ 'Content-Type': 'text/event-stream',
1047
+ 'Cache-Control': 'no-cache, no-transform',
1048
+ 'Connection': 'keep-alive',
1049
+ 'X-Crosstalk-Engine-Version': ctx.version,
1050
+ });
1051
+ // Initial snapshot so the browser has context immediately. The
1052
+ // since= query param replays only newer entries (used when the
1053
+ // client reconnects after a transient disconnect).
1054
+ const sinceParam = Number(u.searchParams.get('since') ?? 0);
1055
+ const since = Number.isFinite(sinceParam) ? sinceParam : 0;
1056
+ for (const entry of logBuffer.recent(since)) {
1057
+ try { res.write(`data: ${JSON.stringify(entry)}\n\n`); } catch { /* socket dead */ }
1058
+ }
1059
+ const writeEntry = (entry: LogEntry): void => {
1060
+ try { res.write(`data: ${JSON.stringify(entry)}\n\n`); }
1061
+ catch { /* socket closing */ }
1062
+ };
1063
+ const unsubscribe = logBuffer.subscribe(writeEntry);
1064
+ // Idle keep-alive every 15s so proxies don't reap the connection.
1065
+ const keepAlive = setInterval(() => {
1066
+ try { res.write(`: keep-alive\n\n`); } catch { /* closed */ }
1067
+ }, 15_000);
1068
+ req.on('close', () => { unsubscribe(); clearInterval(keepAlive); });
1069
+ return true;
1070
+ }
1071
+
1072
+ // Path matched the prefix but no method handler — 405.
1073
+ writeJson(res, 405, { error: `method ${method} not allowed on ${path}` }, ctx.version);
1074
+ return true;
1075
+ }
1076
+
1077
+ /** Try to handle the request as an HTML route. Returns true if handled. */
1078
+ async function tryHtmlRoute(ctx: ApiContext, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
1079
+ const { method = 'GET', url = '/' } = req;
1080
+ if (method !== 'GET') return false;
1081
+ const u = new URL(url, 'http://localhost');
1082
+ const path = u.pathname;
1083
+ const host = hostString();
1084
+
1085
+ if (path === '/') {
1086
+ const hb = readHeartbeat(ctx.transportRoot);
1087
+ const cursor = readCursor(ctx.transportRoot);
1088
+ const channels = allChannelMeta(ctx.transportRoot);
1089
+ writeHtml(res, 200, dashboardPage({
1090
+ host, alias: ctx.alias, version: ctx.version, transportRoot: ctx.transportRoot,
1091
+ heartbeat: hb ? { ts: hb.ts, pid: hb.pid, version: hb.version, alias: hb.alias } : null,
1092
+ cursor: cursor ?? null,
1093
+ claimedModels: [...ctx.claimed.keys()],
1094
+ channels: channels.map(c => ({ uuid: c.uuid, name: c.name, parent: c.parent })),
1095
+ errorsLogged: countErrors(ctx.transportRoot),
1096
+ pendingWork: collectPendingWork(ctx.transportRoot, ctx.claimed),
1097
+ }), ctx.version);
1098
+ return true;
1099
+ }
1100
+
1101
+ if (path === '/c') {
1102
+ const channels = allChannelMeta(ctx.transportRoot);
1103
+ writeHtml(res, 200, channelListPage({
1104
+ host, alias: ctx.alias,
1105
+ channels: channels.map(c => ({ uuid: c.uuid, name: c.name, parent: c.parent })),
1106
+ }), ctx.version);
1107
+ return true;
1108
+ }
1109
+
1110
+ const channelMatch = path.match(/^\/c\/([^/]+)(?:\/(.+))?$/);
1111
+ if (channelMatch) {
1112
+ const handle = decodeURIComponent(channelMatch[1]!);
1113
+ const sub = channelMatch[2];
1114
+ const meta = resolveChannelHandle(ctx.transportRoot, handle);
1115
+ if (!meta) {
1116
+ writeHtml(res, 404, channelListPage({
1117
+ host, alias: ctx.alias,
1118
+ channels: allChannelMeta(ctx.transportRoot).map(c => ({ uuid: c.uuid, name: c.name, parent: c.parent })),
1119
+ }), ctx.version);
1120
+ return true;
1121
+ }
1122
+ if (sub === 'messages.json') {
1123
+ const messages = collectBusMessages(ctx.transportRoot, meta.uuid);
1124
+ writeJson(res, 200, { messages }, ctx.version);
1125
+ return true;
1126
+ }
1127
+ if (sub === undefined) {
1128
+ const messages = collectBusMessages(ctx.transportRoot, meta.uuid);
1129
+ writeHtml(res, 200, channelViewPage({
1130
+ host, alias: ctx.alias,
1131
+ channelHandle: handle, channelUuid: meta.uuid, channelName: meta.name, messages,
1132
+ }), ctx.version);
1133
+ return true;
1134
+ }
1135
+ }
1136
+
1137
+ if (path === '/events') {
1138
+ // Server-Sent Events stream: emits status snapshots whenever the
1139
+ // dispatcher cursor or heartbeat changes. Dashboard subscribes
1140
+ // instead of polling every 5s. Headers per SSE spec; we keep the
1141
+ // connection open and write `data: <json>\n\n` frames.
1142
+ res.writeHead(200, {
1143
+ 'Content-Type': 'text/event-stream',
1144
+ 'Cache-Control': 'no-cache, no-transform',
1145
+ 'Connection': 'keep-alive',
1146
+ 'X-Crosstalk-Engine-Version': ctx.version,
1147
+ });
1148
+ let lastCursor: string | null = readCursor(ctx.transportRoot) ?? null;
1149
+ let lastHb: string | null = readHeartbeat(ctx.transportRoot)?.ts ?? null;
1150
+ const emit = (event: string, data: unknown): void => {
1151
+ try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); }
1152
+ catch { /* socket closed */ }
1153
+ };
1154
+ // Initial snapshot so the client has state immediately.
1155
+ emit('snapshot', {
1156
+ cursor: lastCursor,
1157
+ heartbeat: readHeartbeat(ctx.transportRoot),
1158
+ channels: allChannelMeta(ctx.transportRoot).length,
1159
+ errors: countErrors(ctx.transportRoot),
1160
+ claimedModels: [...ctx.claimed.keys()],
1161
+ });
1162
+ const tick = setInterval(() => {
1163
+ const hb = readHeartbeat(ctx.transportRoot);
1164
+ const cur = readCursor(ctx.transportRoot) ?? null;
1165
+ const hbTs = hb?.ts ?? null;
1166
+ if (cur !== lastCursor || hbTs !== lastHb) {
1167
+ lastCursor = cur; lastHb = hbTs;
1168
+ emit('update', {
1169
+ cursor: cur,
1170
+ heartbeat: hb,
1171
+ channels: allChannelMeta(ctx.transportRoot).length,
1172
+ errors: countErrors(ctx.transportRoot),
1173
+ claimedModels: [...ctx.claimed.keys()],
1174
+ });
1175
+ } else {
1176
+ // Keep-alive comment frame every tick when idle (the spec allows
1177
+ // \`:\` lines; intermediate proxies that buffer SSE will release
1178
+ // them, holding the connection open across long idle windows).
1179
+ try { res.write(`: keep-alive\n\n`); } catch { /* closed */ }
1180
+ }
1181
+ }, 2000);
1182
+ req.on('close', () => { clearInterval(tick); });
1183
+ return true;
1184
+ }
1185
+
1186
+ if (path === '/w') {
1187
+ const channels = allChannelMeta(ctx.transportRoot)
1188
+ .map((c) => ({ uuid: c.uuid, name: c.name }));
1189
+ writeHtml(res, 200, workflowsListPage({
1190
+ host, alias: ctx.alias,
1191
+ workflows: collectWorkflowSummaries(ctx.transportRoot),
1192
+ claimedModels: [...ctx.claimed.keys()],
1193
+ channels,
1194
+ }), ctx.version);
1195
+ return true;
1196
+ }
1197
+ const wMatch = path.match(/^\/w\/([^/]+)$/);
1198
+ if (wMatch) {
1199
+ const childUuid = decodeURIComponent(wMatch[1]!);
1200
+ const detail = collectWorkflowDetail(ctx.transportRoot, childUuid);
1201
+ if (!detail) {
1202
+ writeHtml(res, 404, workflowsListPage({
1203
+ host, alias: ctx.alias,
1204
+ workflows: collectWorkflowSummaries(ctx.transportRoot),
1205
+ claimedModels: [...ctx.claimed.keys()],
1206
+ channels: allChannelMeta(ctx.transportRoot).map((c) => ({ uuid: c.uuid, name: c.name })),
1207
+ }), ctx.version);
1208
+ return true;
1209
+ }
1210
+ writeHtml(res, 200, workflowDetailPage({ host, alias: ctx.alias, ...detail }), ctx.version);
1211
+ return true;
1212
+ }
1213
+
1214
+ if (path === '/agents') {
1215
+ const installed = KNOWN_AGENT_BINARIES.filter((bin) => isOnPath(bin));
1216
+ const yamlReferenced = [...new Set([...ctx.registry.all.values()].map((e) => e.cli))];
1217
+ const claimed = [...new Set([...ctx.claimed.values()].map((e) => e.cli))];
1218
+ writeHtml(res, 200, agentsPage({
1219
+ host, alias: ctx.alias, installed, known: KNOWN_AGENT_BINARIES, yamlReferenced, claimed,
1220
+ }), ctx.version);
1221
+ return true;
1222
+ }
1223
+
1224
+ if (path === '/chat') {
1225
+ const installed = KNOWN_AGENT_BINARIES.filter((bin) => isOnPath(bin));
1226
+ writeHtml(res, 200, chatPage({
1227
+ host, alias: ctx.alias, installedAgents: installed, version: ctx.version,
1228
+ home: homedir(),
1229
+ }), ctx.version);
1230
+ return true;
1231
+ }
1232
+
1233
+ // Auth-aware stub pages. authenticateRequest is a no-op (returns
1234
+ // notOk) when the stores aren't configured — the gate above already
1235
+ // short-circuited unauthed access, so by the time we get here we
1236
+ // know the user is authed in normal operation.
1237
+ if (path === '/account') {
1238
+ let user = null;
1239
+ if (ctx.userStore && ctx.tokenStore) {
1240
+ const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
1241
+ if (auth.ok) user = auth.user;
1242
+ }
1243
+ writeHtml(res, 200, accountPage({ host, alias: ctx.alias, user }), ctx.version);
1244
+ return true;
1245
+ }
1246
+ if (path === '/users') {
1247
+ let users: import('./auth/users.js').User[] = [];
1248
+ let isAdmin = false;
1249
+ let viewerUsername: string | null = null;
1250
+ if (ctx.userStore && ctx.tokenStore) {
1251
+ const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
1252
+ if (auth.ok) { isAdmin = auth.user.admin; viewerUsername = auth.user.username; }
1253
+ if (isAdmin) users = await ctx.userStore.listUsers();
1254
+ }
1255
+ writeHtml(res, 200, usersPage({ host, alias: ctx.alias, users, viewerIsAdmin: isAdmin, viewerUsername }), ctx.version);
1256
+ return true;
1257
+ }
1258
+ if (path === '/tokens') {
1259
+ let tokens: import('./auth/tokens.js').IdentityToken[] = [];
1260
+ let viewerUsername: string | null = null;
1261
+ if (ctx.userStore && ctx.tokenStore) {
1262
+ const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
1263
+ if (auth.ok) {
1264
+ viewerUsername = auth.user.username;
1265
+ tokens = await ctx.tokenStore.list(auth.user.username);
1266
+ }
1267
+ }
1268
+ writeHtml(res, 200, tokensPage({ host, alias: ctx.alias, tokens, viewerUsername }), ctx.version);
1269
+ return true;
1270
+ }
1271
+ if (path === '/logs') {
1272
+ writeHtml(res, 200, logsPage({
1273
+ host, alias: ctx.alias, errorsLogged: countErrors(ctx.transportRoot),
1274
+ transportRoot: ctx.transportRoot,
1275
+ }), ctx.version);
1276
+ return true;
1277
+ }
1278
+ if (path === '/settings') {
1279
+ const port = Number(process.env['CROSSTALK_API_PORT']) || DEFAULT_API_PORT;
1280
+ writeHtml(res, 200, settingsPage({
1281
+ host, alias: ctx.alias, version: ctx.version, transportRoot: ctx.transportRoot,
1282
+ claimedModels: [...ctx.claimed.keys()],
1283
+ pollSeconds: null, // dispatcher cadence isn't on ctx yet; nullable so the UI shows '—'
1284
+ apiPort: port,
1285
+ }), ctx.version);
1286
+ return true;
1287
+ }
1288
+ if (path === '/about') {
1289
+ writeHtml(res, 200, aboutPage({
1290
+ host, alias: ctx.alias, version: ctx.version, transportRoot: ctx.transportRoot,
1291
+ }), ctx.version);
1292
+ return true;
1293
+ }
1294
+
1295
+ return false;
1296
+ }
1297
+
1298
+ /** Build a summary row for every workflow marker (open or complete) on the bus. */
1299
+ function collectWorkflowSummaries(transportRoot: string): WorkflowSummary[] {
1300
+ const channels = discoverChannels(transportRoot);
1301
+ const out: WorkflowSummary[] = [];
1302
+ for (const parentUuid of channels) {
1303
+ const parentMeta = readChannelMeta(transportRoot, parentUuid);
1304
+ const parentMessages = listChannelMessages(transportRoot, parentUuid);
1305
+ for (const m of parentMessages) {
1306
+ if (m.data['type'] !== 'workflow') continue;
1307
+ const childUuid = m.data['child_channel'];
1308
+ if (typeof childUuid !== 'string') continue;
1309
+ out.push(buildWorkflowSummary(transportRoot, parentMeta, childUuid, m));
1310
+ }
1311
+ }
1312
+ // Newest workflow markers first.
1313
+ out.sort((a, b) => (b.markerTimestamp || '').localeCompare(a.markerTimestamp || ''));
1314
+ return out;
1315
+ }
1316
+
1317
+ function buildWorkflowSummary(
1318
+ transportRoot: string,
1319
+ parentMeta: ChannelMeta,
1320
+ childUuid: string,
1321
+ marker: { data: Record<string, unknown>; relPath: string; body: string },
1322
+ ): WorkflowSummary {
1323
+ const completeFile = join(transportRoot, 'data', 'channels', childUuid, 'COMPLETE');
1324
+ const planFile = join(transportRoot, 'data', 'channels', childUuid, 'PLAN.json');
1325
+ const isComplete = existsSync(completeFile);
1326
+ const childMessages = existsSync(join(transportRoot, 'data', 'channels', childUuid))
1327
+ ? listChannelMessages(transportRoot, childUuid)
1328
+ : [];
1329
+
1330
+ // Count fanout dispatches + their replies.
1331
+ const fanoutDispatches = childMessages.filter((m) => m.data['workflow_phase'] === 'fanout');
1332
+ const fanoutDispatchPaths = new Set(fanoutDispatches.map((m) => m.relPath));
1333
+ const fanoutReplies = childMessages.filter((m) => {
1334
+ const re = m.data['re'];
1335
+ const list = Array.isArray(re) ? re : (typeof re === 'string' ? [re] : []);
1336
+ return list.some((p) => fanoutDispatchPaths.has(p));
1337
+ });
1338
+
1339
+ // Failed: any fanout or synthesis reply with failed:true, OR no plan after long enough.
1340
+ const anyFailedReply = childMessages.some((m) => m.data['failed'] === true);
1341
+
1342
+ let phase: WorkflowSummary['phase'];
1343
+ if (isComplete) phase = 'complete';
1344
+ else if (anyFailedReply) phase = 'failed';
1345
+ else if (!existsSync(planFile)) phase = 'pending_compile';
1346
+ else {
1347
+ // Plan exists. Check if synthesis phase has started.
1348
+ const synth = childMessages.find((m) => m.data['workflow_phase'] === 'synthesize');
1349
+ phase = synth ? 'synthesize' : 'fanout';
1350
+ }
1351
+
1352
+ const dispatchHost = typeof marker.data['dispatch_host'] === 'string'
1353
+ ? (marker.data['dispatch_host'] as string)
1354
+ : null;
1355
+
1356
+ return {
1357
+ childChannelUuid: childUuid,
1358
+ parentChannelUuid: parentMeta.uuid,
1359
+ parentChannelName: parentMeta.name,
1360
+ markerFrom: String(marker.data['from'] ?? 'unknown'),
1361
+ markerTimestamp: String(marker.data['timestamp'] ?? ''),
1362
+ phase,
1363
+ fanoutTotal: fanoutDispatches.length,
1364
+ fanoutReplied: fanoutReplies.length,
1365
+ dispatchHost,
1366
+ };
1367
+ }
1368
+
1369
+ /** Full detail for one workflow. Returns null if no matching marker found. */
1370
+ function collectWorkflowDetail(transportRoot: string, childUuid: string):
1371
+ Omit<Parameters<typeof workflowDetailPage>[0], 'host' | 'alias'> | null {
1372
+ const channels = discoverChannels(transportRoot);
1373
+ // Find the parent — the channel that contains the workflow marker
1374
+ // pointing at this child uuid.
1375
+ let markerData: Record<string, unknown> | null = null;
1376
+ let markerBody = '';
1377
+ let markerRelPath = '';
1378
+ let parentMeta: ChannelMeta | null = null;
1379
+ for (const parentUuid of channels) {
1380
+ const parentMessages = listChannelMessages(transportRoot, parentUuid);
1381
+ for (const m of parentMessages) {
1382
+ if (m.data['type'] === 'workflow' && m.data['child_channel'] === childUuid) {
1383
+ markerData = m.data;
1384
+ markerBody = m.body;
1385
+ markerRelPath = m.relPath;
1386
+ parentMeta = readChannelMeta(transportRoot, parentUuid);
1387
+ break;
1388
+ }
1389
+ }
1390
+ if (markerData) break;
1391
+ }
1392
+ if (!markerData || !parentMeta) return null;
1393
+
1394
+ const planFile = join(transportRoot, 'data', 'channels', childUuid, 'PLAN.json');
1395
+ let plan: unknown = null;
1396
+ if (existsSync(planFile)) {
1397
+ try {
1398
+ const parsed = JSON.parse(readFileSync(planFile, 'utf-8')) as unknown;
1399
+ plan = validatePlan(parsed) ? parsed : { invalid: true, raw: parsed };
1400
+ } catch { plan = null; }
1401
+ }
1402
+
1403
+ const childMessages = existsSync(join(transportRoot, 'data', 'channels', childUuid))
1404
+ ? listChannelMessages(transportRoot, childUuid)
1405
+ : [];
1406
+
1407
+ const fanoutDispatches = childMessages
1408
+ .filter((m) => m.data['workflow_phase'] === 'fanout')
1409
+ .map((m) => ({
1410
+ relPath: m.relPath,
1411
+ to: Array.isArray(m.data['to']) ? (m.data['to'] as string[]).join(',') : String(m.data['to'] ?? ''),
1412
+ body: m.body,
1413
+ }));
1414
+ const fanoutDispatchPaths = new Set(fanoutDispatches.map((d) => d.relPath));
1415
+ const fanoutReplies = childMessages
1416
+ .filter((m) => {
1417
+ const re = m.data['re'];
1418
+ const list = Array.isArray(re) ? re : (typeof re === 'string' ? [re] : []);
1419
+ return list.some((p) => fanoutDispatchPaths.has(p));
1420
+ })
1421
+ .map((m) => ({
1422
+ relPath: m.relPath,
1423
+ from: String(m.data['from'] ?? 'unknown'),
1424
+ re: (m.data['re'] as string | string[]) ?? '',
1425
+ body: m.body,
1426
+ failed: m.data['failed'] === true,
1427
+ }));
1428
+
1429
+ const synthDispatchMsg = childMessages.find((m) => m.data['workflow_phase'] === 'synthesize');
1430
+ const synthesisDispatch = synthDispatchMsg
1431
+ ? {
1432
+ relPath: synthDispatchMsg.relPath,
1433
+ to: Array.isArray(synthDispatchMsg.data['to']) ? (synthDispatchMsg.data['to'] as string[]).join(',') : String(synthDispatchMsg.data['to'] ?? ''),
1434
+ body: synthDispatchMsg.body,
1435
+ }
1436
+ : null;
1437
+ const synthesisReplyMsg = synthDispatchMsg
1438
+ ? childMessages.find((m) => {
1439
+ const re = m.data['re'];
1440
+ const list = Array.isArray(re) ? re : (typeof re === 'string' ? [re] : []);
1441
+ return list.includes(synthDispatchMsg.relPath);
1442
+ })
1443
+ : undefined;
1444
+ const synthesisReply = synthesisReplyMsg
1445
+ ? {
1446
+ relPath: synthesisReplyMsg.relPath,
1447
+ from: String(synthesisReplyMsg.data['from'] ?? 'unknown'),
1448
+ body: synthesisReplyMsg.body,
1449
+ failed: synthesisReplyMsg.data['failed'] === true,
1450
+ }
1451
+ : null;
1452
+
1453
+ const completeFile = join(transportRoot, 'data', 'channels', childUuid, 'COMPLETE');
1454
+ const isComplete = existsSync(completeFile);
1455
+ const anyFailed = childMessages.some((m) => m.data['failed'] === true);
1456
+ let phase: WorkflowSummary['phase'];
1457
+ if (isComplete) phase = 'complete';
1458
+ else if (anyFailed) phase = 'failed';
1459
+ else if (!existsSync(planFile)) phase = 'pending_compile';
1460
+ else if (synthDispatchMsg) phase = 'synthesize';
1461
+ else phase = 'fanout';
1462
+
1463
+ // Reference markerRelPath so unused-var checks pass; also useful for
1464
+ // future "open the marker message" deep-links from the detail page.
1465
+ void markerRelPath;
1466
+
1467
+ return {
1468
+ childUuid,
1469
+ parentChannelUuid: parentMeta.uuid,
1470
+ parentChannelName: parentMeta.name,
1471
+ markerFrom: String(markerData['from'] ?? 'unknown'),
1472
+ markerTimestamp: String(markerData['timestamp'] ?? ''),
1473
+ markerBody,
1474
+ plan,
1475
+ phase,
1476
+ fanoutDispatches,
1477
+ fanoutReplies,
1478
+ synthesisDispatch,
1479
+ synthesisReply,
1480
+ dispatchHost: typeof markerData['dispatch_host'] === 'string' ? (markerData['dispatch_host'] as string) : null,
1481
+ };
1482
+ }
1483
+
1484
+ /**
1485
+ * Walk every channel and count messages with no reply pointing at
1486
+ * them, grouped by recipient. Powers the dashboard's "pending work"
1487
+ * card — gives operators an at-a-glance view of what the dispatcher
1488
+ * will be picking up on its next tick(s).
1489
+ *
1490
+ * O(N) over all messages on the bus; cheap for the typical capstone-
1491
+ * scale transport, would need indexing if we ever push past ~10k.
1492
+ */
1493
+ function collectPendingWork(
1494
+ transportRoot: string,
1495
+ claimed: Map<string, ModelEntry>,
1496
+ ): { to: string; count: number; channelLabels: string[] }[] {
1497
+ const channels = discoverChannels(transportRoot);
1498
+ const acc = new Map<string, { count: number; channels: Set<string> }>();
1499
+ // A recipient is dispatcher-actionable if some claimed model name
1500
+ // matches the actor portion (before '@'). Anything else (humans,
1501
+ // unclaimed models, agents on other hosts) won't be picked up by
1502
+ // this dispatcher's next tick, so it shouldn't count as pending.
1503
+ const claimedActors = new Set([...claimed.keys()]);
1504
+ function isClaimable(recipient: string): boolean {
1505
+ if (recipient === 'all') return true;
1506
+ const at = recipient.indexOf('@');
1507
+ const actor = at === -1 ? recipient : recipient.slice(0, at);
1508
+ return claimedActors.has(actor);
1509
+ }
1510
+ for (const channelUuid of channels) {
1511
+ const meta = readChannelMeta(transportRoot, channelUuid);
1512
+ const label = meta.name ?? channelUuid.slice(0, 8) + '…';
1513
+ const messages = listChannelMessages(transportRoot, channelUuid);
1514
+ const replied = new Set<string>();
1515
+ for (const m of messages) {
1516
+ const re = m.data['re'];
1517
+ const list = Array.isArray(re) ? re : (typeof re === 'string' ? [re] : []);
1518
+ for (const t of list) replied.add(t as string);
1519
+ }
1520
+ for (const m of messages) {
1521
+ // Skip workflow markers — they live in the Workflows surface.
1522
+ if (m.data['type'] === 'workflow') continue;
1523
+ // Skip messages internal to a workflow run. Once a fanout
1524
+ // dispatch is sent, its reply is consumed by the workflow
1525
+ // runtime advancing phase — not by another message being
1526
+ // posted in reply. Counting them as pending overstates the
1527
+ // dispatcher's actual queue.
1528
+ if (typeof m.data['workflow_phase'] === 'string') continue;
1529
+ // Skip messages SENT BY the workflow runtime (workflow@<alias>)
1530
+ // — same reason; runtime-managed, not bus-pending.
1531
+ const from = String(m.data['from'] ?? '');
1532
+ if (from.startsWith('workflow@')) continue;
1533
+ if (replied.has(m.relPath)) continue;
1534
+ const to = m.data['to'];
1535
+ const recipients = Array.isArray(to) ? to : (typeof to === 'string' ? [to] : []);
1536
+ for (const r of recipients) {
1537
+ const recipient = String(r);
1538
+ // Skip recipients this dispatcher can't service. Humans
1539
+ // (named users), unclaimed models, or models claimed on a
1540
+ // different host — none of these will be picked up.
1541
+ if (!isClaimable(recipient)) continue;
1542
+ if (!acc.has(recipient)) acc.set(recipient, { count: 0, channels: new Set() });
1543
+ const e = acc.get(recipient)!;
1544
+ e.count++;
1545
+ e.channels.add(label);
1546
+ }
1547
+ }
1548
+ }
1549
+ return Array.from(acc.entries())
1550
+ .map(([to, e]) => ({ to, count: e.count, channelLabels: Array.from(e.channels) }))
1551
+ .sort((a, b) => b.count - a.count);
1552
+ }
1553
+
1554
+ /** Pull all bus messages for a channel into the shape the web UI consumes. */
1555
+ function collectBusMessages(transportRoot: string, channelUuid: string): BusMessage[] {
1556
+ const raw = listChannelMessages(transportRoot, channelUuid);
1557
+ return raw.map((m) => ({
1558
+ relPath: m.relPath,
1559
+ from: String(m.data['from'] ?? ''),
1560
+ to: (m.data['to'] as string | string[]) ?? '',
1561
+ re: (m.data['re'] as string | string[] | null) ?? null,
1562
+ body: m.body,
1563
+ timestamp: String(m.data['timestamp'] ?? ''),
1564
+ failed: m.data['failed'] === true,
1565
+ }));
1566
+ }
1567
+
1568
+ // ── router ───────────────────────────────────────────────────────────
1569
+
1570
+ async function route(ctx: ApiContext, req: IncomingMessage): Promise<JsonRes> {
1571
+ const { method = 'GET', url = '/' } = req;
1572
+ const u = new URL(url, 'http://localhost');
1573
+ const path = u.pathname;
1574
+
1575
+ if (method === 'GET' && path === '/healthz') return handleHealthz(ctx);
1576
+ if (method === 'GET' && path === '/version') return handleVersion(ctx);
1577
+ if (method === 'GET' && path === '/status') return handleStatus(ctx);
1578
+ if (method === 'GET' && path === '/agents/installed') return handleAgentsInstalled(ctx);
1579
+ if (method === 'GET' && path === '/channels') return handleListChannels(ctx);
1580
+ if (method === 'POST' && path === '/channels') {
1581
+ const body = await readJson<CreateChannelReq>(req);
1582
+ return handleCreateChannel(ctx, body);
1583
+ }
1584
+ // PATCH /channels/<handle> — rename
1585
+ // DELETE /channels/<handle>?confirm=<typed-name> — delete
1586
+ {
1587
+ const m = path.match(/^\/channels\/([^/]+)$/);
1588
+ if (m) {
1589
+ const handle = decodeURIComponent(m[1]!);
1590
+ if (method === 'PATCH') {
1591
+ const body = await readJson<RenameChannelReq>(req);
1592
+ return handleRenameChannel(ctx, handle, body);
1593
+ }
1594
+ if (method === 'DELETE') {
1595
+ return handleDeleteChannel(ctx, handle, u.searchParams.get('confirm'));
1596
+ }
1597
+ }
1598
+ }
1599
+ if (method === 'POST' && path === '/messages') {
1600
+ const body = await readJson<PostMessageReq>(req);
1601
+ return handlePostMessage(ctx, body);
1602
+ }
1603
+ if (method === 'GET' && path === '/replies') {
1604
+ const targets = u.searchParams.get('relPaths') ?? '';
1605
+ return handleReplies(ctx, targets);
1606
+ }
1607
+ return { status: 404, body: { error: `no route for ${method} ${path}` } };
1608
+ }
1609
+
1610
+ // ── server lifecycle ─────────────────────────────────────────────────
1611
+
1612
+ export const DEFAULT_API_PORT = 7000;
1613
+
1614
+ export interface StartApiOptions {
1615
+ port?: number;
1616
+ log: (event: string, fields?: Record<string, unknown>) => void;
1617
+ }
1618
+
1619
+ export function startApi(ctx: ApiContext, opts: StartApiOptions): Server {
1620
+ const port = opts.port ?? (Number(process.env['CROSSTALK_API_PORT']) || DEFAULT_API_PORT);
1621
+ // Bind address resolution:
1622
+ // - Native (default): 127.0.0.1 — engine running as a host process
1623
+ // should not expose the API to the network. Localhost only.
1624
+ // - Container: 0.0.0.0 (set via ENV CROSSTALK_API_BIND in
1625
+ // the Dockerfile) — Docker's bridge networking means "127.0.0.1
1626
+ // inside the container" can't be reached via the daemon's port
1627
+ // forwarder. The actual security boundary moves up to compose's
1628
+ // port mapping (`127.0.0.1:HOST:CONTAINER`), which restricts
1629
+ // reachability to host loopback only.
1630
+ //
1631
+ // The engine bind address is therefore env-controlled, BUT operators
1632
+ // never need to set it manually — the container image does it.
1633
+ const bind = process.env['CROSSTALK_API_BIND'] ?? '127.0.0.1';
1634
+ const server = createServer(async (req, res) => {
1635
+ try {
1636
+ // v8 auth gate for web routes. Either handles the request fully
1637
+ // (setup wizard, login, logout, redirect to /setup or /login),
1638
+ // or returns 'allow' to continue to the HTML / JSON routers.
1639
+ const authOutcome = await tryWebAuthFlow(ctx, req, res);
1640
+ if (authOutcome === 'handled' || authOutcome === 'redirect') return;
1641
+
1642
+ // Web-side JSON API (token mgmt, account passphrase, user
1643
+ // admin). Runs before tryHtmlRoute since paths start with /api.
1644
+ if (await tryWebApiRoute(ctx, req, res)) return;
1645
+
1646
+ // Try HTML routes first (/, /c, /c/<handle>, /agents). The HTML
1647
+ // surface is GET-only and lives at paths distinct from the JSON
1648
+ // API, so no conflicts. If no HTML route matches, fall through to
1649
+ // the JSON router (which handles the existing API surface).
1650
+ if (await tryHtmlRoute(ctx, req, res)) return;
1651
+
1652
+ // v8 operational-API auth gate. The historical "if you're on the
1653
+ // host you're trusted" model is replaced by uniform bearer-token
1654
+ // auth — CLI commands authenticate the same way the web UI does.
1655
+ // /healthz and /version stay open as monitoring probes (standard
1656
+ // pattern — kubelet/loadbalancer needs to ping them).
1657
+ if (ctx.userStore && ctx.tokenStore) {
1658
+ const u = new URL(req.url ?? '/', 'http://localhost');
1659
+ const path = u.pathname;
1660
+ const isExempt = path === '/healthz' || path === '/version';
1661
+ if (!isExempt) {
1662
+ // If setup hasn't run yet, return a clear "setup needed"
1663
+ // response with the URL — operators see this immediately
1664
+ // when their first CLI call fails after engine boot.
1665
+ const setup = await setupGate(ctx.userStore, path);
1666
+ if (!setup.allow) {
1667
+ writeJson(res, setup.status ?? 503, setup.body ?? { error: 'setup needed' }, ctx.version);
1668
+ return;
1669
+ }
1670
+ const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
1671
+ if (!auth.ok) {
1672
+ // Body includes a hint operators can act on: run
1673
+ // 'crosstalk auth login' to bootstrap a token.
1674
+ writeJson(res, auth.status, {
1675
+ ...(auth.body as Record<string, unknown>),
1676
+ hint: "run 'crosstalk auth login' to obtain a token",
1677
+ }, ctx.version);
1678
+ return;
1679
+ }
1680
+ }
1681
+ }
1682
+
1683
+ const result = await route(ctx, req);
1684
+ writeJson(res, result.status, result.body, ctx.version);
1685
+ } catch (err) {
1686
+ if (err instanceof HttpError) {
1687
+ writeJson(res, err.status, { error: err.message }, ctx.version);
1688
+ } else {
1689
+ const msg = (err as Error).message;
1690
+ opts.log('api_handler_crash', { error: msg.slice(0, 300) });
1691
+ writeJson(res, 500, { error: `internal error: ${msg.slice(0, 200)}` }, ctx.version);
1692
+ }
1693
+ }
1694
+ });
1695
+ // Mount the chat pty WS bridge. Same auth model as the HTML routes —
1696
+ // if user/token stores are configured, the WS upgrade is gated by a
1697
+ // valid crosstalk_session cookie (or bearer token). Without auth
1698
+ // stores (legacy/loopback-only mode), the WS is open since the bind
1699
+ // address is the security boundary.
1700
+ mountChatPty(server, {
1701
+ authorize: ctx.userStore && ctx.tokenStore
1702
+ ? async (req) => {
1703
+ const auth = await authenticateRequest(req, {
1704
+ tokenStore: ctx.tokenStore!,
1705
+ userStore: ctx.userStore!,
1706
+ });
1707
+ return auth.ok;
1708
+ }
1709
+ : undefined,
1710
+ });
1711
+
1712
+ server.listen(port, bind, () => {
1713
+ opts.log('api_listening', { host: bind, port });
1714
+ });
1715
+ return server;
1716
+ }