ferrycat 0.0.0-stage → 0.3.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/dist/cli.js ADDED
@@ -0,0 +1,1015 @@
1
+ #!/usr/bin/env node
2
+ import { exec, execFile } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { ZodError } from 'zod';
7
+ import { expandTilde, FerryCatEngine } from './engine.js';
8
+ import { DispatchContractSchema, ExtendTtlSchema, FailContractSchema, FulfillContractSchema, InspectMeshStateSchema, PollNextTaskSchema, ReleaseContractSchema, VerifyContractSchema, WorkerCommandSchema, } from './schemas.js';
9
+ function loadPackageVersion() {
10
+ try {
11
+ const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
12
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
13
+ return pkg.version ?? '0.3.0';
14
+ }
15
+ catch {
16
+ return '0.3.0';
17
+ }
18
+ }
19
+ const PKG_VERSION = loadPackageVersion();
20
+ const GLOBAL_HELP = `FerryCat CLI v${PKG_VERSION}
21
+ Contract-driven cross-host AI agent handoff & review bus over MCP and SQLite WAL.
22
+
23
+ Usage:
24
+ ferrycat-cli <command> [options] [json_payload | -]
25
+
26
+ Commands:
27
+ dispatch Dispatch a new contract locking full-file SHA-256 pointers
28
+ poll Atomically claim the oldest SUBMITTED contract for an agent
29
+ verify Read-only pre-flight check of on-disk pointer files against baseline (ADR-0004)
30
+ fulfill Submit execution or review results (with 1-strike TOCTOU soft-failure buffer)
31
+ release Return a CLAIMED contract you hold back to the queue tail (max 3 releases)
32
+ extend Heartbeat renewal of execution window for a CLAIMED contract (max 5 extensions)
33
+ fail Explicitly mark a CLAIMED contract you hold as terminal FAILED with reason
34
+ inspect Query contracts (renders concise table by default; pass --json for raw JSON)
35
+ worker Run background watcher, single-shot claim, or automated script execution
36
+
37
+ Common Options:
38
+ --agent <id> Agent identity (overrides AGENT_NAME env var)
39
+ --id <contract_id> Target contract ID (for verify/fulfill/release/extend/fail/inspect)
40
+ --file <path | -> Read JSON payload from file path, or "-" for stdin pipe
41
+ --help, -h Show global help or subcommand help (e.g., ferrycat-cli worker --help)
42
+ --version, -v Print FerryCat version
43
+
44
+ Examples:
45
+ # 1. Inspect pending or recent contracts for "workbuddy" as a table
46
+ ferrycat-cli inspect --target workbuddy
47
+
48
+ # 2. Dispatch a contract via stdin pipe (no temporary file needed)
49
+ cat payload.json | ferrycat-cli dispatch --agent antigravity -
50
+
51
+ # 3. Pre-flight verify pointer files before fulfilling (ADR-0004)
52
+ ferrycat-cli verify --id fc_1792754417178_48cca09f --agent workbuddy
53
+
54
+ # 4. Run worker in explicit mode (watch | claim | exec) with quote-free --exec-file
55
+ ferrycat-cli worker --agent workbuddy --mode=exec --once --exec-file ./scripts/review.js`;
56
+ const SUBCOMMAND_HELP = {
57
+ dispatch: `Usage: ferrycat-cli dispatch [--agent <requester>] [--file <path|->] ['{...}']
58
+
59
+ Dispatches a new contract and computes full-file SHA-256 fingerprints for all pointer files.
60
+
61
+ Options:
62
+ --agent <id> Requester agent identifier (overrides requester_agent in payload)
63
+ --file <path | -> JSON payload file path, or "-" to read from stdin
64
+
65
+ Examples:
66
+ ferrycat-cli dispatch --file dispatch.json
67
+ cat dispatch.json | ferrycat-cli dispatch --agent antigravity -`,
68
+ poll: `Usage: ferrycat-cli poll [--agent <worker_id>]
69
+
70
+ Atomically claims the oldest SUBMITTED contract assigned to this worker agent and verifies disk fingerprints.
71
+
72
+ Options:
73
+ --agent <id> Worker agent identifier (defaults to AGENT_NAME env var)
74
+
75
+ Examples:
76
+ ferrycat-cli poll --agent workbuddy`,
77
+ verify: `Usage: ferrycat-cli verify --id <contract_id> [--agent <worker_id>]
78
+
79
+ Read-only pre-flight check (ADR-0004): verifies whether on-disk pointer files still match the
80
+ contract's dispatched SHA-256 baseline without mutating any database state.
81
+
82
+ Options:
83
+ --id <contract_id> Contract ID to verify (required)
84
+ --agent <id> Optional caller agent identifier
85
+
86
+ Examples:
87
+ ferrycat-cli verify --id fc_1792754417178_48cca09f --agent workbuddy`,
88
+ fulfill: `Usage: ferrycat-cli fulfill [--id <contract_id>] [--agent <worker_id>] [--token <claim_token>] [--summary <text>] [--file <path|->] ['{...}']
89
+
90
+ Submits execution or review results for a CLAIMED contract. Enforces TOCTOU fingerprint verification
91
+ (with 1-strike soft-failure buffering per ADR-0004) and Ajv JSON Schema validation.
92
+
93
+ Options:
94
+ --id <contract_id> Contract ID to fulfill
95
+ --agent <id> Holder worker agent identifier
96
+ --token <claim_token> Exclusive lease token returned by poll (or FERRYCAT_CLAIM_TOKEN env)
97
+ --summary <text> Result summary text (can be used directly when structured_output is not needed)
98
+ --file <path | -> JSON payload file path, or "-" to read from stdin
99
+
100
+ Examples:
101
+ ferrycat-cli fulfill --id fc_123 --agent workbuddy --token <token> --summary "Audit passed cleanly."
102
+ cat fulfill.json | ferrycat-cli fulfill --agent workbuddy -`,
103
+ release: `Usage: ferrycat-cli release --id <contract_id> [--agent <worker_id>] [--token <claim_token>] [--reason <text>]
104
+
105
+ Hands a CLAIMED contract back to the queue tail as SUBMITTED with a fresh queue window.
106
+ Preserves contract-level retry_count; RESETS holder-level extend_count AND toctou_strike to 0.
107
+ This is holder-level quota isolation (ADR-0004 §4.3): the next holder must not inherit a strike
108
+ consumed by a previous holder. Max 3 releases.
109
+
110
+ Options:
111
+ --id <contract_id> CLAIMED contract ID to release (required)
112
+ --agent <id> Current holder agent identifier
113
+ --token <claim_token> Exclusive lease token returned by poll (or FERRYCAT_CLAIM_TOKEN env)
114
+ --reason <text> Optional reason for releasing the contract
115
+
116
+ Examples:
117
+ ferrycat-cli release --id fc_123 --agent workbuddy --token <token> --reason "wrong local branch"`,
118
+ extend: `Usage: ferrycat-cli extend --id <contract_id> [--agent <worker_id>] [--token <claim_token>]
119
+
120
+ Heartbeat renewal: resets claimed_at to now, restarting the contract's exec_ttl_sec window.
121
+ Max 5 extensions per holder, bounded by created_at + 4 * queue_ttl_sec.
122
+
123
+ Options:
124
+ --id <contract_id> CLAIMED contract ID to renew (required)
125
+ --agent <id> Current holder agent identifier
126
+ --token <claim_token> Exclusive lease token returned by poll (or FERRYCAT_CLAIM_TOKEN env)
127
+
128
+ Examples:
129
+ ferrycat-cli extend --id fc_123 --agent workbuddy --token <token>`,
130
+ fail: `Usage: ferrycat-cli fail --id <contract_id> [--agent <worker_id>] [--token <claim_token>] --reason <text>
131
+
132
+ Explicitly transitions a CLAIMED contract to terminal FAILED state with an error reason.
133
+
134
+ Options:
135
+ --id <contract_id> CLAIMED contract ID to fail (required)
136
+ --agent <id> Current holder agent identifier
137
+ --token <claim_token> Exclusive lease token returned by poll (or FERRYCAT_CLAIM_TOKEN env)
138
+ --reason <text> Mandatory explanation of why the task cannot be completed
139
+
140
+ Examples:
141
+ ferrycat-cli fail --id fc_123 --agent workbuddy --token <token> --reason "Missing compiler toolchain"`,
142
+ inspect: `Usage: ferrycat-cli inspect [--id <contract_id>] [--target <agent>] [--status <status>] [--limit <n>] [--format <table|json>] [--json] [--table]
143
+
144
+ Read-only inspection of mesh contracts. Renders a concise table by default; pass --json for full JSON output.
145
+
146
+ Options:
147
+ --id <contract_id> Inspect a specific contract ID
148
+ --target <agent> Filter contracts assigned to target_agent
149
+ --status <status> SUBMITTED | CLAIMED | COMPLETED | POINTER_STALE | VALIDATION_FAILED | TIMED_OUT | FAILED | ACTIVE | ALL
150
+ --limit <n> Max records to return (1..100, default: 10)
151
+ --format <table|json> Output format (default: table)
152
+ --json Shorthand for --format=json (full raw JSON payload)
153
+ --table Shorthand for --format=table
154
+
155
+ Examples:
156
+ ferrycat-cli inspect --target workbuddy --status ACTIVE
157
+ ferrycat-cli inspect --id fc_123 --json`,
158
+ worker: `Usage: ferrycat-cli worker [--agent <id>] [--mode <watch|claim|exec>] [--once] [--interval <ms>] [--exec <cmd>] [--exec-file <script>]
159
+
160
+ Runs the FerryCat CLI worker in watcher, single-claim, or automated execution mode.
161
+
162
+ Options:
163
+ --agent <id> Worker agent identifier (defaults to AGENT_NAME env var)
164
+ --mode <watch|claim|exec> Explicit worker mode:
165
+ watch: persistent non-claiming notifier loop (default when no --once/--exec)
166
+ claim: single-shot claim and exit (equivalent to --once without --exec)
167
+ exec: auto-claim and run --exec or --exec-file
168
+ --once Run a single poll cycle and exit immediately
169
+ --interval <ms> Polling interval in milliseconds for persistent loop (>= 100, default: 1000)
170
+ --exec-file <script> Recommended: script file (.js/.mjs/.cjs/.ts/.ps1/.sh) executed directly via execFile without shell interpolation
171
+ --exec <cmd> Shell command to run on claim (UNSAFE: executes via system shell, contract JSON piped via stdin)
172
+
173
+ Examples:
174
+ ferrycat-cli worker --agent workbuddy --mode=watch
175
+ ferrycat-cli worker --agent workbuddy --mode=claim
176
+ ferrycat-cli worker --agent workbuddy --mode=exec --once --exec-file ./worker-handler.js`,
177
+ };
178
+ function stripBom(content) {
179
+ return content.replace(/^\uFEFF/, '');
180
+ }
181
+ function parseJsonPayload(raw, sourceLabel) {
182
+ const cleaned = stripBom(raw).trim();
183
+ if (!cleaned) {
184
+ throw new Error(`Empty JSON payload from ${sourceLabel}.`);
185
+ }
186
+ let parsed;
187
+ try {
188
+ parsed = JSON.parse(cleaned);
189
+ }
190
+ catch (err) {
191
+ throw new Error(`Invalid JSON in ${sourceLabel}: ${err instanceof Error ? err.message : String(err)}`);
192
+ }
193
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
194
+ throw new Error(`Payload from ${sourceLabel} must be a JSON object.`);
195
+ }
196
+ return parsed;
197
+ }
198
+ function readStdinSync() {
199
+ try {
200
+ return fs.readFileSync(0, 'utf8');
201
+ }
202
+ catch (err) {
203
+ throw new Error(`Failed to read payload from stdin: ${err instanceof Error ? err.message : String(err)}`);
204
+ }
205
+ }
206
+ async function readPipedStdinWithTimeout(timeoutMs = 60) {
207
+ if (process.stdin.isTTY) {
208
+ return '';
209
+ }
210
+ return new Promise((resolve) => {
211
+ const chunks = [];
212
+ let settled = false;
213
+ const finish = () => {
214
+ if (settled)
215
+ return;
216
+ settled = true;
217
+ clearTimeout(timer);
218
+ process.stdin.removeListener('data', onData);
219
+ process.stdin.removeListener('end', onEnd);
220
+ process.stdin.removeListener('error', onError);
221
+ process.stdin.pause();
222
+ resolve(Buffer.concat(chunks).toString('utf8'));
223
+ };
224
+ const onData = (chunk) => {
225
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
226
+ };
227
+ const onEnd = () => finish();
228
+ const onError = () => finish();
229
+ const timer = setTimeout(() => {
230
+ if (chunks.length === 0) {
231
+ finish();
232
+ }
233
+ }, timeoutMs);
234
+ process.stdin.on('data', onData);
235
+ process.stdin.once('end', onEnd);
236
+ process.stdin.once('error', onError);
237
+ process.stdin.resume();
238
+ });
239
+ }
240
+ function normalizeArgvTokens(tokens) {
241
+ const expanded = [];
242
+ for (const token of tokens) {
243
+ if (token.startsWith('--') && token.includes('=')) {
244
+ const eqIdx = token.indexOf('=');
245
+ expanded.push(token.slice(0, eqIdx), token.slice(eqIdx + 1));
246
+ }
247
+ else {
248
+ expanded.push(token);
249
+ }
250
+ }
251
+ return expanded;
252
+ }
253
+ function requireFlagValue(flag, value) {
254
+ if (value === undefined || (value.startsWith('--') && value !== '-')) {
255
+ throw new Error(`Missing value for CLI flag "${flag}".`);
256
+ }
257
+ return value;
258
+ }
259
+ function parseArgs(argv) {
260
+ const rawTokens = normalizeArgvTokens(argv.slice(2));
261
+ if (rawTokens.includes('--version') || rawTokens.includes('-v') || rawTokens[0] === 'version') {
262
+ return {
263
+ command: 'version',
264
+ helpRequested: false,
265
+ versionRequested: true,
266
+ format: 'table',
267
+ explicitFormat: false,
268
+ hasExplicitPayloadSource: false,
269
+ data: {},
270
+ };
271
+ }
272
+ if (rawTokens.length === 0 || rawTokens[0] === 'help') {
273
+ const topic = rawTokens[0] === 'help' ? rawTokens[1] : undefined;
274
+ return {
275
+ command: 'help',
276
+ helpRequested: true,
277
+ versionRequested: false,
278
+ helpTopic: topic,
279
+ format: 'table',
280
+ explicitFormat: false,
281
+ hasExplicitPayloadSource: false,
282
+ data: {},
283
+ };
284
+ }
285
+ if (rawTokens.includes('--help') || rawTokens.includes('-h')) {
286
+ const firstNonFlag = rawTokens.find((t) => !t.startsWith('-'));
287
+ return {
288
+ command: 'help',
289
+ helpRequested: true,
290
+ versionRequested: false,
291
+ helpTopic: firstNonFlag,
292
+ format: 'table',
293
+ explicitFormat: false,
294
+ hasExplicitPayloadSource: false,
295
+ data: {},
296
+ };
297
+ }
298
+ const command = rawTokens[0];
299
+ const rest = rawTokens.slice(1);
300
+ let filePayload = {};
301
+ let positionalPayload = {};
302
+ const flagOverrides = {};
303
+ let format = 'table';
304
+ let explicitFormat = false;
305
+ let hasExplicitPayloadSource = false;
306
+ for (let i = 0; i < rest.length; i++) {
307
+ const token = rest[i];
308
+ if (token === '--file') {
309
+ const filePath = requireFlagValue('--file', rest[++i]);
310
+ hasExplicitPayloadSource = true;
311
+ if (filePath === '-') {
312
+ filePayload = parseJsonPayload(readStdinSync(), 'stdin (--file -)');
313
+ }
314
+ else {
315
+ const resolved = path.resolve(expandTilde(filePath));
316
+ if (!fs.existsSync(resolved)) {
317
+ throw new Error(`Payload file not found: "${filePath}" (resolved: "${resolved}").`);
318
+ }
319
+ filePayload = parseJsonPayload(fs.readFileSync(resolved, 'utf8'), `--file "${filePath}"`);
320
+ }
321
+ }
322
+ else if (token === '--agent') {
323
+ const agentVal = requireFlagValue('--agent', rest[++i]);
324
+ flagOverrides.agent_id = agentVal;
325
+ if (command === 'dispatch') {
326
+ flagOverrides.requester_agent = agentVal;
327
+ }
328
+ }
329
+ else if (token === '--target') {
330
+ flagOverrides.target_agent = requireFlagValue('--target', rest[++i]);
331
+ }
332
+ else if (token === '--id') {
333
+ flagOverrides.contract_id = requireFlagValue('--id', rest[++i]);
334
+ }
335
+ else if (token === '--token' || token === '--claim-token') {
336
+ flagOverrides.claim_token = requireFlagValue(token, rest[++i]);
337
+ }
338
+ else if (token === '--status') {
339
+ flagOverrides.status = requireFlagValue('--status', rest[++i]);
340
+ }
341
+ else if (token === '--reason') {
342
+ flagOverrides.reason = requireFlagValue('--reason', rest[++i]);
343
+ }
344
+ else if (token === '--summary') {
345
+ flagOverrides.result_summary = requireFlagValue('--summary', rest[++i]);
346
+ }
347
+ else if (token === '--limit') {
348
+ const rawLimit = requireFlagValue('--limit', rest[++i]);
349
+ const num = Number(rawLimit);
350
+ if (!Number.isInteger(num)) {
351
+ throw new Error(`Invalid --limit "${rawLimit}": must be an integer.`);
352
+ }
353
+ flagOverrides.limit = num;
354
+ }
355
+ else if (token === '--once') {
356
+ flagOverrides.once = true;
357
+ }
358
+ else if (token === '--interval') {
359
+ const rawInterval = requireFlagValue('--interval', rest[++i]);
360
+ const num = Number(rawInterval);
361
+ if (!Number.isInteger(num) || num < 100) {
362
+ throw new Error(`Invalid --interval "${rawInterval}": must be an integer >= 100.`);
363
+ }
364
+ flagOverrides.interval_ms = num;
365
+ }
366
+ else if (token === '--mode') {
367
+ const modeVal = requireFlagValue('--mode', rest[++i]).trim();
368
+ if (modeVal !== 'watch' && modeVal !== 'claim' && modeVal !== 'exec') {
369
+ throw new Error(`Invalid --mode "${modeVal}": expected one of "watch", "claim", or "exec".`);
370
+ }
371
+ flagOverrides.mode = modeVal;
372
+ }
373
+ else if (token === '--exec') {
374
+ const cmd = requireFlagValue('--exec', rest[++i]).trim();
375
+ if (!cmd) {
376
+ throw new Error('Invalid --exec value: command cannot be empty.');
377
+ }
378
+ flagOverrides.exec = cmd;
379
+ }
380
+ else if (token === '--exec-file') {
381
+ const scriptPath = requireFlagValue('--exec-file', rest[++i]).trim();
382
+ if (!scriptPath) {
383
+ throw new Error('Invalid --exec-file value: script path cannot be empty.');
384
+ }
385
+ flagOverrides.exec_file = scriptPath;
386
+ }
387
+ else if (token === '--format') {
388
+ const fmt = requireFlagValue('--format', rest[++i]).trim().toLowerCase();
389
+ if (fmt !== 'table' && fmt !== 'json') {
390
+ throw new Error(`Invalid --format "${fmt}": expected "table" or "json".`);
391
+ }
392
+ format = fmt;
393
+ explicitFormat = true;
394
+ }
395
+ else if (token === '--json') {
396
+ format = 'json';
397
+ explicitFormat = true;
398
+ }
399
+ else if (token === '--table') {
400
+ format = 'table';
401
+ explicitFormat = true;
402
+ }
403
+ else if (token === '-') {
404
+ hasExplicitPayloadSource = true;
405
+ positionalPayload = parseJsonPayload(readStdinSync(), 'stdin (-)');
406
+ }
407
+ else if (!token.startsWith('-')) {
408
+ const trimmed = stripBom(token).trim();
409
+ if (trimmed.startsWith('{')) {
410
+ hasExplicitPayloadSource = true;
411
+ positionalPayload = parseJsonPayload(trimmed, 'positional JSON argument');
412
+ }
413
+ else {
414
+ throw new Error(`Unknown positional argument "${token}". Expected a JSON object or "-". Run "ferrycat-cli --help" for usage.`);
415
+ }
416
+ }
417
+ else {
418
+ throw new Error(`Unknown CLI argument: "${token}". Run "ferrycat-cli ${command} --help" for supported options.`);
419
+ }
420
+ }
421
+ // Merge in strict precedence order: --file < positional JSON < explicit CLI flags
422
+ const data = {
423
+ ...filePayload,
424
+ ...positionalPayload,
425
+ ...flagOverrides,
426
+ };
427
+ if (!data.claim_token && process.env.FERRYCAT_CLAIM_TOKEN) {
428
+ const envToken = process.env.FERRYCAT_CLAIM_TOKEN.trim();
429
+ if (envToken) {
430
+ data.claim_token = envToken;
431
+ }
432
+ }
433
+ return {
434
+ command,
435
+ helpRequested: false,
436
+ versionRequested: false,
437
+ format,
438
+ explicitFormat,
439
+ hasExplicitPayloadSource,
440
+ data,
441
+ };
442
+ }
443
+ function resolveWorkerIdentity(agentId) {
444
+ const resolved = (agentId ?? process.env.AGENT_NAME ?? '').trim();
445
+ if (!resolved) {
446
+ throw new Error('Worker identity is missing: please provide it via --agent <id> or set AGENT_NAME environment variable.');
447
+ }
448
+ return resolved;
449
+ }
450
+ function interruptibleSleep(ms, signal) {
451
+ return new Promise((resolve) => {
452
+ let timer;
453
+ const onAbort = () => {
454
+ clearTimeout(timer);
455
+ resolve();
456
+ };
457
+ timer = setTimeout(() => {
458
+ signal.removeEventListener('abort', onAbort);
459
+ resolve();
460
+ }, ms);
461
+ signal.addEventListener('abort', onAbort, { once: true });
462
+ });
463
+ }
464
+ function spawnExecTarget(spec, options, callback) {
465
+ if (spec.execFile) {
466
+ const resolvedScript = path.resolve(expandTilde(spec.execFile));
467
+ const ext = path.extname(resolvedScript).toLowerCase();
468
+ if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
469
+ return execFile(process.execPath, [resolvedScript], options, callback);
470
+ }
471
+ if (ext === '.ts') {
472
+ return execFile(process.execPath, ['--import', 'tsx', resolvedScript], options, callback);
473
+ }
474
+ if (ext === '.ps1') {
475
+ return execFile('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolvedScript], options, callback);
476
+ }
477
+ if (ext === '.sh') {
478
+ return execFile('sh', [resolvedScript], options, callback);
479
+ }
480
+ return execFile(resolvedScript, [], options, callback);
481
+ }
482
+ return exec(spec.execCmd, options, callback);
483
+ }
484
+ async function executeTaskAndFulfill(engine, contract, spec) {
485
+ return new Promise((resolve) => {
486
+ const childEnv = {
487
+ ...process.env,
488
+ FERRYCAT_CONTRACT_ID: contract.contract_id,
489
+ FERRYCAT_TASK_TYPE: contract.task_type,
490
+ FERRYCAT_WORKSPACE_ROOT: contract.workspace_root,
491
+ FERRYCAT_TARGET_AGENT: contract.target_agent,
492
+ FERRYCAT_CLAIM_TOKEN: contract.claim_token ?? '',
493
+ };
494
+ const timeoutMs = Math.max(10000, contract.exec_ttl_sec * 1000);
495
+ const maxBuffer = 10 * 1024 * 1024;
496
+ const label = spec.execFile ?? spec.execCmd ?? 'exec';
497
+ const child = spawnExecTarget(spec, { env: childEnv, timeout: timeoutMs, maxBuffer }, (err, stdout, stderr) => {
498
+ if (err) {
499
+ const execErr = err;
500
+ const execError = `External exec command failed with code ${execErr.code ?? 1}${execErr.killed ? ' (killed after exec timeout)' : ''}: ${stderr || execErr.message}`;
501
+ let contractStatus;
502
+ try {
503
+ contractStatus = engine.failContract({
504
+ contract_id: contract.contract_id,
505
+ agent_id: contract.target_agent,
506
+ claim_token: contract.claim_token,
507
+ reason: execError.slice(0, 2000),
508
+ }).status;
509
+ }
510
+ catch (failErr) {
511
+ contractStatus = `UNCHANGED (${failErr instanceof Error ? failErr.message : String(failErr)})`;
512
+ }
513
+ return resolve({
514
+ ok: false,
515
+ status: 'EXEC_FAILED',
516
+ error: execError,
517
+ contract_status: contractStatus,
518
+ });
519
+ }
520
+ let structuredOutput;
521
+ let resultSummary = stripBom(stdout).trim();
522
+ try {
523
+ const parsed = JSON.parse(resultSummary);
524
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
525
+ if (parsed.structured_output && typeof parsed.structured_output === 'object') {
526
+ structuredOutput = parsed.structured_output;
527
+ }
528
+ else {
529
+ structuredOutput = parsed;
530
+ }
531
+ if (parsed.result_summary && typeof parsed.result_summary === 'string') {
532
+ resultSummary = parsed.result_summary;
533
+ }
534
+ }
535
+ }
536
+ catch {
537
+ // Plain text stdout, fallback to result_summary
538
+ }
539
+ const boundedSummary = (resultSummary || `Completed via automated worker exec: ${label}`).slice(0, 20000);
540
+ try {
541
+ const validatedFulfillInput = FulfillContractSchema.parse({
542
+ contract_id: contract.contract_id,
543
+ agent_id: contract.target_agent,
544
+ claim_token: contract.claim_token,
545
+ result_summary: boundedSummary,
546
+ structured_output: structuredOutput,
547
+ });
548
+ let fulfillRes = engine.fulfillContract(validatedFulfillInput);
549
+ // If RETRY_NEEDED occurs in automated exec (schema retry vs TOCTOU strike 1):
550
+ if (fulfillRes.status === 'RETRY_NEEDED') {
551
+ // RETRY_NEEDED has two distinct causes with incompatible retry semantics:
552
+ // (a) schema validation failure -> retry_count increments, so retrying the
553
+ // same payload until the breaker trips is the intended behavior.
554
+ // (b) TOCTOU dirty pointers -> retry_count does NOT move. The on-disk
555
+ // baseline cannot heal between two consecutive calls, so retrying only
556
+ // burns the single ADR-0004 strike and forces a terminal POINTER_STALE
557
+ // with nobody in the loop to restore the files. Never auto-retry it.
558
+ const isToctouRejection = (fulfillRes.toctou_strike ?? 0) > 0 && (fulfillRes.retry_count ?? 0) === 0;
559
+ if (isToctouRejection) {
560
+ resolve({
561
+ ok: false,
562
+ status: 'TOCTOU_RESCUE_REQUIRED',
563
+ fulfill_result: fulfillRes,
564
+ error: 'Automated exec mutated pointer files. The contract stays CLAIMED with a renewed execution window. Restore the files (git stash / git checkout) and re-run, or deliver edits via structured_output.fixes instead of writing to the workspace.',
565
+ });
566
+ return;
567
+ }
568
+ const maxAttempts = (fulfillRes.max_retries ?? 2) + 1;
569
+ for (let i = fulfillRes.retry_count ?? 1; i < maxAttempts; i++) {
570
+ fulfillRes = engine.fulfillContract(validatedFulfillInput);
571
+ if (fulfillRes.status !== 'RETRY_NEEDED')
572
+ break;
573
+ }
574
+ }
575
+ resolve({ ok: fulfillRes.ok, status: fulfillRes.status, fulfill_result: fulfillRes });
576
+ }
577
+ catch (fulfillErr) {
578
+ const fulfillError = fulfillErr instanceof Error ? fulfillErr.message : String(fulfillErr);
579
+ let contractStatus;
580
+ try {
581
+ contractStatus = engine.failContract({
582
+ contract_id: contract.contract_id,
583
+ agent_id: contract.target_agent,
584
+ reason: `FULFILL_ERROR: ${fulfillError}`.slice(0, 2000),
585
+ }).status;
586
+ }
587
+ catch {
588
+ contractStatus = undefined;
589
+ }
590
+ resolve({
591
+ ok: false,
592
+ status: 'FULFILL_ERROR',
593
+ error: fulfillError,
594
+ contract_status: contractStatus,
595
+ });
596
+ }
597
+ });
598
+ if (child.stdin) {
599
+ child.stdin.on('error', () => {
600
+ // Child process exited without consuming stdin, ignore EPIPE
601
+ });
602
+ child.stdin.write(JSON.stringify(contract));
603
+ child.stdin.end();
604
+ }
605
+ });
606
+ }
607
+ async function runWorkerLoop(engine, agentId, intervalMs, spec) {
608
+ const resolvedAgent = resolveWorkerIdentity(agentId);
609
+ const abortController = new AbortController();
610
+ let running = true;
611
+ let sigintCount = 0;
612
+ let lastNotifiedIds = '';
613
+ const hasExec = Boolean(spec.execCmd || spec.execFile);
614
+ console.log(JSON.stringify({
615
+ status: 'WORKER_STARTED',
616
+ agent_id: resolvedAgent,
617
+ interval_ms: intervalMs,
618
+ mode: hasExec ? 'AUTO_EXEC_AND_FULFILL' : 'WATCHER_ONLY',
619
+ exec_command: spec.execCmd ?? null,
620
+ exec_file: spec.execFile ?? null,
621
+ message: hasExec
622
+ ? `FerryCat worker running in auto-claim & execute mode on agent "${resolvedAgent}"...`
623
+ : `FerryCat worker running in watcher/notifier mode on agent "${resolvedAgent}" (will not claim without --exec / --exec-file)...`,
624
+ }));
625
+ const stopHandler = () => {
626
+ sigintCount++;
627
+ if (sigintCount >= 2) {
628
+ console.error(JSON.stringify({
629
+ status: 'WORKER_FORCE_EXIT',
630
+ message: 'Second interrupt received, exiting immediately.',
631
+ }));
632
+ process.exit(130);
633
+ }
634
+ running = false;
635
+ abortController.abort();
636
+ console.log(JSON.stringify({
637
+ status: 'WORKER_STOPPING',
638
+ agent_id: resolvedAgent,
639
+ message: 'Worker stopping gracefully on signal (send again to force)...',
640
+ }));
641
+ };
642
+ process.on('SIGINT', stopHandler);
643
+ process.on('SIGTERM', stopHandler);
644
+ while (running) {
645
+ try {
646
+ if (hasExec) {
647
+ const pollRes = engine.pollNextTask({ agent_id: resolvedAgent });
648
+ if (pollRes.has_task && pollRes.contract) {
649
+ console.log(JSON.stringify({
650
+ event: 'TASK_CLAIMED',
651
+ contract_id: pollRes.contract.contract_id,
652
+ task_type: pollRes.contract.task_type,
653
+ title: pollRes.contract.title,
654
+ }));
655
+ const execRes = await executeTaskAndFulfill(engine, pollRes.contract, spec);
656
+ console.log(JSON.stringify({
657
+ event: 'TASK_EXECUTION_COMPLETED',
658
+ contract_id: pollRes.contract.contract_id,
659
+ status: execRes.status,
660
+ ok: execRes.ok,
661
+ error: execRes.error ?? null,
662
+ contract_status: execRes.contract_status ?? execRes.status,
663
+ }));
664
+ }
665
+ }
666
+ else {
667
+ // Watcher-only mode: query SQLite filtered by target_agent directly
668
+ const state = engine.inspectMeshState({
669
+ status: 'SUBMITTED',
670
+ target_agent: resolvedAgent,
671
+ limit: 100,
672
+ });
673
+ const pendingForAgent = state.contracts;
674
+ const currentIds = pendingForAgent
675
+ .map((c) => String(c.contract_id))
676
+ .sort()
677
+ .join(',');
678
+ if (pendingForAgent.length > 0 && currentIds !== lastNotifiedIds) {
679
+ lastNotifiedIds = currentIds;
680
+ console.log(JSON.stringify({
681
+ event: 'PENDING_TASKS_DETECTED',
682
+ agent_id: resolvedAgent,
683
+ pending_count: pendingForAgent.length,
684
+ pending_contracts: pendingForAgent.map((c) => ({
685
+ contract_id: c.contract_id,
686
+ title: c.title,
687
+ created_at: c.created_at,
688
+ })),
689
+ message: 'Pending tasks detected. Run with --mode=exec (--exec / --exec-file) for automated processing or claim explicitly via poll.',
690
+ }));
691
+ }
692
+ else if (pendingForAgent.length === 0) {
693
+ lastNotifiedIds = '';
694
+ }
695
+ }
696
+ }
697
+ catch (err) {
698
+ console.error(JSON.stringify({
699
+ event: 'WORKER_CYCLE_ERROR',
700
+ error: err instanceof Error ? err.message : String(err),
701
+ }));
702
+ }
703
+ if (running) {
704
+ await interruptibleSleep(intervalMs, abortController.signal);
705
+ }
706
+ }
707
+ }
708
+ function formatDurationSec(ms) {
709
+ const sec = Math.max(0, Math.round(ms / 1000));
710
+ if (sec < 60)
711
+ return `${sec}s`;
712
+ const min = Math.floor(sec / 60);
713
+ const remSec = sec % 60;
714
+ if (min < 60)
715
+ return `${min}m${String(remSec).padStart(2, '0')}s`;
716
+ const hr = Math.floor(min / 60);
717
+ const remMin = min % 60;
718
+ return `${hr}h${String(remMin).padStart(2, '0')}m`;
719
+ }
720
+ function truncateCell(str, maxLen) {
721
+ const singleLine = str.replace(/\r?\n/g, ' ').trim();
722
+ if (singleLine.length <= maxLen)
723
+ return singleLine;
724
+ return singleLine.slice(0, Math.max(1, maxLen - 3)) + '...';
725
+ }
726
+ function formatInspectTable(state, singleContractView) {
727
+ if (state.contracts.length === 0) {
728
+ return 'No matching contracts found. (Pass --json for raw JSON output)';
729
+ }
730
+ const nowMs = Date.now();
731
+ const rows = state.contracts.map((c) => {
732
+ const id = String(c.contract_id ?? '');
733
+ const rawStatus = String(c.status ?? '');
734
+ const status = c.is_logically_expired ? `${rawStatus}*` : rawStatus;
735
+ const route = `${c.requester_agent ?? '?'}->${c.target_agent ?? '?'}`;
736
+ const type = String(c.task_type ?? '');
737
+ const createdAt = Number(c.created_at ?? nowMs);
738
+ const completedAt = c.completed_at ? Number(c.completed_at) : null;
739
+ const elapsedMs = completedAt ? completedAt - createdAt : nowMs - createdAt;
740
+ const elapsed = formatDurationSec(elapsedMs);
741
+ const title = truncateCell(String(c.title ?? ''), 42);
742
+ return { id, status, route, type, elapsed, title };
743
+ });
744
+ const headers = {
745
+ id: 'CONTRACT_ID',
746
+ status: 'STATUS',
747
+ route: 'ROUTE',
748
+ type: 'TASK_TYPE',
749
+ elapsed: 'ELAPSED',
750
+ title: 'TITLE',
751
+ };
752
+ const widths = {
753
+ id: Math.max(headers.id.length, ...rows.map((r) => r.id.length)),
754
+ status: Math.max(headers.status.length, ...rows.map((r) => r.status.length)),
755
+ route: Math.max(headers.route.length, ...rows.map((r) => r.route.length)),
756
+ type: Math.max(headers.type.length, ...rows.map((r) => r.type.length)),
757
+ elapsed: Math.max(headers.elapsed.length, ...rows.map((r) => r.elapsed.length)),
758
+ title: Math.max(headers.title.length, ...rows.map((r) => r.title.length)),
759
+ };
760
+ const pad = (val, len) => val.padEnd(len, ' ');
761
+ const headerLine = [
762
+ pad(headers.id, widths.id),
763
+ pad(headers.status, widths.status),
764
+ pad(headers.route, widths.route),
765
+ pad(headers.type, widths.type),
766
+ pad(headers.elapsed, widths.elapsed),
767
+ headers.title,
768
+ ].join(' ');
769
+ const divider = [
770
+ '-'.repeat(widths.id),
771
+ '-'.repeat(widths.status),
772
+ '-'.repeat(widths.route),
773
+ '-'.repeat(widths.type),
774
+ '-'.repeat(widths.elapsed),
775
+ '-'.repeat(widths.title),
776
+ ].join(' ');
777
+ const bodyLines = rows.map((r) => [
778
+ pad(r.id, widths.id),
779
+ pad(r.status, widths.status),
780
+ pad(r.route, widths.route),
781
+ pad(r.type, widths.type),
782
+ pad(r.elapsed, widths.elapsed),
783
+ r.title,
784
+ ].join(' '));
785
+ const lines = [
786
+ `FerryCat Mesh Contracts (${state.total_returned} returned):`,
787
+ headerLine,
788
+ divider,
789
+ ...bodyLines,
790
+ ];
791
+ if (singleContractView && state.contracts.length === 1) {
792
+ const c = state.contracts[0];
793
+ lines.push('');
794
+ lines.push(`Counters : retry=${c.retry_count}/${c.max_retries} extend=${c.extend_count} release=${c.release_count} toctou_strike=${c.toctou_strike ?? 0}`);
795
+ if (c.error_reason) {
796
+ lines.push(`Error : ${c.error_reason}`);
797
+ }
798
+ if (c.result_summary) {
799
+ lines.push(`Result : ${truncateCell(String(c.result_summary), 200)}`);
800
+ }
801
+ if (c.salvaged_result) {
802
+ lines.push(`Salvaged : Preserved in salvaged_result (pass --json to inspect full payload)`);
803
+ }
804
+ }
805
+ lines.push(`\nTip: pass --json (or --format=json) for full raw JSON output.`);
806
+ return lines.join('\n');
807
+ }
808
+ function formatCliError(err, command) {
809
+ if (err instanceof ZodError) {
810
+ const issues = err.issues.map((issue) => {
811
+ const pathStr = issue.path.length > 0 ? issue.path.join('.') : '(payload)';
812
+ return `missing/invalid "${pathStr}" (${issue.message})`;
813
+ });
814
+ const cmdHint = command && SUBCOMMAND_HELP[command] ? `${command} --help` : '--help';
815
+ return `Validation failed for "${command || 'cli'}": ${issues.join('; ')}. Run "ferrycat-cli ${cmdHint}" for usage.`;
816
+ }
817
+ return err instanceof Error ? err.message : String(err);
818
+ }
819
+ async function main() {
820
+ let parsed;
821
+ try {
822
+ parsed = parseArgs(process.argv);
823
+ }
824
+ catch (err) {
825
+ console.error(JSON.stringify({ ok: false, error: formatCliError(err, process.argv[2] ?? '') }, null, 2));
826
+ process.exitCode = 1;
827
+ return;
828
+ }
829
+ if (parsed.versionRequested) {
830
+ console.log(PKG_VERSION);
831
+ return;
832
+ }
833
+ if (parsed.helpRequested) {
834
+ const topic = parsed.helpTopic?.toLowerCase();
835
+ if (topic && SUBCOMMAND_HELP[topic]) {
836
+ console.log(SUBCOMMAND_HELP[topic]);
837
+ }
838
+ else {
839
+ console.log(GLOBAL_HELP);
840
+ }
841
+ return;
842
+ }
843
+ const { command, data } = parsed;
844
+ let engine;
845
+ try {
846
+ // Auto-detect piped stdin for payload-consuming commands when neither --file nor positional JSON was passed
847
+ if ((command === 'dispatch' || command === 'fulfill') &&
848
+ !parsed.hasExplicitPayloadSource &&
849
+ !process.stdin.isTTY) {
850
+ const piped = await readPipedStdinWithTimeout(60);
851
+ if (piped.trim().length > 0) {
852
+ const stdinObj = parseJsonPayload(piped, 'piped stdin');
853
+ // Explicit CLI flags in `data` take precedence over piped stdin fields
854
+ for (const [k, v] of Object.entries(stdinObj)) {
855
+ if (data[k] === undefined) {
856
+ data[k] = v;
857
+ }
858
+ }
859
+ }
860
+ }
861
+ let output;
862
+ switch (command) {
863
+ case 'dispatch': {
864
+ if (!data.requester_agent && data.agent_id) {
865
+ data.requester_agent = data.agent_id;
866
+ }
867
+ engine = new FerryCatEngine();
868
+ output = engine.dispatchContract(DispatchContractSchema.parse(data));
869
+ console.log(JSON.stringify(output, null, 2));
870
+ break;
871
+ }
872
+ case 'poll': {
873
+ engine = new FerryCatEngine();
874
+ output = engine.pollNextTask(PollNextTaskSchema.parse(data));
875
+ console.log(JSON.stringify(output, null, 2));
876
+ break;
877
+ }
878
+ case 'verify': {
879
+ engine = new FerryCatEngine();
880
+ output = engine.verifyContract(VerifyContractSchema.parse(data));
881
+ console.log(JSON.stringify(output, null, 2));
882
+ break;
883
+ }
884
+ case 'fulfill': {
885
+ engine = new FerryCatEngine();
886
+ output = engine.fulfillContract(FulfillContractSchema.parse(data));
887
+ console.log(JSON.stringify(output, null, 2));
888
+ break;
889
+ }
890
+ case 'inspect': {
891
+ engine = new FerryCatEngine();
892
+ const state = engine.inspectMeshState(InspectMeshStateSchema.parse(data));
893
+ if (parsed.format === 'json') {
894
+ console.log(JSON.stringify(state, null, 2));
895
+ }
896
+ else {
897
+ console.log(formatInspectTable(state, Boolean(data.contract_id)));
898
+ }
899
+ break;
900
+ }
901
+ case 'release': {
902
+ engine = new FerryCatEngine();
903
+ output = engine.releaseContract(ReleaseContractSchema.parse(data));
904
+ console.log(JSON.stringify(output, null, 2));
905
+ break;
906
+ }
907
+ case 'extend': {
908
+ engine = new FerryCatEngine();
909
+ output = engine.extendTtl(ExtendTtlSchema.parse(data));
910
+ console.log(JSON.stringify(output, null, 2));
911
+ break;
912
+ }
913
+ case 'fail': {
914
+ engine = new FerryCatEngine();
915
+ output = engine.failContract(FailContractSchema.parse(data));
916
+ console.log(JSON.stringify(output, null, 2));
917
+ break;
918
+ }
919
+ case 'worker': {
920
+ const validated = WorkerCommandSchema.parse(data);
921
+ const resolvedAgent = resolveWorkerIdentity(validated.agent_id);
922
+ if (validated.exec && validated.exec_file) {
923
+ throw new Error('Cannot specify both --exec and --exec-file; please choose one.');
924
+ }
925
+ if (validated.exec) {
926
+ console.error('[FerryCat Security Notice] --exec executes commands via system shell with shell interpolation. For safer execution, prefer --exec-file <script>.');
927
+ }
928
+ if (validated.exec_file) {
929
+ const resolvedScript = path.resolve(expandTilde(validated.exec_file));
930
+ if (!fs.existsSync(resolvedScript)) {
931
+ throw new Error(`Worker --exec-file script does not exist: "${validated.exec_file}" (resolved: "${resolvedScript}").`);
932
+ }
933
+ }
934
+ const hasExec = Boolean(validated.exec || validated.exec_file);
935
+ // Normalize explicit --mode with backward-compatible --once / --exec flags
936
+ let effectiveOnce = Boolean(validated.once);
937
+ if (validated.mode === 'watch') {
938
+ if (hasExec) {
939
+ throw new Error('Worker --mode=watch cannot be combined with --exec or --exec-file (use --mode=exec instead).');
940
+ }
941
+ if (validated.once) {
942
+ throw new Error('Worker --mode=watch is for persistent monitoring and cannot be combined with --once (use --mode=claim instead).');
943
+ }
944
+ effectiveOnce = false;
945
+ }
946
+ else if (validated.mode === 'claim') {
947
+ if (hasExec) {
948
+ throw new Error('Worker --mode=claim cannot be combined with --exec or --exec-file (use --mode=exec instead).');
949
+ }
950
+ effectiveOnce = true;
951
+ }
952
+ else if (validated.mode === 'exec') {
953
+ if (!hasExec) {
954
+ throw new Error('Worker --mode=exec requires either --exec <cmd> or --exec-file <script>.');
955
+ }
956
+ }
957
+ const execSpec = {
958
+ execCmd: validated.exec,
959
+ execFile: validated.exec_file,
960
+ };
961
+ engine = new FerryCatEngine();
962
+ if (effectiveOnce) {
963
+ const pollRes = engine.pollNextTask({ agent_id: resolvedAgent });
964
+ if (!pollRes.has_task || !pollRes.contract) {
965
+ // Preserve engine's specific poll status/message/stale_error (fixes G8)
966
+ output = {
967
+ has_task: false,
968
+ agent_id: resolvedAgent,
969
+ ...(pollRes.status ? { status: pollRes.status } : {}),
970
+ ...(pollRes.stale_error ? { stale_error: pollRes.stale_error } : {}),
971
+ message: pollRes.message ?? `No pending contracts for agent "${resolvedAgent}".`,
972
+ };
973
+ console.log(JSON.stringify(output, null, 2));
974
+ }
975
+ else if (hasExec) {
976
+ const execRes = await executeTaskAndFulfill(engine, pollRes.contract, execSpec);
977
+ output = {
978
+ has_task: true,
979
+ agent_id: resolvedAgent,
980
+ contract_id: pollRes.contract.contract_id,
981
+ execution: execRes,
982
+ };
983
+ console.log(JSON.stringify(output, null, 2));
984
+ }
985
+ else {
986
+ output = {
987
+ ...pollRes,
988
+ mode: 'CLAIM_ONLY',
989
+ notice: 'Task claimed. Provide --exec <cmd> / --exec-file <script> to automatically execute and fulfill, or invoke fulfill subcommand.',
990
+ };
991
+ console.log(JSON.stringify(output, null, 2));
992
+ }
993
+ }
994
+ else {
995
+ await runWorkerLoop(engine, resolvedAgent, validated.interval_ms ?? 1000, execSpec);
996
+ }
997
+ break;
998
+ }
999
+ default:
1000
+ throw new Error(`Unknown subcommand "${command}". Supported commands: dispatch, poll, verify, fulfill, release, extend, fail, inspect, worker. Run "ferrycat-cli --help" for usage.`);
1001
+ }
1002
+ }
1003
+ catch (err) {
1004
+ console.error(JSON.stringify({ ok: false, error: formatCliError(err, command) }, null, 2));
1005
+ process.exitCode = 1;
1006
+ }
1007
+ finally {
1008
+ engine?.close();
1009
+ }
1010
+ }
1011
+ main().catch((err) => {
1012
+ console.error('Fatal CLI Error:', err);
1013
+ process.exitCode = 1;
1014
+ });
1015
+ //# sourceMappingURL=cli.js.map