@cordfuse/crosstalkd 7.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/channel.ts ADDED
@@ -0,0 +1,202 @@
1
+ // crosstalkd channel — channel operations.
2
+ //
3
+ // Three operations on one subcommand (V7-SPEC §7):
4
+ // crosstalkd channel <name> — create
5
+ // crosstalkd channel <name-or-uuid> --rename <new> — rename
6
+ // crosstalkd channel <name-or-uuid> --delete — hard delete (typed-name confirmation)
7
+ //
8
+ // No archive, no restore, no purge, no --force. History stays in git log
9
+ // if recovery is ever needed.
10
+
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs';
12
+ import { resolve, join } from 'path';
13
+ import { randomUUID } from 'crypto';
14
+ import { spawnSync } from 'child_process';
15
+ import { parseFrontmatter, serializeFrontmatter } from './frontmatter.js';
16
+ import { gitCommitAndPush, discoverChannels } from './transport.js';
17
+
18
+ const transportRoot = resolve(process.cwd());
19
+ const argv = process.argv.slice(2);
20
+
21
+ function flag(name: string): string | undefined {
22
+ const i = argv.indexOf(name);
23
+ if (i === -1 || i === argv.length - 1) return undefined;
24
+ return argv[i + 1];
25
+ }
26
+
27
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
28
+
29
+ interface ChannelMeta {
30
+ uuid: string;
31
+ name: string | null;
32
+ parent: string | null;
33
+ }
34
+
35
+ function readChannelMeta(uuid: string): ChannelMeta {
36
+ const chPath = join(transportRoot, 'data', 'channels', uuid, 'CHANNEL.md');
37
+ if (!existsSync(chPath)) return { uuid, name: null, parent: null };
38
+ const raw = readFileSync(chPath, 'utf-8');
39
+ const { data } = parseFrontmatter<{ name?: unknown; parent?: unknown }>(raw);
40
+ return {
41
+ uuid,
42
+ name: typeof data.name === 'string' ? data.name : null,
43
+ parent: typeof data.parent === 'string' ? data.parent : null,
44
+ };
45
+ }
46
+
47
+ function allChannelMeta(): ChannelMeta[] {
48
+ return discoverChannels(transportRoot).map(readChannelMeta);
49
+ }
50
+
51
+ function resolveChannel(input: string): ChannelMeta | null {
52
+ if (UUID_RE.test(input)) {
53
+ const path = join(transportRoot, 'data', 'channels', input);
54
+ if (!existsSync(path)) return null;
55
+ return readChannelMeta(input);
56
+ }
57
+ for (const meta of allChannelMeta()) {
58
+ if (meta.name === input) return meta;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function nameExists(name: string): boolean {
64
+ return allChannelMeta().some((m) => m.name === name);
65
+ }
66
+
67
+ function commit(message: string): void {
68
+ const r = gitCommitAndPush(transportRoot, message);
69
+ if (!r.ok && r.error) {
70
+ console.error(`git ${r.committed ? 'push' : 'commit'} FAILED: ${r.error.slice(0, 300)}`);
71
+ process.exit(3);
72
+ }
73
+ }
74
+
75
+ function create(name: string): void {
76
+ if (UUID_RE.test(name)) {
77
+ console.error(`crosstalkd channel: '${name}' looks like a UUID — channel names cannot be UUID-shaped.`);
78
+ process.exit(1);
79
+ }
80
+ if (nameExists(name)) {
81
+ console.error(`crosstalkd channel: a channel named '${name}' already exists.`);
82
+ process.exit(1);
83
+ }
84
+ const uuid = randomUUID();
85
+ const dir = join(transportRoot, 'data', 'channels', uuid);
86
+ mkdirSync(dir, { recursive: true });
87
+ writeFileSync(join(dir, 'CHANNEL.md'), serializeFrontmatter({ name }, ''));
88
+ commit(`channel(create): ${name} (${uuid.slice(0, 8)})`);
89
+ console.log(`Created channel: ${uuid}`);
90
+ console.log(` name: ${name}`);
91
+ }
92
+
93
+ function rename(input: string, newName: string): void {
94
+ if (UUID_RE.test(newName)) {
95
+ console.error(`crosstalkd channel: '${newName}' looks like a UUID — channel names cannot be UUID-shaped.`);
96
+ process.exit(1);
97
+ }
98
+ const meta = resolveChannel(input);
99
+ if (!meta) {
100
+ console.error(`crosstalkd channel: '${input}' not found.`);
101
+ process.exit(1);
102
+ }
103
+ if (meta.name === newName) {
104
+ console.log(`crosstalkd channel: '${meta.name}' already has that name — no-op.`);
105
+ return;
106
+ }
107
+ if (nameExists(newName)) {
108
+ console.error(`crosstalkd channel: a channel named '${newName}' already exists.`);
109
+ process.exit(1);
110
+ }
111
+ const chPath = join(transportRoot, 'data', 'channels', meta.uuid, 'CHANNEL.md');
112
+ const fm: Record<string, unknown> = { name: newName };
113
+ if (meta.parent) fm['parent'] = meta.parent;
114
+ writeFileSync(chPath, serializeFrontmatter(fm, ''));
115
+ commit(`channel(rename): ${meta.name ?? '(unnamed)'} -> ${newName} (${meta.uuid.slice(0, 8)})`);
116
+ console.log(`Renamed: ${meta.name ?? '(unnamed)'} -> ${newName}`);
117
+ }
118
+
119
+ function readStdinLine(): string {
120
+ try {
121
+ const raw = readFileSync(0, 'utf-8');
122
+ return raw.split('\n')[0]!.trim();
123
+ } catch {
124
+ return '';
125
+ }
126
+ }
127
+
128
+ function del(input: string): void {
129
+ const meta = resolveChannel(input);
130
+ if (!meta) {
131
+ console.error(`crosstalkd channel: '${input}' not found.`);
132
+ process.exit(1);
133
+ }
134
+ const displayName = meta.name ?? '(unnamed)';
135
+ const confirmTarget = meta.name ?? meta.uuid;
136
+
137
+ // If stdin is a TTY, prompt; otherwise read one line of piped input.
138
+ // Either way, the user/script must type the name back to confirm.
139
+ if (process.stdin.isTTY) {
140
+ process.stderr.write(
141
+ `type "${confirmTarget}" to confirm deletion of channel ${displayName} (${meta.uuid}): `,
142
+ );
143
+ // Naive blocking read of one line via spawnSync — bun + tsx don't
144
+ // expose a clean blocking readline in pure ESM.
145
+ const r = spawnSync('sh', ['-c', 'IFS= read -r line; echo "$line"'], { stdio: ['inherit', 'pipe', 'inherit'] });
146
+ const typed = (r.stdout?.toString() ?? '').trim();
147
+ if (typed !== confirmTarget) {
148
+ console.error('crosstalkd channel: confirmation mismatch; nothing deleted.');
149
+ process.exit(1);
150
+ }
151
+ } else {
152
+ const typed = readStdinLine();
153
+ if (typed !== confirmTarget) {
154
+ console.error(`crosstalkd channel: confirmation mismatch (expected '${confirmTarget}', got '${typed}'); nothing deleted.`);
155
+ process.exit(1);
156
+ }
157
+ }
158
+
159
+ const dir = join(transportRoot, 'data', 'channels', meta.uuid);
160
+ rmSync(dir, { recursive: true, force: true });
161
+ commit(`channel(delete): ${displayName} (${meta.uuid.slice(0, 8)})`);
162
+ console.log(`Deleted channel: ${displayName} (${meta.uuid})`);
163
+ }
164
+
165
+ // arg parsing — find the positional (first arg not starting with --, not the
166
+ // value of a flag we recognise).
167
+ const flagsTakingValue = new Set(['--rename']);
168
+ let positional: string | undefined;
169
+ for (let i = 0; i < argv.length; i++) {
170
+ const a = argv[i]!;
171
+ if (a.startsWith('--')) {
172
+ if (flagsTakingValue.has(a)) i++;
173
+ continue;
174
+ }
175
+ positional = a;
176
+ break;
177
+ }
178
+
179
+ const renameTarget = flag('--rename');
180
+ const deleteMode = argv.includes('--delete');
181
+
182
+ if (!positional) {
183
+ console.error('Usage:');
184
+ console.error(' crosstalkd channel <name> # create');
185
+ console.error(' crosstalkd channel <name-or-uuid> --rename <new> # rename');
186
+ console.error(' crosstalkd channel <name-or-uuid> --delete # hard delete (typed-name confirmation)');
187
+ process.exit(1);
188
+ }
189
+
190
+ const channelsDir = join(transportRoot, 'data', 'channels');
191
+ if (!existsSync(channelsDir)) mkdirSync(channelsDir, { recursive: true });
192
+
193
+ if (renameTarget && deleteMode) {
194
+ console.error('crosstalkd channel: --rename and --delete are mutually exclusive.');
195
+ process.exit(1);
196
+ } else if (renameTarget) {
197
+ rename(positional, renameTarget);
198
+ } else if (deleteMode) {
199
+ del(positional);
200
+ } else {
201
+ create(positional);
202
+ }
@@ -0,0 +1,430 @@
1
+ // crosstalkd dispatch — the v7 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/models.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 } 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 type { Server as HttpServer } from 'http';
61
+
62
+ const RUNTIME_VERSION: string = (() => {
63
+ try {
64
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
65
+ return (JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string }).version ?? 'unknown';
66
+ } catch {
67
+ return 'unknown';
68
+ }
69
+ })();
70
+
71
+ const transportRoot = resolve(process.cwd());
72
+ const argv = process.argv.slice(2);
73
+
74
+ function flag(name: string): string | undefined {
75
+ const i = argv.indexOf(name);
76
+ if (i === -1 || i === argv.length - 1) return undefined;
77
+ return argv[i + 1];
78
+ }
79
+
80
+ const alias = flag('--alias');
81
+ const onceMode = argv.includes('--once');
82
+ const jsonMode = argv.includes('--json');
83
+ const pollSeconds = Number(flag('--poll')) || 30;
84
+ const logFile = flag('--log-file');
85
+
86
+ if (!alias) {
87
+ console.error('crosstalkd dispatch: --alias <name> is required (machine identity in the bus).');
88
+ process.exit(1);
89
+ }
90
+
91
+ function log(event: string, fields: Record<string, unknown> = {}): void {
92
+ let line: string;
93
+ if (jsonMode) {
94
+ line = JSON.stringify({ ts: new Date().toISOString(), event, ...fields });
95
+ } else {
96
+ const tail = Object.entries(fields)
97
+ .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
98
+ .join(' ');
99
+ line = `[${new Date().toISOString()}] ${event}${tail ? ' ' + tail : ''}`;
100
+ }
101
+ console.log(line);
102
+ if (logFile) {
103
+ try { appendFileSync(logFile, line + '\n'); } catch { /* best-effort */ }
104
+ }
105
+ }
106
+
107
+ // One pending dispatch = one message → one CLI invocation → one reply.
108
+ // v6 grouped by (channel, model) into batches; v7 doesn't (see V7-SPEC
109
+ // post-M2 fanout note). Each fanout sibling, each operator-piled-up
110
+ // request, each fan-in reply gets its own invocation in parallel — N
111
+ // model calls, N replies, fanout works as designed.
112
+ interface PendingDispatch {
113
+ channelUuid: string;
114
+ modelName: string;
115
+ model: ModelEntry;
116
+ msg: ChannelMessage;
117
+ // Persona resolved by dispatchTick: prefers msg.as, falls back to the
118
+ // re:'d message's wake_as (workflow persona continuity).
119
+ invokeAs?: string;
120
+ }
121
+
122
+ async function dispatchOne(d: PendingDispatch, protocolPrompt: string): Promise<boolean> {
123
+ const childChannel = typeof d.msg.data['child_channel'] === 'string'
124
+ ? (d.msg.data['child_channel'] as string)
125
+ : undefined;
126
+ const actorName = d.invokeAs;
127
+ const persona = loadActorPersona(transportRoot, actorName);
128
+ const systemPrompt = composeSystemPrompt([protocolPrompt, persona]);
129
+ const fromIdentity = `${d.modelName}@${alias}`;
130
+ const sender = messageSender(d.msg);
131
+
132
+ log('dispatch', {
133
+ model: d.modelName,
134
+ alias,
135
+ channel: d.channelUuid.slice(0, 8),
136
+ actor: actorName ?? null,
137
+ child_channel: childChannel ? childChannel.slice(0, 8) : null,
138
+ msg: d.msg.relPath,
139
+ });
140
+
141
+ const env: Record<string, string> = {
142
+ CROSSTALK_DISPATCH_ACTOR: fromIdentity,
143
+ CROSSTALK_DISPATCH_CHANNEL: d.channelUuid,
144
+ CROSSTALK_DISPATCH_RE: d.msg.relPath,
145
+ };
146
+ if (childChannel) env['CROSSTALK_CHILD_CHANNEL'] = childChannel;
147
+
148
+ const result = await invokeModelCli(d.model, systemPrompt, d.msg.body, env);
149
+
150
+ if (result.status !== 0) {
151
+ writeReply({
152
+ transportRoot,
153
+ channelUuid: d.channelUuid,
154
+ fromModel: fromIdentity,
155
+ to: sender === 'unknown' ? messageSender(d.msg) : sender,
156
+ re: d.msg.relPath,
157
+ body: result.stderr.slice(0, 4000) || `(no stderr captured)`,
158
+ failed: { error: `model exit=${result.status}\n${result.stderr.slice(0, 1000)}` },
159
+ });
160
+ log('dispatch_failed', {
161
+ model: d.modelName,
162
+ channel: d.channelUuid.slice(0, 8),
163
+ msg: d.msg.relPath,
164
+ exit: result.status,
165
+ });
166
+ return true;
167
+ }
168
+
169
+ const reply = result.stdout.trim();
170
+ if (reply.length === 0) {
171
+ // Actor routed its answer via `crosstalkd run` (which auto-links re:).
172
+ // If it truly did nothing, the asker's `crosstalkd replies` stays
173
+ // PENDING — visible, never silently lost.
174
+ log('dispatch_silent', { model: d.modelName, channel: d.channelUuid.slice(0, 8), msg: d.msg.relPath });
175
+ return true;
176
+ }
177
+
178
+ writeReply({
179
+ transportRoot,
180
+ channelUuid: d.channelUuid,
181
+ fromModel: fromIdentity,
182
+ to: sender,
183
+ re: d.msg.relPath,
184
+ body: reply,
185
+ });
186
+ log('dispatch_done', { model: d.modelName, channel: d.channelUuid.slice(0, 8), msg: d.msg.relPath });
187
+ return true;
188
+ }
189
+
190
+ async function dispatchTick(claimed: Map<string, ModelEntry>, protocolPrompt: string): Promise<boolean> {
191
+ writeHeartbeat(transportRoot, RUNTIME_VERSION, alias!);
192
+
193
+ const pullResult = gitPull(transportRoot);
194
+ if (!pullResult.ok) {
195
+ logError(transportRoot, `git pull failed: ${pullResult.error}`);
196
+ log('git_pull_failed', { error: (pullResult.error ?? '').slice(0, 200) });
197
+ return false;
198
+ }
199
+
200
+ const head = cursorBaseline(transportRoot);
201
+ if (!head) {
202
+ logError(transportRoot, 'git rev-parse failed for origin/HEAD and HEAD — skipping tick');
203
+ return false;
204
+ }
205
+
206
+ let cursor = readCursor(transportRoot);
207
+ if (cursor === null) {
208
+ // First-boot seed to HEAD: pre-join history is ignored. Operators
209
+ // sending messages BEFORE this dispatcher first booted are not
210
+ // delivered — symmetric with v6's host-file-commit waterline.
211
+ writeCursor(transportRoot, head);
212
+ cursor = head;
213
+ log('cursor_seeded', { commit: head.slice(0, 12) });
214
+ }
215
+ const channels = discoverChannels(transportRoot);
216
+ const activationDue = cursor !== head;
217
+
218
+ let didWork = false;
219
+ if (activationDue) {
220
+ didWork = await runActivationPass(claimed, protocolPrompt, channels, cursor, head);
221
+ }
222
+
223
+ // Workflow tick runs unconditionally — an open workflow may need to
224
+ // advance (e.g. compile a freshly-committed marker, or progress to the
225
+ // synthesize phase now that all fanout replies have landed) regardless
226
+ // of whether the activation pass had new messages this tick.
227
+ const workflowProgressed = await workflowTick({ transportRoot, alias: alias!, claimed, log }, channels);
228
+
229
+ // Advance cursor regardless of per-batch success: at-least-once was
230
+ // attempted; failure replies are committed alongside successes. A
231
+ // crash mid-tick leaves the cursor where it was — next tick replays.
232
+ writeCursor(transportRoot, head);
233
+
234
+ const writeNeeded = didWork || workflowProgressed;
235
+ if (writeNeeded) {
236
+ const push = gitCommitAndPush(transportRoot, `dispatch(${alias}): replies ${new Date().toISOString()}`);
237
+ if (!push.ok && push.error) {
238
+ logError(transportRoot, `${push.committed ? 'push' : 'commit'} failed: ${push.error}`);
239
+ log('git_push_failed', { committed: push.committed, error: push.error.slice(0, 200) });
240
+ }
241
+ }
242
+
243
+ return writeNeeded;
244
+ }
245
+
246
+ async function runActivationPass(
247
+ claimed: Map<string, ModelEntry>,
248
+ protocolPrompt: string,
249
+ channels: string[],
250
+ cursor: string,
251
+ head: string,
252
+ ): Promise<boolean> {
253
+ const addedList = newFilesSince(transportRoot, cursor);
254
+ if (addedList === null) {
255
+ logError(transportRoot, `cursor ${cursor.slice(0, 12)} unknown — re-scanning all channels`);
256
+ }
257
+ const added: Set<string> | null = addedList === null ? null : new Set(addedList);
258
+
259
+ void head; // head is the diff's upper bound implicitly (newFilesSince uses HEAD).
260
+ const pending: PendingDispatch[] = [];
261
+
262
+ for (const channelUuid of channels) {
263
+ const messages = listChannelMessages(transportRoot, channelUuid);
264
+ const senderByRelPath = new Map(messages.map((m) => [m.relPath, messageSender(m)]));
265
+ // Normalize asker identity the same way self-suppression does: strip
266
+ // the @machine suffix before comparing against actorName. Without
267
+ // this, an actor that sent a sub-message never wakes for its reply,
268
+ // because asker='alice@cachy' never matches actorName='alice'. The
269
+ // bug was masked by the workflow smoke test, which used direct
270
+ // stdout replies — fan-out + fan-in is the case that surfaces it.
271
+ const senderOf = (relPath: string) => {
272
+ const raw = senderByRelPath.get(relPath);
273
+ return raw === undefined ? undefined : extractActor(raw);
274
+ };
275
+
276
+ let post: ChannelMessage[];
277
+ if (added === null) {
278
+ post = messages;
279
+ } else {
280
+ const prefix = `data/channels/${channelUuid}/`;
281
+ post = messages.filter((m) => added.has(prefix + m.relPath));
282
+ }
283
+
284
+ for (const msg of post) {
285
+ const recipientList = recipients(msg.data['to']);
286
+ for (const [modelName, model] of claimed) {
287
+ const decision = decideWake(
288
+ {
289
+ from: extractActor(messageSender(msg)), // self-suppression: model name, not model@alias
290
+ to: recipientList,
291
+ re: reList(msg.data['re']),
292
+ },
293
+ modelName,
294
+ alias!,
295
+ senderOf,
296
+ );
297
+ if (decision === 'wake') {
298
+ const invokeAs: string | undefined = typeof msg.data['as'] === 'string'
299
+ ? (msg.data['as'] as string)
300
+ : undefined;
301
+ pending.push({ channelUuid, modelName, model, msg, invokeAs });
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ // Fire all pending dispatches in parallel. Each is one model CLI call,
308
+ // captured by detached spawn with SIGKILL timeout (invoke.ts). N parallel
309
+ // claude/gemini/etc. invocations comfortably saturate one machine; if it
310
+ // ever becomes a problem, dispatch.ts can grow a per-model concurrency
311
+ // semaphore — for now KISS wins, every message gets its own dispatch.
312
+ const results = pending.length === 0
313
+ ? []
314
+ : await Promise.all(pending.map((d) => dispatchOne(d, protocolPrompt)));
315
+ return results.some(Boolean);
316
+ }
317
+
318
+ async function waitForWakeOrTimeout(ms: number): Promise<void> {
319
+ const dir = stateDir(transportRoot);
320
+ const ac = new AbortController();
321
+ const timer = setTimeout(() => ac.abort(), ms);
322
+ try {
323
+ const watcher = watch(dir, { signal: ac.signal });
324
+ for await (const ev of watcher) {
325
+ if (ev.filename === 'wake.signal') return;
326
+ }
327
+ } catch {
328
+ /* abort = timeout */
329
+ } finally {
330
+ clearTimeout(timer);
331
+ }
332
+ }
333
+
334
+ async function main(): Promise<void> {
335
+ writePidfile(transportRoot);
336
+ // Regular exit: drop the pidfile only. Do NOT drop the registry entry —
337
+ // (a) `--once` mode exits cleanly every tick and would churn the file
338
+ // between tick runs, and (b) an uncommitted local delete breaks the
339
+ // next `git pull --rebase` until something else commits. Registry
340
+ // entries should live until explicit shutdown.
341
+ process.on('exit', () => removePidfile(transportRoot));
342
+ // Explicit shutdown signals: remove pidfile + registry entry, commit
343
+ // the deregistration so other machines stop routing to us. Hard crash
344
+ // skips this and leaves a stale entry — operator-cleanup concern,
345
+ // not runtime correctness.
346
+ let apiServer: HttpServer | null = null;
347
+ const cleanupShutdown = () => {
348
+ if (apiServer) {
349
+ try { apiServer.close(); } catch { /* best-effort */ }
350
+ }
351
+ removePidfile(transportRoot);
352
+ if (alias) removeRegistryEntry(transportRoot, alias);
353
+ const push = gitCommitAndPush(transportRoot, `dispatch(${alias}): deregister entry`);
354
+ if (!push.ok && push.error) {
355
+ logError(transportRoot, `registry deregister failed: ${push.error}`);
356
+ }
357
+ };
358
+ process.on('SIGTERM', () => { cleanupShutdown(); process.exit(0); });
359
+ process.on('SIGINT', () => { cleanupShutdown(); process.exit(0); });
360
+
361
+ let registry;
362
+ try {
363
+ registry = loadRegistry(transportRoot);
364
+ } catch (err) {
365
+ console.error(`crosstalkd dispatch: ${(err as Error).message}`);
366
+ process.exit(1);
367
+ }
368
+
369
+ const protocolPrompt = loadProtocolPrompt(transportRoot);
370
+
371
+ // Publish our registry entry so workflow.ts (running on this or any
372
+ // other machine) can scope fanout sub-primitives to specific dispatchers
373
+ // instead of broadcasting bare-recipient fanouts that every claimant
374
+ // races to duplicate. Commit+push immediately so cross-machine
375
+ // visibility doesn't wait for first reply traffic.
376
+ writeRegistryEntry(transportRoot, alias!, [...registry.claimed.keys()], RUNTIME_VERSION);
377
+ const registryPush = gitCommitAndPush(transportRoot, `dispatch(${alias}): register entry`);
378
+ if (!registryPush.ok && registryPush.error) {
379
+ logError(transportRoot, `registry publish failed: ${registryPush.error}`);
380
+ }
381
+
382
+ log('dispatch_start', {
383
+ transport: transportRoot,
384
+ alias,
385
+ version: RUNTIME_VERSION,
386
+ state_dir: stateDir(transportRoot),
387
+ claimed_models: [...registry.claimed.keys()],
388
+ total_models: registry.all.size,
389
+ });
390
+
391
+ // Start the HTTP API on 127.0.0.1 so the host-side `crosstalk` client
392
+ // can talk to us. --once mode skips this — short-lived processes don't
393
+ // serve, and the test harnesses that invoke --once would conflict on
394
+ // the port. Operators get the API when running the continuous loop.
395
+ if (!onceMode) {
396
+ apiServer = startApi(
397
+ {
398
+ transportRoot,
399
+ alias: alias!,
400
+ version: RUNTIME_VERSION,
401
+ claimed: registry.claimed,
402
+ },
403
+ { log },
404
+ );
405
+ }
406
+
407
+ if (onceMode) {
408
+ await dispatchTick(registry.claimed, protocolPrompt);
409
+ process.exit(0);
410
+ }
411
+ log('dispatch_running', { poll_s: pollSeconds });
412
+
413
+ while (true) {
414
+ try {
415
+ const didWork = await dispatchTick(registry.claimed, protocolPrompt);
416
+ if (didWork) {
417
+ await new Promise((res) => setTimeout(res, 1_000));
418
+ } else {
419
+ await waitForWakeOrTimeout(pollSeconds * 1_000);
420
+ }
421
+ } catch (err) {
422
+ const msg = (err as Error).message;
423
+ logError(transportRoot, `tick error: ${msg}`);
424
+ log('tick_error', { message: msg });
425
+ await new Promise((res) => setTimeout(res, pollSeconds * 1_000));
426
+ }
427
+ }
428
+ }
429
+
430
+ main();