@cordfuse/crosstalk 5.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 (52) hide show
  1. package/bin/crosstalk.js +111 -0
  2. package/package.json +46 -0
  3. package/src/actor.ts +106 -0
  4. package/src/attach.ts +118 -0
  5. package/src/channel.ts +62 -0
  6. package/src/chat.ts +203 -0
  7. package/src/cursor.ts +48 -0
  8. package/src/dispatch.ts +519 -0
  9. package/src/dlq.ts +263 -0
  10. package/src/filenames.ts +28 -0
  11. package/src/frontmatter.ts +26 -0
  12. package/src/init.ts +157 -0
  13. package/src/open.ts +183 -0
  14. package/src/send.ts +80 -0
  15. package/src/status.ts +114 -0
  16. package/src/transport.ts +303 -0
  17. package/src/turnq.ts +59 -0
  18. package/src/upgrade.ts +213 -0
  19. package/src/wake.ts +8 -0
  20. package/template/.amazonq/rules/crosstalk.md +2 -0
  21. package/template/.continue/rules/crosstalk.md +7 -0
  22. package/template/.cursor/rules/crosstalk.mdc +7 -0
  23. package/template/.github/copilot-instructions.md +2 -0
  24. package/template/.windsurfrules +2 -0
  25. package/template/AGENTS.md +2 -0
  26. package/template/ANTIGRAVITY.md +2 -0
  27. package/template/CLAUDE.md +2 -0
  28. package/template/GEMINI.md +2 -0
  29. package/template/OPENCODE.md +2 -0
  30. package/template/QWEN.md +2 -0
  31. package/template/README.md +22 -0
  32. package/template/local/CROSSTALK.md +4 -0
  33. package/template/upstream/CROSSTALK-VERSION +1 -0
  34. package/template/upstream/CROSSTALK.md +589 -0
  35. package/template/upstream/JITTER.md +24 -0
  36. package/template/upstream/OPERATOR.md +60 -0
  37. package/template/upstream/PROTOCOL.md +180 -0
  38. package/template/upstream/actors/cloud-architect.md +83 -0
  39. package/template/upstream/actors/concierge.md +105 -0
  40. package/template/upstream/actors/devops-engineer.md +83 -0
  41. package/template/upstream/actors/documentation-engineer.md +107 -0
  42. package/template/upstream/actors/infrastructure-engineer.md +83 -0
  43. package/template/upstream/actors/junior-developer.md +83 -0
  44. package/template/upstream/actors/precise-generalist.md +48 -0
  45. package/template/upstream/actors/product-manager.md +83 -0
  46. package/template/upstream/actors/qa-engineer.md +83 -0
  47. package/template/upstream/actors/security-engineer.md +92 -0
  48. package/template/upstream/actors/senior-generalist-engineer.md +111 -0
  49. package/template/upstream/actors/senior-software-engineer.md +94 -0
  50. package/template/upstream/actors/skeptic.md +89 -0
  51. package/template/upstream/actors/technical-writer.md +89 -0
  52. package/template/upstream/actors/ux-designer.md +83 -0
@@ -0,0 +1,519 @@
1
+ import { resolve, join } from 'path';
2
+ import { spawn } from 'child_process';
3
+ import {
4
+ mkdirSync,
5
+ writeFileSync,
6
+ readFileSync,
7
+ existsSync,
8
+ appendFileSync,
9
+ openSync,
10
+ closeSync,
11
+ } from 'fs';
12
+ import { watch } from 'fs/promises';
13
+ import {
14
+ findHostFile,
15
+ loadActorProfile,
16
+ pickTier,
17
+ tokenizeCli,
18
+ type HostActorTiers,
19
+ type HostFile,
20
+ } from './actor.js';
21
+ import {
22
+ discoverChannels,
23
+ listChannelMessages,
24
+ gitPull,
25
+ gitCommitAndPush,
26
+ writeErrorLog,
27
+ sweepStaleReadReceipts,
28
+ type ChannelMessage,
29
+ } from './transport.js';
30
+ import { readCursor, writeCursor } from './cursor.js';
31
+ import { now, messageFilename } from './filenames.js';
32
+ import { serializeFrontmatter } from './frontmatter.js';
33
+ import { withLock } from './turnq.js';
34
+ import { writeDlqEntry, isQuarantined, isActorQuarantined } from './dlq.js';
35
+
36
+ const transportRoot = resolve(process.cwd());
37
+ const argv = process.argv.slice(2);
38
+
39
+ function flag(name: string): string | undefined {
40
+ const i = argv.indexOf(name);
41
+ if (i === -1 || i === argv.length - 1) return undefined;
42
+ return argv[i + 1];
43
+ }
44
+
45
+ const onceMode = argv.includes('--once');
46
+ const jsonMode = argv.includes('--json');
47
+ const hostOverride = flag('--host');
48
+ const pollSeconds = Number(flag('--poll')) || 30;
49
+ const logFile = flag('--log-file');
50
+
51
+ // Backoff config — persistent infra failures (git pull/push) trigger
52
+ // exponential delay. Reset on any successful pull+push cycle.
53
+ const MAX_BACKOFF_MULTIPLIER = 10; // cap: pollSeconds * 10
54
+ const BACKOFF_GRACE = 2; // first N failures don't trigger backoff
55
+
56
+ // Stale-read-receipt sweep config — runs at most every SWEEP_INTERVAL_MS
57
+ // of wall-clock to surface read receipts that never produced a reply
58
+ // (indicates dispatch crashed mid-tick or CLI hung silently).
59
+ const SWEEP_INTERVAL_MS = 5 * 60_000;
60
+ const STALE_RECEIPT_THRESHOLD_MS = 5 * 60_000;
61
+ let lastSweepAt = 0;
62
+
63
+ function log(event: string, fields: Record<string, unknown> = {}): void {
64
+ let line: string;
65
+ if (jsonMode) {
66
+ line = JSON.stringify({ ts: new Date().toISOString(), event, ...fields });
67
+ } else {
68
+ const tail = Object.entries(fields)
69
+ .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
70
+ .join(' ');
71
+ line = `[${new Date().toISOString()}] ${event}${tail ? ' ' + tail : ''}`;
72
+ }
73
+ console.log(line);
74
+ if (logFile) {
75
+ try { appendFileSync(logFile, line + '\n'); } catch { /* best-effort */ }
76
+ }
77
+ }
78
+
79
+ function writeHeartbeat(): void {
80
+ try {
81
+ const dir = join(transportRoot, '.turnq');
82
+ mkdirSync(dir, { recursive: true });
83
+ const data = { ts: new Date().toISOString(), pid: process.pid, version: '5.0.0-alpha.1' };
84
+ writeFileSync(join(dir, 'heartbeat'), JSON.stringify(data) + '\n');
85
+ } catch { /* best-effort */ }
86
+ }
87
+
88
+ function loadProtocolPrompt(): string {
89
+ const p = join(transportRoot, 'upstream', 'PROTOCOL.md');
90
+ if (!existsSync(p)) return '';
91
+ return readFileSync(p, 'utf-8').trim();
92
+ }
93
+
94
+ const protocolPrompt = loadProtocolPrompt();
95
+
96
+ function recipients(toField: unknown): string[] {
97
+ if (Array.isArray(toField)) return toField.map(String);
98
+ if (typeof toField === 'string') return [toField];
99
+ return [];
100
+ }
101
+
102
+ function composeSystemPrompt(actorPrompt: string): string {
103
+ return [protocolPrompt, actorPrompt]
104
+ .filter((p) => p.length > 0)
105
+ .join('\n\n---\n\n');
106
+ }
107
+
108
+ function actorConcurrency(tiers: HostActorTiers): number {
109
+ for (const value of Object.values(tiers)) {
110
+ if (typeof value === 'object' && typeof value.count === 'number' && value.count > 0) {
111
+ return value.count;
112
+ }
113
+ }
114
+ return 1;
115
+ }
116
+
117
+ interface CliResult {
118
+ status: number;
119
+ stdout: string;
120
+ stderr: string;
121
+ }
122
+
123
+ function invokeCli(cli: string, systemPrompt: string, userMessage: string): Promise<CliResult> {
124
+ return new Promise((res) => {
125
+ const fullPrompt = `${systemPrompt}\n\n---\n\n${userMessage}`;
126
+ const parts = tokenizeCli(cli);
127
+ if (parts.length === 0) {
128
+ res({ status: 1, stdout: '', stderr: 'tokenized cli is empty' });
129
+ return;
130
+ }
131
+ const child = spawn(parts[0], parts.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
132
+ let stdout = '';
133
+ let stderr = '';
134
+ let resolved = false;
135
+ const timeout = setTimeout(() => {
136
+ if (resolved) return;
137
+ resolved = true;
138
+ child.kill('SIGKILL');
139
+ res({ status: 124, stdout, stderr: stderr + '\n[timeout]' });
140
+ }, 5 * 60_000);
141
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
142
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
143
+ child.on('close', (code) => {
144
+ if (resolved) return;
145
+ resolved = true;
146
+ clearTimeout(timeout);
147
+ res({ status: code ?? 1, stdout, stderr });
148
+ });
149
+ child.on('error', (err) => {
150
+ if (resolved) return;
151
+ resolved = true;
152
+ clearTimeout(timeout);
153
+ res({ status: 1, stdout, stderr: stderr + '\n' + err.message });
154
+ });
155
+ // The child may exit before reading stdin (e.g. cli=`false`). Attach
156
+ // an error handler so EPIPE is swallowed instead of crashing dispatch,
157
+ // and guard the write itself.
158
+ child.stdin.on('error', () => { /* EPIPE/etc. — child closed stdin */ });
159
+ try {
160
+ child.stdin.write(fullPrompt);
161
+ } catch { /* same: child closed stdin before we could write */ }
162
+ try {
163
+ child.stdin.end();
164
+ } catch { /* ignore */ }
165
+ });
166
+ }
167
+
168
+ function writeReply(
169
+ channelUuid: string,
170
+ fromActor: string,
171
+ toActor: string,
172
+ body: string,
173
+ ): void {
174
+ const ts = now();
175
+ const dir = join(transportRoot, 'data', 'channels', channelUuid, ts.pathDate);
176
+ mkdirSync(dir, { recursive: true });
177
+ const content = serializeFrontmatter(
178
+ { from: fromActor, to: toActor, type: 'text', timestamp: ts.iso },
179
+ body,
180
+ );
181
+ writeFileSync(join(dir, messageFilename(ts)), content);
182
+ }
183
+
184
+ function writeReadReceipt(
185
+ channelUuid: string,
186
+ fromActor: string,
187
+ toActor: string,
188
+ ref: string,
189
+ ): void {
190
+ const ts = now();
191
+ const dir = join(transportRoot, 'data', 'channels', channelUuid, ts.pathDate);
192
+ mkdirSync(dir, { recursive: true });
193
+ const content = serializeFrontmatter(
194
+ { from: fromActor, to: toActor, type: 'read', ref, timestamp: ts.iso },
195
+ '',
196
+ );
197
+ writeFileSync(join(dir, messageFilename(ts)), content);
198
+ }
199
+
200
+ interface PendingDispatch {
201
+ actorName: string;
202
+ channelUuid: string;
203
+ msg: ChannelMessage;
204
+ from: string;
205
+ tiers: HostActorTiers;
206
+ }
207
+
208
+ async function dispatchOne(p: PendingDispatch): Promise<boolean> {
209
+ // Resolve CLI per-message — message frontmatter may request a specific
210
+ // tier via `tier: <name>`. Falls back to first declared tier.
211
+ const preferredTier = typeof p.msg.data['tier'] === 'string'
212
+ ? (p.msg.data['tier'] as string)
213
+ : undefined;
214
+ let resolved;
215
+ try {
216
+ resolved = pickTier(p.tiers, preferredTier);
217
+ } catch (err) {
218
+ const r = writeDlqEntry(
219
+ transportRoot,
220
+ 'config',
221
+ p.actorName,
222
+ '(config)',
223
+ '(config)',
224
+ `tier selection failed: ${(err as Error).message}`,
225
+ );
226
+ log('actor_config_error', {
227
+ actor: p.actorName,
228
+ dlq_id: r.id,
229
+ attempts: r.attempts,
230
+ quarantined: r.quarantined,
231
+ });
232
+ return false;
233
+ }
234
+ const cli = resolved.cli;
235
+ if (isQuarantined(transportRoot, 'dispatch', p.actorName, p.channelUuid, p.msg.relPath)) {
236
+ log('dispatch_skipped_quarantined', {
237
+ actor: p.actorName,
238
+ channel: p.channelUuid.slice(0, 8),
239
+ msg: p.msg.relPath,
240
+ });
241
+ return false;
242
+ }
243
+
244
+ log('dispatch', {
245
+ actor: p.actorName,
246
+ channel: p.channelUuid.slice(0, 8),
247
+ msg: p.msg.relPath,
248
+ });
249
+
250
+ writeReadReceipt(p.channelUuid, p.actorName, p.from, p.msg.relPath);
251
+
252
+ let profile;
253
+ try {
254
+ profile = loadActorProfile(transportRoot, p.actorName);
255
+ } catch (err) {
256
+ const r = writeDlqEntry(
257
+ transportRoot,
258
+ 'config',
259
+ p.actorName,
260
+ '(config)',
261
+ '(config)',
262
+ `actor profile load failed: ${(err as Error).message}`,
263
+ );
264
+ log('dispatch_config_error', {
265
+ actor: p.actorName,
266
+ dlq_id: r.id,
267
+ attempts: r.attempts,
268
+ quarantined: r.quarantined,
269
+ });
270
+ return false;
271
+ }
272
+
273
+ const systemPrompt = composeSystemPrompt(profile.systemPrompt);
274
+ const result = await invokeCli(cli, systemPrompt, p.msg.body);
275
+
276
+ if (result.status !== 0) {
277
+ const r = writeDlqEntry(
278
+ transportRoot,
279
+ 'dispatch',
280
+ p.actorName,
281
+ p.channelUuid,
282
+ p.msg.relPath,
283
+ `cli exit=${result.status}\n${result.stderr.slice(0, 1000)}`,
284
+ );
285
+ log('dispatch_failed', {
286
+ actor: p.actorName,
287
+ channel: p.channelUuid.slice(0, 8),
288
+ dlq_id: r.id,
289
+ attempts: r.attempts,
290
+ quarantined: r.quarantined,
291
+ exit: result.status,
292
+ });
293
+ return false;
294
+ }
295
+
296
+ const reply = result.stdout.trim();
297
+ if (reply.length === 0) {
298
+ const r = writeDlqEntry(
299
+ transportRoot,
300
+ 'dispatch',
301
+ p.actorName,
302
+ p.channelUuid,
303
+ p.msg.relPath,
304
+ 'cli returned empty reply',
305
+ );
306
+ log('dispatch_empty_reply', {
307
+ actor: p.actorName,
308
+ channel: p.channelUuid.slice(0, 8),
309
+ dlq_id: r.id,
310
+ attempts: r.attempts,
311
+ quarantined: r.quarantined,
312
+ });
313
+ return false;
314
+ }
315
+
316
+ writeReply(p.channelUuid, p.actorName, p.from, reply);
317
+ return true;
318
+ }
319
+
320
+ interface TickResult {
321
+ didWork: boolean;
322
+ infraOk: boolean;
323
+ }
324
+
325
+ async function dispatchTick(): Promise<TickResult> {
326
+ writeHeartbeat();
327
+
328
+ return withLock('dispatch', async () => {
329
+ let infraOk = true;
330
+
331
+ const pullResult = gitPull(transportRoot);
332
+ if (!pullResult.ok && pullResult.error) {
333
+ const errId = writeErrorLog(transportRoot, 'git_pull', pullResult.error);
334
+ log('git_pull_failed', { error_id: errId, error: pullResult.error.slice(0, 120) });
335
+ infraOk = false;
336
+ }
337
+
338
+ let host: HostFile;
339
+ try {
340
+ host = findHostFile(transportRoot, hostOverride);
341
+ } catch (err) {
342
+ const r = writeDlqEntry(
343
+ transportRoot,
344
+ 'config',
345
+ '(host)',
346
+ '(config)',
347
+ '(config)',
348
+ `host file load failed: ${(err as Error).message}`,
349
+ );
350
+ log('tick_config_error', {
351
+ scope: 'host',
352
+ dlq_id: r.id,
353
+ attempts: r.attempts,
354
+ quarantined: r.quarantined,
355
+ });
356
+ return { didWork: false, infraOk };
357
+ }
358
+
359
+ let didWork = false;
360
+
361
+ for (const actorName of Object.keys(host.actors)) {
362
+ if (isActorQuarantined(transportRoot, actorName)) {
363
+ log('actor_skipped_quarantined', { actor: actorName });
364
+ continue;
365
+ }
366
+
367
+ const tiers = host.actors[actorName];
368
+ const concurrency = actorConcurrency(tiers);
369
+
370
+ const pending: PendingDispatch[] = [];
371
+ const channels = discoverChannels(transportRoot);
372
+ for (const channelUuid of channels) {
373
+ const cursor = readCursor(transportRoot, actorName, channelUuid);
374
+ const messages = listChannelMessages(transportRoot, channelUuid);
375
+ const post = cursor ? messages.filter((m) => m.relPath > cursor) : messages;
376
+
377
+ log('tick_scan', {
378
+ actor: actorName,
379
+ channel: channelUuid.slice(0, 8),
380
+ cursor: cursor ?? '(none)',
381
+ total_msgs: messages.length,
382
+ post_cursor_msgs: post.length,
383
+ });
384
+
385
+ for (const msg of post) {
386
+ const to = recipients(msg.data['to']);
387
+ const from = typeof msg.data['from'] === 'string' ? msg.data['from'] : 'unknown';
388
+ if (!to.includes(actorName) || from === actorName) {
389
+ writeCursor(transportRoot, actorName, channelUuid, msg.relPath);
390
+ continue;
391
+ }
392
+ pending.push({ actorName, channelUuid, msg, from, tiers });
393
+ }
394
+ }
395
+
396
+ for (let i = 0; i < pending.length; i += concurrency) {
397
+ const batch = pending.slice(i, i + concurrency);
398
+ const results = await Promise.all(batch.map((p) => dispatchOne(p)));
399
+ for (let j = 0; j < batch.length; j++) {
400
+ writeCursor(
401
+ transportRoot,
402
+ batch[j].actorName,
403
+ batch[j].channelUuid,
404
+ batch[j].msg.relPath,
405
+ );
406
+ if (results[j]) didWork = true;
407
+ }
408
+ }
409
+ }
410
+
411
+ // Always attempt commit+push at end of tick — gitCommitAndPush
412
+ // short-circuits if the working tree is clean. This is required
413
+ // even when no replies were produced, because cursors advance for
414
+ // messages addressed to other actors (the actor's own replies and
415
+ // read receipts appear on the next pull and need to be skipped past).
416
+ // Without this commit, the orphan cursor change blocks the next
417
+ // git pull --rebase and dispatch dead-ends in backoff.
418
+ const commitMsg = didWork
419
+ ? `dispatch: replies + cursor advance ${new Date().toISOString()}`
420
+ : `dispatch: cursor advance ${new Date().toISOString()}`;
421
+ const pushResult = gitCommitAndPush(transportRoot, commitMsg);
422
+ if (!pushResult.ok && pushResult.error) {
423
+ const kind = pushResult.committed ? 'git_push' : 'git_commit';
424
+ const errId = writeErrorLog(transportRoot, kind, pushResult.error);
425
+ log('git_push_failed', {
426
+ error_id: errId,
427
+ committed_locally: pushResult.committed,
428
+ error: pushResult.error.slice(0, 120),
429
+ });
430
+ infraOk = false;
431
+ }
432
+
433
+ // Periodic stale-read-receipt sweep
434
+ if (Date.now() - lastSweepAt > SWEEP_INTERVAL_MS) {
435
+ const surfaced = sweepStaleReadReceipts(transportRoot, STALE_RECEIPT_THRESHOLD_MS);
436
+ lastSweepAt = Date.now();
437
+ if (surfaced > 0) {
438
+ log('stale_receipts_surfaced', { count: surfaced });
439
+ }
440
+ }
441
+
442
+ return { didWork, infraOk };
443
+ });
444
+ }
445
+
446
+ async function waitForWakeOrTimeout(ms: number): Promise<'wake' | 'timeout'> {
447
+ const wakeDir = join(transportRoot, '.turnq');
448
+ mkdirSync(wakeDir, { recursive: true });
449
+ const ac = new AbortController();
450
+ const timer = setTimeout(() => ac.abort(), ms);
451
+ try {
452
+ const watcher = watch(wakeDir, { signal: ac.signal });
453
+ for await (const ev of watcher) {
454
+ if (ev.filename === 'wake.signal') {
455
+ clearTimeout(timer);
456
+ return 'wake';
457
+ }
458
+ }
459
+ return 'timeout';
460
+ } catch {
461
+ return 'timeout';
462
+ } finally {
463
+ clearTimeout(timer);
464
+ }
465
+ }
466
+
467
+ async function main(): Promise<void> {
468
+ log('dispatch_start', {
469
+ transport: transportRoot,
470
+ version: '5.0.0-alpha.1',
471
+ log_file: logFile ?? null,
472
+ });
473
+ if (onceMode) {
474
+ await dispatchTick();
475
+ return;
476
+ }
477
+ log('coordinator_running', { quiet_poll_s: pollSeconds, active_poll_s: 1 });
478
+
479
+ let consecutiveInfraFailures = 0;
480
+
481
+ while (true) {
482
+ try {
483
+ const r = await dispatchTick();
484
+ if (r.infraOk) {
485
+ if (consecutiveInfraFailures > 0) {
486
+ log('backoff_cleared', { previous_consecutive_failures: consecutiveInfraFailures });
487
+ }
488
+ consecutiveInfraFailures = 0;
489
+ } else {
490
+ consecutiveInfraFailures++;
491
+ }
492
+
493
+ // Backoff kicks in only after a grace period of failures.
494
+ const beyondGrace = Math.max(0, consecutiveInfraFailures - BACKOFF_GRACE);
495
+ const backoffFactor = Math.min(MAX_BACKOFF_MULTIPLIER, 2 ** beyondGrace);
496
+
497
+ if (consecutiveInfraFailures > BACKOFF_GRACE) {
498
+ log('backoff_active', {
499
+ consecutive_failures: consecutiveInfraFailures,
500
+ factor: backoffFactor,
501
+ });
502
+ }
503
+
504
+ if (r.didWork) {
505
+ await new Promise((res) => setTimeout(res, 1_000 * backoffFactor));
506
+ } else {
507
+ await waitForWakeOrTimeout(pollSeconds * 1_000 * backoffFactor);
508
+ }
509
+ } catch (err) {
510
+ const msg = (err as Error).message;
511
+ writeErrorLog(transportRoot, 'other', `tick error: ${msg}`);
512
+ log('tick_error', { message: msg });
513
+ consecutiveInfraFailures++;
514
+ await new Promise((res) => setTimeout(res, pollSeconds * 1_000));
515
+ }
516
+ }
517
+ }
518
+
519
+ main();