@cordfuse/crosstalk 5.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/crosstalk.js +111 -0
- package/package.json +46 -0
- package/src/actor.ts +106 -0
- package/src/attach.ts +118 -0
- package/src/channel.ts +62 -0
- package/src/chat.ts +203 -0
- package/src/cursor.ts +48 -0
- package/src/dispatch.ts +519 -0
- package/src/dlq.ts +263 -0
- package/src/filenames.ts +28 -0
- package/src/frontmatter.ts +26 -0
- package/src/init.ts +157 -0
- package/src/open.ts +183 -0
- package/src/send.ts +80 -0
- package/src/status.ts +114 -0
- package/src/transport.ts +303 -0
- package/src/turnq.ts +59 -0
- package/src/upgrade.ts +213 -0
- package/src/wake.ts +8 -0
- package/template/.amazonq/rules/crosstalk.md +2 -0
- package/template/.continue/rules/crosstalk.md +7 -0
- package/template/.cursor/rules/crosstalk.mdc +7 -0
- package/template/.github/copilot-instructions.md +2 -0
- package/template/.windsurfrules +2 -0
- package/template/AGENTS.md +2 -0
- package/template/ANTIGRAVITY.md +2 -0
- package/template/CLAUDE.md +2 -0
- package/template/GEMINI.md +2 -0
- package/template/OPENCODE.md +2 -0
- package/template/QWEN.md +2 -0
- package/template/README.md +22 -0
- package/template/local/CROSSTALK.md +4 -0
- package/template/upstream/CROSSTALK-VERSION +1 -0
- package/template/upstream/CROSSTALK.md +589 -0
- package/template/upstream/JITTER.md +24 -0
- package/template/upstream/OPERATOR.md +60 -0
- package/template/upstream/PROTOCOL.md +180 -0
- package/template/upstream/actors/cloud-architect.md +83 -0
- package/template/upstream/actors/concierge.md +105 -0
- package/template/upstream/actors/devops-engineer.md +83 -0
- package/template/upstream/actors/documentation-engineer.md +107 -0
- package/template/upstream/actors/infrastructure-engineer.md +83 -0
- package/template/upstream/actors/junior-developer.md +83 -0
- package/template/upstream/actors/precise-generalist.md +48 -0
- package/template/upstream/actors/product-manager.md +83 -0
- package/template/upstream/actors/qa-engineer.md +83 -0
- package/template/upstream/actors/security-engineer.md +92 -0
- package/template/upstream/actors/senior-generalist-engineer.md +111 -0
- package/template/upstream/actors/senior-software-engineer.md +94 -0
- package/template/upstream/actors/skeptic.md +89 -0
- package/template/upstream/actors/technical-writer.md +89 -0
- package/template/upstream/actors/ux-designer.md +83 -0
package/src/dlq.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readdirSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
} from 'fs';
|
|
9
|
+
import { resolve, join } from 'path';
|
|
10
|
+
import { pathToFileURL } from 'url';
|
|
11
|
+
import { now } from './filenames.js';
|
|
12
|
+
import { serializeFrontmatter, parseFrontmatter } from './frontmatter.js';
|
|
13
|
+
|
|
14
|
+
const QUARANTINE_THRESHOLD_ATTEMPTS = 4;
|
|
15
|
+
const QUARANTINE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
16
|
+
|
|
17
|
+
export type DlqKind = 'dispatch' | 'config';
|
|
18
|
+
|
|
19
|
+
export interface DlqEntry {
|
|
20
|
+
id: string;
|
|
21
|
+
kind: DlqKind;
|
|
22
|
+
actor: string;
|
|
23
|
+
channel: string; // "(config)" for kind=config without channel context
|
|
24
|
+
messageRelPath: string; // "(config)" for kind=config
|
|
25
|
+
attempts: number;
|
|
26
|
+
quarantined: boolean;
|
|
27
|
+
firstFailedAt: string;
|
|
28
|
+
lastFailedAt: string;
|
|
29
|
+
error: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
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
|
+
function dlqDir(transportRoot: string): string {
|
|
45
|
+
return join(transportRoot, 'dlq');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function findDlqEntry(
|
|
49
|
+
transportRoot: string,
|
|
50
|
+
kind: DlqKind,
|
|
51
|
+
actor: string,
|
|
52
|
+
channel: string,
|
|
53
|
+
messageRelPath: string,
|
|
54
|
+
): FoundDlqEntry | null {
|
|
55
|
+
const dir = dlqDir(transportRoot);
|
|
56
|
+
if (!existsSync(dir)) return null;
|
|
57
|
+
for (const f of readdirSync(dir).filter((x) => x.endsWith('.md'))) {
|
|
58
|
+
const path = join(dir, f);
|
|
59
|
+
try {
|
|
60
|
+
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
|
+
) {
|
|
67
|
+
return { id: f.replace(/\.md$/, ''), path, entry: data };
|
|
68
|
+
}
|
|
69
|
+
} catch { /* skip unparseable */ }
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isQuarantined(
|
|
75
|
+
transportRoot: string,
|
|
76
|
+
kind: DlqKind,
|
|
77
|
+
actor: string,
|
|
78
|
+
channel: string,
|
|
79
|
+
messageRelPath: string,
|
|
80
|
+
): 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;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function writeDlqEntry(
|
|
98
|
+
transportRoot: string,
|
|
99
|
+
kind: DlqKind,
|
|
100
|
+
actor: string,
|
|
101
|
+
channelUuid: string,
|
|
102
|
+
messageRelPath: string,
|
|
103
|
+
error: string,
|
|
104
|
+
): WriteDlqResult {
|
|
105
|
+
const dir = dlqDir(transportRoot);
|
|
106
|
+
mkdirSync(dir, { recursive: true });
|
|
107
|
+
|
|
108
|
+
const existing = findDlqEntry(transportRoot, kind, actor, channelUuid, messageRelPath);
|
|
109
|
+
const lastFailedAt = new Date().toISOString();
|
|
110
|
+
|
|
111
|
+
if (existing) {
|
|
112
|
+
const attempts = (existing.entry.attempts ?? 1) + 1;
|
|
113
|
+
const firstFailedAt = existing.entry.firstFailedAt;
|
|
114
|
+
const ageMs = Date.now() - new Date(firstFailedAt).getTime();
|
|
115
|
+
const quarantined =
|
|
116
|
+
existing.entry.quarantined ||
|
|
117
|
+
(attempts >= QUARANTINE_THRESHOLD_ATTEMPTS && ageMs < QUARANTINE_WINDOW_MS);
|
|
118
|
+
|
|
119
|
+
const updated: DlqEntry = {
|
|
120
|
+
...existing.entry,
|
|
121
|
+
attempts,
|
|
122
|
+
lastFailedAt,
|
|
123
|
+
error: error.slice(0, 500),
|
|
124
|
+
quarantined,
|
|
125
|
+
};
|
|
126
|
+
writeFileSync(
|
|
127
|
+
existing.path,
|
|
128
|
+
serializeFrontmatter(updated as unknown as Record<string, unknown>, error),
|
|
129
|
+
);
|
|
130
|
+
return { id: existing.id, attempts, quarantined };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ts = now();
|
|
134
|
+
const id = `${ts.fileTime}-${ts.hex}`;
|
|
135
|
+
const entry: DlqEntry = {
|
|
136
|
+
id,
|
|
137
|
+
kind,
|
|
138
|
+
actor,
|
|
139
|
+
channel: channelUuid,
|
|
140
|
+
messageRelPath,
|
|
141
|
+
attempts: 1,
|
|
142
|
+
quarantined: false,
|
|
143
|
+
firstFailedAt: ts.iso,
|
|
144
|
+
lastFailedAt,
|
|
145
|
+
error: error.slice(0, 500),
|
|
146
|
+
};
|
|
147
|
+
writeFileSync(
|
|
148
|
+
join(dir, `${id}.md`),
|
|
149
|
+
serializeFrontmatter(entry as unknown as Record<string, unknown>, error),
|
|
150
|
+
);
|
|
151
|
+
return { id, attempts: 1, quarantined: false };
|
|
152
|
+
}
|
|
153
|
+
|
|
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 }[] {
|
|
162
|
+
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
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── 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
|
+
|
|
184
|
+
const isEntry = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
|
|
185
|
+
|
|
186
|
+
if (isEntry) {
|
|
187
|
+
const list = argv.includes('--list');
|
|
188
|
+
const show = flag('--show');
|
|
189
|
+
const retryId = flag('--retry');
|
|
190
|
+
const clear = argv.includes('--clear');
|
|
191
|
+
|
|
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`);
|
|
206
|
+
if (!existsSync(path)) {
|
|
207
|
+
console.error(`No DLQ entry: ${show}`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
console.log(readFileSync(path, 'utf-8'));
|
|
211
|
+
} 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'));
|
|
218
|
+
for (const f of files) unlinkSync(join(dir, f));
|
|
219
|
+
console.log(`Cleared ${files.length} DLQ entries`);
|
|
220
|
+
} else if (retryId) {
|
|
221
|
+
const path = join(dlqDir(transportRoot), `${retryId}.md`);
|
|
222
|
+
if (!existsSync(path)) {
|
|
223
|
+
console.error(`No DLQ entry: ${retryId}`);
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
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.
|
|
247
|
+
data.quarantined = false;
|
|
248
|
+
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
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
console.error(
|
|
259
|
+
'Usage: npm run dlq -- [--list | --show <id> | --retry <id> | --clear]',
|
|
260
|
+
);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
}
|
package/src/filenames.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
|
|
3
|
+
export interface Timestamp {
|
|
4
|
+
iso: string;
|
|
5
|
+
pathDate: string;
|
|
6
|
+
fileTime: string;
|
|
7
|
+
hex: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function now(): Timestamp {
|
|
11
|
+
const d = new Date();
|
|
12
|
+
const iso = d.toISOString();
|
|
13
|
+
const yyyy = iso.slice(0, 4);
|
|
14
|
+
const mm = iso.slice(5, 7);
|
|
15
|
+
const dd = iso.slice(8, 10);
|
|
16
|
+
const time = iso.slice(11, 13) + iso.slice(14, 16) + iso.slice(17, 19);
|
|
17
|
+
const ms = iso.slice(20, 23);
|
|
18
|
+
return {
|
|
19
|
+
iso,
|
|
20
|
+
pathDate: `${yyyy}/${mm}/${dd}`,
|
|
21
|
+
fileTime: `${time}${ms}Z`,
|
|
22
|
+
hex: randomBytes(4).toString('hex'),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function messageFilename(ts: Timestamp = now()): string {
|
|
27
|
+
return `${ts.fileTime}-${ts.hex}.md`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
2
|
+
|
|
3
|
+
export interface ParsedDocument<T = Record<string, unknown>> {
|
|
4
|
+
data: T;
|
|
5
|
+
body: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseFrontmatter<T = Record<string, unknown>>(raw: string): ParsedDocument<T> {
|
|
9
|
+
if (!raw.startsWith('---')) return { data: {} as T, body: raw };
|
|
10
|
+
const lines = raw.split('\n');
|
|
11
|
+
let end = -1;
|
|
12
|
+
for (let i = 1; i < lines.length; i++) {
|
|
13
|
+
if (lines[i].trim() === '---') { end = i; break; }
|
|
14
|
+
}
|
|
15
|
+
if (end === -1) return { data: {} as T, body: raw };
|
|
16
|
+
const yamlBlock = lines.slice(1, end).join('\n');
|
|
17
|
+
const body = lines.slice(end + 1).join('\n').replace(/^\n/, '');
|
|
18
|
+
const data = (parseYaml(yamlBlock) as T) ?? ({} as T);
|
|
19
|
+
return { data, body };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function serializeFrontmatter(data: Record<string, unknown>, body: string): string {
|
|
23
|
+
const yaml = stringifyYaml(data).trimEnd();
|
|
24
|
+
const trailing = body.endsWith('\n') ? '' : '\n';
|
|
25
|
+
return `---\n${yaml}\n---\n\n${body}${trailing}`;
|
|
26
|
+
}
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// crosstalk init <directory> — scaffold a new transport
|
|
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).
|
|
7
|
+
//
|
|
8
|
+
// Template lookup order:
|
|
9
|
+
// 1. <runtime_root>/template/ — bundled at publish time (production)
|
|
10
|
+
// 2. <runtime_root>/../transport/ — monorepo layout (local dev)
|
|
11
|
+
//
|
|
12
|
+
// If neither exists, exits with a clear error.
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, writeFileSync, cpSync } from 'fs';
|
|
15
|
+
import { resolve, join, dirname } from 'path';
|
|
16
|
+
import { hostname as osHostname } from 'os';
|
|
17
|
+
import { randomUUID } from 'crypto';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
|
|
20
|
+
const argv = process.argv.slice(2);
|
|
21
|
+
const force = argv.includes('--force');
|
|
22
|
+
|
|
23
|
+
const positional = argv.filter((a) => !a.startsWith('--'));
|
|
24
|
+
if (positional.length === 0) {
|
|
25
|
+
console.error('Usage: crosstalk init <directory> [--force]');
|
|
26
|
+
console.error(' crosstalk init . (scaffold into current dir)');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const targetDir = resolve(positional[0]);
|
|
31
|
+
|
|
32
|
+
if (existsSync(join(targetDir, 'upstream', 'CROSSTALK-VERSION')) && !force) {
|
|
33
|
+
console.error(`crosstalk init: ${targetDir} already contains a transport.`);
|
|
34
|
+
console.error('Pass --force to overwrite.');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Locate the bundled transport template.
|
|
39
|
+
const thisFileDir = dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const runtimeRoot = resolve(thisFileDir, '..');
|
|
41
|
+
const candidates = [
|
|
42
|
+
join(runtimeRoot, 'template'),
|
|
43
|
+
join(runtimeRoot, '..', 'transport'),
|
|
44
|
+
];
|
|
45
|
+
const templateDir = candidates.find((c) => existsSync(join(c, 'upstream', 'CROSSTALK-VERSION')));
|
|
46
|
+
|
|
47
|
+
if (!templateDir) {
|
|
48
|
+
console.error('crosstalk init: cannot find the transport template.');
|
|
49
|
+
console.error('Looked in:');
|
|
50
|
+
for (const c of candidates) console.error(` ${c}`);
|
|
51
|
+
console.error('The @cordfuse/crosstalk installation may be corrupted; try reinstalling.');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
mkdirSync(targetDir, { recursive: true });
|
|
56
|
+
|
|
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
|
+
const hostname = osHostname();
|
|
70
|
+
const hostsDir = join(targetDir, 'hosts');
|
|
71
|
+
mkdirSync(hostsDir, { recursive: true });
|
|
72
|
+
const hostPath = join(hostsDir, `${hostname}.md`);
|
|
73
|
+
if (!existsSync(hostPath) || force) {
|
|
74
|
+
writeFileSync(
|
|
75
|
+
hostPath,
|
|
76
|
+
`---
|
|
77
|
+
alias: ${hostname}
|
|
78
|
+
hostname: ${hostname}
|
|
79
|
+
actors:
|
|
80
|
+
concierge:
|
|
81
|
+
claude:
|
|
82
|
+
cli: claude --print --dangerously-skip-permissions
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
Host file for ${hostname}. One actor (concierge) on Claude Code by default.
|
|
86
|
+
Add more actors as you need them; declare each tier under its CLI invocation.
|
|
87
|
+
|
|
88
|
+
To add an actor with multiple parallel slots (e.g. 10 junior-developer instances
|
|
89
|
+
each picking up messages independently), use \`count: N\` under the tier:
|
|
90
|
+
|
|
91
|
+
actors:
|
|
92
|
+
junior-developer:
|
|
93
|
+
haiku:
|
|
94
|
+
cli: claude --model claude-haiku-4-5 --print --dangerously-skip-permissions
|
|
95
|
+
count: 10
|
|
96
|
+
`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// First channel.
|
|
101
|
+
const chId = randomUUID();
|
|
102
|
+
const channelDir = join(targetDir, 'data', 'channels', chId);
|
|
103
|
+
mkdirSync(channelDir, { recursive: true });
|
|
104
|
+
const channelMd = join(channelDir, 'CHANNEL.md');
|
|
105
|
+
if (!existsSync(channelMd) || force) {
|
|
106
|
+
writeFileSync(
|
|
107
|
+
channelMd,
|
|
108
|
+
`---
|
|
109
|
+
name: general
|
|
110
|
+
created_by: ${process.env['USER'] || 'operator'}
|
|
111
|
+
created_at: ${new Date().toISOString()}
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
General channel. First channel of this transport.
|
|
115
|
+
`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
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
|
+
const readmePath = join(targetDir, 'README.md');
|
|
129
|
+
if (!existsSync(readmePath) || force) {
|
|
130
|
+
writeFileSync(
|
|
131
|
+
readmePath,
|
|
132
|
+
`# Transport
|
|
133
|
+
|
|
134
|
+
A Crosstalk transport created by \`crosstalk init\`.
|
|
135
|
+
|
|
136
|
+
- Spec: \`upstream/CROSSTALK.md\`
|
|
137
|
+
- Agent orientation: \`upstream/PROTOCOL.md\` and \`upstream/OPERATOR.md\`
|
|
138
|
+
- Your custom actor profiles: \`local/actors/\`
|
|
139
|
+
- 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
|
+
`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log('');
|
|
148
|
+
console.log(`Transport initialized at ${targetDir}`);
|
|
149
|
+
console.log(` host file: hosts/${hostname}.md`);
|
|
150
|
+
console.log(` first channel UUID: ${chId}`);
|
|
151
|
+
console.log('');
|
|
152
|
+
console.log('Next steps:');
|
|
153
|
+
console.log(` cd ${targetDir}`);
|
|
154
|
+
console.log(' git init && git add -A && git commit -m "initial transport"');
|
|
155
|
+
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');
|
package/src/open.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { resolve, join } from 'path';
|
|
2
|
+
import { spawnSync } from 'child_process';
|
|
3
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'fs';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
import { createInterface } from 'readline/promises';
|
|
6
|
+
import { statSync } from 'fs';
|
|
7
|
+
import { findHostFile, loadActorProfile, pickTier, tokenizeCli } from './actor.js';
|
|
8
|
+
import { now, messageFilename } from './filenames.js';
|
|
9
|
+
import { serializeFrontmatter } from './frontmatter.js';
|
|
10
|
+
import { writeDlqEntry } from './dlq.js';
|
|
11
|
+
|
|
12
|
+
const transportRoot = resolve(process.cwd());
|
|
13
|
+
const argv = process.argv.slice(2);
|
|
14
|
+
|
|
15
|
+
function flag(name: string): string | undefined {
|
|
16
|
+
const i = argv.indexOf(name);
|
|
17
|
+
if (i === -1 || i === argv.length - 1) return undefined;
|
|
18
|
+
return argv[i + 1];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const actorName = flag('--actor');
|
|
22
|
+
let channelUuid = flag('--channel');
|
|
23
|
+
const hostOverride = flag('--host');
|
|
24
|
+
const operatorName = flag('--as') ?? process.env.USER ?? 'steve';
|
|
25
|
+
|
|
26
|
+
if (!actorName) {
|
|
27
|
+
console.error('Usage: npm run open -- --actor <name> [--channel <uuid>] [--host <alias>] [--as <name>]');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const host = findHostFile(transportRoot, hostOverride);
|
|
32
|
+
if (!host.actors[actorName]) {
|
|
33
|
+
console.error(`Actor '${actorName}' not declared in host file ${host.alias}`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!channelUuid) {
|
|
38
|
+
channelUuid = randomUUID();
|
|
39
|
+
const dir = join(transportRoot, 'data', 'channels', channelUuid);
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
writeFileSync(
|
|
42
|
+
join(dir, 'CHANNEL.md'),
|
|
43
|
+
`---
|
|
44
|
+
name: open-${actorName}-${now().fileTime}
|
|
45
|
+
created_by: ${operatorName}
|
|
46
|
+
created_at: ${new Date().toISOString()}
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
Interactive session channel — \`npm run open\` invocation.
|
|
50
|
+
`,
|
|
51
|
+
);
|
|
52
|
+
console.log(`(created channel ${channelUuid})`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
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`);
|
|
58
|
+
|
|
59
|
+
interface CachedPrompt {
|
|
60
|
+
systemPrompt: string;
|
|
61
|
+
protocolMtime: number;
|
|
62
|
+
profileMtime: number;
|
|
63
|
+
profilePath: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mtime(p: string): number {
|
|
67
|
+
try { return statSync(p).mtimeMs; } catch { return 0; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function loadComposedPrompt(): CachedPrompt {
|
|
71
|
+
const profile = loadActorProfile(transportRoot, actorName!);
|
|
72
|
+
const protocolPrompt = existsSync(protocolPath)
|
|
73
|
+
? readFileSync(protocolPath, 'utf-8').trim()
|
|
74
|
+
: '';
|
|
75
|
+
const systemPrompt = [protocolPrompt, profile.systemPrompt]
|
|
76
|
+
.filter((p) => p.length > 0)
|
|
77
|
+
.join('\n\n---\n\n');
|
|
78
|
+
const profilePath = existsSync(actorProfilePath) ? actorProfilePath : fwActorProfilePath;
|
|
79
|
+
return {
|
|
80
|
+
systemPrompt,
|
|
81
|
+
protocolMtime: mtime(protocolPath),
|
|
82
|
+
profileMtime: mtime(profilePath),
|
|
83
|
+
profilePath,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let cached = loadComposedPrompt();
|
|
88
|
+
|
|
89
|
+
function getCurrentSystemPrompt(): string {
|
|
90
|
+
const currentProtocolMtime = mtime(protocolPath);
|
|
91
|
+
const currentProfileMtime = mtime(cached.profilePath);
|
|
92
|
+
if (currentProtocolMtime !== cached.protocolMtime || currentProfileMtime !== cached.profileMtime) {
|
|
93
|
+
console.log('(reloaded actor profile + PROTOCOL.md after detected mtime change)\n');
|
|
94
|
+
cached = loadComposedPrompt();
|
|
95
|
+
}
|
|
96
|
+
return cached.systemPrompt;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { cli } = pickTier(host.actors[actorName]);
|
|
100
|
+
|
|
101
|
+
function logToChannel(from: string, to: string, body: string): void {
|
|
102
|
+
const ts = now();
|
|
103
|
+
const dir = join(transportRoot, 'data', 'channels', channelUuid!, ts.pathDate);
|
|
104
|
+
mkdirSync(dir, { recursive: true });
|
|
105
|
+
const content = serializeFrontmatter(
|
|
106
|
+
{ from, to, type: 'text', timestamp: ts.iso },
|
|
107
|
+
body,
|
|
108
|
+
);
|
|
109
|
+
writeFileSync(join(dir, messageFilename(ts)), content);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log(`crosstalk v4 open — actor=${actorName} channel=${channelUuid.slice(0, 8)}`);
|
|
113
|
+
console.log('Type a message and press Enter. Ctrl-C or Ctrl-D to exit.');
|
|
114
|
+
console.log('');
|
|
115
|
+
|
|
116
|
+
async function main(): Promise<void> {
|
|
117
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
while (true) {
|
|
121
|
+
let userMsg: string;
|
|
122
|
+
try {
|
|
123
|
+
userMsg = await rl.question('you> ');
|
|
124
|
+
} catch {
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
if (userMsg === undefined || userMsg === null) break;
|
|
128
|
+
const trimmed = userMsg.trim();
|
|
129
|
+
if (!trimmed) continue;
|
|
130
|
+
|
|
131
|
+
const ts = now();
|
|
132
|
+
const userMsgRelPath = `${ts.pathDate}/${messageFilename(ts)}`;
|
|
133
|
+
logToChannel(operatorName, actorName!, trimmed);
|
|
134
|
+
|
|
135
|
+
const fullPrompt = `${getCurrentSystemPrompt()}\n\n---\n\n${trimmed}`;
|
|
136
|
+
const parts = tokenizeCli(cli);
|
|
137
|
+
if (parts.length === 0) {
|
|
138
|
+
console.error('\n[open] tokenized cli is empty — check host file\n');
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const result = spawnSync(parts[0], parts.slice(1), {
|
|
142
|
+
input: fullPrompt,
|
|
143
|
+
encoding: 'utf-8',
|
|
144
|
+
timeout: 5 * 60_000,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (result.status !== 0) {
|
|
148
|
+
const r = writeDlqEntry(
|
|
149
|
+
transportRoot,
|
|
150
|
+
'dispatch',
|
|
151
|
+
actorName!,
|
|
152
|
+
channelUuid!,
|
|
153
|
+
userMsgRelPath,
|
|
154
|
+
`cli exit=${result.status} (open mode)\n${(result.stderr || '').slice(0, 1000)}`,
|
|
155
|
+
);
|
|
156
|
+
const quarantineMark = r.quarantined ? ' [QUARANTINED]' : '';
|
|
157
|
+
console.error(`\n[cli exit=${result.status} → dlq:${r.id}${quarantineMark}] ${(result.stderr || '').slice(0, 200)}\n`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const reply = (result.stdout || '').trim();
|
|
162
|
+
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`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
console.log(`${actorName}> ${reply}\n`);
|
|
176
|
+
logToChannel(actorName!, operatorName, reply);
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
rl.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
main();
|