@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.
@@ -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
+ }
@@ -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);