@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.
- package/GUIDE-CLI.md +298 -0
- package/GUIDE-PROMPTS.md +132 -0
- package/README.md +139 -0
- package/bin/crosstalk.js +51 -80
- package/package.json +9 -5
- package/src/activation.ts +104 -0
- package/src/actor.ts +29 -4
- package/src/attach.ts +1 -1
- package/src/channel.ts +8 -21
- package/src/chat.ts +52 -115
- package/src/dispatch.ts +288 -660
- package/src/dlq.ts +89 -136
- package/src/init.ts +23 -42
- package/src/open.ts +55 -31
- package/src/replies.ts +59 -0
- package/src/send.ts +87 -72
- package/src/state.ts +173 -0
- package/src/status.ts +18 -57
- package/src/stop.ts +37 -0
- package/src/transport.ts +81 -198
- package/src/turnq.ts +64 -32
- package/src/upgrade.ts +9 -11
- package/src/wake.ts +5 -6
- package/template/CLAUDE.md +12 -2
- package/template/gitignore +4 -0
- package/template/upstream/CROSSTALK-VERSION +1 -1
- package/template/upstream/CROSSTALK.md +172 -463
- package/template/upstream/OPERATOR.md +9 -9
- package/template/upstream/PROTOCOL.md +64 -244
- package/template/upstream/actors/concierge.md +24 -118
- package/src/cursor.ts +0 -48
- package/template/.amazonq/rules/crosstalk.md +0 -2
- package/template/.continue/rules/crosstalk.md +0 -7
- package/template/.cursor/rules/crosstalk.mdc +0 -7
- package/template/.github/copilot-instructions.md +0 -2
- package/template/.windsurfrules +0 -2
- package/template/AGENTS.md +0 -2
- package/template/ANTIGRAVITY.md +0 -2
- package/template/GEMINI.md +0 -2
- package/template/OPENCODE.md +0 -2
- package/template/QWEN.md +0 -2
- package/template/README.md +0 -22
- package/template/local/CROSSTALK.md +0 -4
- package/template/upstream/JITTER.md +0 -24
- package/template/upstream/actors/cloud-architect.md +0 -83
- package/template/upstream/actors/devops-engineer.md +0 -83
- package/template/upstream/actors/documentation-engineer.md +0 -107
- package/template/upstream/actors/infrastructure-engineer.md +0 -83
- package/template/upstream/actors/junior-developer.md +0 -83
- package/template/upstream/actors/precise-generalist.md +0 -48
- package/template/upstream/actors/product-manager.md +0 -83
- package/template/upstream/actors/qa-engineer.md +0 -83
- package/template/upstream/actors/security-engineer.md +0 -92
- package/template/upstream/actors/senior-generalist-engineer.md +0 -111
- package/template/upstream/actors/senior-software-engineer.md +0 -94
- package/template/upstream/actors/skeptic.md +0 -89
- package/template/upstream/actors/technical-writer.md +0 -89
- 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;
|
|
24
|
-
messageRelPath: string;
|
|
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
|
-
|
|
40
|
+
function findEntry(
|
|
49
41
|
transportRoot: string,
|
|
50
|
-
kind: DlqKind,
|
|
51
42
|
actor: string,
|
|
52
43
|
channel: string,
|
|
53
44
|
messageRelPath: string,
|
|
54
|
-
):
|
|
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
|
|
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
|
-
):
|
|
75
|
+
): { id: string; attempts: number; quarantined: boolean } {
|
|
105
76
|
const dir = dlqDir(transportRoot);
|
|
106
77
|
mkdirSync(dir, { recursive: true });
|
|
107
78
|
|
|
108
|
-
const existing =
|
|
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
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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 (
|
|
193
|
-
const
|
|
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
|
|
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(
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 (
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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/
|
|
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
|
-
|
|
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
|
|
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\`
|
|
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
|
|
157
|
-
console.log(' crosstalk
|
|
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
|
|
34
|
+
const operatorName = flag('--as') ?? process.env['USER'] ?? 'operator';
|
|
25
35
|
|
|
26
36
|
if (!actorName) {
|
|
27
|
-
console.error('Usage:
|
|
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 — \`
|
|
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
|
|
57
|
-
const
|
|
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(
|
|
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):
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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]
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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();
|