@cordfuse/crosstalk 6.0.0-alpha.9 → 7.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.
Files changed (46) hide show
  1. package/README.md +26 -0
  2. package/bin/crosstalk.js +60 -74
  3. package/commands/channel.js +69 -0
  4. package/commands/chat.js +174 -0
  5. package/commands/down.js +37 -0
  6. package/commands/init.js +105 -0
  7. package/commands/logs.js +39 -0
  8. package/commands/pull.js +24 -0
  9. package/commands/replies.js +39 -0
  10. package/commands/restart.js +24 -0
  11. package/commands/run.js +121 -0
  12. package/commands/status.js +52 -0
  13. package/commands/up.js +129 -0
  14. package/commands/version.js +30 -0
  15. package/lib/api-client.js +80 -0
  16. package/lib/argv.js +28 -0
  17. package/lib/errors.js +19 -0
  18. package/lib/transport.js +51 -0
  19. package/package.json +5 -21
  20. package/src/activation.ts +0 -104
  21. package/src/actor.ts +0 -131
  22. package/src/attach.ts +0 -118
  23. package/src/channel.ts +0 -49
  24. package/src/chat.ts +0 -142
  25. package/src/dispatch.ts +0 -531
  26. package/src/dlq.ts +0 -216
  27. package/src/filenames.ts +0 -28
  28. package/src/frontmatter.ts +0 -26
  29. package/src/init.ts +0 -138
  30. package/src/open.ts +0 -207
  31. package/src/replies.ts +0 -59
  32. package/src/send.ts +0 -122
  33. package/src/state.ts +0 -173
  34. package/src/status.ts +0 -75
  35. package/src/stop.ts +0 -37
  36. package/src/transport.ts +0 -213
  37. package/src/turnq.ts +0 -91
  38. package/src/upgrade.ts +0 -211
  39. package/src/wake.ts +0 -7
  40. package/template/CLAUDE.md +0 -12
  41. package/template/gitignore +0 -4
  42. package/template/upstream/CROSSTALK-VERSION +0 -1
  43. package/template/upstream/CROSSTALK.md +0 -298
  44. package/template/upstream/OPERATOR.md +0 -60
  45. package/template/upstream/PROTOCOL.md +0 -80
  46. package/template/upstream/actors/concierge.md +0 -36
package/src/dlq.ts DELETED
@@ -1,216 +0,0 @@
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
-
6
- import {
7
- readdirSync,
8
- readFileSync,
9
- writeFileSync,
10
- mkdirSync,
11
- existsSync,
12
- unlinkSync,
13
- } from 'fs';
14
- import { resolve, join, dirname } from 'path';
15
- import { pathToFileURL } from 'url';
16
- import { spawnSync } from 'child_process';
17
- import { now } from './filenames.js';
18
- import { serializeFrontmatter, parseFrontmatter } from './frontmatter.js';
19
- import { stateDir, cursorPath } from './state.js';
20
-
21
- const QUARANTINE_THRESHOLD_ATTEMPTS = 4;
22
- const QUARANTINE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
23
-
24
- export interface DlqEntry {
25
- id: string;
26
- actor: string;
27
- channel: string;
28
- messageRelPath: string;
29
- attempts: number;
30
- quarantined: boolean;
31
- firstFailedAt: string;
32
- lastFailedAt: string;
33
- error: string;
34
- }
35
-
36
- function dlqDir(transportRoot: string): string {
37
- return join(stateDir(transportRoot), 'dlq');
38
- }
39
-
40
- function findEntry(
41
- transportRoot: string,
42
- actor: string,
43
- channel: string,
44
- messageRelPath: string,
45
- ): { id: string; path: string; entry: DlqEntry } | null {
46
- const dir = dlqDir(transportRoot);
47
- if (!existsSync(dir)) return null;
48
- for (const f of readdirSync(dir).filter((x) => x.endsWith('.md'))) {
49
- const path = join(dir, f);
50
- try {
51
- const { data } = parseFrontmatter<DlqEntry>(readFileSync(path, 'utf-8'));
52
- if (data.actor === actor && data.channel === channel && data.messageRelPath === messageRelPath) {
53
- return { id: f.replace(/\.md$/, ''), path, entry: data };
54
- }
55
- } catch { /* skip unparseable */ }
56
- }
57
- return null;
58
- }
59
-
60
- export function isQuarantined(
61
- transportRoot: string,
62
- actor: string,
63
- channel: string,
64
- messageRelPath: string,
65
- ): boolean {
66
- return findEntry(transportRoot, actor, channel, messageRelPath)?.entry.quarantined ?? false;
67
- }
68
-
69
- export function writeDlqEntry(
70
- transportRoot: string,
71
- actor: string,
72
- channelUuid: string,
73
- messageRelPath: string,
74
- error: string,
75
- ): { id: string; attempts: number; quarantined: boolean } {
76
- const dir = dlqDir(transportRoot);
77
- mkdirSync(dir, { recursive: true });
78
-
79
- const existing = findEntry(transportRoot, actor, channelUuid, messageRelPath);
80
- const lastFailedAt = new Date().toISOString();
81
-
82
- if (existing) {
83
- const attempts = (existing.entry.attempts ?? 1) + 1;
84
- const ageMs = Date.now() - new Date(existing.entry.firstFailedAt).getTime();
85
- const quarantined =
86
- existing.entry.quarantined ||
87
- (attempts >= QUARANTINE_THRESHOLD_ATTEMPTS && ageMs < QUARANTINE_WINDOW_MS);
88
- const updated: DlqEntry = {
89
- ...existing.entry,
90
- attempts,
91
- lastFailedAt,
92
- error: error.slice(0, 500),
93
- quarantined,
94
- };
95
- writeFileSync(existing.path, serializeFrontmatter(updated as unknown as Record<string, unknown>, error));
96
- return { id: existing.id, attempts, quarantined };
97
- }
98
-
99
- const ts = now();
100
- const id = `${ts.fileTime}-${ts.hex}`;
101
- const entry: DlqEntry = {
102
- id,
103
- actor,
104
- channel: channelUuid,
105
- messageRelPath,
106
- attempts: 1,
107
- quarantined: false,
108
- firstFailedAt: ts.iso,
109
- lastFailedAt,
110
- error: error.slice(0, 500),
111
- };
112
- writeFileSync(join(dir, `${id}.md`), serializeFrontmatter(entry as unknown as Record<string, unknown>, error));
113
- return { id, attempts: 1, quarantined: false };
114
- }
115
-
116
- export function countDlqEntries(transportRoot: string): { total: number; quarantined: number } {
117
- const dir = dlqDir(transportRoot);
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 };
129
- }
130
-
131
- // ── CLI entry point ──
132
-
133
- const isEntry = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
134
-
135
- if (isEntry) {
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
-
143
- const show = flag('--show');
144
- const retryId = flag('--retry');
145
- const clear = argv.includes('--clear');
146
- const dir = dlqDir(transportRoot);
147
-
148
- if (show) {
149
- const path = join(dir, `${show}.md`);
150
- if (!existsSync(path)) {
151
- console.error(`No DLQ entry: ${show}`);
152
- process.exit(1);
153
- }
154
- console.log(readFileSync(path, 'utf-8'));
155
- } else if (clear) {
156
- const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')) : [];
157
- for (const f of files) unlinkSync(join(dir, f));
158
- console.log(`Cleared ${files.length} DLQ entries`);
159
- } else if (retryId) {
160
- const path = join(dir, `${retryId}.md`);
161
- if (!existsSync(path)) {
162
- console.error(`No DLQ entry: ${retryId}`);
163
- process.exit(1);
164
- }
165
- const { data } = parseFrontmatter<DlqEntry>(readFileSync(path, 'utf-8'));
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) {
191
- data.quarantined = false;
192
- writeFileSync(path, serializeFrontmatter(data as unknown as Record<string, unknown>, data.error));
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.');
197
- } else {
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);
215
- }
216
- }
package/src/filenames.ts DELETED
@@ -1,28 +0,0 @@
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
- }
@@ -1,26 +0,0 @@
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 DELETED
@@ -1,138 +0,0 @@
1
- // crosstalk init <directory> — scaffold a new transport.
2
- //
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
- //
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
- import { existsSync, mkdirSync, writeFileSync, cpSync, renameSync } from 'fs';
13
- import { resolve, join, dirname } from 'path';
14
- import { hostname as osHostname } from 'os';
15
- import { randomUUID } from 'crypto';
16
- import { fileURLToPath } from 'url';
17
-
18
- const argv = process.argv.slice(2);
19
- const force = argv.includes('--force');
20
-
21
- const positional = argv.filter((a) => !a.startsWith('--'));
22
- if (positional.length === 0) {
23
- console.error('Usage: crosstalk init <directory> [--force]');
24
- console.error(' crosstalk init . (scaffold into current dir)');
25
- process.exit(1);
26
- }
27
-
28
- const targetDir = resolve(positional[0]!);
29
-
30
- if (existsSync(join(targetDir, 'upstream', 'CROSSTALK-VERSION')) && !force) {
31
- console.error(`crosstalk init: ${targetDir} already contains a transport.`);
32
- console.error('Pass --force to overwrite.');
33
- process.exit(1);
34
- }
35
-
36
- const runtimeRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
37
- const candidates = [
38
- join(runtimeRoot, 'template'),
39
- join(runtimeRoot, '..', 'transport'),
40
- ];
41
- const templateDir = candidates.find((c) => existsSync(join(c, 'upstream', 'CROSSTALK-VERSION')));
42
-
43
- if (!templateDir) {
44
- console.error('crosstalk init: cannot find the transport template.');
45
- console.error('Looked in:');
46
- for (const c of candidates) console.error(` ${c}`);
47
- console.error('The @cordfuse/crosstalk installation may be corrupted; try reinstalling.');
48
- process.exit(1);
49
- }
50
-
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);
62
-
63
- const hostname = osHostname();
64
- const hostsDir = join(targetDir, 'hosts');
65
- mkdirSync(hostsDir, { recursive: true });
66
- const hostPath = join(hostsDir, `${hostname}.md`);
67
- if (!existsSync(hostPath) || force) {
68
- writeFileSync(
69
- hostPath,
70
- `---
71
- alias: ${hostname}
72
- hostname: ${hostname}
73
- actors:
74
- concierge:
75
- claude:
76
- cli: claude --print --dangerously-skip-permissions
77
- ---
78
-
79
- Host file for ${hostname}. One actor (concierge) on Claude Code by default.
80
- Add more actors as you need them; declare each tier under its CLI invocation.
81
-
82
- To give an actor multiple parallel slots (e.g. 10 junior-developer instances
83
- each picking up messages independently), use \`count: N\` under the tier:
84
-
85
- actors:
86
- junior-developer:
87
- haiku:
88
- cli: claude --model claude-haiku-4-5 --print --dangerously-skip-permissions
89
- count: 10
90
- `,
91
- );
92
- }
93
-
94
- const chId = randomUUID();
95
- const channelDir = join(targetDir, 'data', 'channels', chId);
96
- mkdirSync(channelDir, { recursive: true });
97
- const channelMd = join(channelDir, 'CHANNEL.md');
98
- if (!existsSync(channelMd) || force) {
99
- writeFileSync(
100
- channelMd,
101
- `---
102
- name: general
103
- created_by: ${process.env['USER'] || 'operator'}
104
- created_at: ${new Date().toISOString()}
105
- ---
106
-
107
- General channel. First channel of this transport.
108
- `,
109
- );
110
- }
111
-
112
- const readmePath = join(targetDir, 'README.md');
113
- if (!existsSync(readmePath) || force) {
114
- writeFileSync(
115
- readmePath,
116
- `# Transport
117
-
118
- A Crosstalk transport created by \`crosstalk init\`.
119
-
120
- - Spec: \`upstream/CROSSTALK.md\`
121
- - Agent orientation: \`upstream/PROTOCOL.md\`
122
- - Your custom actor profiles: \`local/actors/\`
123
- - Host configuration for this machine: \`hosts/${hostname}.md\`
124
- `,
125
- );
126
- }
127
-
128
- console.log('');
129
- console.log(`Transport initialized at ${targetDir}`);
130
- console.log(` host file: hosts/${hostname}.md`);
131
- console.log(` first channel UUID: ${chId}`);
132
- console.log('');
133
- console.log('Next steps:');
134
- console.log(` cd ${targetDir}`);
135
- console.log(' git init && git add -A && git commit -m "initial transport"');
136
- console.log(' crosstalk status # verify scaffold');
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 DELETED
@@ -1,207 +0,0 @@
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
-
10
- import { resolve, join } from 'path';
11
- import { spawnSync } from 'child_process';
12
- import { mkdirSync, writeFileSync, existsSync, readFileSync, statSync } from 'fs';
13
- import { randomUUID } from 'crypto';
14
- import { createInterface } from 'readline/promises';
15
- import { findHostFile, loadActorProfile, pickTier, tokenizeCli } from './actor.js';
16
- import { now, messageFilename } from './filenames.js';
17
- import { serializeFrontmatter } from './frontmatter.js';
18
- import { gitCommitAndPush } from './transport.js';
19
- import { withLock } from './turnq.js';
20
- import { writeDlqEntry } from './dlq.js';
21
-
22
- const transportRoot = resolve(process.cwd());
23
- const argv = process.argv.slice(2);
24
-
25
- function flag(name: string): string | undefined {
26
- const i = argv.indexOf(name);
27
- if (i === -1 || i === argv.length - 1) return undefined;
28
- return argv[i + 1];
29
- }
30
-
31
- const actorName = flag('--actor');
32
- let channelUuid = flag('--channel');
33
- const hostOverride = flag('--host');
34
- const operatorName = flag('--as') ?? process.env['USER'] ?? 'operator';
35
-
36
- if (!actorName) {
37
- console.error('Usage: crosstalk open --actor <name> [--channel <uuid>] [--host <alias>] [--as <name>]');
38
- process.exit(1);
39
- }
40
-
41
- const host = findHostFile(transportRoot, hostOverride);
42
- if (!host.actors[actorName]) {
43
- console.error(`Actor '${actorName}' not declared in host file ${host.alias}`);
44
- process.exit(1);
45
- }
46
-
47
- if (!channelUuid) {
48
- channelUuid = randomUUID();
49
- const dir = join(transportRoot, 'data', 'channels', channelUuid);
50
- mkdirSync(dir, { recursive: true });
51
- writeFileSync(
52
- join(dir, 'CHANNEL.md'),
53
- `---
54
- name: open-${actorName}-${now().fileTime}
55
- created_by: ${operatorName}
56
- created_at: ${new Date().toISOString()}
57
- ---
58
-
59
- Interactive session channel — \`crosstalk open\` invocation.
60
- `,
61
- );
62
- console.log(`(created channel ${channelUuid})`);
63
- }
64
-
65
- const protocolPath = join(transportRoot, 'upstream', 'PROTOCOL.md');
66
- const localProfilePath = join(transportRoot, 'local', 'actors', `${actorName}.md`);
67
- const upstreamProfilePath = join(transportRoot, 'upstream', 'actors', `${actorName}.md`);
68
-
69
- interface CachedPrompt {
70
- systemPrompt: string;
71
- protocolMtime: number;
72
- profileMtime: number;
73
- profilePath: string;
74
- }
75
-
76
- function mtime(p: string): number {
77
- try { return statSync(p).mtimeMs; } catch { return 0; }
78
- }
79
-
80
- function loadComposedPrompt(): CachedPrompt {
81
- const profile = loadActorProfile(transportRoot, actorName!);
82
- const protocolPrompt = existsSync(protocolPath)
83
- ? readFileSync(protocolPath, 'utf-8').trim()
84
- : '';
85
- const systemPrompt = [protocolPrompt, profile.systemPrompt]
86
- .filter((p) => p.length > 0)
87
- .join('\n\n---\n\n');
88
- const profilePath = existsSync(localProfilePath) ? localProfilePath : upstreamProfilePath;
89
- return {
90
- systemPrompt,
91
- protocolMtime: mtime(protocolPath),
92
- profileMtime: mtime(profilePath),
93
- profilePath,
94
- };
95
- }
96
-
97
- let cached = loadComposedPrompt();
98
-
99
- function getCurrentSystemPrompt(): string {
100
- const currentProtocolMtime = mtime(protocolPath);
101
- const currentProfileMtime = mtime(cached.profilePath);
102
- if (currentProtocolMtime !== cached.protocolMtime || currentProfileMtime !== cached.profileMtime) {
103
- console.log('(reloaded actor profile + PROTOCOL.md after detected mtime change)\n');
104
- cached = loadComposedPrompt();
105
- }
106
- return cached.systemPrompt;
107
- }
108
-
109
- const { cli } = pickTier(host.actors[actorName]!);
110
-
111
- function logToChannel(from: string, to: string, body: string, re?: string): string {
112
- const ts = now();
113
- const dir = join(transportRoot, 'data', 'channels', channelUuid!, ts.pathDate);
114
- mkdirSync(dir, { recursive: true });
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
- ),
128
- );
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
- }
133
- }
134
-
135
- console.log(`crosstalk open — actor=${actorName} channel=${channelUuid.slice(0, 8)}`);
136
- console.log('Type a message and press Enter. Ctrl-C or Ctrl-D to exit.');
137
- console.log('');
138
-
139
- async function main(): Promise<void> {
140
- const rl = createInterface({ input: process.stdin, output: process.stdout });
141
-
142
- try {
143
- while (true) {
144
- let userMsg: string;
145
- try {
146
- userMsg = await rl.question(`${operatorName}> `);
147
- } catch {
148
- break;
149
- }
150
- if (userMsg === undefined || userMsg === null) break;
151
- const trimmed = userMsg.trim();
152
- if (!trimmed) continue;
153
-
154
- const userMsgRelPath = logToChannel(operatorName, actorName!, trimmed);
155
-
156
- const fullPrompt = `${getCurrentSystemPrompt()}\n\n---\n\n${trimmed}`;
157
- const parts = tokenizeCli(cli);
158
- if (parts.length === 0) {
159
- console.error('\n[open] tokenized cli is empty — check host file\n');
160
- continue;
161
- }
162
- const result = spawnSync(parts[0]!, parts.slice(1), {
163
- input: fullPrompt,
164
- encoding: 'utf-8',
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
- },
172
- });
173
-
174
- if (result.status !== 0) {
175
- const r = writeDlqEntry(
176
- transportRoot,
177
- actorName!,
178
- channelUuid!,
179
- userMsgRelPath,
180
- `cli exit=${result.status} (open mode)\n${(result.stderr || '').slice(0, 1000)}`,
181
- );
182
- const quarantineMark = r.quarantined ? ' [QUARANTINED]' : '';
183
- console.error(`\n[cli exit=${result.status} → dlq:${r.id}${quarantineMark}] ${(result.stderr || '').slice(0, 200)}\n`);
184
- await commitTurn();
185
- continue;
186
- }
187
-
188
- const reply = (result.stdout || '').trim();
189
- if (reply.length === 0) {
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();
194
- continue;
195
- }
196
-
197
- console.log(`${actorName}> ${reply}\n`);
198
- logToChannel(actorName!, operatorName, reply, userMsgRelPath);
199
- await commitTurn();
200
- }
201
- } finally {
202
- rl.close();
203
- }
204
- process.exit(0);
205
- }
206
-
207
- main();
package/src/replies.ts DELETED
@@ -1,59 +0,0 @@
1
- // crosstalk replies — have my messages been answered yet?
2
- //
3
- // Checks each given relPath for a message whose `re:` points back at it.
4
- // This replaces v5's read-receipt machinery: instead of recipients writing
5
- // "I saw it" markers into the channel, the asker just looks for actual
6
- // replies. Exit 0 when everything is answered, 2 while anything is pending
7
- // — so agents can poll cheaply in a loop.
8
-
9
- import { resolve } from 'path';
10
- import { gitPull, discoverChannels, listChannelMessages } from './transport.js';
11
- import { reList } from './activation.js';
12
-
13
- const transportRoot = resolve(process.cwd());
14
- const argv = process.argv.slice(2);
15
-
16
- function flag(name: string): string | undefined {
17
- const i = argv.indexOf(name);
18
- if (i === -1 || i === argv.length - 1) return undefined;
19
- return argv[i + 1];
20
- }
21
-
22
- const reArg = flag('--re');
23
- const channelArg = flag('--channel') ?? process.env['CROSSTALK_DISPATCH_CHANNEL'];
24
-
25
- if (!reArg) {
26
- console.error('Usage: crosstalk replies --re <relPath[,relPath...]> [--channel <uuid>]');
27
- process.exit(1);
28
- }
29
-
30
- const targets = reArg.split(',').map((s) => s.trim()).filter(Boolean);
31
-
32
- gitPull(transportRoot); // best-effort freshness; a failed pull just means slightly stale answers
33
-
34
- const channels = channelArg ? [channelArg] : discoverChannels(transportRoot);
35
-
36
- // target relPath -> reply found
37
- const found = new Map<string, { from: string; relPath: string }>();
38
- for (const channel of channels) {
39
- for (const msg of listChannelMessages(transportRoot, channel)) {
40
- for (const entry of reList(msg.data['re'])) {
41
- if (targets.includes(entry) && !found.has(entry)) {
42
- found.set(entry, { from: String(msg.data['from']), relPath: msg.relPath });
43
- }
44
- }
45
- }
46
- }
47
-
48
- let pending = 0;
49
- for (const target of targets) {
50
- const reply = found.get(target);
51
- if (reply) {
52
- console.log(`REPLIED ${target} <- ${reply.from} (${reply.relPath})`);
53
- } else {
54
- console.log(`PENDING ${target}`);
55
- pending++;
56
- }
57
- }
58
-
59
- process.exit(pending > 0 ? 2 : 0);