model-orchestrator 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli-run.mjs CHANGED
@@ -27,18 +27,30 @@
27
27
  // bounded by the OS ARG_MAX, so: no secrets in a prompt, and very large briefs
28
28
  // should be referenced by path in the prompt rather than pasted into it.
29
29
  //
30
- // Exit codes
31
- // 0 structurally accepted non-empty response (and every --expect-* contract met)
32
- // 10 ran, produced no deliverable, or a contract was not met, or killed by signal
33
- // 11 produced no output at all
34
- // 12 timed out (the lane AND its descendants are killed as a process group)
35
- // 13 lane unavailable (missing binary, disabled in lanes.json, or lanes.json malformed)
30
+ // Exit codes: one per failure CLASS, so the code says what to do next.
31
+ // 0 ok: structurally accepted non-empty response (and every --expect-* contract met)
32
+ // 10 empty: ran and delivered nothing, or a contract was not met
33
+ // 11 no_output: produced no output at all
34
+ // 12 timeout: the lane AND its descendants are killed as a process group
35
+ // 13 unavailable: missing binary, disabled in lanes.json, or lanes.json malformed
36
+ // 14 auth: the lane's own error says a credential is missing or not logged in
37
+ // 15 quota: the lane's own error says usage limit, credits or rate limit
38
+ // 16 rejected: the upstream rejected the request (bad model id, bad request)
39
+ // 17 refused: no deliverable, and the lane reports tool calls a hook or deny rule blocked
40
+ // 18 cut_short: no trustworthy finish: a missing or non-success terminal event, a lane
41
+ // killed by a signal, output past the 16 MiB buffer, or a nonzero vendor exit
36
42
  // 130 / 143 cli-run itself received SIGINT / SIGTERM: the lane's process group was killed first
37
43
  // 2 usage error in cli-run itself
38
- // N the lane exited N != 0: passed through, verdict exit_nonzero, even if text came back
44
+ // A run that delivered AND had tool calls refused is still 0, with refused=N and a
45
+ // problem/fix pair on the terminal. The vendor's own exit code is logged as cli_rc.
46
+ // Precedence when several signals are present: auth, quota, rejected, refused,
47
+ // cut_short, empty. Only the lane's authoritative error fields are searched,
48
+ // never the model's prose, so an answer that merely mentions "rate limit" is
49
+ // not a quota failure.
39
50
  //
40
- // The durable log stores a FIXED reason code per run (see REASONS), never a
41
- // provider-supplied string. Bounded vendor stderr goes to your terminal only.
51
+ // The durable log stores a FIXED reason code and class per run (see REASONS,
52
+ // CLASS_CODES), never a provider-supplied string. Bounded vendor stderr and the
53
+ // problem/fix lines go to your terminal only, redacted.
42
54
  //
43
55
  // ROUTE: which model and reasoning effort a lane ran with.
44
56
  // A lane with no --model and no lanes.json default inherits whatever its own
@@ -55,12 +67,21 @@ import { spawn, spawnSync } from 'node:child_process';
55
67
  import { StringDecoder } from 'node:string_decoder';
56
68
  import { createHash } from 'node:crypto';
57
69
  import { readFileSync, existsSync, mkdirSync, appendFileSync, mkdtempSync, rmSync, accessSync, constants, realpathSync, statSync, lstatSync, openSync, readSync, closeSync } from 'node:fs';
58
- import { join, dirname, delimiter, resolve } from 'node:path';
70
+ import { join, dirname, delimiter, resolve, relative, isAbsolute, sep } from 'node:path';
59
71
  import { tmpdir, homedir } from 'node:os';
60
72
  import { fileURLToPath, pathToFileURL } from 'node:url';
61
73
 
62
74
  export const LANES = ['grok', 'codex', 'agy', 'hermes', 'qwen'];
63
75
  export const OK = 0, NO_DELIVERABLE = 10, NO_OUTPUT = 11, TIMEOUT = 12, UNAVAILABLE = 13, USAGE = 2;
76
+ export const AUTH = 14, QUOTA = 15, REJECTED = 16, REFUSED = 17, CUT_SHORT = 18;
77
+
78
+ // The closed set of failure classes. Every judged run lands in exactly one, and
79
+ // the class owns the exit code. `interrupted` (cli-run itself was signalled)
80
+ // is logged as a class but exits 130 or 143.
81
+ export const CLASS_CODES = {
82
+ ok: OK, empty: NO_DELIVERABLE, no_output: NO_OUTPUT, timeout: TIMEOUT, unavailable: UNAVAILABLE,
83
+ auth: AUTH, quota: QUOTA, rejected: REJECTED, refused: REFUSED, cut_short: CUT_SHORT
84
+ };
64
85
 
65
86
  // Every reason that may reach the durable log. A judge or the wrapper picks
66
87
  // one of these; anything else is written as 'unknown'. Provider text never
@@ -69,8 +90,8 @@ export const REASONS = new Set([
69
90
  'ok', 'not_json', 'bad_stop_reason', 'empty_text', 'no_terminal_event', 'empty_output_file',
70
91
  'bad_status', 'empty_response', 'exit_nonzero', 'empty_stdout', 'bad_event_array', 'bad_last_event',
71
92
  'not_result', 'bad_subtype', 'is_error', 'result_not_string', 'empty_result', 'api_error_in_result',
72
- 'telemetry_absent', 'total_errors_unreadable', 'total_errors', 'contract_unmet',
73
- 'timeout', 'unavailable', 'killed', 'disabled', 'lanes_json_malformed', 'no_output', 'unknown'
93
+ 'telemetry_absent', 'total_errors_unreadable', 'total_errors', 'contract_unmet', 'error_message_not_string',
94
+ 'judge_raised', 'timeout', 'unavailable', 'killed', 'disabled', 'lanes_json_malformed', 'no_output', 'unknown'
74
95
  ]);
75
96
 
76
97
  const LOG = join(homedir(), '.ai-orchestrator', 'cli-run.log.jsonl');
@@ -163,7 +184,8 @@ export function judgeCodex(rc, out, err, fileText) {
163
184
  export function judgeAgy(rc, out) {
164
185
  const ev = lastJsonLine(out, '"result"', (o) => o && o.event === 'result');
165
186
  if (ev === null) return fail('no_terminal_event', 'no terminal result event');
166
- const term = ev.result && typeof ev.result === 'object' && !Array.isArray(ev.result) ? ev.result : {};
187
+ if (!ev.result || typeof ev.result !== 'object' || Array.isArray(ev.result)) return fail('bad_last_event', 'terminal result was not an object');
188
+ const term = ev.result;
167
189
  const status = term.status;
168
190
  const text = typeof term.response === 'string' ? term.response.trim() : '';
169
191
  if (status !== 'SUCCESS') return fail('bad_status', `status=${JSON.stringify(status)}`);
@@ -173,7 +195,12 @@ export function judgeAgy(rc, out) {
173
195
  export function judgeHermes(rc, out, err) {
174
196
  const text = String(out || '').trim();
175
197
  if (rc !== 0) {
176
- const why = { 1: 'no final response (agent produced nothing)', 2: 'bad args, or completed with an empty response' }[rc] || 'unknown failure';
198
+ let why = { 1: 'no final response (agent produced nothing)', 2: 'bad args, or completed with an empty response' }[rc] || 'unknown failure';
199
+ // hermes collapses every upstream failure into one exit code; its stderr
200
+ // is the only place the cause is named.
201
+ const blob = String(err || '').toLowerCase(); // stderr only: stdout is the agent's own prose
202
+ if (hermesQuotaText(blob)) why += ': upstream free tier degraded or limited, a retry is reasonable';
203
+ else if (blob.includes('toolset')) why += ': invalid --toolsets value, a caller bug and not a lane fault';
177
204
  return fail('exit_nonzero', `hermes exit ${rc}: ${why}`);
178
205
  }
179
206
  return text ? pass(text, 'exit 0') : fail('empty_stdout', 'exit 0 but empty stdout');
@@ -192,8 +219,11 @@ export function judgeQwen(rc, out) {
192
219
  if (term.type !== 'result') return fail('not_result', `last event was ${JSON.stringify(term.type)}, not result`);
193
220
  if (term.subtype !== 'success') {
194
221
  const e = term.error;
222
+ if (e && typeof e === 'object' && e.message !== undefined && typeof e.message !== 'string') {
223
+ return fail('error_message_not_string', `subtype=${JSON.stringify(term.subtype)}, error.message was not a string`);
224
+ }
195
225
  const msg = e && typeof e === 'object' && typeof e.message === 'string' ? e.message : e ? String(e) : '';
196
- return fail('bad_subtype', `subtype=${JSON.stringify(term.subtype)}` + (msg ? `: ${msg.slice(0, 120)}` : ''));
226
+ return fail('bad_subtype', `subtype=${JSON.stringify(term.subtype)}` + (msg ? `: ${redact(msg).slice(0, 120)}` : ''));
197
227
  }
198
228
  if (term.is_error) return fail('is_error', 'is_error true');
199
229
  if (term.result != null && typeof term.result !== 'string') return fail('result_not_string', `result was ${typeof term.result}, not a string`);
@@ -201,7 +231,7 @@ export function judgeQwen(rc, out) {
201
231
  if (!text) return fail('empty_result', 'success but empty result');
202
232
  // qwen reports success even when the upstream API rejected the call; the
203
233
  // error text lands in `result`. These two checks are the honest ones.
204
- if (text.startsWith('[API Error:')) return fail('api_error_in_result', `success flag lied, result is an API error: ${text.slice(0, 140)}`);
234
+ if (text.startsWith('[API Error:')) return fail('api_error_in_result', `success flag lied, result is an API error: ${redact(text).slice(0, 140)}`);
205
235
  const stats = term.stats;
206
236
  const models = stats && typeof stats === 'object' ? stats.models : null;
207
237
  if (!models || typeof models !== 'object' || Array.isArray(models) || Object.keys(models).length === 0) {
@@ -383,6 +413,406 @@ export function judge(lane, rc, out, err, outFile) {
383
413
  }
384
414
  }
385
415
 
416
+ // A judge is type-guarded, but this is the backstop: an exception from any
417
+ // judge would escape main() as cli-run's own crash and be misread as a
418
+ // wrapper bug. It becomes a classifiable verdict instead (class cut_short).
419
+ export function safeJudge(lane, rc, out, err, outFile) {
420
+ try {
421
+ return judge(lane, rc, out, err, outFile);
422
+ } catch (e) {
423
+ return fail('judge_raised', `judge raised ${(e && e.name) || 'Error'}: ${(e && e.message) || e}`);
424
+ }
425
+ }
426
+
427
+ // --- failure classes: WHY a run failed --------------------------------------
428
+ // Every function here returns a safe default instead of throwing. Signals are
429
+ // read from a lane's authoritative error fields only, never from assistant
430
+ // prose, and each one was taken from a captured vendor shape, not guessed.
431
+
432
+ // Terminal output only; nothing redacted here reaches the durable log anyway.
433
+ // Redact BEFORE clipping: a clip first can cut a long token so its tail no
434
+ // longer matches any pattern.
435
+ export const SECRET_PATTERNS = [
436
+ // A JSON string value, escapes included; an unterminated value (a clipped line) runs to the end.
437
+ // The optional backslashes also catch a JSON body escaped inside another string.
438
+ [/(\\?"(?:api[_-]?key|token|secret|password|access[_-]?token)\\?"\s*:\s*)\\?"(?:[^"\\]|\\.)*(?:"|$)/gi, '$1"[REDACTED]"'],
439
+ [/(authorization\s*:\s*)(\S+)\s+\S+/gi, '$1$2 [REDACTED]'],
440
+ [/([?&](?:token|key|api_key|access_token|sig)=)[^&\s"'<>]+/gi, '$1[REDACTED]'],
441
+ [/(bearer\s+)[A-Za-z0-9._+/=-]{8,}/gi, '$1[REDACTED]'],
442
+ [/sk-[A-Za-z0-9_-]{8,}/g, '[REDACTED]'],
443
+ [/xai-[A-Za-z0-9_-]{8,}/g, '[REDACTED]'],
444
+ [/ghp_[A-Za-z0-9_-]{8,}/g, '[REDACTED]'],
445
+ [/AIza[A-Za-z0-9_-]{8,}/g, '[REDACTED]']
446
+ ];
447
+ export function redact(text) {
448
+ if (typeof text !== 'string') return text;
449
+ let s = text;
450
+ for (const [pat, repl] of SECRET_PATTERNS) s = s.replace(pat, repl);
451
+ return s;
452
+ }
453
+
454
+ function* jsonLines(out) {
455
+ for (const raw of String(out || '').split('\n')) {
456
+ const line = raw.trim();
457
+ if (!line.startsWith('{') || line.length > 1_000_000) continue;
458
+ let o;
459
+ try {
460
+ o = JSON.parse(line);
461
+ } catch {
462
+ continue;
463
+ }
464
+ if (o && typeof o === 'object' && !Array.isArray(o)) yield o;
465
+ }
466
+ }
467
+
468
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
469
+
470
+ // codex: only its own `error`, `turn.failed` and error-item events. Never an
471
+ // agent_message, which is the model talking.
472
+ export function codexErrorEventsText(out) {
473
+ const parts = [];
474
+ for (const o of jsonLines(out)) {
475
+ if (o.type === 'error' && typeof o.message === 'string') parts.push(o.message);
476
+ else if (o.type === 'turn.failed') {
477
+ if (isObj(o.error) && typeof o.error.message === 'string') parts.push(o.error.message);
478
+ else if (typeof o.error === 'string') parts.push(o.error);
479
+ } else if (o.type === 'item.completed' && isObj(o.item) && o.item.type === 'error' && typeof o.item.message === 'string') {
480
+ parts.push(o.item.message);
481
+ }
482
+ }
483
+ return parts.join('\n');
484
+ }
485
+
486
+ // agy: only the terminal result event's own status and error fields.
487
+ export function agyResultFieldsText(out) {
488
+ const parts = [];
489
+ for (const o of jsonLines(out)) {
490
+ if (o.event !== 'result' || !isObj(o.result)) continue;
491
+ if (typeof o.result.status === 'string') parts.push(o.result.status);
492
+ const e = o.result.error;
493
+ if (typeof e === 'string') parts.push(e);
494
+ else if (isObj(e) && typeof e.message === 'string') parts.push(e.message);
495
+ }
496
+ return parts.join('\n');
497
+ }
498
+
499
+ // codex often carries the upstream's JSON error body as a string inside
500
+ // `message`; one level is unwrapped to the human-readable text underneath.
501
+ function unwrapJsonMessage(msg) {
502
+ try {
503
+ const p = JSON.parse(msg);
504
+ if (isObj(p) && isObj(p.error) && typeof p.error.message === 'string') return p.error.message;
505
+ } catch {
506
+ /* not JSON */
507
+ }
508
+ return msg;
509
+ }
510
+
511
+ // The single most specific native codex error, preferring a top-level `error`
512
+ // event over `turn.failed`, so a problem line names the real cause instead of
513
+ // "no terminal turn.completed event".
514
+ export function codexPrimaryError(out) {
515
+ const errors = [], failed = [];
516
+ for (const o of jsonLines(out)) {
517
+ if (o.type === 'error' && typeof o.message === 'string' && o.message) errors.push(unwrapJsonMessage(o.message));
518
+ else if (o.type === 'turn.failed') {
519
+ const m = isObj(o.error) ? o.error.message : o.error;
520
+ if (typeof m === 'string' && m) failed.push(unwrapJsonMessage(m));
521
+ }
522
+ }
523
+ return errors[0] || failed[0] || null;
524
+ }
525
+
526
+ function hermesQuotaText(lower) {
527
+ return lower.includes('no usable content') || lower.includes('limit') || lower.includes('degraded');
528
+ }
529
+
530
+ // qwen: the terminal event's full error text, and a result that is an API error.
531
+ // Never the display detail, which is clipped and can carry a model name.
532
+ export function qwenErrorText(out) {
533
+ let events;
534
+ try {
535
+ events = JSON.parse(out);
536
+ } catch {
537
+ return '';
538
+ }
539
+ const term = Array.isArray(events) && events.length ? events[events.length - 1] : null;
540
+ if (!isObj(term)) return '';
541
+ const parts = [];
542
+ if (typeof term.error === 'string') parts.push(term.error);
543
+ else if (isObj(term.error) && typeof term.error.message === 'string') parts.push(term.error.message);
544
+ if (typeof term.result === 'string' && term.result.trim().startsWith('[API Error:')) parts.push(term.result);
545
+ return parts.join('\n');
546
+ }
547
+
548
+ function authoritativeBlob(lane, out, err, detail, rc) {
549
+ if (lane === 'codex') return `${codexErrorEventsText(out)}\n${err || ''}`;
550
+ if (lane === 'agy') return `${agyResultFieldsText(out)}\n${err || ''}`;
551
+ if (lane === 'hermes') return rc !== 0 ? String(err || '') : '';
552
+ if (lane === 'qwen') return `${qwenErrorText(out)}\n${err || ''}`;
553
+ return `${detail || ''}\n${err || ''}`; // grok: no auth, quota or rejected signal is defined
554
+ }
555
+
556
+ export function sigAuth(lane, blob) {
557
+ const b = blob.toLowerCase();
558
+ if (lane === 'qwen') return b.includes('missing api key');
559
+ if (lane === 'agy') return b.includes('you are not logged into antigravity') || b.includes('not authenticated');
560
+ return false; // codex, grok, hermes: no documented native auth signal
561
+ }
562
+
563
+ export function sigQuota(lane, blob) {
564
+ const b = blob.toLowerCase();
565
+ if (lane === 'qwen') return b.includes('[api error: 402') || b.includes('requires more credits') || blob.includes(' 429') || b.includes('rate limit');
566
+ if (lane === 'codex') return blob.includes('usage_limit_exceeded') || b.includes("you've hit your usage limit");
567
+ if (lane === 'hermes') return hermesQuotaText(b);
568
+ return false;
569
+ }
570
+
571
+ export function sigRejected(lane, blob) {
572
+ const b = blob.toLowerCase();
573
+ if (lane === 'qwen') return b.includes('[api error: 400') || b.includes('no endpoints found') || b.includes('failed to parse grammar');
574
+ if (lane === 'codex') return blob.includes('invalid_request_error');
575
+ if (lane === 'hermes') return b.includes('toolset');
576
+ return false;
577
+ }
578
+
579
+ // Which judge reasons mean the lane never reached a trustworthy finish, as
580
+ // opposed to finishing cleanly with nothing in it (class empty).
581
+ const CUT_SHORT_REASONS = {
582
+ grok: new Set(['not_json', 'bad_stop_reason']),
583
+ codex: new Set(['no_terminal_event']),
584
+ agy: new Set(['no_terminal_event', 'bad_last_event']),
585
+ qwen: new Set(['not_json', 'bad_event_array', 'bad_last_event', 'not_result', 'error_message_not_string']),
586
+ hermes: new Set()
587
+ };
588
+ function isCutShort(lane, reason, rc) {
589
+ if (reason === 'judge_raised') return true;
590
+ if (reason === 'ok') return true; // text came back but the vendor exited nonzero
591
+ // A nonzero vendor exit no signal explains never finished on its own terms.
592
+ // hermes' exit 2 is the one honest vendor code for "bad args, or an empty response".
593
+ if (rc !== 0) return !(lane === 'hermes' && rc === 2);
594
+ return !!(CUT_SHORT_REASONS[lane] && CUT_SHORT_REASONS[lane].has(reason));
595
+ }
596
+
597
+ // --- refused: how many tool calls a hook or deny rule blocked ----------------
598
+ // null means the lane gave no readable signal, which is an unknown, never 0.
599
+ export function refusedQwen(out) {
600
+ let events;
601
+ try {
602
+ events = JSON.parse(out);
603
+ } catch {
604
+ return null;
605
+ }
606
+ if (!Array.isArray(events) || !events.length || !isObj(events[events.length - 1])) return null;
607
+ const pd = events[events.length - 1].permission_denials;
608
+ return Array.isArray(pd) ? pd.length : null;
609
+ }
610
+
611
+ export function refusedAgy(out) {
612
+ let found = false, count = 0;
613
+ for (const o of jsonLines(out)) {
614
+ found = true;
615
+ const e = isObj(o.step_update) && isObj(o.step_update.tool_info) ? o.step_update.tool_info.error : null;
616
+ if (isObj(e) && e.type === 'TOOL_ERROR' && typeof e.message === 'string') {
617
+ const m = e.message.toLowerCase();
618
+ if (m.includes('permission check failed') || m.includes('matches user-configured deny rule')) count++;
619
+ }
620
+ }
621
+ return found ? count : null;
622
+ }
623
+
624
+ // codex's router writes one `Rejected(` line per blocked command on stderr.
625
+ // Each line counts. Prose such as "operation not permitted" in an answer does
626
+ // not: it matches a model merely explaining the phrase.
627
+ const CODEX_ROUTER_REJECTED = /codex_core::tools::router:[^\n]*Rejected\(/gi;
628
+ export function refusedCodex(out, err) {
629
+ let count = (String(err || '').match(CODEX_ROUTER_REJECTED) || []).length;
630
+ for (const o of jsonLines(out)) {
631
+ // Forward-compatible only: a structural denial item, never prose.
632
+ if (o.type === 'item.completed' && isObj(o.item) && (o.item.type === 'command_denied' || o.item.type === 'denied')) count++;
633
+ }
634
+ return count || null;
635
+ }
636
+
637
+ // grok never reports a refusal in stdout. It lives in the session transcript,
638
+ // ~/.grok/sessions/<cwd, percent-encoded>/<sessionId>/updates.jsonl, one
639
+ // {"params":{"sessionId","update"}} envelope per line. Two denial shapes are
640
+ // counted: a PreToolUse hook run with status.blocked, and grok's own deny-rule
641
+ // engine ("Denied by permission policy"). The sessionId comes from lane output,
642
+ // so it is held to a narrow charset before any path is built, the resolved path
643
+ // must stay inside the sessions root, every counted line must name the same
644
+ // session, and the read is capped.
645
+ export const GROK_SESSION_ID = /^[A-Za-z0-9-]{8,64}$/;
646
+ export const GROK_TRANSCRIPT_CAP = 5 * 1024 * 1024;
647
+ // grok encodes the cwd the way Python's urllib.parse.quote(cwd, safe="") does.
648
+ function percentEncodePath(p) {
649
+ return encodeURIComponent(p).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
650
+ }
651
+ // Path-aware containment. A string-prefix test accepts a sibling such as
652
+ // "sessions-2", or on POSIX a directory literally named "sessions\outside".
653
+ export function isInsideRoot(root, p, pathApi = { relative, isAbsolute, sep }) {
654
+ const rel = pathApi.relative(root, p);
655
+ return !!rel && !pathApi.isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + pathApi.sep);
656
+ }
657
+ export function refusedGrok(out, { root = process.env.CLI_RUN_GROK_SESSIONS_ROOT || join(homedir(), '.grok', 'sessions'), cwd = process.cwd(), cap = GROK_TRANSCRIPT_CAP } = {}) {
658
+ let o;
659
+ try {
660
+ o = JSON.parse(out);
661
+ } catch {
662
+ return null;
663
+ }
664
+ const sid = isObj(o) ? o.sessionId : null;
665
+ if (typeof sid !== 'string' || !GROK_SESSION_ID.test(sid)) return null;
666
+ const path = join(root, percentEncodePath(cwd), sid, 'updates.jsonl');
667
+ let data;
668
+ let fd;
669
+ try {
670
+ const rootReal = realpathSync(root);
671
+ const pathReal = realpathSync(path);
672
+ if (!isInsideRoot(rootReal, pathReal)) return null;
673
+ if (!statSync(pathReal).isFile()) return null;
674
+ fd = openSync(pathReal, 'r');
675
+ const buf = Buffer.alloc(Math.max(0, Math.min(cap, statSync(pathReal).size)));
676
+ let read = 0;
677
+ while (read < buf.length) {
678
+ const n = readSync(fd, buf, read, buf.length - read, read);
679
+ if (!n) break;
680
+ read += n;
681
+ }
682
+ data = buf.subarray(0, read).toString('utf8');
683
+ } catch {
684
+ return null;
685
+ } finally {
686
+ if (fd !== undefined) try { closeSync(fd); } catch {}
687
+ }
688
+ let count = 0, matched = false;
689
+ for (const rec of jsonLines(data)) {
690
+ if (!isObj(rec.params) || rec.params.sessionId !== sid) continue;
691
+ matched = true;
692
+ const u = rec.params.update;
693
+ if (!isObj(u)) continue;
694
+ if (u.sessionUpdate === 'hook_execution' && Array.isArray(u.runs)) {
695
+ for (const r of u.runs) if (isObj(r) && isObj(r.status) && r.status.blocked === true) count++;
696
+ } else if (u.sessionUpdate === 'tool_call_update' && u.status === 'failed') {
697
+ let blob = '';
698
+ try { blob = JSON.stringify(u.content ?? '').toLowerCase(); } catch {}
699
+ if (blob.includes('denied by permission policy') || blob.includes('hook denied')) count++;
700
+ }
701
+ }
702
+ return matched ? count : null;
703
+ }
704
+
705
+ export function countRefused(lane, out, err, opts) {
706
+ try {
707
+ if (lane === 'qwen') return refusedQwen(out);
708
+ if (lane === 'agy') return refusedAgy(out);
709
+ if (lane === 'codex') return refusedCodex(out, err);
710
+ if (lane === 'grok') return refusedGrok(out, opts);
711
+ return null; // hermes: no native refusal signal
712
+ } catch {
713
+ return null;
714
+ }
715
+ }
716
+
717
+ // Runs after the lane has exited, so adversarial output must not make it slow:
718
+ // the input is bounded and every repeat is bounded. Redacted before any match
719
+ // can clip a secret.
720
+ const DENIAL_SCAN_BYTES = 256 * 1024;
721
+ function firstDenialText(out, err) {
722
+ const blob = redact(`${String(out || '').slice(0, DENIAL_SCAN_BYTES)}\n${String(err || '').slice(0, DENIAL_SCAN_BYTES)}`);
723
+ for (const pat of [
724
+ /Permission denied for command\([^)\n]{0,200}\)\. Matches user-configured deny rule\./,
725
+ /Denied by permission policy:[^\n"]{0,160}/,
726
+ /Hook denied:[^\n"]{0,160}/,
727
+ /denied:[^\n"]{0,160}/,
728
+ /[Rr]ejected:[^\n"]{0,160}/,
729
+ /error=exec_command failed:[^\n]{0,160}/
730
+ ]) {
731
+ const m = pat.exec(blob);
732
+ if (m) return m[0].trim();
733
+ }
734
+ return null;
735
+ }
736
+
737
+ // A lane often paraphrases a refusal in its own words; a short head of its
738
+ // answer names it when no exact denial phrase matches.
739
+ function deliverableSnippet(out, limit = 160) {
740
+ let o;
741
+ try {
742
+ o = JSON.parse(out);
743
+ } catch {
744
+ return redact(String(out || '').slice(0, 4096)).trim().slice(0, limit) || null;
745
+ }
746
+ if (!isObj(o)) return null;
747
+ for (const k of ['text', 'response', 'result']) if (typeof o[k] === 'string' && o[k].trim()) return redact(o[k].slice(0, 4096)).trim().slice(0, limit);
748
+ return null;
749
+ }
750
+
751
+ const FIX = {
752
+ auth: 'set the credential the message above names (its environment variable, or the lane\'s own login command), then rerun',
753
+ quota: 'switch to another lane, or wait for the reset time if the message gave one',
754
+ rejected: 'correct the model id, flag or request the upstream message names',
755
+ refused: 'adjust the hook or deny rule named above, or give this lane the tool it needs',
756
+ cut_short: 'rerun once; if it recurs, run without --quiet and read the lane\'s stderr on the terminal',
757
+ empty: 'rerun once, or use another lane',
758
+ timeout: 'raise --timeout, or split the brief into smaller pieces',
759
+ no_output: 'rerun once; if it recurs, check that the lane runs on its own outside cli-run'
760
+ };
761
+
762
+ // A one-line problem naming the concrete cause, and a one-line fix. The caller
763
+ // relays both. Returns { problem: null, fix: null } when there is nothing to say.
764
+ export function problemAndFix(lane, cls, { out = '', err = '', detail = '', refused = null, authoritative = null } = {}) {
765
+ const cause = typeof authoritative === 'string' && authoritative.trim() ? redact(authoritative).trim() : null;
766
+ const d = typeof detail === 'string' && detail ? redact(detail) : null;
767
+ const tag = `cli-run[${lane}]`;
768
+ const denial = () => firstDenialText(out, err) || deliverableSnippet(out) || d;
769
+ switch (cls) {
770
+ case 'auth': return { problem: `${tag} auth: ${cause || d || 'missing or invalid credentials'}`, fix: FIX.auth };
771
+ case 'quota': return { problem: `${tag} quota: ${cause || d || 'rate limit or credits exhausted'}`, fix: FIX.quota };
772
+ case 'rejected': return { problem: `${tag} rejected: ${cause || d || 'the upstream rejected the request'}`, fix: FIX.rejected };
773
+ case 'refused': return { problem: `${tag} refused: ${denial() || 'a hook or deny rule blocked the call'}`, fix: FIX.refused };
774
+ case 'cut_short': return { problem: `${tag} cut short: ${d || 'no terminal success event, cause not identifiable'}`, fix: FIX.cut_short };
775
+ case 'empty': return { problem: `${tag} empty: ${d || 'completed but delivered nothing'}`, fix: FIX.empty };
776
+ case 'timeout': return { problem: `${tag} timeout: ${d || 'exceeded the wall clock'}`, fix: FIX.timeout };
777
+ case 'unavailable': return { problem: `${tag} unavailable: ${d || 'binary not found on PATH'}`, fix: `install the ${lane} CLI and put it on PATH, or enable it in lanes.json` };
778
+ case 'no_output': return { problem: `${tag} no output: the process wrote nothing to stdout or stderr`, fix: FIX.no_output };
779
+ case 'ok':
780
+ if (Number.isInteger(refused) && refused > 0) {
781
+ return { problem: `${tag} ok, but ${refused} call(s) were refused: ${denial() || 'a hook or deny rule blocked part of the call'}`, fix: FIX.refused };
782
+ }
783
+ return { problem: null, fix: null };
784
+ default:
785
+ return { problem: null, fix: null };
786
+ }
787
+ }
788
+
789
+ // Classify a judged run. `reason`/`detail` are the judge's own (not the
790
+ // wrapper's decorated detail). A nonzero vendor exit is never ok, whatever
791
+ // came back. Never throws.
792
+ export function classifyRun(lane, { rc = 0, out = '', err = '', reason = '', detail = '', text = '', refusedOpts } = {}) {
793
+ out = typeof out === 'string' ? out : '';
794
+ err = typeof err === 'string' ? err : '';
795
+ detail = typeof detail === 'string' ? detail : '';
796
+ const refused = countRefused(lane, out, err, refusedOpts);
797
+ let cls;
798
+ try {
799
+ if (text && rc === 0) cls = 'ok';
800
+ else if (!out.trim() && !err.trim()) cls = 'no_output';
801
+ else {
802
+ const blob = authoritativeBlob(lane, out, err, detail, rc);
803
+ if (sigAuth(lane, blob)) cls = 'auth';
804
+ else if (sigQuota(lane, blob)) cls = 'quota';
805
+ else if (sigRejected(lane, blob)) cls = 'rejected';
806
+ else if (Number.isInteger(refused) && refused > 0) cls = 'refused';
807
+ else if (isCutShort(lane, reason, rc)) cls = 'cut_short';
808
+ else cls = 'empty';
809
+ }
810
+ } catch {
811
+ cls = 'cut_short';
812
+ }
813
+ return { cls, refused };
814
+ }
815
+
386
816
  // --- the process boundary --------------------------------------------------
387
817
  // The lane runs DETACHED, so it leads its own process group. On timeout (or an
388
818
  // output-buffer overrun) the whole group is killed, not just the direct child:
@@ -694,7 +1124,9 @@ function usage(msg) {
694
1124
  --model / --effort pin what a lane runs with, instead of letting it inherit its
695
1125
  own config. Every lane takes --model; every lane except qwen takes --effort.
696
1126
  Levels are the vendor's own (agy low|medium|high, hermes none|minimal|...): an
697
- unknown level is rejected by the lane, and reported as that lane's exit code.
1127
+ unknown level is rejected by the lane, and reported by class (codex: rejected, 16).
1128
+ Exit codes: 0 ok, 10 empty, 11 no output, 12 timeout, 13 unavailable, 14 auth,
1129
+ 15 quota, 16 rejected, 17 refused, 18 cut short. Failures print a problem and a fix line.
698
1130
  auto sizes per call: below 4,000 prompt characters is medium, otherwise high; a codex audit is always high. Auto never resolves above high.
699
1131
  Pin them per lane instead of per call with "defaults" in bin/lanes.json.`);
700
1132
  return USAGE;
@@ -703,7 +1135,7 @@ function usage(msg) {
703
1135
  // Bounded, control-character-free head of vendor stderr for the terminal.
704
1136
  // Never logged: provider text can echo whatever the prompt contained.
705
1137
  function stderrHead(err, n = 300) {
706
- const s = String(err || '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').trim();
1138
+ const s = redact(String(err || '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')).trim();
707
1139
  if (!s) return '';
708
1140
  return s.length > n ? s.slice(0, n) + '…' : s;
709
1141
  }
@@ -877,21 +1309,21 @@ export async function main(argv) {
877
1309
  effort_truncated: sizing.truncated
878
1310
  });
879
1311
  const enabled = cfg === null ? null : cfg.enabled;
880
- if (enabled === null) {
881
- console.error('cli-run: lanes.json exists but is not a valid {"enabled": [...]} file; refusing every lane until it is fixed');
882
- log({ ...base, verdict: 'unavailable', rc: UNAVAILABLE, reason: 'lanes_json_malformed' });
1312
+ const unavailable = ({ msg, reason, fix }) => {
1313
+ console.error('cli-run: ' + msg);
1314
+ if (!opts.quiet) console.error(`cli-run fix: ${fix}`);
1315
+ log({ ...base, verdict: 'unavailable', class: 'unavailable', rc: UNAVAILABLE, refused: null, reason });
883
1316
  return UNAVAILABLE;
1317
+ };
1318
+ if (enabled === null) {
1319
+ return unavailable({ msg: 'lanes.json exists but is not a valid {"enabled": [...]} file; refusing every lane until it is fixed', reason: 'lanes_json_malformed', fix: 'fix bin/lanes.json, or delete it to enable every lane' });
884
1320
  }
885
1321
  if (!enabled.includes(lane)) {
886
- console.error(`cli-run: ${lane} is not enabled in lanes.json`);
887
- log({ ...base, verdict: 'unavailable', rc: UNAVAILABLE, reason: 'disabled' });
888
- return UNAVAILABLE;
1322
+ return unavailable({ msg: `${lane} is not enabled in lanes.json`, reason: 'disabled', fix: `add "${lane}" to "enabled" in bin/lanes.json, or use an enabled lane` });
889
1323
  }
890
1324
  const binary = which(lane);
891
1325
  if (!binary) {
892
- console.error(`cli-run: ${lane} not found on PATH`);
893
- log({ ...base, verdict: 'unavailable', rc: UNAVAILABLE, reason: 'unavailable' });
894
- return UNAVAILABLE;
1326
+ return unavailable({ msg: `${lane} not found on PATH`, reason: 'unavailable', fix: `install the ${lane} CLI and put it on PATH` });
895
1327
  }
896
1328
 
897
1329
  if (route.effort === 'auto' && !opts.quiet) console.error(`cli-run: effort auto -> ${sizing.resolved} (${sizing.basis})`);
@@ -903,52 +1335,60 @@ export async function main(argv) {
903
1335
  const r = await runBounded(cmd, opts.timeout);
904
1336
  const out = r.stdout || '';
905
1337
  const err = r.stderr || '';
906
- let verdict, reason, detail, code, text = '';
1338
+ let verdict, reason, detail, cls, text = '', refused = null;
907
1339
  if (r.interrupted) {
908
- verdict = 'interrupted'; reason = 'killed'; detail = `cli-run received ${r.interrupted}; the lane's process group was killed`; code = 128 + (r.interrupted === 'SIGINT' ? 2 : 15);
1340
+ verdict = 'interrupted'; reason = 'killed'; detail = `cli-run received ${r.interrupted}; the lane's process group was killed`; cls = 'interrupted';
909
1341
  } else if (r.timedOut) {
910
- verdict = 'timeout'; reason = 'timeout'; detail = `exceeded ${opts.timeout}s; process group killed`; code = TIMEOUT;
1342
+ verdict = 'timeout'; reason = 'timeout'; detail = `exceeded ${opts.timeout}s; process group killed`; cls = 'timeout';
911
1343
  } else if (r.overrun) {
912
- verdict = 'no_deliverable'; reason = 'no_output'; detail = 'output exceeded the 16 MiB buffer; process group killed'; code = NO_DELIVERABLE;
1344
+ verdict = 'no_deliverable'; reason = 'no_output'; detail = 'output exceeded the 16 MiB buffer; process group killed'; cls = 'cut_short';
913
1345
  } else if (r.error) {
914
- verdict = 'unavailable'; reason = 'unavailable'; detail = r.error.message; code = UNAVAILABLE;
1346
+ verdict = 'unavailable'; reason = 'unavailable'; detail = r.error.message; cls = 'unavailable';
915
1347
  } else if (r.signal || r.status === null) {
916
1348
  // A lane killed by a signal has no honest exit status. Whatever it printed
917
1349
  // before dying is not a deliverable; a null status must never become exit 0.
918
- verdict = 'killed'; reason = 'killed'; detail = `lane killed by ${r.signal || 'unknown signal'}`; code = NO_DELIVERABLE;
1350
+ verdict = 'killed'; reason = 'killed'; detail = `lane killed by ${r.signal || 'unknown signal'}`; cls = 'cut_short';
919
1351
  } else {
920
- const j = judge(lane, r.status, out, err, outFile);
921
- text = j.text || '';
1352
+ const j = safeJudge(lane, r.status, out, err, outFile);
922
1353
  reason = j.reason;
923
1354
  detail = j.detail;
1355
+ ({ cls, refused } = classifyRun(lane, { rc: r.status, out, err, reason: j.reason, detail: j.detail, text: j.text || '' }));
924
1356
  if (r.status !== 0) {
925
1357
  // A nonzero vendor exit is a failure on the vendor's own terms, whether or
926
- // not something parseable came back. Pass the code through, keep the
927
- // verdict honest, and show what the vendor said on stderr.
928
- verdict = 'exit_nonzero'; code = r.status;
1358
+ // not something parseable came back. The class names why; the vendor's
1359
+ // own code is kept as cli_rc, and its stderr head shows on the terminal.
1360
+ verdict = 'exit_nonzero';
929
1361
  if (reason === 'ok') reason = 'exit_nonzero';
930
1362
  const head = stderrHead(err);
931
1363
  detail = `lane exited ${r.status}` + (head ? `; stderr: ${head}` : '') + (j.reason !== 'ok' ? `; ${j.detail}` : '');
932
- } else if (text) {
933
- const unmet = checkContracts(opts, text, before);
1364
+ } else if (cls === 'ok') {
1365
+ const unmet = checkContracts(opts, j.text, before);
934
1366
  if (unmet) {
935
- verdict = 'no_deliverable'; reason = 'contract_unmet'; detail = unmet; code = NO_DELIVERABLE;
1367
+ verdict = 'no_deliverable'; reason = 'contract_unmet'; detail = unmet; cls = 'empty';
936
1368
  } else {
937
- verdict = 'ok'; code = OK;
1369
+ verdict = 'ok'; text = j.text;
938
1370
  }
939
- } else if (!out.trim() && !err.trim()) {
940
- verdict = 'no_output'; reason = 'no_output'; code = NO_OUTPUT;
1371
+ } else if (cls === 'no_output') {
1372
+ verdict = 'no_output'; reason = 'no_output';
941
1373
  } else {
942
- verdict = 'no_deliverable'; code = NO_DELIVERABLE;
1374
+ verdict = 'no_deliverable';
943
1375
  const head = stderrHead(err);
944
1376
  if (head) detail += `; stderr: ${head}`;
945
1377
  }
946
1378
  }
1379
+ const code = cls === 'interrupted' ? 128 + (r.interrupted === 'SIGINT' ? 2 : 15) : CLASS_CODES[cls];
947
1380
  if (text && code === OK) process.stdout.write(text + '\n');
948
1381
  const routeNote = route.model || route.effort ? `${route.model || 'lane default'}/${route.effort || 'lane default'}` : 'lane default';
949
- if (!opts.quiet) console.error(`cli-run[${lane}] ${verdict} rc=${code} ${r.seconds.toFixed(1)}s raw=${r.outBytes || 0}B route=${routeNote} :: ${detail}`);
950
- // Durable log: fixed reason code and structural numbers only.
951
- log({ ...base, verdict, rc: code, cli_rc: r.status, signal: r.signal || null, seconds: Math.round(r.seconds * 100) / 100, raw_bytes: r.outBytes || 0, deliverable_bytes: Buffer.byteLength(text), reason: REASONS.has(reason) ? reason : 'unknown' });
1382
+ if (!opts.quiet) {
1383
+ console.error(`cli-run[${lane}] ${verdict} rc=${code} class=${cls} refused=${refused === null ? 'null' : refused} ${r.seconds.toFixed(1)}s raw=${r.outBytes || 0}B route=${routeNote} :: ${redact(detail)}`);
1384
+ let authoritative = null;
1385
+ if (lane === 'codex' && (cls === 'quota' || cls === 'rejected')) authoritative = codexPrimaryError(out);
1386
+ const pf = problemAndFix(lane, cls, { out, err, detail, refused, authoritative });
1387
+ if (pf.problem) console.error('cli-run problem: ' + redact(pf.problem).slice(0, 600));
1388
+ if (pf.fix) console.error('cli-run fix: ' + pf.fix);
1389
+ }
1390
+ // Durable log: fixed reason code, fixed class and structural numbers only.
1391
+ log({ ...base, verdict, class: cls in CLASS_CODES || cls === 'interrupted' ? cls : 'unknown', rc: code, cli_rc: r.status, signal: r.signal || null, refused: Number.isInteger(refused) ? refused : null, seconds: Math.round(r.seconds * 100) / 100, raw_bytes: r.outBytes || 0, deliverable_bytes: Buffer.byteLength(text), reason: REASONS.has(reason) ? reason : 'unknown' });
952
1392
  return code;
953
1393
  } finally {
954
1394
  rmSync(tmp, { recursive: true, force: true });