@cordfuse/crosstalk 6.0.0-alpha.9 → 7.0.0-alpha.2

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 (46) hide show
  1. package/README.md +26 -0
  2. package/bin/crosstalk.js +60 -74
  3. package/commands/channel.js +69 -0
  4. package/commands/chat.js +174 -0
  5. package/commands/down.js +37 -0
  6. package/commands/init.js +105 -0
  7. package/commands/logs.js +39 -0
  8. package/commands/pull.js +24 -0
  9. package/commands/replies.js +39 -0
  10. package/commands/restart.js +24 -0
  11. package/commands/run.js +121 -0
  12. package/commands/status.js +52 -0
  13. package/commands/up.js +129 -0
  14. package/commands/version.js +30 -0
  15. package/lib/api-client.js +80 -0
  16. package/lib/argv.js +28 -0
  17. package/lib/errors.js +19 -0
  18. package/lib/transport.js +51 -0
  19. package/package.json +5 -21
  20. package/src/activation.ts +0 -104
  21. package/src/actor.ts +0 -131
  22. package/src/attach.ts +0 -118
  23. package/src/channel.ts +0 -49
  24. package/src/chat.ts +0 -142
  25. package/src/dispatch.ts +0 -531
  26. package/src/dlq.ts +0 -216
  27. package/src/filenames.ts +0 -28
  28. package/src/frontmatter.ts +0 -26
  29. package/src/init.ts +0 -138
  30. package/src/open.ts +0 -207
  31. package/src/replies.ts +0 -59
  32. package/src/send.ts +0 -122
  33. package/src/state.ts +0 -173
  34. package/src/status.ts +0 -75
  35. package/src/stop.ts +0 -37
  36. package/src/transport.ts +0 -213
  37. package/src/turnq.ts +0 -91
  38. package/src/upgrade.ts +0 -211
  39. package/src/wake.ts +0 -7
  40. package/template/CLAUDE.md +0 -12
  41. package/template/gitignore +0 -4
  42. package/template/upstream/CROSSTALK-VERSION +0 -1
  43. package/template/upstream/CROSSTALK.md +0 -298
  44. package/template/upstream/OPERATOR.md +0 -60
  45. package/template/upstream/PROTOCOL.md +0 -80
  46. package/template/upstream/actors/concierge.md +0 -36
package/src/dispatch.ts DELETED
@@ -1,531 +0,0 @@
1
- // crosstalk dispatch — the loop.
2
- //
3
- // Tick: pull → for each local actor, scan channels for messages past the
4
- // cursor → decideWake (activation.ts, the one rule) → invoke the actor's
5
- // CLI per batch → write replies (re: linked per sender) → commit+push.
6
- //
7
- // Only the commit+push is locked, and the lock is advisory (turnq.ts) —
8
- // git arbitrates correctness. Cursors, DLQ, heartbeat and the error log
9
- // live in the machine-local state dir (state.ts), so a tick's commit only
10
- // ever contains data/ and there is no self-inflicted git deadlock to heal.
11
-
12
- import { resolve, join, dirname } from 'path';
13
- import { spawn } from 'child_process';
14
- import { mkdirSync, writeFileSync, readFileSync, existsSync, appendFileSync } from 'fs';
15
- import { watch } from 'fs/promises';
16
- import { fileURLToPath } from 'url';
17
- import {
18
- findHostFile,
19
- loadActorProfile,
20
- pickTier,
21
- tokenizeCli,
22
- type HostActorTiers,
23
- type HostFile,
24
- } from './actor.js';
25
- import {
26
- discoverChannels,
27
- listChannelMessages,
28
- gitPull,
29
- gitCommitAndPush,
30
- cursorBaseline,
31
- newFilesSince,
32
- hostFileCommit,
33
- type ChannelMessage,
34
- } from './transport.js';
35
- import {
36
- stateDir,
37
- readCursor,
38
- writeCursor,
39
- writeHeartbeat,
40
- writePidfile,
41
- removePidfile,
42
- logError,
43
- } from './state.js';
44
- import { recipients, reList, decideWake, splitForConcurrency } from './activation.js';
45
- import { now, messageFilename } from './filenames.js';
46
- import { serializeFrontmatter } from './frontmatter.js';
47
- import { withLock } from './turnq.js';
48
- import { writeDlqEntry, isQuarantined } from './dlq.js';
49
-
50
- const RUNTIME_VERSION: string = (() => {
51
- try {
52
- const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
53
- return (JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string }).version ?? 'unknown';
54
- } catch {
55
- return 'unknown';
56
- }
57
- })();
58
-
59
- const transportRoot = resolve(process.cwd());
60
- const argv = process.argv.slice(2);
61
-
62
- function flag(name: string): string | undefined {
63
- const i = argv.indexOf(name);
64
- if (i === -1 || i === argv.length - 1) return undefined;
65
- return argv[i + 1];
66
- }
67
-
68
- const onceMode = argv.includes('--once');
69
- const jsonMode = argv.includes('--json');
70
- const hostOverride = flag('--host');
71
- const pollSeconds = Number(flag('--poll')) || 30;
72
- const logFile = flag('--log-file');
73
-
74
- const CLI_TIMEOUT_MS = 5 * 60_000;
75
- const MAX_BACKOFF_MULTIPLIER = 10;
76
- const BACKOFF_GRACE = 2;
77
-
78
- function log(event: string, fields: Record<string, unknown> = {}): void {
79
- let line: string;
80
- if (jsonMode) {
81
- line = JSON.stringify({ ts: new Date().toISOString(), event, ...fields });
82
- } else {
83
- const tail = Object.entries(fields)
84
- .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
85
- .join(' ');
86
- line = `[${new Date().toISOString()}] ${event}${tail ? ' ' + tail : ''}`;
87
- }
88
- console.log(line);
89
- if (logFile) {
90
- try { appendFileSync(logFile, line + '\n'); } catch { /* best-effort */ }
91
- }
92
- }
93
-
94
- // Config errors (bad host file, bad actor profile) repeat every tick until
95
- // fixed — log each distinct one once per process run, not once per tick.
96
- const loggedConfigErrors = new Set<string>();
97
- function logConfigError(scope: string, message: string): void {
98
- const key = `${scope}::${message}`;
99
- if (loggedConfigErrors.has(key)) return;
100
- loggedConfigErrors.add(key);
101
- logError(transportRoot, 'parse', `${scope}: ${message}`);
102
- log('config_error', { scope, message: message.slice(0, 200) });
103
- }
104
-
105
- const protocolPrompt = (() => {
106
- const p = join(transportRoot, 'upstream', 'PROTOCOL.md');
107
- return existsSync(p) ? readFileSync(p, 'utf-8').trim() : '';
108
- })();
109
-
110
- function composeSystemPrompt(actorPrompt: string): string {
111
- return [protocolPrompt, actorPrompt].filter((p) => p.length > 0).join('\n\n---\n\n');
112
- }
113
-
114
- function actorConcurrency(tiers: HostActorTiers): number {
115
- for (const value of Object.values(tiers)) {
116
- if (typeof value === 'object' && typeof value.count === 'number' && value.count > 0) {
117
- return value.count;
118
- }
119
- }
120
- return 1;
121
- }
122
-
123
- function messageSender(msg: ChannelMessage): string {
124
- return typeof msg.data['from'] === 'string' ? (msg.data['from'] as string) : 'unknown';
125
- }
126
-
127
- interface CliResult {
128
- status: number;
129
- stdout: string;
130
- stderr: string;
131
- }
132
-
133
- function invokeCli(
134
- cli: string,
135
- systemPrompt: string,
136
- userMessage: string,
137
- env: Record<string, string>,
138
- ): Promise<CliResult> {
139
- return new Promise((res) => {
140
- const fullPrompt = `${systemPrompt}\n\n---\n\n${userMessage}`;
141
- const parts = tokenizeCli(cli);
142
- if (parts.length === 0) {
143
- res({ status: 1, stdout: '', stderr: 'tokenized cli is empty' });
144
- return;
145
- }
146
- // detached: new process group, so the timeout SIGKILL takes the actor's
147
- // children with it — orphans writing to the transport after a timeout
148
- // was an observed v5 hazard.
149
- const child = spawn(parts[0]!, parts.slice(1), {
150
- stdio: ['pipe', 'pipe', 'pipe'],
151
- detached: true,
152
- env: { ...process.env, ...env },
153
- });
154
- let stdout = '';
155
- let stderr = '';
156
- let resolved = false;
157
- const timeout = setTimeout(() => {
158
- if (resolved) return;
159
- resolved = true;
160
- try {
161
- if (typeof child.pid === 'number') process.kill(-child.pid, 'SIGKILL');
162
- else child.kill('SIGKILL');
163
- } catch {
164
- try { child.kill('SIGKILL'); } catch { /* already dead */ }
165
- }
166
- res({ status: 124, stdout, stderr: stderr + '\n[timeout]' });
167
- }, CLI_TIMEOUT_MS);
168
- child.stdout.on('data', (d) => { stdout += d.toString(); });
169
- child.stderr.on('data', (d) => { stderr += d.toString(); });
170
- child.on('close', (code) => {
171
- if (resolved) return;
172
- resolved = true;
173
- clearTimeout(timeout);
174
- res({ status: code ?? 1, stdout, stderr });
175
- });
176
- child.on('error', (err) => {
177
- if (resolved) return;
178
- resolved = true;
179
- clearTimeout(timeout);
180
- res({ status: 1, stdout, stderr: stderr + '\n' + err.message });
181
- });
182
- child.stdin.on('error', () => { /* child closed stdin */ });
183
- try { child.stdin.write(fullPrompt); } catch { /* same */ }
184
- try { child.stdin.end(); } catch { /* ignore */ }
185
- });
186
- }
187
-
188
- function writeReply(
189
- channelUuid: string,
190
- fromActor: string,
191
- toActor: string,
192
- re: string | string[],
193
- body: string,
194
- ): void {
195
- const ts = now();
196
- const dir = join(transportRoot, 'data', 'channels', channelUuid, ts.pathDate);
197
- mkdirSync(dir, { recursive: true });
198
- const content = serializeFrontmatter(
199
- { from: fromActor, to: toActor, type: 'text', timestamp: ts.iso, re },
200
- body,
201
- );
202
- writeFileSync(join(dir, messageFilename(ts)), content);
203
- }
204
-
205
- function formatBatchedUserMessage(msgs: ChannelMessage[]): string {
206
- if (msgs.length === 1) return msgs[0]!.body;
207
- const parts = [`You have ${msgs.length} new messages in this channel. Process them collectively and reply once.`];
208
- for (let i = 0; i < msgs.length; i++) {
209
- const m = msgs[i]!;
210
- const ts = typeof m.data['timestamp'] === 'string' ? `, ts: ${m.data['timestamp']}` : '';
211
- parts.push(`--- Message ${i + 1} of ${msgs.length} (from: ${messageSender(m)}, ref: ${m.relPath}${ts}) ---`);
212
- parts.push(m.body);
213
- }
214
- return parts.join('\n\n');
215
- }
216
-
217
- interface PendingDispatch {
218
- actorName: string;
219
- channelUuid: string;
220
- msgs: ChannelMessage[];
221
- tiers: HostActorTiers;
222
- }
223
-
224
- async function dispatchOne(p: PendingDispatch): Promise<boolean> {
225
- const firstMsg = p.msgs[0]!;
226
- const lastMsg = p.msgs[p.msgs.length - 1]!;
227
-
228
- if (isQuarantined(transportRoot, p.actorName, p.channelUuid, lastMsg.relPath)) {
229
- log('dispatch_skipped_quarantined', {
230
- actor: p.actorName,
231
- channel: p.channelUuid.slice(0, 8),
232
- msg: lastMsg.relPath,
233
- });
234
- return false;
235
- }
236
-
237
- const preferredTier = typeof firstMsg.data['tier'] === 'string' ? (firstMsg.data['tier'] as string) : undefined;
238
- let cli: string;
239
- let profile;
240
- try {
241
- cli = pickTier(p.tiers, preferredTier).cli;
242
- profile = loadActorProfile(transportRoot, p.actorName);
243
- } catch (err) {
244
- logConfigError(`actor:${p.actorName}`, (err as Error).message);
245
- return false;
246
- }
247
-
248
- log('dispatch', {
249
- actor: p.actorName,
250
- channel: p.channelUuid.slice(0, 8),
251
- batch_size: p.msgs.length,
252
- first_msg: firstMsg.relPath,
253
- last_msg: lastMsg.relPath,
254
- });
255
-
256
- const result = await invokeCli(
257
- cli,
258
- composeSystemPrompt(profile.systemPrompt),
259
- formatBatchedUserMessage(p.msgs),
260
- {
261
- CROSSTALK_DISPATCH_ACTOR: p.actorName,
262
- CROSSTALK_DISPATCH_CHANNEL: p.channelUuid,
263
- // Every relPath in the batch — `crosstalk send` records them all as
264
- // the reply's re: list, so batching never loses an answered message.
265
- CROSSTALK_DISPATCH_RE: p.msgs.map((m) => m.relPath).join(','),
266
- },
267
- );
268
-
269
- if (result.status !== 0) {
270
- const r = writeDlqEntry(
271
- transportRoot,
272
- p.actorName,
273
- p.channelUuid,
274
- lastMsg.relPath,
275
- `cli exit=${result.status}\n${result.stderr.slice(0, 1000)}`,
276
- );
277
- log('dispatch_failed', {
278
- actor: p.actorName,
279
- channel: p.channelUuid.slice(0, 8),
280
- batch_size: p.msgs.length,
281
- dlq_id: r.id,
282
- attempts: r.attempts,
283
- quarantined: r.quarantined,
284
- exit: result.status,
285
- });
286
- return false;
287
- }
288
-
289
- const reply = result.stdout.trim();
290
- if (reply.length === 0) {
291
- // Legitimate: the actor routed its answer via `crosstalk send` (which
292
- // auto-links re:). If it truly did nothing, the asker's `crosstalk
293
- // replies` stays PENDING — visible, not silently lost.
294
- log('dispatch_silent', { actor: p.actorName, channel: p.channelUuid.slice(0, 8), batch_size: p.msgs.length });
295
- log('dispatch_done', { actor: p.actorName, channel: p.channelUuid.slice(0, 8), batch_size: p.msgs.length, replied: false });
296
- return true;
297
- }
298
-
299
- // One reply per distinct sender, re:-linked to EVERY message that sender
300
- // had in the batch — the asker's activation rule fires, and `crosstalk
301
- // replies` sees each individual message as answered.
302
- const bySender = new Map<string, string[]>();
303
- for (const m of p.msgs) {
304
- const sender = messageSender(m);
305
- bySender.set(sender, [...(bySender.get(sender) ?? []), m.relPath]);
306
- }
307
- bySender.delete('unknown');
308
- if (bySender.size === 0) bySender.set(messageSender(firstMsg), [firstMsg.relPath]);
309
- for (const [sender, relPaths] of bySender) {
310
- writeReply(p.channelUuid, p.actorName, sender, relPaths.length === 1 ? relPaths[0]! : relPaths, reply);
311
- }
312
- log('dispatch_done', { actor: p.actorName, channel: p.channelUuid.slice(0, 8), batch_size: p.msgs.length, replied: true });
313
- return true;
314
- }
315
-
316
- interface TickResult {
317
- didWork: boolean;
318
- infraOk: boolean;
319
- }
320
-
321
- async function dispatchTick(): Promise<TickResult> {
322
- writeHeartbeat(transportRoot, RUNTIME_VERSION);
323
- let infraOk = true;
324
-
325
- const pullResult = gitPull(transportRoot);
326
- if (!pullResult.ok) {
327
- // Skip the whole tick: a failed pull can leave origin/HEAD (the cursor
328
- // baseline) ahead of the working tree, and scanning against that would
329
- // advance cursors past messages that never materialized.
330
- logError(transportRoot, 'git_pull', pullResult.error ?? 'unknown');
331
- log('git_pull_failed', { error: (pullResult.error ?? '').slice(0, 200) });
332
- return { didWork: false, infraOk: false };
333
- }
334
-
335
- let host: HostFile;
336
- try {
337
- host = findHostFile(transportRoot, hostOverride);
338
- } catch (err) {
339
- logConfigError('host', (err as Error).message);
340
- return { didWork: false, infraOk };
341
- }
342
-
343
- // Cursors are commit hashes, not relPaths: filenames order by sender
344
- // timestamp but arrive in push order, so a relPath cursor can advance
345
- // past a slower writer's earlier-stamped message and lose it forever.
346
- // "New since cursor" is asked of git, which records arrival truthfully.
347
- const head = cursorBaseline(transportRoot);
348
- if (!head) {
349
- logError(transportRoot, 'other', 'git rev-parse failed for origin/HEAD and HEAD — skipping tick');
350
- return { didWork: false, infraOk: false };
351
- }
352
- // diff results keyed by cursor commit (shared across actors on the same
353
- // cursor); null = commit unknown to this clone -> full re-scan.
354
- const addedSince = new Map<string, Set<string> | null>();
355
-
356
- let didWork = false;
357
- const channels = discoverChannels(transportRoot);
358
-
359
- for (const actorName of Object.keys(host.actors)) {
360
- const tiers = host.actors[actorName]!;
361
- const concurrency = actorConcurrency(tiers);
362
- const pending: PendingDispatch[] = [];
363
-
364
- for (const channelUuid of channels) {
365
- const persistedCursor = readCursor(transportRoot, actorName, channelUuid);
366
- if (persistedCursor === head) continue;
367
-
368
- // First encounter: seed to the commit that introduced this actor's host
369
- // file. Messages sent after the host joined are delivered (store-and-
370
- // forward); pre-join history is ignored. Seeding to HEAD would silently
371
- // drop messages sent while the dispatcher was offline — the wrong trade.
372
- // Fall through after seeding so this tick processes the post-join backlog
373
- // (otherwise `--once` users hit a seed-then-dispatch two-tick gotcha).
374
- let cursor: string;
375
- if (persistedCursor === null) {
376
- const joinCommit = hostFileCommit(transportRoot, host.alias);
377
- cursor = joinCommit ?? head;
378
- writeCursor(transportRoot, actorName, channelUuid, cursor);
379
- if (cursor === head) continue;
380
- } else {
381
- cursor = persistedCursor;
382
- }
383
-
384
- const messages = listChannelMessages(transportRoot, channelUuid);
385
- const senderByRelPath = new Map(messages.map((m) => [m.relPath, messageSender(m)]));
386
- const senderOf = (relPath: string) => senderByRelPath.get(relPath);
387
-
388
- let added = addedSince.get(cursor);
389
- if (added === undefined) {
390
- const files = newFilesSince(transportRoot, cursor);
391
- added = files === null ? null : new Set(files);
392
- addedSince.set(cursor, added);
393
- if (added === null) {
394
- logError(transportRoot, 'other', `cursor commit ${cursor.slice(0, 12)} unknown to this clone — full channel re-scan`);
395
- }
396
- }
397
- let post = messages;
398
- if (added !== null) {
399
- const prefix = `data/channels/${channelUuid}/`;
400
- post = messages.filter((m) => added.has(prefix + m.relPath));
401
- }
402
- if (post.length === 0) {
403
- writeCursor(transportRoot, actorName, channelUuid, head);
404
- continue;
405
- }
406
-
407
- const batch: ChannelMessage[] = [];
408
- for (const msg of post) {
409
- if (msg.data['type'] !== 'text') continue;
410
- const decision = decideWake(
411
- {
412
- from: messageSender(msg),
413
- to: recipients(msg.data['to']),
414
- re: reList(msg.data['re']),
415
- },
416
- actorName,
417
- host.alias,
418
- senderOf,
419
- );
420
- if (decision === 'wake') {
421
- batch.push(msg);
422
- } else if (decision === 'wrong-host') {
423
- log('host_routing_mismatch', {
424
- actor: actorName,
425
- this_host: host.alias,
426
- channel: channelUuid.slice(0, 8),
427
- msg: msg.relPath,
428
- to: recipients(msg.data['to']),
429
- });
430
- }
431
- }
432
-
433
- if (batch.length === 0) {
434
- writeCursor(transportRoot, actorName, channelUuid, head);
435
- continue;
436
- }
437
- for (const g of splitForConcurrency(batch, concurrency)) {
438
- pending.push({ actorName, channelUuid, msgs: g, tiers });
439
- }
440
- }
441
-
442
- // Waves of `concurrency` parallel CLI invocations. The cursor advances
443
- // to the scanned commit whether each batch succeeded or DLQ'd —
444
- // at-least-once was attempted; `crosstalk dlq --retry` rewinds the
445
- // cursor explicitly. A crash mid-wave leaves the cursor behind, so the
446
- // whole span replays next tick (at-least-once, never lost).
447
- for (let i = 0; i < pending.length; i += concurrency) {
448
- const wave = pending.slice(i, i + concurrency);
449
- const results = await Promise.all(wave.map((p) => dispatchOne(p)));
450
- if (results.some(Boolean)) didWork = true;
451
- }
452
- for (const p of pending) {
453
- writeCursor(transportRoot, p.actorName, p.channelUuid, head);
454
- }
455
- }
456
-
457
- if (didWork) {
458
- const pushResult = await withLock(transportRoot, 'git', async () =>
459
- gitCommitAndPush(transportRoot, `dispatch: replies ${new Date().toISOString()}`),
460
- );
461
- if (!pushResult.ok && pushResult.error) {
462
- logError(transportRoot, pushResult.committed ? 'git_push' : 'git_commit', pushResult.error);
463
- log('git_push_failed', { committed_locally: pushResult.committed, error: pushResult.error.slice(0, 200) });
464
- infraOk = false;
465
- }
466
- }
467
-
468
- return { didWork, infraOk };
469
- }
470
-
471
- async function waitForWakeOrTimeout(ms: number): Promise<void> {
472
- const dir = stateDir(transportRoot);
473
- const ac = new AbortController();
474
- const timer = setTimeout(() => ac.abort(), ms);
475
- try {
476
- const watcher = watch(dir, { signal: ac.signal });
477
- for await (const ev of watcher) {
478
- if (ev.filename === 'wake.signal') return;
479
- }
480
- } catch {
481
- /* abort = timeout */
482
- } finally {
483
- clearTimeout(timer);
484
- }
485
- }
486
-
487
- async function main(): Promise<void> {
488
- writePidfile(transportRoot);
489
- const cleanup = () => removePidfile(transportRoot);
490
- process.on('exit', cleanup);
491
- process.on('SIGTERM', () => { cleanup(); process.exit(0); });
492
- process.on('SIGINT', () => { cleanup(); process.exit(0); });
493
-
494
- log('dispatch_start', { transport: transportRoot, version: RUNTIME_VERSION, state_dir: stateDir(transportRoot) });
495
- if (onceMode) {
496
- await dispatchTick();
497
- process.exit(0);
498
- }
499
- log('dispatch_running', { quiet_poll_s: pollSeconds });
500
-
501
- let consecutiveInfraFailures = 0;
502
- while (true) {
503
- try {
504
- const r = await dispatchTick();
505
- if (r.infraOk) {
506
- if (consecutiveInfraFailures > 0) log('backoff_cleared', { previous_failures: consecutiveInfraFailures });
507
- consecutiveInfraFailures = 0;
508
- } else {
509
- consecutiveInfraFailures++;
510
- }
511
- const beyondGrace = Math.max(0, consecutiveInfraFailures - BACKOFF_GRACE);
512
- const backoffFactor = Math.min(MAX_BACKOFF_MULTIPLIER, 2 ** beyondGrace);
513
- if (backoffFactor > 1) {
514
- log('backoff_active', { consecutive_failures: consecutiveInfraFailures, factor: backoffFactor });
515
- }
516
- if (r.didWork) {
517
- await new Promise((res) => setTimeout(res, 1_000 * backoffFactor));
518
- } else {
519
- await waitForWakeOrTimeout(pollSeconds * 1_000 * backoffFactor);
520
- }
521
- } catch (err) {
522
- const msg = (err as Error).message;
523
- logError(transportRoot, 'other', `tick error: ${msg}`);
524
- log('tick_error', { message: msg });
525
- consecutiveInfraFailures++;
526
- await new Promise((res) => setTimeout(res, pollSeconds * 1_000));
527
- }
528
- }
529
- }
530
-
531
- main();