@cordfuse/crosstalk 5.0.0-alpha.7 → 6.0.0-alpha.10

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 (58) hide show
  1. package/GUIDE-CLI.md +298 -0
  2. package/GUIDE-PROMPTS.md +132 -0
  3. package/README.md +139 -0
  4. package/bin/crosstalk.js +51 -80
  5. package/package.json +9 -5
  6. package/src/activation.ts +104 -0
  7. package/src/actor.ts +29 -4
  8. package/src/attach.ts +1 -1
  9. package/src/channel.ts +8 -21
  10. package/src/chat.ts +52 -115
  11. package/src/dispatch.ts +288 -660
  12. package/src/dlq.ts +89 -136
  13. package/src/init.ts +23 -42
  14. package/src/open.ts +55 -31
  15. package/src/replies.ts +59 -0
  16. package/src/send.ts +87 -72
  17. package/src/state.ts +173 -0
  18. package/src/status.ts +18 -57
  19. package/src/stop.ts +37 -0
  20. package/src/transport.ts +81 -198
  21. package/src/turnq.ts +64 -32
  22. package/src/upgrade.ts +9 -11
  23. package/src/wake.ts +5 -6
  24. package/template/CLAUDE.md +12 -2
  25. package/template/gitignore +4 -0
  26. package/template/upstream/CROSSTALK-VERSION +1 -1
  27. package/template/upstream/CROSSTALK.md +172 -463
  28. package/template/upstream/OPERATOR.md +9 -9
  29. package/template/upstream/PROTOCOL.md +64 -244
  30. package/template/upstream/actors/concierge.md +24 -118
  31. package/src/cursor.ts +0 -48
  32. package/template/.amazonq/rules/crosstalk.md +0 -2
  33. package/template/.continue/rules/crosstalk.md +0 -7
  34. package/template/.cursor/rules/crosstalk.mdc +0 -7
  35. package/template/.github/copilot-instructions.md +0 -2
  36. package/template/.windsurfrules +0 -2
  37. package/template/AGENTS.md +0 -2
  38. package/template/ANTIGRAVITY.md +0 -2
  39. package/template/GEMINI.md +0 -2
  40. package/template/OPENCODE.md +0 -2
  41. package/template/QWEN.md +0 -2
  42. package/template/README.md +0 -22
  43. package/template/local/CROSSTALK.md +0 -4
  44. package/template/upstream/JITTER.md +0 -24
  45. package/template/upstream/actors/cloud-architect.md +0 -83
  46. package/template/upstream/actors/devops-engineer.md +0 -83
  47. package/template/upstream/actors/documentation-engineer.md +0 -107
  48. package/template/upstream/actors/infrastructure-engineer.md +0 -83
  49. package/template/upstream/actors/junior-developer.md +0 -83
  50. package/template/upstream/actors/precise-generalist.md +0 -48
  51. package/template/upstream/actors/product-manager.md +0 -83
  52. package/template/upstream/actors/qa-engineer.md +0 -83
  53. package/template/upstream/actors/security-engineer.md +0 -92
  54. package/template/upstream/actors/senior-generalist-engineer.md +0 -111
  55. package/template/upstream/actors/senior-software-engineer.md +0 -94
  56. package/template/upstream/actors/skeptic.md +0 -89
  57. package/template/upstream/actors/technical-writer.md +0 -89
  58. 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,21 @@ 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';
16
+ import { spawnSync } from 'child_process';
11
17
  import { now } from './filenames.js';
12
18
  import { serializeFrontmatter, parseFrontmatter } from './frontmatter.js';
19
+ import { stateDir, cursorPath } from './state.js';
13
20
 
14
21
  const QUARANTINE_THRESHOLD_ATTEMPTS = 4;
15
22
  const QUARANTINE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
16
23
 
17
- export type DlqKind = 'dispatch' | 'config';
18
-
19
24
  export interface DlqEntry {
20
25
  id: string;
21
- kind: DlqKind;
22
26
  actor: string;
23
- channel: string; // "(config)" for kind=config without channel context
24
- messageRelPath: string; // "(config)" for kind=config
27
+ channel: string;
28
+ messageRelPath: string;
25
29
  attempts: number;
26
30
  quarantined: boolean;
27
31
  firstFailedAt: string;
@@ -29,41 +33,23 @@ export interface DlqEntry {
29
33
  error: string;
30
34
  }
31
35
 
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
36
  function dlqDir(transportRoot: string): string {
45
- return join(transportRoot, 'dlq');
37
+ return join(stateDir(transportRoot), 'dlq');
46
38
  }
47
39
 
48
- export function findDlqEntry(
40
+ function findEntry(
49
41
  transportRoot: string,
50
- kind: DlqKind,
51
42
  actor: string,
52
43
  channel: string,
53
44
  messageRelPath: string,
54
- ): FoundDlqEntry | null {
45
+ ): { id: string; path: string; entry: DlqEntry } | null {
55
46
  const dir = dlqDir(transportRoot);
56
47
  if (!existsSync(dir)) return null;
57
48
  for (const f of readdirSync(dir).filter((x) => x.endsWith('.md'))) {
58
49
  const path = join(dir, f);
59
50
  try {
60
51
  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
- ) {
52
+ if (data.actor === actor && data.channel === channel && data.messageRelPath === messageRelPath) {
67
53
  return { id: f.replace(/\.md$/, ''), path, entry: data };
68
54
  }
69
55
  } catch { /* skip unparseable */ }
@@ -73,49 +59,32 @@ export function findDlqEntry(
73
59
 
74
60
  export function isQuarantined(
75
61
  transportRoot: string,
76
- kind: DlqKind,
77
62
  actor: string,
78
63
  channel: string,
79
64
  messageRelPath: string,
80
65
  ): 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;
66
+ return findEntry(transportRoot, actor, channel, messageRelPath)?.entry.quarantined ?? false;
95
67
  }
96
68
 
97
69
  export function writeDlqEntry(
98
70
  transportRoot: string,
99
- kind: DlqKind,
100
71
  actor: string,
101
72
  channelUuid: string,
102
73
  messageRelPath: string,
103
74
  error: string,
104
- ): WriteDlqResult {
75
+ ): { id: string; attempts: number; quarantined: boolean } {
105
76
  const dir = dlqDir(transportRoot);
106
77
  mkdirSync(dir, { recursive: true });
107
78
 
108
- const existing = findDlqEntry(transportRoot, kind, actor, channelUuid, messageRelPath);
79
+ const existing = findEntry(transportRoot, actor, channelUuid, messageRelPath);
109
80
  const lastFailedAt = new Date().toISOString();
110
81
 
111
82
  if (existing) {
112
83
  const attempts = (existing.entry.attempts ?? 1) + 1;
113
- const firstFailedAt = existing.entry.firstFailedAt;
114
- const ageMs = Date.now() - new Date(firstFailedAt).getTime();
84
+ const ageMs = Date.now() - new Date(existing.entry.firstFailedAt).getTime();
115
85
  const quarantined =
116
86
  existing.entry.quarantined ||
117
87
  (attempts >= QUARANTINE_THRESHOLD_ATTEMPTS && ageMs < QUARANTINE_WINDOW_MS);
118
-
119
88
  const updated: DlqEntry = {
120
89
  ...existing.entry,
121
90
  attempts,
@@ -123,10 +92,7 @@ export function writeDlqEntry(
123
92
  error: error.slice(0, 500),
124
93
  quarantined,
125
94
  };
126
- writeFileSync(
127
- existing.path,
128
- serializeFrontmatter(updated as unknown as Record<string, unknown>, error),
129
- );
95
+ writeFileSync(existing.path, serializeFrontmatter(updated as unknown as Record<string, unknown>, error));
130
96
  return { id: existing.id, attempts, quarantined };
131
97
  }
132
98
 
@@ -134,7 +100,6 @@ export function writeDlqEntry(
134
100
  const id = `${ts.fileTime}-${ts.hex}`;
135
101
  const entry: DlqEntry = {
136
102
  id,
137
- kind,
138
103
  actor,
139
104
  channel: channelUuid,
140
105
  messageRelPath,
@@ -144,120 +109,108 @@ export function writeDlqEntry(
144
109
  lastFailedAt,
145
110
  error: error.slice(0, 500),
146
111
  };
147
- writeFileSync(
148
- join(dir, `${id}.md`),
149
- serializeFrontmatter(entry as unknown as Record<string, unknown>, error),
150
- );
112
+ writeFileSync(join(dir, `${id}.md`), serializeFrontmatter(entry as unknown as Record<string, unknown>, error));
151
113
  return { id, attempts: 1, quarantined: false };
152
114
  }
153
115
 
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 }[] {
116
+ export function countDlqEntries(transportRoot: string): { total: number; quarantined: number } {
162
117
  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
- });
118
+ if (!existsSync(dir)) return { total: 0, quarantined: 0 };
119
+ let total = 0;
120
+ let quarantined = 0;
121
+ for (const f of readdirSync(dir).filter((x) => x.endsWith('.md'))) {
122
+ try {
123
+ const { data } = parseFrontmatter<DlqEntry>(readFileSync(join(dir, f), 'utf-8'));
124
+ total++;
125
+ if (data.quarantined) quarantined++;
126
+ } catch { /* skip */ }
127
+ }
128
+ return { total, quarantined };
172
129
  }
173
130
 
174
131
  // ── 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
132
 
184
133
  const isEntry = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
185
134
 
186
135
  if (isEntry) {
187
- const list = argv.includes('--list');
136
+ const transportRoot = resolve(process.cwd());
137
+ const argv = process.argv.slice(2);
138
+ const flag = (name: string): string | undefined => {
139
+ const i = argv.indexOf(name);
140
+ return i === -1 || i === argv.length - 1 ? undefined : argv[i + 1];
141
+ };
142
+
188
143
  const show = flag('--show');
189
144
  const retryId = flag('--retry');
190
145
  const clear = argv.includes('--clear');
146
+ const dir = dlqDir(transportRoot);
191
147
 
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`);
148
+ if (show) {
149
+ const path = join(dir, `${show}.md`);
206
150
  if (!existsSync(path)) {
207
151
  console.error(`No DLQ entry: ${show}`);
208
152
  process.exit(1);
209
153
  }
210
154
  console.log(readFileSync(path, 'utf-8'));
211
155
  } 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'));
156
+ const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')) : [];
218
157
  for (const f of files) unlinkSync(join(dir, f));
219
158
  console.log(`Cleared ${files.length} DLQ entries`);
220
159
  } else if (retryId) {
221
- const path = join(dlqDir(transportRoot), `${retryId}.md`);
160
+ const path = join(dir, `${retryId}.md`);
222
161
  if (!existsSync(path)) {
223
162
  console.error(`No DLQ entry: ${retryId}`);
224
163
  process.exit(1);
225
164
  }
226
165
  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.
166
+ const cursorFile = cursorPath(transportRoot, data.actor, data.channel);
167
+
168
+ // Find the commit that introduced the failed message and rewind to its
169
+ // parent. This ensures the next dispatch tick re-sees the message.
170
+ // Fallback: if git log fails, wipe the cursor so a full re-scan runs
171
+ // (less targeted but still recovers the message).
172
+ const relPath = `data/channels/${data.channel}/${data.messageRelPath}`;
173
+ const logResult = spawnSync('git', ['log', '--format=%H', '-1', '--', relPath], {
174
+ cwd: transportRoot,
175
+ encoding: 'utf-8',
176
+ });
177
+ const msgCommit = logResult.status === 0 ? logResult.stdout.trim() : '';
178
+ let rewindTo = '';
179
+ if (msgCommit) {
180
+ const parentResult = spawnSync('git', ['rev-parse', `${msgCommit}^`], {
181
+ cwd: transportRoot,
182
+ encoding: 'utf-8',
183
+ });
184
+ if (parentResult.status === 0) rewindTo = parentResult.stdout.trim();
185
+ }
186
+
187
+ mkdirSync(dirname(cursorFile), { recursive: true });
188
+ writeFileSync(cursorFile, rewindTo ? rewindTo + '\n' : '');
189
+
190
+ if (data.quarantined) {
247
191
  data.quarantined = false;
248
192
  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
193
  }
194
+ const rewindDesc = rewindTo ? rewindTo.slice(0, 12) : 'start (full re-scan)';
195
+ console.log(`Retried ${retryId}: cursor for ${data.actor}@${data.channel.slice(0, 8)} rewound to ${rewindDesc}; quarantine cleared.`);
196
+ console.log(' Entry kept — re-evaluated on next dispatch tick.');
257
197
  } else {
258
- console.error(
259
- 'Usage: npm run dlq -- [--list | --show <id> | --retry <id> | --clear]',
260
- );
261
- process.exit(1);
198
+ const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')).sort() : [];
199
+ let quarantinedCount = 0;
200
+ const rows: string[] = [];
201
+ for (const f of files) {
202
+ try {
203
+ const { data } = parseFrontmatter<DlqEntry>(readFileSync(join(dir, f), 'utf-8'));
204
+ if (data.quarantined) quarantinedCount++;
205
+ rows.push(
206
+ ` ${f.replace(/\.md$/, '')}${data.quarantined ? ' [QUARANTINED]' : ''}\n` +
207
+ ` actor=${data.actor} channel=${data.channel.slice(0, 8)} msg=${data.messageRelPath}\n` +
208
+ ` attempts=${data.attempts} first=${data.firstFailedAt} last=${data.lastFailedAt}\n` +
209
+ ` error=${(data.error || '').slice(0, 80)}`,
210
+ );
211
+ } catch { /* skip */ }
212
+ }
213
+ console.log(`DLQ entries: ${rows.length} (${quarantinedCount} quarantined)`);
214
+ for (const row of rows) console.log(row);
262
215
  }
263
216
  }
package/src/init.ts CHANGED
@@ -1,17 +1,15 @@
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
- import { existsSync, mkdirSync, writeFileSync, cpSync } from 'fs';
12
+ import { existsSync, mkdirSync, writeFileSync, cpSync, renameSync } from 'fs';
15
13
  import { resolve, join, dirname } from 'path';
16
14
  import { hostname as osHostname } from 'os';
17
15
  import { randomUUID } from 'crypto';
@@ -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,17 @@ 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
+ });
57
+ // npm strips .gitignore from published packages; the template ships it as
58
+ // `gitignore` and we rename it here.
59
+ const gitignoreSrc = join(targetDir, 'gitignore');
60
+ const gitignoreDst = join(targetDir, '.gitignore');
61
+ if (existsSync(gitignoreSrc)) renameSync(gitignoreSrc, gitignoreDst);
56
62
 
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
63
  const hostname = osHostname();
70
64
  const hostsDir = join(targetDir, 'hosts');
71
65
  mkdirSync(hostsDir, { recursive: true });
@@ -85,7 +79,7 @@ actors:
85
79
  Host file for ${hostname}. One actor (concierge) on Claude Code by default.
86
80
  Add more actors as you need them; declare each tier under its CLI invocation.
87
81
 
88
- To add an actor with multiple parallel slots (e.g. 10 junior-developer instances
82
+ To give an actor multiple parallel slots (e.g. 10 junior-developer instances
89
83
  each picking up messages independently), use \`count: N\` under the tier:
90
84
 
91
85
  actors:
@@ -97,7 +91,6 @@ each picking up messages independently), use \`count: N\` under the tier:
97
91
  );
98
92
  }
99
93
 
100
- // First channel.
101
94
  const chId = randomUUID();
102
95
  const channelDir = join(targetDir, 'data', 'channels', chId);
103
96
  mkdirSync(channelDir, { recursive: true });
@@ -116,15 +109,6 @@ General channel. First channel of this transport.
116
109
  );
117
110
  }
118
111
 
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
112
  const readmePath = join(targetDir, 'README.md');
129
113
  if (!existsSync(readmePath) || force) {
130
114
  writeFileSync(
@@ -134,12 +118,9 @@ if (!existsSync(readmePath) || force) {
134
118
  A Crosstalk transport created by \`crosstalk init\`.
135
119
 
136
120
  - Spec: \`upstream/CROSSTALK.md\`
137
- - Agent orientation: \`upstream/PROTOCOL.md\` and \`upstream/OPERATOR.md\`
121
+ - Agent orientation: \`upstream/PROTOCOL.md\`
138
122
  - Your custom actor profiles: \`local/actors/\`
139
123
  - 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
124
  `,
144
125
  );
145
126
  }
@@ -153,5 +134,5 @@ console.log('Next steps:');
153
134
  console.log(` cd ${targetDir}`);
154
135
  console.log(' git init && git add -A && git commit -m "initial transport"');
155
136
  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');
137
+ console.log(' crosstalk dispatch # run the dispatch loop on this machine');
138
+ 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();