@cordfuse/crosstalkd 7.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/GUIDE-CLI.md +315 -0
- package/GUIDE-PROMPTS.md +107 -0
- package/README.md +118 -0
- package/bin/crosstalkd.js +101 -0
- package/package.json +48 -0
- package/src/activation.ts +104 -0
- package/src/api.ts +430 -0
- package/src/channel.ts +202 -0
- package/src/dispatch.ts +430 -0
- package/src/dispatchers.ts +91 -0
- package/src/filenames.ts +28 -0
- package/src/frontmatter.ts +26 -0
- package/src/init.ts +108 -0
- package/src/invoke.ts +148 -0
- package/src/models.ts +86 -0
- package/src/replies.ts +73 -0
- package/src/run.ts +236 -0
- package/src/state.ts +159 -0
- package/src/status.ts +84 -0
- package/src/stop.ts +37 -0
- package/src/transport.ts +236 -0
- package/src/workflow.ts +458 -0
- package/template/CLAUDE.md +10 -0
- package/template/CROSSTALK-VERSION +1 -0
- package/template/CROSSTALK.md +242 -0
- package/template/PROTOCOL.md +66 -0
- package/template/README.md +69 -0
- package/template/data/models.yaml +27 -0
- package/template/gitignore +4 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// dispatchers.ts — the shared dispatcher registry.
|
|
2
|
+
//
|
|
3
|
+
// Each running dispatcher publishes its identity + claimed models to
|
|
4
|
+
// `data/dispatchers/<alias>.yaml` so workflow.ts can scope fanout
|
|
5
|
+
// sub-primitives across the actually-running dispatcher set instead of
|
|
6
|
+
// addressing every fanout to bare `<model>` and watching every claimant
|
|
7
|
+
// race for it.
|
|
8
|
+
//
|
|
9
|
+
// Lifecycle:
|
|
10
|
+
// - dispatcher startup → writeRegistryEntry (committed alongside other
|
|
11
|
+
// state changes by the next tick's gitCommitAndPush)
|
|
12
|
+
// - dispatcher SIGTERM/SIGINT → removeRegistryEntry (committed same way)
|
|
13
|
+
// - hard crash → entry stays; operator runs cleanup, or restarts
|
|
14
|
+
//
|
|
15
|
+
// Liveness is best-effort. v7.0.0-alpha.1 does NOT track per-tick
|
|
16
|
+
// heartbeats in the registry — the timestamp would force a commit every
|
|
17
|
+
// tick on every machine, which is far more push traffic than the system
|
|
18
|
+
// otherwise generates. Heartbeats live in the machine-local state dir
|
|
19
|
+
// (state.ts) for "is THIS dispatcher alive" checks; the registry answers
|
|
20
|
+
// "which dispatchers exist on the bus." A stale registry entry from a
|
|
21
|
+
// crashed dispatcher is an operator-cleanup concern, not a runtime concern.
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs';
|
|
24
|
+
import { join } from 'path';
|
|
25
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
26
|
+
|
|
27
|
+
export interface RegistryEntry {
|
|
28
|
+
alias: string;
|
|
29
|
+
claims: string[]; // model names this dispatcher has on PATH
|
|
30
|
+
version: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function registryDir(transportRoot: string): string {
|
|
34
|
+
return join(transportRoot, 'data', 'dispatchers');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registryFile(transportRoot: string, alias: string): string {
|
|
38
|
+
return join(registryDir(transportRoot), `${alias}.yaml`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeRegistryEntry(
|
|
42
|
+
transportRoot: string,
|
|
43
|
+
alias: string,
|
|
44
|
+
claims: string[],
|
|
45
|
+
version: string,
|
|
46
|
+
): void {
|
|
47
|
+
mkdirSync(registryDir(transportRoot), { recursive: true });
|
|
48
|
+
const entry: RegistryEntry = { alias, claims, version };
|
|
49
|
+
writeFileSync(registryFile(transportRoot, alias), stringifyYaml(entry));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function removeRegistryEntry(transportRoot: string, alias: string): void {
|
|
53
|
+
try {
|
|
54
|
+
unlinkSync(registryFile(transportRoot, alias));
|
|
55
|
+
} catch { /* already gone */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function readAllRegistry(transportRoot: string): RegistryEntry[] {
|
|
59
|
+
const dir = registryDir(transportRoot);
|
|
60
|
+
if (!existsSync(dir)) return [];
|
|
61
|
+
const out: RegistryEntry[] = [];
|
|
62
|
+
for (const entry of readdirSync(dir)) {
|
|
63
|
+
if (!entry.endsWith('.yaml')) continue;
|
|
64
|
+
try {
|
|
65
|
+
const raw = readFileSync(join(dir, entry), 'utf-8');
|
|
66
|
+
const parsed = parseYaml(raw) as Partial<RegistryEntry> | null;
|
|
67
|
+
if (
|
|
68
|
+
!parsed ||
|
|
69
|
+
typeof parsed.alias !== 'string' ||
|
|
70
|
+
!Array.isArray(parsed.claims) ||
|
|
71
|
+
typeof parsed.version !== 'string'
|
|
72
|
+
) continue;
|
|
73
|
+
out.push({
|
|
74
|
+
alias: parsed.alias,
|
|
75
|
+
claims: parsed.claims.map(String),
|
|
76
|
+
version: parsed.version,
|
|
77
|
+
});
|
|
78
|
+
} catch { /* skip malformed */ }
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Aliases of dispatchers currently claiming the given model, sorted for
|
|
84
|
+
// deterministic ordering — round-robin assignment becomes reproducible,
|
|
85
|
+
// which makes the test path predictable.
|
|
86
|
+
export function dispatchersForModel(transportRoot: string, modelName: string): string[] {
|
|
87
|
+
return readAllRegistry(transportRoot)
|
|
88
|
+
.filter((e) => e.claims.includes(modelName))
|
|
89
|
+
.map((e) => e.alias)
|
|
90
|
+
.sort();
|
|
91
|
+
}
|
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,108 @@
|
|
|
1
|
+
// crosstalkd init <directory> — scaffold a new transport.
|
|
2
|
+
//
|
|
3
|
+
// Copies the bundled flat template (CROSSTALK-VERSION, PROTOCOL.md,
|
|
4
|
+
// CROSSTALK.md, data/models.yaml, local/actors/orchestrator.md, etc.)
|
|
5
|
+
// into the target directory and renames `gitignore` → `.gitignore`.
|
|
6
|
+
//
|
|
7
|
+
// v7 vs v6 init differences:
|
|
8
|
+
// - No host file is generated. v7 has no host files at all; machine
|
|
9
|
+
// identity comes from the --alias flag at dispatch boot.
|
|
10
|
+
// - No first channel is auto-created. Operator runs
|
|
11
|
+
// `crosstalkd channel <name>` themselves.
|
|
12
|
+
// - Template layout is flat: no upstream/ prefix on CROSSTALK-VERSION
|
|
13
|
+
// or PROTOCOL.md.
|
|
14
|
+
//
|
|
15
|
+
// Template lookup order:
|
|
16
|
+
// 1. <runtime_root>/template/ — bundled at publish time (production)
|
|
17
|
+
// 2. <runtime_root>/../transport/ — monorepo layout (local dev)
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, writeFileSync, cpSync, renameSync } from 'fs';
|
|
20
|
+
import { resolve, join, dirname } from 'path';
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
|
|
23
|
+
const argv = process.argv.slice(2);
|
|
24
|
+
const force = argv.includes('--force');
|
|
25
|
+
|
|
26
|
+
const positional = argv.filter((a) => !a.startsWith('--'));
|
|
27
|
+
if (positional.length === 0) {
|
|
28
|
+
console.error('Usage: crosstalkd init <directory> [--force]');
|
|
29
|
+
console.error(' crosstalkd init . (scaffold into current dir)');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const targetDir = resolve(positional[0]!);
|
|
34
|
+
|
|
35
|
+
if (existsSync(join(targetDir, 'CROSSTALK-VERSION')) && !force) {
|
|
36
|
+
console.error(`crosstalkd init: ${targetDir} already contains a transport.`);
|
|
37
|
+
console.error('Pass --force to overwrite.');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const runtimeRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
const candidates = [
|
|
43
|
+
join(runtimeRoot, 'template'),
|
|
44
|
+
join(runtimeRoot, '..', 'transport'),
|
|
45
|
+
];
|
|
46
|
+
const templateDir = candidates.find((c) => existsSync(join(c, 'CROSSTALK-VERSION')));
|
|
47
|
+
|
|
48
|
+
if (!templateDir) {
|
|
49
|
+
console.error('crosstalkd init: cannot find the transport template.');
|
|
50
|
+
console.error('Looked in:');
|
|
51
|
+
for (const c of candidates) console.error(` ${c}`);
|
|
52
|
+
console.error('The @cordfuse/crosstalkd installation may be corrupted; try reinstalling.');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
mkdirSync(targetDir, { recursive: true });
|
|
57
|
+
// Skip the template directory's own README — that's the meta-readme
|
|
58
|
+
// documenting the seed template itself, not content the operator wants
|
|
59
|
+
// inside their transport. (We write a fresh operator-facing README below.)
|
|
60
|
+
const templateReadme = join(templateDir, 'README.md');
|
|
61
|
+
cpSync(templateDir, targetDir, {
|
|
62
|
+
recursive: true,
|
|
63
|
+
force,
|
|
64
|
+
filter: (src) => src !== templateReadme,
|
|
65
|
+
});
|
|
66
|
+
// npm strips .gitignore from published packages; the template ships it as
|
|
67
|
+
// `gitignore` and we rename it here.
|
|
68
|
+
const gitignoreSrc = join(targetDir, 'gitignore');
|
|
69
|
+
const gitignoreDst = join(targetDir, '.gitignore');
|
|
70
|
+
if (existsSync(gitignoreSrc)) renameSync(gitignoreSrc, gitignoreDst);
|
|
71
|
+
|
|
72
|
+
const readmePath = join(targetDir, 'README.md');
|
|
73
|
+
if (!existsSync(readmePath) || force) {
|
|
74
|
+
writeFileSync(
|
|
75
|
+
readmePath,
|
|
76
|
+
`# Transport
|
|
77
|
+
|
|
78
|
+
A Crosstalk transport created by \`crosstalk init\`.
|
|
79
|
+
|
|
80
|
+
- Protocol spec: [\`CROSSTALK.md\`](./CROSSTALK.md)
|
|
81
|
+
- Agent orientation: [\`PROTOCOL.md\`](./PROTOCOL.md)
|
|
82
|
+
- Model registry: [\`data/models.yaml\`](./data/models.yaml)
|
|
83
|
+
- Actor persona files: [\`local/actors/\`](./local/actors/)
|
|
84
|
+
|
|
85
|
+
## Next steps
|
|
86
|
+
|
|
87
|
+
From the transport directory, on a host with the \`crosstalk\` client installed:
|
|
88
|
+
|
|
89
|
+
\`\`\`sh
|
|
90
|
+
git init && git add -A && git commit -m "initial transport"
|
|
91
|
+
crosstalk up # start the engine container
|
|
92
|
+
crosstalk channel general # create your first channel
|
|
93
|
+
crosstalk run --type primitive --to sonnet "hello" # send your first message
|
|
94
|
+
crosstalk status # check state
|
|
95
|
+
\`\`\`
|
|
96
|
+
`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log('');
|
|
101
|
+
console.log(`Transport initialized at ${targetDir}`);
|
|
102
|
+
console.log('');
|
|
103
|
+
console.log('Next steps (from a host with the `crosstalk` client installed):');
|
|
104
|
+
console.log(` cd ${targetDir}`);
|
|
105
|
+
console.log(' git init && git add -A && git commit -m "initial transport"');
|
|
106
|
+
console.log(' crosstalk up # start the engine container');
|
|
107
|
+
console.log(' crosstalk channel general # create your first channel');
|
|
108
|
+
console.log(' crosstalk run --type primitive --to sonnet "hello" # send your first message');
|
package/src/invoke.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// invoke.ts — model invocation, persona loading, reply writing.
|
|
2
|
+
//
|
|
3
|
+
// Called from dispatch.ts when a message wakes a claimed model. Composes
|
|
4
|
+
// the system prompt (PROTOCOL.md + optional actor persona), invokes the
|
|
5
|
+
// model CLI from data/models.yaml, captures stdout, writes a reply
|
|
6
|
+
// message back into the same channel (success or failed:true).
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { spawn } from 'child_process';
|
|
11
|
+
import { now, messageFilename } from './filenames.js';
|
|
12
|
+
import { serializeFrontmatter } from './frontmatter.js';
|
|
13
|
+
import type { ModelEntry } from './models.js';
|
|
14
|
+
import type { ChannelMessage } from './transport.js';
|
|
15
|
+
|
|
16
|
+
export interface InvokeResult {
|
|
17
|
+
status: number;
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 10 minutes — orchestrator-persona workflows can take 3-5 minutes
|
|
23
|
+
// of model thinking before the first tool call. v6's 5-minute timeout
|
|
24
|
+
// was tight for any orchestrator pattern.
|
|
25
|
+
const CLI_TIMEOUT_MS = 10 * 60_000;
|
|
26
|
+
const ARGV_PROMPT_LIMIT = 64 * 1024;
|
|
27
|
+
|
|
28
|
+
export function loadProtocolPrompt(transportRoot: string): string {
|
|
29
|
+
const p = join(transportRoot, 'PROTOCOL.md');
|
|
30
|
+
return existsSync(p) ? readFileSync(p, 'utf-8').trim() : '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadActorPersona(transportRoot: string, actorName: string | undefined): string {
|
|
34
|
+
if (!actorName) return '';
|
|
35
|
+
const p = join(transportRoot, 'local', 'actors', `${actorName}.md`);
|
|
36
|
+
if (!existsSync(p)) return '';
|
|
37
|
+
return readFileSync(p, 'utf-8').trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function composeSystemPrompt(parts: string[]): string {
|
|
41
|
+
return parts.filter((p) => p && p.length > 0).join('\n\n---\n\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function messageSender(msg: ChannelMessage): string {
|
|
45
|
+
return typeof msg.data['from'] === 'string' ? (msg.data['from'] as string) : 'unknown';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Pass the prompt as the CLI's last argv entry. Every modern agent CLI
|
|
49
|
+
// (Claude --print, codex exec, gemini -p, qwen --yolo, opencode -p,
|
|
50
|
+
// agy -p) reads its prompt from the trailing positional, so appending
|
|
51
|
+
// works universally. Fallback to stdin when the prompt would exceed a
|
|
52
|
+
// safe argv size — ARG_MAX is ~128 KB on Linux and ~256 KB on macOS.
|
|
53
|
+
export function invokeModelCli(
|
|
54
|
+
model: ModelEntry,
|
|
55
|
+
systemPrompt: string,
|
|
56
|
+
userMessage: string,
|
|
57
|
+
env: Record<string, string>,
|
|
58
|
+
): Promise<InvokeResult> {
|
|
59
|
+
return new Promise((res) => {
|
|
60
|
+
const fullPrompt = systemPrompt.length > 0
|
|
61
|
+
? `${systemPrompt}\n\n---\n\n${userMessage}`
|
|
62
|
+
: userMessage;
|
|
63
|
+
const useStdin = Buffer.byteLength(fullPrompt, 'utf-8') > ARGV_PROMPT_LIMIT;
|
|
64
|
+
const argv = useStdin ? [...model.args] : [...model.args, fullPrompt];
|
|
65
|
+
// detached: new process group, so the timeout SIGKILL takes the model's
|
|
66
|
+
// children with it — orphans writing to the transport after a timeout
|
|
67
|
+
// was an observed v5/v6 hazard.
|
|
68
|
+
const child = spawn(model.cli, argv, {
|
|
69
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
70
|
+
detached: true,
|
|
71
|
+
env: { ...process.env, ...env },
|
|
72
|
+
});
|
|
73
|
+
let stdout = '';
|
|
74
|
+
let stderr = '';
|
|
75
|
+
let resolved = false;
|
|
76
|
+
const timeout = setTimeout(() => {
|
|
77
|
+
if (resolved) return;
|
|
78
|
+
resolved = true;
|
|
79
|
+
try {
|
|
80
|
+
if (typeof child.pid === 'number') process.kill(-child.pid, 'SIGKILL');
|
|
81
|
+
else child.kill('SIGKILL');
|
|
82
|
+
} catch {
|
|
83
|
+
try { child.kill('SIGKILL'); } catch { /* already dead */ }
|
|
84
|
+
}
|
|
85
|
+
res({ status: 124, stdout, stderr: stderr + '\n[timeout]' });
|
|
86
|
+
}, CLI_TIMEOUT_MS);
|
|
87
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
88
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
89
|
+
child.on('close', (code) => {
|
|
90
|
+
if (resolved) return;
|
|
91
|
+
resolved = true;
|
|
92
|
+
clearTimeout(timeout);
|
|
93
|
+
res({ status: code ?? 1, stdout, stderr });
|
|
94
|
+
});
|
|
95
|
+
child.on('error', (err) => {
|
|
96
|
+
if (resolved) return;
|
|
97
|
+
resolved = true;
|
|
98
|
+
clearTimeout(timeout);
|
|
99
|
+
res({ status: 1, stdout, stderr: stderr + '\n' + err.message });
|
|
100
|
+
});
|
|
101
|
+
child.stdin.on('error', () => { /* child closed stdin */ });
|
|
102
|
+
if (useStdin) {
|
|
103
|
+
try { child.stdin.write(fullPrompt); } catch { /* same */ }
|
|
104
|
+
}
|
|
105
|
+
try { child.stdin.end(); } catch { /* ignore */ }
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function formatBatchedUserMessage(msgs: ChannelMessage[]): string {
|
|
110
|
+
if (msgs.length === 1) return msgs[0]!.body;
|
|
111
|
+
const parts = [`You have ${msgs.length} new messages in this channel. Process them collectively and reply once.`];
|
|
112
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
113
|
+
const m = msgs[i]!;
|
|
114
|
+
const ts = typeof m.data['timestamp'] === 'string' ? `, ts: ${m.data['timestamp']}` : '';
|
|
115
|
+
parts.push(`--- Message ${i + 1} of ${msgs.length} (from: ${messageSender(m)}, ref: ${m.relPath}${ts}) ---`);
|
|
116
|
+
parts.push(m.body);
|
|
117
|
+
}
|
|
118
|
+
return parts.join('\n\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface ReplyOpts {
|
|
122
|
+
transportRoot: string;
|
|
123
|
+
channelUuid: string;
|
|
124
|
+
fromModel: string; // e.g. "sonnet@cachy"
|
|
125
|
+
to: string; // the requester
|
|
126
|
+
re: string | string[];
|
|
127
|
+
body: string;
|
|
128
|
+
failed?: { error: string };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function writeReply(opts: ReplyOpts): string {
|
|
132
|
+
const ts = now();
|
|
133
|
+
const dir = join(opts.transportRoot, 'data', 'channels', opts.channelUuid, ts.pathDate);
|
|
134
|
+
mkdirSync(dir, { recursive: true });
|
|
135
|
+
const fm: Record<string, unknown> = {
|
|
136
|
+
from: opts.fromModel,
|
|
137
|
+
to: opts.to,
|
|
138
|
+
timestamp: ts.iso,
|
|
139
|
+
re: opts.re,
|
|
140
|
+
};
|
|
141
|
+
if (opts.failed) {
|
|
142
|
+
fm['failed'] = true;
|
|
143
|
+
fm['error'] = opts.failed.error.slice(0, 2000);
|
|
144
|
+
}
|
|
145
|
+
const filename = messageFilename(ts);
|
|
146
|
+
writeFileSync(join(dir, filename), serializeFrontmatter(fm, opts.body));
|
|
147
|
+
return join(ts.pathDate, filename);
|
|
148
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// models.ts — model registry parsing and PATH-based self-selection.
|
|
2
|
+
//
|
|
3
|
+
// Reads data/models.yaml from the transport root, returns the parsed
|
|
4
|
+
// registry plus the subset whose CLI is on PATH (the "claimed" models for
|
|
5
|
+
// the current dispatcher).
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
8
|
+
import { join, delimiter } from 'path';
|
|
9
|
+
import { parse as parseYaml } from 'yaml';
|
|
10
|
+
|
|
11
|
+
export interface ModelEntry {
|
|
12
|
+
name: string;
|
|
13
|
+
command: string; // raw command-template string
|
|
14
|
+
cli: string; // first token — the binary the dispatcher checks PATH for
|
|
15
|
+
args: string[]; // remaining tokens (the body is appended at invocation time)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ModelsRegistry {
|
|
19
|
+
all: Map<string, ModelEntry>;
|
|
20
|
+
claimed: Map<string, ModelEntry>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function modelsYamlPath(transportRoot: string): string {
|
|
24
|
+
return join(transportRoot, 'data', 'models.yaml');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readModels(transportRoot: string): Map<string, ModelEntry> {
|
|
28
|
+
const path = modelsYamlPath(transportRoot);
|
|
29
|
+
if (!existsSync(path)) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`crosstalkd: data/models.yaml not found at ${path}. ` +
|
|
32
|
+
`Run from a v7 transport root, or regenerate via 'crosstalkd init'.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const raw = readFileSync(path, 'utf8');
|
|
36
|
+
const parsed = parseYaml(raw);
|
|
37
|
+
if (parsed == null || typeof parsed !== 'object') {
|
|
38
|
+
throw new Error(`crosstalkd: data/models.yaml parsed to non-object (got ${typeof parsed})`);
|
|
39
|
+
}
|
|
40
|
+
const out = new Map<string, ModelEntry>();
|
|
41
|
+
for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
42
|
+
if (typeof value !== 'string') {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`crosstalkd: data/models.yaml entry '${name}' has non-string value ` +
|
|
45
|
+
`(expected a command-template string, got ${typeof value})`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const tokens = value.trim().split(/\s+/);
|
|
49
|
+
if (tokens.length === 0 || tokens[0] === '') {
|
|
50
|
+
throw new Error(`crosstalkd: data/models.yaml entry '${name}' is empty`);
|
|
51
|
+
}
|
|
52
|
+
out.set(name, { name, command: value, cli: tokens[0], args: tokens.slice(1) });
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isOnPath(binary: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
58
|
+
const pathVar = env.PATH ?? '';
|
|
59
|
+
for (const dir of pathVar.split(delimiter)) {
|
|
60
|
+
if (!dir) continue;
|
|
61
|
+
const candidate = join(dir, binary);
|
|
62
|
+
try {
|
|
63
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return true;
|
|
64
|
+
} catch {
|
|
65
|
+
// unreadable entry, skip
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function claimModels(
|
|
72
|
+
all: Map<string, ModelEntry>,
|
|
73
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
74
|
+
): Map<string, ModelEntry> {
|
|
75
|
+
const claimed = new Map<string, ModelEntry>();
|
|
76
|
+
for (const [name, entry] of all) {
|
|
77
|
+
if (isOnPath(entry.cli, env)) claimed.set(name, entry);
|
|
78
|
+
}
|
|
79
|
+
return claimed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function loadRegistry(transportRoot: string): ModelsRegistry {
|
|
83
|
+
const all = readModels(transportRoot);
|
|
84
|
+
const claimed = claimModels(all);
|
|
85
|
+
return { all, claimed };
|
|
86
|
+
}
|
package/src/replies.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// crosstalkd replies — have my messages been answered yet?
|
|
2
|
+
//
|
|
3
|
+
// Checks each given relPath for a message whose `re:` points back at it.
|
|
4
|
+
// Exit 0 when everything is answered, 2 while anything is pending — so
|
|
5
|
+
// agents can poll cheaply in a loop.
|
|
6
|
+
//
|
|
7
|
+
// v7 surface: positional relPaths (space-separated), no --re flag.
|
|
8
|
+
// `crosstalkd replies <relPath> [<relPath>...]`
|
|
9
|
+
|
|
10
|
+
import { resolve } from 'path';
|
|
11
|
+
import { gitPull, discoverChannels, listChannelMessages } from './transport.js';
|
|
12
|
+
import { reList } from './activation.js';
|
|
13
|
+
|
|
14
|
+
const transportRoot = resolve(process.cwd());
|
|
15
|
+
const argv = process.argv.slice(2);
|
|
16
|
+
|
|
17
|
+
function flag(name: string): string | undefined {
|
|
18
|
+
const i = argv.indexOf(name);
|
|
19
|
+
if (i === -1 || i === argv.length - 1) return undefined;
|
|
20
|
+
return argv[i + 1];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const channelArg = flag('--channel') ?? process.env['CROSSTALK_DISPATCH_CHANNEL'];
|
|
24
|
+
|
|
25
|
+
// All non-flag args are positional relPaths. Skip flag values too.
|
|
26
|
+
const flagsTakingValue = new Set(['--channel']);
|
|
27
|
+
const targets: string[] = [];
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i]!;
|
|
30
|
+
if (a.startsWith('--')) {
|
|
31
|
+
if (flagsTakingValue.has(a)) i++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
targets.push(a);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (targets.length === 0) {
|
|
38
|
+
console.error('Usage: crosstalkd replies <relPath> [<relPath>...] [--channel <uuid>]');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
gitPull(transportRoot); // best-effort freshness
|
|
43
|
+
|
|
44
|
+
const channels = channelArg ? [channelArg] : discoverChannels(transportRoot);
|
|
45
|
+
|
|
46
|
+
const found = new Map<string, { from: string; relPath: string; failed: boolean }>();
|
|
47
|
+
for (const channel of channels) {
|
|
48
|
+
for (const msg of listChannelMessages(transportRoot, channel)) {
|
|
49
|
+
for (const entry of reList(msg.data['re'])) {
|
|
50
|
+
if (targets.includes(entry) && !found.has(entry)) {
|
|
51
|
+
found.set(entry, {
|
|
52
|
+
from: String(msg.data['from']),
|
|
53
|
+
relPath: msg.relPath,
|
|
54
|
+
failed: msg.data['failed'] === true,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let pending = 0;
|
|
62
|
+
for (const target of targets) {
|
|
63
|
+
const reply = found.get(target);
|
|
64
|
+
if (reply) {
|
|
65
|
+
const tag = reply.failed ? 'FAILED ' : 'REPLIED ';
|
|
66
|
+
console.log(`${tag} ${target} <- ${reply.from} (${reply.relPath})`);
|
|
67
|
+
} else {
|
|
68
|
+
console.log(`PENDING ${target}`);
|
|
69
|
+
pending++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
process.exit(pending > 0 ? 2 : 0);
|