@cordfuse/crosstalk 5.0.0-alpha.7 → 6.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.
Files changed (52) hide show
  1. package/bin/crosstalk.js +34 -78
  2. package/package.json +4 -4
  3. package/src/activation.ts +104 -0
  4. package/src/attach.ts +1 -1
  5. package/src/channel.ts +8 -21
  6. package/src/chat.ts +52 -115
  7. package/src/dispatch.ts +252 -661
  8. package/src/dlq.ts +68 -136
  9. package/src/init.ts +17 -41
  10. package/src/open.ts +55 -31
  11. package/src/replies.ts +59 -0
  12. package/src/send.ts +48 -67
  13. package/src/state.ts +143 -0
  14. package/src/status.ts +18 -57
  15. package/src/transport.ts +68 -198
  16. package/src/turnq.ts +64 -32
  17. package/src/upgrade.ts +9 -11
  18. package/src/wake.ts +5 -6
  19. package/src/cursor.ts +0 -48
  20. package/template/.amazonq/rules/crosstalk.md +0 -2
  21. package/template/.continue/rules/crosstalk.md +0 -7
  22. package/template/.cursor/rules/crosstalk.mdc +0 -7
  23. package/template/.github/copilot-instructions.md +0 -2
  24. package/template/.windsurfrules +0 -2
  25. package/template/AGENTS.md +0 -2
  26. package/template/ANTIGRAVITY.md +0 -2
  27. package/template/CLAUDE.md +0 -2
  28. package/template/GEMINI.md +0 -2
  29. package/template/OPENCODE.md +0 -2
  30. package/template/QWEN.md +0 -2
  31. package/template/README.md +0 -22
  32. package/template/local/CROSSTALK.md +0 -4
  33. package/template/upstream/CROSSTALK-VERSION +0 -1
  34. package/template/upstream/CROSSTALK.md +0 -589
  35. package/template/upstream/JITTER.md +0 -24
  36. package/template/upstream/OPERATOR.md +0 -60
  37. package/template/upstream/PROTOCOL.md +0 -260
  38. package/template/upstream/actors/cloud-architect.md +0 -83
  39. package/template/upstream/actors/concierge.md +0 -130
  40. package/template/upstream/actors/devops-engineer.md +0 -83
  41. package/template/upstream/actors/documentation-engineer.md +0 -107
  42. package/template/upstream/actors/infrastructure-engineer.md +0 -83
  43. package/template/upstream/actors/junior-developer.md +0 -83
  44. package/template/upstream/actors/precise-generalist.md +0 -48
  45. package/template/upstream/actors/product-manager.md +0 -83
  46. package/template/upstream/actors/qa-engineer.md +0 -83
  47. package/template/upstream/actors/security-engineer.md +0 -92
  48. package/template/upstream/actors/senior-generalist-engineer.md +0 -111
  49. package/template/upstream/actors/senior-software-engineer.md +0 -94
  50. package/template/upstream/actors/skeptic.md +0 -89
  51. package/template/upstream/actors/technical-writer.md +0 -89
  52. package/template/upstream/actors/ux-designer.md +0 -83
package/src/dlq.ts CHANGED
@@ -1,3 +1,8 @@
1
+ // Dead-letter queue — machine-local (state dir), never committed.
2
+ // A message that fails dispatch gets an entry; repeated failures inside
3
+ // the window quarantine it so a poison message can't spin the dispatcher.
4
+ // `crosstalk dlq --retry <id>` rewinds the cursor and clears the flag.
5
+
1
6
  import {
2
7
  readdirSync,
3
8
  readFileSync,
@@ -6,22 +11,20 @@ import {
6
11
  existsSync,
7
12
  unlinkSync,
8
13
  } from 'fs';
9
- import { resolve, join } from 'path';
14
+ import { resolve, join, dirname } from 'path';
10
15
  import { pathToFileURL } from 'url';
11
16
  import { now } from './filenames.js';
12
17
  import { serializeFrontmatter, parseFrontmatter } from './frontmatter.js';
18
+ import { stateDir, cursorPath } from './state.js';
13
19
 
14
20
  const QUARANTINE_THRESHOLD_ATTEMPTS = 4;
15
21
  const QUARANTINE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
16
22
 
17
- export type DlqKind = 'dispatch' | 'config';
18
-
19
23
  export interface DlqEntry {
20
24
  id: string;
21
- kind: DlqKind;
22
25
  actor: string;
23
- channel: string; // "(config)" for kind=config without channel context
24
- messageRelPath: string; // "(config)" for kind=config
26
+ channel: string;
27
+ messageRelPath: string;
25
28
  attempts: number;
26
29
  quarantined: boolean;
27
30
  firstFailedAt: string;
@@ -29,41 +32,23 @@ export interface DlqEntry {
29
32
  error: string;
30
33
  }
31
34
 
32
- export interface FoundDlqEntry {
33
- id: string;
34
- path: string;
35
- entry: DlqEntry;
36
- }
37
-
38
- export interface WriteDlqResult {
39
- id: string;
40
- attempts: number;
41
- quarantined: boolean;
42
- }
43
-
44
35
  function dlqDir(transportRoot: string): string {
45
- return join(transportRoot, 'dlq');
36
+ return join(stateDir(transportRoot), 'dlq');
46
37
  }
47
38
 
48
- export function findDlqEntry(
39
+ function findEntry(
49
40
  transportRoot: string,
50
- kind: DlqKind,
51
41
  actor: string,
52
42
  channel: string,
53
43
  messageRelPath: string,
54
- ): FoundDlqEntry | null {
44
+ ): { id: string; path: string; entry: DlqEntry } | null {
55
45
  const dir = dlqDir(transportRoot);
56
46
  if (!existsSync(dir)) return null;
57
47
  for (const f of readdirSync(dir).filter((x) => x.endsWith('.md'))) {
58
48
  const path = join(dir, f);
59
49
  try {
60
50
  const { data } = parseFrontmatter<DlqEntry>(readFileSync(path, 'utf-8'));
61
- if (
62
- data.kind === kind &&
63
- data.actor === actor &&
64
- data.channel === channel &&
65
- data.messageRelPath === messageRelPath
66
- ) {
51
+ if (data.actor === actor && data.channel === channel && data.messageRelPath === messageRelPath) {
67
52
  return { id: f.replace(/\.md$/, ''), path, entry: data };
68
53
  }
69
54
  } catch { /* skip unparseable */ }
@@ -73,49 +58,32 @@ export function findDlqEntry(
73
58
 
74
59
  export function isQuarantined(
75
60
  transportRoot: string,
76
- kind: DlqKind,
77
61
  actor: string,
78
62
  channel: string,
79
63
  messageRelPath: string,
80
64
  ): boolean {
81
- return findDlqEntry(transportRoot, kind, actor, channel, messageRelPath)?.entry.quarantined ?? false;
82
- }
83
-
84
- export function isActorQuarantined(transportRoot: string, actor: string): boolean {
85
- // Any config-kind DLQ entry for this actor that's quarantined gates the whole actor.
86
- const dir = dlqDir(transportRoot);
87
- if (!existsSync(dir)) return false;
88
- for (const f of readdirSync(dir).filter((x) => x.endsWith('.md'))) {
89
- try {
90
- const { data } = parseFrontmatter<DlqEntry>(readFileSync(join(dir, f), 'utf-8'));
91
- if (data.kind === 'config' && data.actor === actor && data.quarantined) return true;
92
- } catch { /* skip */ }
93
- }
94
- return false;
65
+ return findEntry(transportRoot, actor, channel, messageRelPath)?.entry.quarantined ?? false;
95
66
  }
96
67
 
97
68
  export function writeDlqEntry(
98
69
  transportRoot: string,
99
- kind: DlqKind,
100
70
  actor: string,
101
71
  channelUuid: string,
102
72
  messageRelPath: string,
103
73
  error: string,
104
- ): WriteDlqResult {
74
+ ): { id: string; attempts: number; quarantined: boolean } {
105
75
  const dir = dlqDir(transportRoot);
106
76
  mkdirSync(dir, { recursive: true });
107
77
 
108
- const existing = findDlqEntry(transportRoot, kind, actor, channelUuid, messageRelPath);
78
+ const existing = findEntry(transportRoot, actor, channelUuid, messageRelPath);
109
79
  const lastFailedAt = new Date().toISOString();
110
80
 
111
81
  if (existing) {
112
82
  const attempts = (existing.entry.attempts ?? 1) + 1;
113
- const firstFailedAt = existing.entry.firstFailedAt;
114
- const ageMs = Date.now() - new Date(firstFailedAt).getTime();
83
+ const ageMs = Date.now() - new Date(existing.entry.firstFailedAt).getTime();
115
84
  const quarantined =
116
85
  existing.entry.quarantined ||
117
86
  (attempts >= QUARANTINE_THRESHOLD_ATTEMPTS && ageMs < QUARANTINE_WINDOW_MS);
118
-
119
87
  const updated: DlqEntry = {
120
88
  ...existing.entry,
121
89
  attempts,
@@ -123,10 +91,7 @@ export function writeDlqEntry(
123
91
  error: error.slice(0, 500),
124
92
  quarantined,
125
93
  };
126
- writeFileSync(
127
- existing.path,
128
- serializeFrontmatter(updated as unknown as Record<string, unknown>, error),
129
- );
94
+ writeFileSync(existing.path, serializeFrontmatter(updated as unknown as Record<string, unknown>, error));
130
95
  return { id: existing.id, attempts, quarantined };
131
96
  }
132
97
 
@@ -134,7 +99,6 @@ export function writeDlqEntry(
134
99
  const id = `${ts.fileTime}-${ts.hex}`;
135
100
  const entry: DlqEntry = {
136
101
  id,
137
- kind,
138
102
  actor,
139
103
  channel: channelUuid,
140
104
  messageRelPath,
@@ -144,120 +108,88 @@ export function writeDlqEntry(
144
108
  lastFailedAt,
145
109
  error: error.slice(0, 500),
146
110
  };
147
- writeFileSync(
148
- join(dir, `${id}.md`),
149
- serializeFrontmatter(entry as unknown as Record<string, unknown>, error),
150
- );
111
+ writeFileSync(join(dir, `${id}.md`), serializeFrontmatter(entry as unknown as Record<string, unknown>, error));
151
112
  return { id, attempts: 1, quarantined: false };
152
113
  }
153
114
 
154
- export function deleteDlqEntry(transportRoot: string, id: string): boolean {
155
- const path = join(dlqDir(transportRoot), `${id}.md`);
156
- if (!existsSync(path)) return false;
157
- unlinkSync(path);
158
- return true;
159
- }
160
-
161
- function listEntries(transportRoot: string): { id: string; entry: DlqEntry }[] {
115
+ export function countDlqEntries(transportRoot: string): { total: number; quarantined: number } {
162
116
  const dir = dlqDir(transportRoot);
163
- if (!existsSync(dir)) return [];
164
- return readdirSync(dir)
165
- .filter((f) => f.endsWith('.md'))
166
- .sort()
167
- .map((f) => {
168
- const raw = readFileSync(join(dir, f), 'utf-8');
169
- const { data } = parseFrontmatter<DlqEntry>(raw);
170
- return { id: f.replace(/\.md$/, ''), entry: data };
171
- });
117
+ if (!existsSync(dir)) return { total: 0, quarantined: 0 };
118
+ let total = 0;
119
+ let quarantined = 0;
120
+ for (const f of readdirSync(dir).filter((x) => x.endsWith('.md'))) {
121
+ try {
122
+ const { data } = parseFrontmatter<DlqEntry>(readFileSync(join(dir, f), 'utf-8'));
123
+ total++;
124
+ if (data.quarantined) quarantined++;
125
+ } catch { /* skip */ }
126
+ }
127
+ return { total, quarantined };
172
128
  }
173
129
 
174
130
  // ── CLI entry point ──
175
- const transportRoot = resolve(process.cwd());
176
- const argv = process.argv.slice(2);
177
-
178
- function flag(name: string): string | undefined {
179
- const i = argv.indexOf(name);
180
- if (i === -1 || i === argv.length - 1) return undefined;
181
- return argv[i + 1];
182
- }
183
131
 
184
132
  const isEntry = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
185
133
 
186
134
  if (isEntry) {
187
- const list = argv.includes('--list');
135
+ const transportRoot = resolve(process.cwd());
136
+ const argv = process.argv.slice(2);
137
+ const flag = (name: string): string | undefined => {
138
+ const i = argv.indexOf(name);
139
+ return i === -1 || i === argv.length - 1 ? undefined : argv[i + 1];
140
+ };
141
+
188
142
  const show = flag('--show');
189
143
  const retryId = flag('--retry');
190
144
  const clear = argv.includes('--clear');
145
+ const dir = dlqDir(transportRoot);
191
146
 
192
- if (list || (!show && !retryId && !clear && argv.length === 0)) {
193
- const entries = listEntries(transportRoot);
194
- const quarantinedCount = entries.filter((e) => e.entry.quarantined).length;
195
- console.log(`DLQ entries: ${entries.length} (${quarantinedCount} quarantined)`);
196
- for (const { id, entry } of entries) {
197
- const quarantineMark = entry.quarantined ? ' [QUARANTINED]' : '';
198
- console.log(` ${id}${quarantineMark}`);
199
- console.log(` kind=${entry.kind} actor=${entry.actor} channel=${entry.channel?.slice(0, 8)}`);
200
- console.log(` msg=${entry.messageRelPath}`);
201
- console.log(` error=${(entry.error || '').slice(0, 80)}`);
202
- console.log(` attempts=${entry.attempts} first=${entry.firstFailedAt} last=${entry.lastFailedAt}`);
203
- }
204
- } else if (show) {
205
- const path = join(dlqDir(transportRoot), `${show}.md`);
147
+ if (show) {
148
+ const path = join(dir, `${show}.md`);
206
149
  if (!existsSync(path)) {
207
150
  console.error(`No DLQ entry: ${show}`);
208
151
  process.exit(1);
209
152
  }
210
153
  console.log(readFileSync(path, 'utf-8'));
211
154
  } else if (clear) {
212
- const dir = dlqDir(transportRoot);
213
- if (!existsSync(dir)) {
214
- console.log('dlq/ does not exist; nothing to clear');
215
- process.exit(0);
216
- }
217
- const files = readdirSync(dir).filter((f) => f.endsWith('.md'));
155
+ const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')) : [];
218
156
  for (const f of files) unlinkSync(join(dir, f));
219
157
  console.log(`Cleared ${files.length} DLQ entries`);
220
158
  } else if (retryId) {
221
- const path = join(dlqDir(transportRoot), `${retryId}.md`);
159
+ const path = join(dir, `${retryId}.md`);
222
160
  if (!existsSync(path)) {
223
161
  console.error(`No DLQ entry: ${retryId}`);
224
162
  process.exit(1);
225
163
  }
226
164
  const { data } = parseFrontmatter<DlqEntry>(readFileSync(path, 'utf-8'));
227
- if (data.kind === 'dispatch') {
228
- const cursorFile = join(transportRoot, 'cursors', data.actor, `${data.channel}.md`);
229
- if (existsSync(cursorFile)) {
230
- writeFileSync(cursorFile, '\n');
231
- }
232
- // Also clear the quarantine flag — otherwise dispatch would skip the
233
- // message on the next tick and retry would be a no-op.
234
- if (data.quarantined) {
235
- data.quarantined = false;
236
- writeFileSync(path, serializeFrontmatter(data as unknown as Record<string, unknown>, data.error));
237
- }
238
- console.log(
239
- `Retried DLQ entry ${retryId}. Cursor for ${data.actor}@${data.channel.slice(0, 8)} cleared; quarantine flag reset.`,
240
- );
241
- console.log(
242
- ' (DLQ entry kept — will be re-evaluated on next dispatch. If it fails again, attempts will increment.)',
243
- );
244
- console.log(' Run: npm run dispatch -- --once (or wait for next tick)');
245
- } else if (data.kind === 'config') {
246
- // For config errors: clear the quarantine flag so the actor is retried.
165
+ const cursor = cursorPath(transportRoot, data.actor, data.channel);
166
+ if (existsSync(cursor)) {
167
+ mkdirSync(dirname(cursor), { recursive: true });
168
+ writeFileSync(cursor, '');
169
+ }
170
+ if (data.quarantined) {
247
171
  data.quarantined = false;
248
172
  writeFileSync(path, serializeFrontmatter(data as unknown as Record<string, unknown>, data.error));
249
- console.log(
250
- `Retried config DLQ entry ${retryId}. Quarantine flag cleared on ${data.actor}.`,
251
- );
252
- console.log(' Run: npm run dispatch -- --once (or wait for next tick)');
253
- } else {
254
- console.error(`Unknown DLQ kind: ${data.kind}`);
255
- process.exit(1);
256
173
  }
174
+ console.log(`Retried ${retryId}: cursor for ${data.actor}@${data.channel.slice(0, 8)} rewound; quarantine cleared.`);
175
+ console.log(' Entry kept — re-evaluated on next dispatch tick.');
257
176
  } else {
258
- console.error(
259
- 'Usage: npm run dlq -- [--list | --show <id> | --retry <id> | --clear]',
260
- );
261
- process.exit(1);
177
+ const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')).sort() : [];
178
+ let quarantinedCount = 0;
179
+ const rows: string[] = [];
180
+ for (const f of files) {
181
+ try {
182
+ const { data } = parseFrontmatter<DlqEntry>(readFileSync(join(dir, f), 'utf-8'));
183
+ if (data.quarantined) quarantinedCount++;
184
+ rows.push(
185
+ ` ${f.replace(/\.md$/, '')}${data.quarantined ? ' [QUARANTINED]' : ''}\n` +
186
+ ` actor=${data.actor} channel=${data.channel.slice(0, 8)} msg=${data.messageRelPath}\n` +
187
+ ` attempts=${data.attempts} first=${data.firstFailedAt} last=${data.lastFailedAt}\n` +
188
+ ` error=${(data.error || '').slice(0, 80)}`,
189
+ );
190
+ } catch { /* skip */ }
191
+ }
192
+ console.log(`DLQ entries: ${rows.length} (${quarantinedCount} quarantined)`);
193
+ for (const row of rows) console.log(row);
262
194
  }
263
195
  }
package/src/init.ts CHANGED
@@ -1,15 +1,13 @@
1
- // crosstalk init <directory> — scaffold a new transport
1
+ // crosstalk init <directory> — scaffold a new transport.
2
2
  //
3
- // Copies the bundled transport template (markdown spec, agent pointer
4
- // files, default actor profiles) into the target directory, then adds
5
- // host-specific scaffolding (host file for this machine, first channel,
6
- // runtime state directories).
3
+ // Copies the bundled transport template (spec, agent pointer files, default
4
+ // actor profiles), then adds a host file for this machine and a first
5
+ // channel. No state directories machine state lives outside the repo
6
+ // (state.ts) and is created on demand.
7
7
  //
8
8
  // Template lookup order:
9
- // 1. <runtime_root>/template/ — bundled at publish time (production)
9
+ // 1. <runtime_root>/template/ — bundled at publish time (production)
10
10
  // 2. <runtime_root>/../transport/ — monorepo layout (local dev)
11
- //
12
- // If neither exists, exits with a clear error.
13
11
 
14
12
  import { existsSync, mkdirSync, writeFileSync, cpSync } from 'fs';
15
13
  import { resolve, join, dirname } from 'path';
@@ -27,7 +25,7 @@ if (positional.length === 0) {
27
25
  process.exit(1);
28
26
  }
29
27
 
30
- const targetDir = resolve(positional[0]);
28
+ const targetDir = resolve(positional[0]!);
31
29
 
32
30
  if (existsSync(join(targetDir, 'upstream', 'CROSSTALK-VERSION')) && !force) {
33
31
  console.error(`crosstalk init: ${targetDir} already contains a transport.`);
@@ -35,9 +33,7 @@ if (existsSync(join(targetDir, 'upstream', 'CROSSTALK-VERSION')) && !force) {
35
33
  process.exit(1);
36
34
  }
37
35
 
38
- // Locate the bundled transport template.
39
- const thisFileDir = dirname(fileURLToPath(import.meta.url));
40
- const runtimeRoot = resolve(thisFileDir, '..');
36
+ const runtimeRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
41
37
  const candidates = [
42
38
  join(runtimeRoot, 'template'),
43
39
  join(runtimeRoot, '..', 'transport'),
@@ -53,19 +49,12 @@ if (!templateDir) {
53
49
  }
54
50
 
55
51
  mkdirSync(targetDir, { recursive: true });
52
+ cpSync(templateDir, targetDir, {
53
+ recursive: true,
54
+ force,
55
+ filter: (src) => !src.endsWith('/transport/README.md') && !src.endsWith('\\transport\\README.md'),
56
+ });
56
57
 
57
- // Copy the template, excluding the template's own README.md (operator
58
- // gets a fresh README, generated below).
59
- function copyTemplate(): void {
60
- cpSync(templateDir!, targetDir, {
61
- recursive: true,
62
- force,
63
- filter: (src) => !src.endsWith('/transport/README.md') && !src.endsWith('\\transport\\README.md'),
64
- });
65
- }
66
- copyTemplate();
67
-
68
- // Host file: per-machine actor declaration.
69
58
  const hostname = osHostname();
70
59
  const hostsDir = join(targetDir, 'hosts');
71
60
  mkdirSync(hostsDir, { recursive: true });
@@ -85,7 +74,7 @@ actors:
85
74
  Host file for ${hostname}. One actor (concierge) on Claude Code by default.
86
75
  Add more actors as you need them; declare each tier under its CLI invocation.
87
76
 
88
- To add an actor with multiple parallel slots (e.g. 10 junior-developer instances
77
+ To give an actor multiple parallel slots (e.g. 10 junior-developer instances
89
78
  each picking up messages independently), use \`count: N\` under the tier:
90
79
 
91
80
  actors:
@@ -97,7 +86,6 @@ each picking up messages independently), use \`count: N\` under the tier:
97
86
  );
98
87
  }
99
88
 
100
- // First channel.
101
89
  const chId = randomUUID();
102
90
  const channelDir = join(targetDir, 'data', 'channels', chId);
103
91
  mkdirSync(channelDir, { recursive: true });
@@ -116,15 +104,6 @@ General channel. First channel of this transport.
116
104
  );
117
105
  }
118
106
 
119
- // Runtime state directories (cursors, dlq, errors) — start empty.
120
- for (const d of ['cursors', 'dlq', 'errors']) {
121
- const dir = join(targetDir, d);
122
- mkdirSync(dir, { recursive: true });
123
- const keep = join(dir, '.gitkeep');
124
- if (!existsSync(keep)) writeFileSync(keep, '');
125
- }
126
-
127
- // Fresh transport README replacing the template's self-description.
128
107
  const readmePath = join(targetDir, 'README.md');
129
108
  if (!existsSync(readmePath) || force) {
130
109
  writeFileSync(
@@ -134,12 +113,9 @@ if (!existsSync(readmePath) || force) {
134
113
  A Crosstalk transport created by \`crosstalk init\`.
135
114
 
136
115
  - Spec: \`upstream/CROSSTALK.md\`
137
- - Agent orientation: \`upstream/PROTOCOL.md\` and \`upstream/OPERATOR.md\`
116
+ - Agent orientation: \`upstream/PROTOCOL.md\`
138
117
  - Your custom actor profiles: \`local/actors/\`
139
118
  - Host configuration for this machine: \`hosts/${hostname}.md\`
140
-
141
- To run dispatch: \`crosstalk dispatch\`. To chat with deployed actors via your
142
- preferred agent CLI: \`crosstalk attach\`.
143
119
  `,
144
120
  );
145
121
  }
@@ -153,5 +129,5 @@ console.log('Next steps:');
153
129
  console.log(` cd ${targetDir}`);
154
130
  console.log(' git init && git add -A && git commit -m "initial transport"');
155
131
  console.log(' crosstalk status # verify scaffold');
156
- console.log(' crosstalk dispatch # run dispatch loop locally');
157
- console.log(' crosstalk attach # chat with actors via your preferred CLI');
132
+ console.log(' crosstalk dispatch # run the dispatch loop on this machine');
133
+ console.log(' crosstalk send --to concierge "hello" # first message');
package/src/open.ts CHANGED
@@ -1,12 +1,22 @@
1
+ // crosstalk open — interactive session with an actor, spawning its CLI
2
+ // locally on every turn. Needs the actor's CLI installed and authed on
3
+ // this machine; does not use any dispatcher (and must not run while one
4
+ // is processing this transport — the two would race).
5
+ //
6
+ // Each turn: log the operator's message to the channel, spawn the actor's
7
+ // CLI with the composed system prompt (PROTOCOL.md + actor profile), log
8
+ // the reply with re: pointing at the operator's message, commit + push.
9
+
1
10
  import { resolve, join } from 'path';
2
11
  import { spawnSync } from 'child_process';
3
- import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'fs';
12
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, statSync } from 'fs';
4
13
  import { randomUUID } from 'crypto';
5
14
  import { createInterface } from 'readline/promises';
6
- import { statSync } from 'fs';
7
15
  import { findHostFile, loadActorProfile, pickTier, tokenizeCli } from './actor.js';
8
16
  import { now, messageFilename } from './filenames.js';
9
17
  import { serializeFrontmatter } from './frontmatter.js';
18
+ import { gitCommitAndPush } from './transport.js';
19
+ import { withLock } from './turnq.js';
10
20
  import { writeDlqEntry } from './dlq.js';
11
21
 
12
22
  const transportRoot = resolve(process.cwd());
@@ -21,10 +31,10 @@ function flag(name: string): string | undefined {
21
31
  const actorName = flag('--actor');
22
32
  let channelUuid = flag('--channel');
23
33
  const hostOverride = flag('--host');
24
- const operatorName = flag('--as') ?? process.env.USER ?? 'steve';
34
+ const operatorName = flag('--as') ?? process.env['USER'] ?? 'operator';
25
35
 
26
36
  if (!actorName) {
27
- console.error('Usage: npm run open -- --actor <name> [--channel <uuid>] [--host <alias>] [--as <name>]');
37
+ console.error('Usage: crosstalk open --actor <name> [--channel <uuid>] [--host <alias>] [--as <name>]');
28
38
  process.exit(1);
29
39
  }
30
40
 
@@ -46,15 +56,15 @@ created_by: ${operatorName}
46
56
  created_at: ${new Date().toISOString()}
47
57
  ---
48
58
 
49
- Interactive session channel — \`npm run open\` invocation.
59
+ Interactive session channel — \`crosstalk open\` invocation.
50
60
  `,
51
61
  );
52
62
  console.log(`(created channel ${channelUuid})`);
53
63
  }
54
64
 
55
65
  const protocolPath = join(transportRoot, 'upstream', 'PROTOCOL.md');
56
- const actorProfilePath = join(transportRoot, 'local', 'actors', `${actorName}.md`);
57
- const fwActorProfilePath = join(transportRoot, 'upstream', 'actors', `${actorName}.md`);
66
+ const localProfilePath = join(transportRoot, 'local', 'actors', `${actorName}.md`);
67
+ const upstreamProfilePath = join(transportRoot, 'upstream', 'actors', `${actorName}.md`);
58
68
 
59
69
  interface CachedPrompt {
60
70
  systemPrompt: string;
@@ -75,7 +85,7 @@ function loadComposedPrompt(): CachedPrompt {
75
85
  const systemPrompt = [protocolPrompt, profile.systemPrompt]
76
86
  .filter((p) => p.length > 0)
77
87
  .join('\n\n---\n\n');
78
- const profilePath = existsSync(actorProfilePath) ? actorProfilePath : fwActorProfilePath;
88
+ const profilePath = existsSync(localProfilePath) ? localProfilePath : upstreamProfilePath;
79
89
  return {
80
90
  systemPrompt,
81
91
  protocolMtime: mtime(protocolPath),
@@ -96,20 +106,33 @@ function getCurrentSystemPrompt(): string {
96
106
  return cached.systemPrompt;
97
107
  }
98
108
 
99
- const { cli } = pickTier(host.actors[actorName]);
109
+ const { cli } = pickTier(host.actors[actorName]!);
100
110
 
101
- function logToChannel(from: string, to: string, body: string): void {
111
+ function logToChannel(from: string, to: string, body: string, re?: string): string {
102
112
  const ts = now();
103
113
  const dir = join(transportRoot, 'data', 'channels', channelUuid!, ts.pathDate);
104
114
  mkdirSync(dir, { recursive: true });
105
- const content = serializeFrontmatter(
106
- { from, to, type: 'text', timestamp: ts.iso },
107
- body,
115
+ const frontmatter: Record<string, unknown> = { from, to, type: 'text', timestamp: ts.iso };
116
+ if (re) frontmatter['re'] = re;
117
+ const filename = messageFilename(ts);
118
+ writeFileSync(join(dir, filename), serializeFrontmatter(frontmatter, body));
119
+ return `${ts.pathDate}/${filename}`;
120
+ }
121
+
122
+ async function commitTurn(): Promise<void> {
123
+ const r = await withLock(transportRoot, 'git', async () =>
124
+ gitCommitAndPush(
125
+ transportRoot,
126
+ `open: ${operatorName} <-> ${actorName} in ${channelUuid!.slice(0, 8)}`,
127
+ ),
108
128
  );
109
- writeFileSync(join(dir, messageFilename(ts)), content);
129
+ if (!r.ok && r.error) {
130
+ const kind = r.committed ? 'push' : 'commit';
131
+ console.error(`(${kind} failed: ${r.error.slice(0, 200)} — turn is local-only)`);
132
+ }
110
133
  }
111
134
 
112
- console.log(`crosstalk v4 open — actor=${actorName} channel=${channelUuid.slice(0, 8)}`);
135
+ console.log(`crosstalk open — actor=${actorName} channel=${channelUuid.slice(0, 8)}`);
113
136
  console.log('Type a message and press Enter. Ctrl-C or Ctrl-D to exit.');
114
137
  console.log('');
115
138
 
@@ -120,7 +143,7 @@ async function main(): Promise<void> {
120
143
  while (true) {
121
144
  let userMsg: string;
122
145
  try {
123
- userMsg = await rl.question('you> ');
146
+ userMsg = await rl.question(`${operatorName}> `);
124
147
  } catch {
125
148
  break;
126
149
  }
@@ -128,9 +151,7 @@ async function main(): Promise<void> {
128
151
  const trimmed = userMsg.trim();
129
152
  if (!trimmed) continue;
130
153
 
131
- const ts = now();
132
- const userMsgRelPath = `${ts.pathDate}/${messageFilename(ts)}`;
133
- logToChannel(operatorName, actorName!, trimmed);
154
+ const userMsgRelPath = logToChannel(operatorName, actorName!, trimmed);
134
155
 
135
156
  const fullPrompt = `${getCurrentSystemPrompt()}\n\n---\n\n${trimmed}`;
136
157
  const parts = tokenizeCli(cli);
@@ -138,16 +159,21 @@ async function main(): Promise<void> {
138
159
  console.error('\n[open] tokenized cli is empty — check host file\n');
139
160
  continue;
140
161
  }
141
- const result = spawnSync(parts[0], parts.slice(1), {
162
+ const result = spawnSync(parts[0]!, parts.slice(1), {
142
163
  input: fullPrompt,
143
164
  encoding: 'utf-8',
144
165
  timeout: 5 * 60_000,
166
+ env: {
167
+ ...process.env,
168
+ CROSSTALK_DISPATCH_ACTOR: actorName!,
169
+ CROSSTALK_DISPATCH_CHANNEL: channelUuid!,
170
+ CROSSTALK_DISPATCH_RE: userMsgRelPath,
171
+ },
145
172
  });
146
173
 
147
174
  if (result.status !== 0) {
148
175
  const r = writeDlqEntry(
149
176
  transportRoot,
150
- 'dispatch',
151
177
  actorName!,
152
178
  channelUuid!,
153
179
  userMsgRelPath,
@@ -155,29 +181,27 @@ async function main(): Promise<void> {
155
181
  );
156
182
  const quarantineMark = r.quarantined ? ' [QUARANTINED]' : '';
157
183
  console.error(`\n[cli exit=${result.status} → dlq:${r.id}${quarantineMark}] ${(result.stderr || '').slice(0, 200)}\n`);
184
+ await commitTurn();
158
185
  continue;
159
186
  }
160
187
 
161
188
  const reply = (result.stdout || '').trim();
162
189
  if (reply.length === 0) {
163
- const r = writeDlqEntry(
164
- transportRoot,
165
- 'dispatch',
166
- actorName!,
167
- channelUuid!,
168
- userMsgRelPath,
169
- 'cli returned empty reply (open mode)',
170
- );
171
- console.error(`\n[empty reply → dlq:${r.id}] (run dlq --show ${r.id} for context)\n`);
190
+ // Legitimate: the actor may have routed its answer via `crosstalk
191
+ // send` (re: auto-linked from the env above). Not a failure.
192
+ console.log(`(${actorName} replied silently — check the channel)\n`);
193
+ await commitTurn();
172
194
  continue;
173
195
  }
174
196
 
175
197
  console.log(`${actorName}> ${reply}\n`);
176
- logToChannel(actorName!, operatorName, reply);
198
+ logToChannel(actorName!, operatorName, reply, userMsgRelPath);
199
+ await commitTurn();
177
200
  }
178
201
  } finally {
179
202
  rl.close();
180
203
  }
204
+ process.exit(0);
181
205
  }
182
206
 
183
207
  main();