@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.
- package/bin/crosstalk.js +34 -78
- package/package.json +4 -4
- package/src/activation.ts +104 -0
- package/src/attach.ts +1 -1
- package/src/channel.ts +8 -21
- package/src/chat.ts +52 -115
- package/src/dispatch.ts +252 -661
- package/src/dlq.ts +68 -136
- package/src/init.ts +17 -41
- package/src/open.ts +55 -31
- package/src/replies.ts +59 -0
- package/src/send.ts +48 -67
- package/src/state.ts +143 -0
- package/src/status.ts +18 -57
- package/src/transport.ts +68 -198
- package/src/turnq.ts +64 -32
- package/src/upgrade.ts +9 -11
- package/src/wake.ts +5 -6
- 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/CLAUDE.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/CROSSTALK-VERSION +0 -1
- package/template/upstream/CROSSTALK.md +0 -589
- package/template/upstream/JITTER.md +0 -24
- package/template/upstream/OPERATOR.md +0 -60
- package/template/upstream/PROTOCOL.md +0 -260
- package/template/upstream/actors/cloud-architect.md +0 -83
- package/template/upstream/actors/concierge.md +0 -130
- 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,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;
|
|
24
|
-
messageRelPath: string;
|
|
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
|
-
|
|
39
|
+
function findEntry(
|
|
49
40
|
transportRoot: string,
|
|
50
|
-
kind: DlqKind,
|
|
51
41
|
actor: string,
|
|
52
42
|
channel: string,
|
|
53
43
|
messageRelPath: string,
|
|
54
|
-
):
|
|
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
|
|
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
|
-
):
|
|
74
|
+
): { id: string; attempts: number; quarantined: boolean } {
|
|
105
75
|
const dir = dlqDir(transportRoot);
|
|
106
76
|
mkdirSync(dir, { recursive: true });
|
|
107
77
|
|
|
108
|
-
const existing =
|
|
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
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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 (
|
|
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`);
|
|
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
|
|
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(
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 (
|
|
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
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
|
-
|
|
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
|
|
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\`
|
|
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
|
|
157
|
-
console.log(' crosstalk
|
|
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
|
|
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();
|