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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/GUIDE-CLI.md +322 -0
  2. package/GUIDE-PROMPTS.md +118 -0
  3. package/LICENSE +21 -0
  4. package/README.md +402 -61
  5. package/bin/crosstalk.js +88 -54
  6. package/commands/agent.js +69 -0
  7. package/commands/auth.js +273 -0
  8. package/commands/channel.js +54 -44
  9. package/commands/chat.js +107 -71
  10. package/commands/daemon.js +120 -0
  11. package/commands/logs.js +108 -19
  12. package/commands/message.js +125 -0
  13. package/commands/server.js +153 -0
  14. package/commands/settings.js +49 -0
  15. package/commands/status.js +37 -13
  16. package/commands/token.js +136 -0
  17. package/commands/transport.js +270 -0
  18. package/commands/version.js +3 -3
  19. package/commands/workflow.js +234 -0
  20. package/deploy/crosstalk@.service +62 -0
  21. package/deploy/install.sh +82 -0
  22. package/lib/api-client.js +77 -22
  23. package/lib/credentials.js +207 -0
  24. package/lib/nativeServer.js +173 -0
  25. package/lib/resolve.js +101 -34
  26. package/package.json +27 -4
  27. package/src/activation.ts +104 -0
  28. package/src/api.ts +1716 -0
  29. package/src/auth/enforce.ts +68 -0
  30. package/src/auth/handlers.ts +266 -0
  31. package/src/auth/middleware.ts +132 -0
  32. package/src/auth/setup.ts +263 -0
  33. package/src/auth/tokens.ts +285 -0
  34. package/src/auth/users.ts +267 -0
  35. package/src/dispatch.ts +492 -0
  36. package/src/dispatchers.ts +91 -0
  37. package/src/filenames.ts +28 -0
  38. package/src/frontmatter.ts +26 -0
  39. package/src/init.ts +116 -0
  40. package/src/invoke.ts +201 -0
  41. package/src/log-buffer.ts +67 -0
  42. package/src/models.ts +283 -0
  43. package/src/resolve.ts +100 -0
  44. package/src/state.ts +190 -0
  45. package/src/stop.ts +37 -0
  46. package/src/transport.ts +243 -0
  47. package/src/web/auth-pages.ts +160 -0
  48. package/src/web/channels.ts +395 -0
  49. package/src/web/chat-page.ts +636 -0
  50. package/src/web/chat-pty.ts +254 -0
  51. package/src/web/dashboard.ts +129 -0
  52. package/src/web/layout.ts +237 -0
  53. package/src/web/stubs.ts +510 -0
  54. package/src/web/workflows.ts +490 -0
  55. package/src/workflow.ts +470 -0
  56. package/template/CLAUDE.md +10 -0
  57. package/template/CROSSTALK-VERSION +1 -0
  58. package/template/CROSSTALK.md +262 -0
  59. package/template/PROTOCOL.md +70 -0
  60. package/template/README.md +64 -0
  61. package/template/auth/.gitkeep +0 -0
  62. package/template/auth/README.md +224 -0
  63. package/template/data/crosstalk.yaml +196 -0
  64. package/template/gitignore +4 -0
  65. package/commands/down.js +0 -40
  66. package/commands/init.js +0 -243
  67. package/commands/pull.js +0 -22
  68. package/commands/replies.js +0 -40
  69. package/commands/restart.js +0 -29
  70. package/commands/rm.js +0 -109
  71. package/commands/run.js +0 -115
  72. package/commands/up.js +0 -135
@@ -0,0 +1,492 @@
1
+ // crosstalk daemon dispatch — the engine main loop.
2
+ //
3
+ // Tick: pull → scan channels for messages past the global cursor → for each
4
+ // addressed message that wakes a claimed model, invoke the model CLI →
5
+ // write reply (success body or failed:true + error) → commit + push.
6
+ //
7
+ // v7 changes vs v6 dispatch:
8
+ // - No host file. Machine identity is the --alias flag; claimed models
9
+ // come from data/crosstalk.yaml + PATH self-selection (models.ts).
10
+ // - Single global cursor (state.ts), not per-actor-per-channel.
11
+ // - No DLQ. Failures land in the channel as normal messages with
12
+ // failed:true + error: in frontmatter — wake-loud.
13
+ // - No turnq. Git rebase-retry is the correctness mechanism; turnq was
14
+ // a churn-reducer that didn't earn its weight.
15
+ // - No host-file commit / waterline logic. Cursors seed to HEAD on first
16
+ // boot; pre-join history is ignored (same effect as v6 waterline).
17
+
18
+ import { resolve, join, dirname } from 'path';
19
+ import { readFileSync, existsSync, appendFileSync } from 'fs';
20
+ import { watch } from 'fs/promises';
21
+ import { fileURLToPath } from 'url';
22
+
23
+ import { loadRegistry, type ModelEntry, type ModelsRegistry } from './models.js';
24
+ import {
25
+ discoverChannels,
26
+ listChannelMessages,
27
+ gitPull,
28
+ gitCommitAndPush,
29
+ cursorBaseline,
30
+ newFilesSince,
31
+ type ChannelMessage,
32
+ } from './transport.js';
33
+ import {
34
+ stateDir,
35
+ readCursor,
36
+ writeCursor,
37
+ writeHeartbeat,
38
+ writePidfile,
39
+ removePidfile,
40
+ logError,
41
+ } from './state.js';
42
+ import {
43
+ recipients,
44
+ reList,
45
+ extractActor,
46
+ decideWake,
47
+ } from './activation.js';
48
+ import {
49
+ loadProtocolPrompt,
50
+ loadActorPersona,
51
+ composeSystemPrompt,
52
+ invokeModelCli,
53
+ formatBatchedUserMessage,
54
+ messageSender,
55
+ writeReply,
56
+ } from './invoke.js';
57
+ import { workflowTick } from './workflow.js';
58
+ import { writeRegistryEntry, removeRegistryEntry } from './dispatchers.js';
59
+ import { startApi } from './api.js';
60
+ import { logBuffer } from './log-buffer.js';
61
+ import { FileUserStore } from './auth/users.js';
62
+ import { FileTokenStore } from './auth/tokens.js';
63
+ import { SetupState } from './auth/setup.js';
64
+ import type { Server as HttpServer } from 'http';
65
+
66
+ const RUNTIME_VERSION: string = (() => {
67
+ try {
68
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
69
+ return (JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string }).version ?? 'unknown';
70
+ } catch {
71
+ return 'unknown';
72
+ }
73
+ })();
74
+
75
+ const transportRoot = resolve(process.cwd());
76
+ const argv = process.argv.slice(2);
77
+
78
+ function flag(name: string): string | undefined {
79
+ const i = argv.indexOf(name);
80
+ if (i === -1 || i === argv.length - 1) return undefined;
81
+ return argv[i + 1];
82
+ }
83
+
84
+ const alias = flag('--alias');
85
+ const onceMode = argv.includes('--once');
86
+ const jsonMode = argv.includes('--json');
87
+ const pollSeconds = Number(flag('--poll')) || 30;
88
+ const logFile = flag('--log-file');
89
+
90
+ if (!alias) {
91
+ console.error('crosstalk daemon dispatch: --alias <name> is required (machine identity in the bus).');
92
+ process.exit(1);
93
+ }
94
+
95
+ function log(event: string, fields: Record<string, unknown> = {}): void {
96
+ let line: string;
97
+ if (jsonMode) {
98
+ line = JSON.stringify({ ts: new Date().toISOString(), event, ...fields });
99
+ } else {
100
+ const tail = Object.entries(fields)
101
+ .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
102
+ .join(' ');
103
+ line = `[${new Date().toISOString()}] ${event}${tail ? ' ' + tail : ''}`;
104
+ }
105
+ console.log(line);
106
+ if (logFile) {
107
+ try { appendFileSync(logFile, line + '\n'); } catch { /* best-effort */ }
108
+ }
109
+ // Tee into the in-memory ring buffer so the /logs web page + the
110
+ // /api/logs/stream SSE endpoint can serve a live tail without
111
+ // re-parsing stdout from another process.
112
+ logBuffer.push(event, fields);
113
+ }
114
+
115
+ // One pending dispatch = one message → one CLI invocation → one reply.
116
+ // v6 grouped by (channel, model) into batches; v7 doesn't (see V7-SPEC
117
+ // post-M2 fanout note). Each fanout sibling, each operator-piled-up
118
+ // request, each fan-in reply gets its own invocation in parallel — N
119
+ // model calls, N replies, fanout works as designed.
120
+ interface PendingDispatch {
121
+ channelUuid: string;
122
+ modelName: string;
123
+ model: ModelEntry;
124
+ msg: ChannelMessage;
125
+ // Persona resolved by dispatchTick: prefers msg.as, falls back to the
126
+ // re:'d message's wake_as (workflow persona continuity).
127
+ invokeAs?: string;
128
+ }
129
+
130
+ async function dispatchOne(d: PendingDispatch, protocolPrompt: string): Promise<boolean> {
131
+ const childChannel = typeof d.msg.data['child_channel'] === 'string'
132
+ ? (d.msg.data['child_channel'] as string)
133
+ : undefined;
134
+ const actorName = d.invokeAs;
135
+ const persona = loadActorPersona(transportRoot, actorName);
136
+ const systemPrompt = composeSystemPrompt([protocolPrompt, persona]);
137
+ const fromIdentity = `${d.modelName}@${alias}`;
138
+ const sender = messageSender(d.msg);
139
+
140
+ log('dispatch', {
141
+ model: d.modelName,
142
+ alias,
143
+ channel: d.channelUuid.slice(0, 8),
144
+ actor: actorName ?? null,
145
+ child_channel: childChannel ? childChannel.slice(0, 8) : null,
146
+ msg: d.msg.relPath,
147
+ });
148
+
149
+ const env: Record<string, string> = {
150
+ CROSSTALK_DISPATCH_ACTOR: fromIdentity,
151
+ CROSSTALK_DISPATCH_CHANNEL: d.channelUuid,
152
+ CROSSTALK_DISPATCH_RE: d.msg.relPath,
153
+ };
154
+ if (childChannel) env['CROSSTALK_CHILD_CHANNEL'] = childChannel;
155
+
156
+ const result = await invokeModelCli(d.model, systemPrompt, d.msg.body, env);
157
+
158
+ if (result.status !== 0) {
159
+ // Some CLIs (claude --print, codex exec, others) write auth/config
160
+ // errors to stdout, not stderr. Surface both so failures aren't a
161
+ // black box when only one stream has the message.
162
+ const errBody = result.stderr.trim() || result.stdout.trim() || '(no output captured)';
163
+ const errDetail = [
164
+ `model exit=${result.status}`,
165
+ result.stderr.trim() ? `stderr:\n${result.stderr.slice(0, 1000)}` : '',
166
+ result.stdout.trim() ? `stdout:\n${result.stdout.slice(0, 1000)}` : '',
167
+ ].filter(Boolean).join('\n');
168
+ writeReply({
169
+ transportRoot,
170
+ channelUuid: d.channelUuid,
171
+ fromModel: fromIdentity,
172
+ to: sender === 'unknown' ? messageSender(d.msg) : sender,
173
+ re: d.msg.relPath,
174
+ body: errBody.slice(0, 4000),
175
+ failed: { error: errDetail },
176
+ });
177
+ log('dispatch_failed', {
178
+ model: d.modelName,
179
+ channel: d.channelUuid.slice(0, 8),
180
+ msg: d.msg.relPath,
181
+ exit: result.status,
182
+ });
183
+ return true;
184
+ }
185
+
186
+ const reply = result.stdout.trim();
187
+ if (reply.length === 0) {
188
+ // Actor routed its answer via `crosstalk message send` (which auto-links re:).
189
+ // If it truly did nothing, the asker's `crosstalk message replies` stays
190
+ // PENDING — visible, never silently lost.
191
+ log('dispatch_silent', { model: d.modelName, channel: d.channelUuid.slice(0, 8), msg: d.msg.relPath });
192
+ return true;
193
+ }
194
+
195
+ writeReply({
196
+ transportRoot,
197
+ channelUuid: d.channelUuid,
198
+ fromModel: fromIdentity,
199
+ to: sender,
200
+ re: d.msg.relPath,
201
+ body: reply,
202
+ });
203
+ log('dispatch_done', { model: d.modelName, channel: d.channelUuid.slice(0, 8), msg: d.msg.relPath });
204
+ return true;
205
+ }
206
+
207
+ async function dispatchTick(registry: ModelsRegistry, protocolPrompt: string): Promise<boolean> {
208
+ const claimed = registry.claimed;
209
+ writeHeartbeat(transportRoot, RUNTIME_VERSION, alias!);
210
+
211
+ const pullResult = gitPull(transportRoot);
212
+ if (!pullResult.ok) {
213
+ logError(transportRoot, `git pull failed: ${pullResult.error}`, 'warn');
214
+ log('git_pull_failed', { error: (pullResult.error ?? '').slice(0, 200) });
215
+ return false;
216
+ }
217
+
218
+ const head = cursorBaseline(transportRoot);
219
+ if (!head) {
220
+ logError(transportRoot, 'git rev-parse failed for origin/HEAD and HEAD — skipping tick');
221
+ return false;
222
+ }
223
+
224
+ let cursor = readCursor(transportRoot);
225
+ if (cursor === null) {
226
+ // First-boot seed to HEAD: pre-join history is ignored. Operators
227
+ // sending messages BEFORE this dispatcher first booted are not
228
+ // delivered — ephemeral-alias semantics: an alias only exists from
229
+ // its first boot onward (no committed per-host "join" marker).
230
+ writeCursor(transportRoot, head);
231
+ cursor = head;
232
+ log('cursor_seeded', { commit: head.slice(0, 12) });
233
+ }
234
+ const channels = discoverChannels(transportRoot);
235
+ const activationDue = cursor !== head;
236
+
237
+ let didWork = false;
238
+ if (activationDue) {
239
+ didWork = await runActivationPass(claimed, protocolPrompt, channels, cursor, head);
240
+ }
241
+
242
+ // Workflow tick runs unconditionally — an open workflow may need to
243
+ // advance (e.g. compile a freshly-committed marker, or progress to the
244
+ // synthesize phase now that all fanout replies have landed) regardless
245
+ // of whether the activation pass had new messages this tick.
246
+ const workflowProgressed = await workflowTick({ transportRoot, alias: alias!, registry, claimed, log }, channels);
247
+
248
+ // Advance cursor regardless of per-batch success: at-least-once was
249
+ // attempted; failure replies are committed alongside successes. A
250
+ // crash mid-tick leaves the cursor where it was — next tick replays.
251
+ writeCursor(transportRoot, head);
252
+
253
+ const writeNeeded = didWork || workflowProgressed;
254
+ if (writeNeeded) {
255
+ const push = gitCommitAndPush(transportRoot, `dispatch(${alias}): replies ${new Date().toISOString()}`);
256
+ if (!push.ok && push.error) {
257
+ // Commit failed is a real error (local state out of sync); push
258
+ // failed with the commit landing is a warning (push will retry).
259
+ logError(transportRoot, `${push.committed ? 'push' : 'commit'} failed: ${push.error}`, push.committed ? 'warn' : 'error');
260
+ log('git_push_failed', { committed: push.committed, error: push.error.slice(0, 200) });
261
+ }
262
+ }
263
+
264
+ return writeNeeded;
265
+ }
266
+
267
+ async function runActivationPass(
268
+ claimed: Map<string, ModelEntry>,
269
+ protocolPrompt: string,
270
+ channels: string[],
271
+ cursor: string,
272
+ head: string,
273
+ ): Promise<boolean> {
274
+ const addedList = newFilesSince(transportRoot, cursor);
275
+ if (addedList === null) {
276
+ logError(transportRoot, `cursor ${cursor.slice(0, 12)} unknown — re-scanning all channels`, 'warn');
277
+ }
278
+ const added: Set<string> | null = addedList === null ? null : new Set(addedList);
279
+
280
+ void head; // head is the diff's upper bound implicitly (newFilesSince uses HEAD).
281
+ const pending: PendingDispatch[] = [];
282
+
283
+ for (const channelUuid of channels) {
284
+ const messages = listChannelMessages(transportRoot, channelUuid);
285
+ const senderByRelPath = new Map(messages.map((m) => [m.relPath, messageSender(m)]));
286
+ // Normalize asker identity the same way self-suppression does: strip
287
+ // the @machine suffix before comparing against actorName. Without
288
+ // this, an actor that sent a sub-message never wakes for its reply,
289
+ // because asker='alice@cachy' never matches actorName='alice'. The
290
+ // bug was masked by the workflow smoke test, which used direct
291
+ // stdout replies — fan-out + fan-in is the case that surfaces it.
292
+ const senderOf = (relPath: string) => {
293
+ const raw = senderByRelPath.get(relPath);
294
+ return raw === undefined ? undefined : extractActor(raw);
295
+ };
296
+
297
+ let post: ChannelMessage[];
298
+ if (added === null) {
299
+ post = messages;
300
+ } else {
301
+ const prefix = `data/channels/${channelUuid}/`;
302
+ post = messages.filter((m) => added.has(prefix + m.relPath));
303
+ }
304
+
305
+ for (const msg of post) {
306
+ const recipientList = recipients(msg.data['to']);
307
+ for (const [modelName, model] of claimed) {
308
+ const decision = decideWake(
309
+ {
310
+ from: extractActor(messageSender(msg)), // self-suppression: model name, not model@alias
311
+ to: recipientList,
312
+ re: reList(msg.data['re']),
313
+ },
314
+ modelName,
315
+ alias!,
316
+ senderOf,
317
+ );
318
+ if (decision === 'wake') {
319
+ const invokeAs: string | undefined = typeof msg.data['as'] === 'string'
320
+ ? (msg.data['as'] as string)
321
+ : undefined;
322
+ pending.push({ channelUuid, modelName, model, msg, invokeAs });
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ // Fire all pending dispatches in parallel. Each is one model CLI call,
329
+ // captured by detached spawn with SIGKILL timeout (invoke.ts). N parallel
330
+ // claude/gemini/etc. invocations comfortably saturate one machine; if it
331
+ // ever becomes a problem, dispatch.ts can grow a per-model concurrency
332
+ // semaphore — for now KISS wins, every message gets its own dispatch.
333
+ const results = pending.length === 0
334
+ ? []
335
+ : await Promise.all(pending.map((d) => dispatchOne(d, protocolPrompt)));
336
+ return results.some(Boolean);
337
+ }
338
+
339
+ async function waitForWakeOrTimeout(ms: number): Promise<void> {
340
+ const dir = stateDir(transportRoot);
341
+ const ac = new AbortController();
342
+ const timer = setTimeout(() => ac.abort(), ms);
343
+ try {
344
+ const watcher = watch(dir, { signal: ac.signal });
345
+ for await (const ev of watcher) {
346
+ if (ev.filename === 'wake.signal') return;
347
+ }
348
+ } catch {
349
+ /* abort = timeout */
350
+ } finally {
351
+ clearTimeout(timer);
352
+ }
353
+ }
354
+
355
+ async function main(): Promise<void> {
356
+ writePidfile(transportRoot);
357
+ // Regular exit: drop the pidfile only. Do NOT drop the registry entry —
358
+ // (a) `--once` mode exits cleanly every tick and would churn the file
359
+ // between tick runs, and (b) an uncommitted local delete breaks the
360
+ // next `git pull --rebase` until something else commits. Registry
361
+ // entries should live until explicit shutdown.
362
+ process.on('exit', () => removePidfile(transportRoot));
363
+ // Explicit shutdown signals: remove pidfile + registry entry, commit
364
+ // the deregistration so other machines stop routing to us. Hard crash
365
+ // skips this and leaves a stale entry — operator-cleanup concern,
366
+ // not runtime correctness.
367
+ let apiServer: HttpServer | null = null;
368
+ const cleanupShutdown = () => {
369
+ if (apiServer) {
370
+ try { apiServer.close(); } catch { /* best-effort */ }
371
+ }
372
+ removePidfile(transportRoot);
373
+ if (alias) removeRegistryEntry(transportRoot, alias);
374
+ const push = gitCommitAndPush(transportRoot, `dispatch(${alias}): deregister entry`);
375
+ if (!push.ok && push.error) {
376
+ logError(transportRoot, `registry deregister failed: ${push.error}`, 'warn');
377
+ }
378
+ };
379
+ process.on('SIGTERM', () => { cleanupShutdown(); process.exit(0); });
380
+ process.on('SIGINT', () => { cleanupShutdown(); process.exit(0); });
381
+
382
+ // Load the model registry. On parse failure (operator-edited
383
+ // crosstalk.yaml broken mid-flight, missing file, malformed YAML),
384
+ // log the error and continue with an empty claimed set rather than
385
+ // exiting. The container's restart: unless-stopped would otherwise
386
+ // crash-loop forever; this way the engine stays up, the API keeps
387
+ // serving /status, and the operator can `crosstalk status` to see
388
+ // the diagnostic without needing `crosstalk logs`.
389
+ //
390
+ // Note: alpha.11 made the parser skip-bad-provider — a single typo'd
391
+ // provider no longer throws; it logs a warning and the rest of the
392
+ // registry loads. This whole-registry catch now fires only on truly
393
+ // unrecoverable errors (missing file, top-level shape broken, etc.).
394
+ let registry: ReturnType<typeof loadRegistry>;
395
+ try {
396
+ registry = loadRegistry(transportRoot);
397
+ } catch (err) {
398
+ const msg = (err as Error).message;
399
+ logError(transportRoot, `crosstalk.yaml load failed — running with empty claimed set: ${msg}`);
400
+ console.error(`crosstalk daemon dispatch: ${msg}`);
401
+ console.error(`crosstalk daemon dispatch: continuing with EMPTY claimed-model set. Fix data/crosstalk.yaml, then 'crosstalk server restart' to reload.`);
402
+ registry = {
403
+ all: new Map(),
404
+ byBareName: new Map(),
405
+ claimed: new Map(),
406
+ };
407
+ }
408
+
409
+ const protocolPrompt = loadProtocolPrompt(transportRoot);
410
+
411
+ // Publish our registry entry so workflow.ts (running on this or any
412
+ // other machine) can scope fanout sub-primitives to specific dispatchers
413
+ // instead of broadcasting bare-recipient fanouts that every claimant
414
+ // races to duplicate. Commit+push immediately so cross-machine
415
+ // visibility doesn't wait for first reply traffic.
416
+ writeRegistryEntry(transportRoot, alias!, [...registry.claimed.keys()], RUNTIME_VERSION);
417
+ const registryPush = gitCommitAndPush(transportRoot, `dispatch(${alias}): register entry`);
418
+ if (!registryPush.ok && registryPush.error) {
419
+ logError(transportRoot, `registry publish failed: ${registryPush.error}`, 'warn');
420
+ }
421
+
422
+ log('dispatch_start', {
423
+ transport: transportRoot,
424
+ alias,
425
+ version: RUNTIME_VERSION,
426
+ state_dir: stateDir(transportRoot),
427
+ claimed_models: [...registry.claimed.keys()],
428
+ total_models: registry.all.size,
429
+ });
430
+
431
+ // Start the HTTP API on 127.0.0.1 so the host-side `crosstalk` client
432
+ // can talk to us. --once mode skips this — short-lived processes don't
433
+ // serve, and the test harnesses that invoke --once would conflict on
434
+ // the port. Operators get the API when running the continuous loop.
435
+ if (!onceMode) {
436
+ // v8 auth stores — gate the web UI behind a passphrase. JSON API
437
+ // stays open on loopback (matches v7 trust model).
438
+ // Storage lives under the transport state dir so it travels with
439
+ // the transport but stays out of the git repo (it's not committed).
440
+ const authDir = join(transportRoot, '..', 'crosstalk-state', 'auth');
441
+ const userStore = new FileUserStore(join(authDir, 'users.json'));
442
+ const tokenStore = new FileTokenStore(join(authDir, 'tokens.json'));
443
+ const setupState = new SetupState(join(authDir, 'setup-token'));
444
+ // First-run: mint setup token + log the URL so the operator can
445
+ // bootstrap from the browser. Subsequent boots: leave the
446
+ // existing token if present (operator may have stalled mid-setup).
447
+ if (await userStore.isEmpty() && !setupState.hasActiveToken) {
448
+ const rec = setupState.mint();
449
+ log('auth_first_run_setup_token', {
450
+ url: `http://127.0.0.1:<port>/setup?token=${rec.token}`,
451
+ note: 'Visit this URL in a browser to create the admin account. Replace <port> with the bound API port.',
452
+ });
453
+ }
454
+ apiServer = startApi(
455
+ {
456
+ transportRoot,
457
+ alias: alias!,
458
+ version: RUNTIME_VERSION,
459
+ registry,
460
+ claimed: registry.claimed,
461
+ userStore,
462
+ tokenStore,
463
+ setupState,
464
+ },
465
+ { log },
466
+ );
467
+ }
468
+
469
+ if (onceMode) {
470
+ await dispatchTick(registry, protocolPrompt);
471
+ process.exit(0);
472
+ }
473
+ log('dispatch_running', { poll_s: pollSeconds });
474
+
475
+ while (true) {
476
+ try {
477
+ const didWork = await dispatchTick(registry, protocolPrompt);
478
+ if (didWork) {
479
+ await new Promise((res) => setTimeout(res, 1_000));
480
+ } else {
481
+ await waitForWakeOrTimeout(pollSeconds * 1_000);
482
+ }
483
+ } catch (err) {
484
+ const msg = (err as Error).message;
485
+ logError(transportRoot, `tick error: ${msg}`);
486
+ log('tick_error', { message: msg });
487
+ await new Promise((res) => setTimeout(res, pollSeconds * 1_000));
488
+ }
489
+ }
490
+ }
491
+
492
+ main();
@@ -0,0 +1,91 @@
1
+ // dispatchers.ts — the shared dispatcher registry.
2
+ //
3
+ // Each running dispatcher publishes its identity + claimed models to
4
+ // `data/dispatchers/<alias>.yaml` so workflow.ts can scope fanout
5
+ // sub-primitives across the actually-running dispatcher set instead of
6
+ // addressing every fanout to bare `<model>` and watching every claimant
7
+ // race for it.
8
+ //
9
+ // Lifecycle:
10
+ // - dispatcher startup → writeRegistryEntry (committed alongside other
11
+ // state changes by the next tick's gitCommitAndPush)
12
+ // - dispatcher SIGTERM/SIGINT → removeRegistryEntry (committed same way)
13
+ // - hard crash → entry stays; operator runs cleanup, or restarts
14
+ //
15
+ // Liveness is best-effort. v7.0.0-alpha.1 does NOT track per-tick
16
+ // heartbeats in the registry — the timestamp would force a commit every
17
+ // tick on every machine, which is far more push traffic than the system
18
+ // otherwise generates. Heartbeats live in the machine-local state dir
19
+ // (state.ts) for "is THIS dispatcher alive" checks; the registry answers
20
+ // "which dispatchers exist on the bus." A stale registry entry from a
21
+ // crashed dispatcher is an operator-cleanup concern, not a runtime concern.
22
+
23
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs';
24
+ import { join } from 'path';
25
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
26
+
27
+ export interface RegistryEntry {
28
+ alias: string;
29
+ claims: string[]; // model names this dispatcher has on PATH
30
+ version: string;
31
+ }
32
+
33
+ export function registryDir(transportRoot: string): string {
34
+ return join(transportRoot, 'data', 'dispatchers');
35
+ }
36
+
37
+ export function registryFile(transportRoot: string, alias: string): string {
38
+ return join(registryDir(transportRoot), `${alias}.yaml`);
39
+ }
40
+
41
+ export function writeRegistryEntry(
42
+ transportRoot: string,
43
+ alias: string,
44
+ claims: string[],
45
+ version: string,
46
+ ): void {
47
+ mkdirSync(registryDir(transportRoot), { recursive: true });
48
+ const entry: RegistryEntry = { alias, claims, version };
49
+ writeFileSync(registryFile(transportRoot, alias), stringifyYaml(entry));
50
+ }
51
+
52
+ export function removeRegistryEntry(transportRoot: string, alias: string): void {
53
+ try {
54
+ unlinkSync(registryFile(transportRoot, alias));
55
+ } catch { /* already gone */ }
56
+ }
57
+
58
+ export function readAllRegistry(transportRoot: string): RegistryEntry[] {
59
+ const dir = registryDir(transportRoot);
60
+ if (!existsSync(dir)) return [];
61
+ const out: RegistryEntry[] = [];
62
+ for (const entry of readdirSync(dir)) {
63
+ if (!entry.endsWith('.yaml')) continue;
64
+ try {
65
+ const raw = readFileSync(join(dir, entry), 'utf-8');
66
+ const parsed = parseYaml(raw) as Partial<RegistryEntry> | null;
67
+ if (
68
+ !parsed ||
69
+ typeof parsed.alias !== 'string' ||
70
+ !Array.isArray(parsed.claims) ||
71
+ typeof parsed.version !== 'string'
72
+ ) continue;
73
+ out.push({
74
+ alias: parsed.alias,
75
+ claims: parsed.claims.map(String),
76
+ version: parsed.version,
77
+ });
78
+ } catch { /* skip malformed */ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ // Aliases of dispatchers currently claiming the given model, sorted for
84
+ // deterministic ordering — round-robin assignment becomes reproducible,
85
+ // which makes the test path predictable.
86
+ export function dispatchersForModel(transportRoot: string, modelName: string): string[] {
87
+ return readAllRegistry(transportRoot)
88
+ .filter((e) => e.claims.includes(modelName))
89
+ .map((e) => e.alias)
90
+ .sort();
91
+ }
@@ -0,0 +1,28 @@
1
+ import { randomBytes } from 'crypto';
2
+
3
+ export interface Timestamp {
4
+ iso: string;
5
+ pathDate: string;
6
+ fileTime: string;
7
+ hex: string;
8
+ }
9
+
10
+ export function now(): Timestamp {
11
+ const d = new Date();
12
+ const iso = d.toISOString();
13
+ const yyyy = iso.slice(0, 4);
14
+ const mm = iso.slice(5, 7);
15
+ const dd = iso.slice(8, 10);
16
+ const time = iso.slice(11, 13) + iso.slice(14, 16) + iso.slice(17, 19);
17
+ const ms = iso.slice(20, 23);
18
+ return {
19
+ iso,
20
+ pathDate: `${yyyy}/${mm}/${dd}`,
21
+ fileTime: `${time}${ms}Z`,
22
+ hex: randomBytes(4).toString('hex'),
23
+ };
24
+ }
25
+
26
+ export function messageFilename(ts: Timestamp = now()): string {
27
+ return `${ts.fileTime}-${ts.hex}.md`;
28
+ }
@@ -0,0 +1,26 @@
1
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
2
+
3
+ export interface ParsedDocument<T = Record<string, unknown>> {
4
+ data: T;
5
+ body: string;
6
+ }
7
+
8
+ export function parseFrontmatter<T = Record<string, unknown>>(raw: string): ParsedDocument<T> {
9
+ if (!raw.startsWith('---')) return { data: {} as T, body: raw };
10
+ const lines = raw.split('\n');
11
+ let end = -1;
12
+ for (let i = 1; i < lines.length; i++) {
13
+ if (lines[i].trim() === '---') { end = i; break; }
14
+ }
15
+ if (end === -1) return { data: {} as T, body: raw };
16
+ const yamlBlock = lines.slice(1, end).join('\n');
17
+ const body = lines.slice(end + 1).join('\n').replace(/^\n/, '');
18
+ const data = (parseYaml(yamlBlock) as T) ?? ({} as T);
19
+ return { data, body };
20
+ }
21
+
22
+ export function serializeFrontmatter(data: Record<string, unknown>, body: string): string {
23
+ const yaml = stringifyYaml(data).trimEnd();
24
+ const trailing = body.endsWith('\n') ? '' : '\n';
25
+ return `---\n${yaml}\n---\n\n${body}${trailing}`;
26
+ }