@commonlyai/cli 0.1.67 → 0.1.69

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -39,6 +39,7 @@ import {
39
39
  formatPodFocusFrame,
40
40
  readPodFocus,
41
41
  } from '../lib/pod-focus.js';
42
+ import { claimReleaseFor, ackResultFor } from '../lib/claim-outcome.js';
42
43
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
43
44
  import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
44
45
  import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
@@ -1045,6 +1046,7 @@ export const performRun = ({
1045
1046
  // goes in a sibling field instead of being spelled into the reason.
1046
1047
  return {
1047
1048
  outcome: 'no_action',
1049
+ refused: { reason: 'cascade-cap' },
1048
1050
  reason: 'cascade-cap',
1049
1051
  details: {
1050
1052
  // The id of what was dropped. `reason` and `streak` say a refusal
@@ -1139,16 +1141,19 @@ export const performRun = ({
1139
1141
  } finally {
1140
1142
  // A silent, normally completed human wake is an explicit decline, not
1141
1143
  // a successful answer. Tell the kernel so it can hand the message to
1142
- // exactly one remaining original listener. Any posted reply, refusal
1143
- // with a reason, or thrown spawn retains completion/legacy semantics:
1144
- // re-offering those would duplicate a visible response or defeat normal
1145
- // at-least-once redelivery after an infrastructure failure.
1146
- const claimOutcome = event.type === 'message.posted'
1147
- && event.payload?.senderIsHuman === true
1148
- && turnResult?.outcome === 'no_action' && !turnResult?.reason
1149
- ? 'declined'
1150
- : (turnResult ? 'completed' : undefined);
1151
- await claimKeeper?.release(claimOutcome);
1144
+ // exactly one remaining original listener. A refusal — the upstream
1145
+ // route down, or the server refusing the post — is NAMED (TASK-099): it
1146
+ // carries its class so the kernel's record can tell "answered" from
1147
+ // "never ran", and on a human wake the kernel hands it on exactly as it
1148
+ // does a decline, because a failure local to THIS seat must not make a
1149
+ // human's message disappear. A thrown spawn keeps the legacy release, so
1150
+ // normal at-least-once redelivery still happens after an infrastructure
1151
+ // failure. One decision point, shared with the batch path — see
1152
+ // lib/claim-outcome.js.
1153
+ const release = claimReleaseFor(event, turnResult);
1154
+ await claimKeeper?.release(release.outcome, {
1155
+ reason: release.reason, status: release.status,
1156
+ });
1152
1157
  }
1153
1158
  };
1154
1159
 
@@ -1397,6 +1402,7 @@ export const performRun = ({
1397
1402
  if (deliveryRefusal) {
1398
1403
  return {
1399
1404
  outcome: 'no_action',
1405
+ refused: { reason: 'delivery-refused' },
1400
1406
  reason: deliveryRefusal.reason,
1401
1407
  details: {
1402
1408
  mode: deliveryRefusal.mode,
@@ -1409,14 +1415,22 @@ export const performRun = ({
1409
1415
  },
1410
1416
  };
1411
1417
  }
1412
- // A refusal is not a decline. `no_action` with no reason means "the agent
1413
- // chose not to answer" and hands a human wake to one remaining listener;
1414
- // nothing about the agent's choice happened here — the model never ran — so
1415
- // say what did happen, and let the event close rather than fan a
1416
- // route-wide refusal out across the rest of the fleet.
1418
+ // A refusal is not a decline, and it is not a close either. `no_action`
1419
+ // with no reason means "the agent chose not to answer" and hands a human
1420
+ // wake to one remaining listener; nothing about the agent's choice happened
1421
+ // here — the model never ran — so the release says WHICH class of refusal
1422
+ // it was (TASK-099). The kernel routes from there: on a human wake the
1423
+ // message is handed to one remaining listener exactly as a decline is
1424
+ // (this seat's upstream route being down says nothing about theirs), and on
1425
+ // any other wake it is terminal. The free-text detail stays in the seat
1426
+ // log and the event ack; the kernel records the class.
1417
1427
  if (!delivered && upstream) {
1418
1428
  return {
1419
1429
  outcome: 'no_action',
1430
+ refused: {
1431
+ reason: 'upstream-refused',
1432
+ ...(Number.isInteger(upstream.status) ? { status: upstream.status } : {}),
1433
+ },
1420
1434
  reason: `upstream-refused-${upstream.status}`,
1421
1435
  details: {
1422
1436
  status: upstream.status,
@@ -1461,6 +1475,7 @@ export const performRun = ({
1461
1475
 
1462
1476
  const batchAdmissionResult = (event, admission) => ({
1463
1477
  outcome: 'no_action',
1478
+ refused: { reason: 'cascade-cap' },
1464
1479
  reason: 'cascade-cap',
1465
1480
  details: {
1466
1481
  messageId: event?.payload?.messageId || null,
@@ -1646,14 +1661,14 @@ export const performRun = ({
1646
1661
  .filter((entry) => entry.claimKeeper)
1647
1662
  .map(async (entry) => {
1648
1663
  // Preserve ADR-018 D6.1 per binding message: a silent human
1649
- // broadcast may be handed to one remaining listener, while every
1650
- // other completed batch item closes normally.
1651
- const claimOutcome = entry.event.type === 'message.posted'
1652
- && entry.event.payload?.senderIsHuman === true
1653
- && turnResult?.outcome === 'no_action' && !turnResult?.reason
1654
- ? 'declined'
1655
- : (turnResult ? 'completed' : undefined);
1656
- await entry.claimKeeper.release(claimOutcome);
1664
+ // broadcast may be handed to one remaining listener, a refusal
1665
+ // closes with its reason, and every other completed batch item
1666
+ // closes normally. Same decision point as the single-event path.
1667
+ // Same decision point as the single-event path.
1668
+ const release = claimReleaseFor(entry.event, turnResult);
1669
+ await entry.claimKeeper.release(release.outcome, {
1670
+ reason: release.reason, status: release.status,
1671
+ });
1657
1672
  }));
1658
1673
  }
1659
1674
  };
@@ -1743,7 +1758,7 @@ export const performRun = ({
1743
1758
  try {
1744
1759
  const deliveryId = event.payload?.deliveryId;
1745
1760
  await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
1746
- result: entry.result,
1761
+ result: ackResultFor(entry.result),
1747
1762
  ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
1748
1763
  });
1749
1764
  } catch (ackErr) {
@@ -1843,7 +1858,7 @@ export const performRun = ({
1843
1858
  try {
1844
1859
  const deliveryId = event.payload?.deliveryId;
1845
1860
  await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
1846
- result,
1861
+ result: ackResultFor(result),
1847
1862
  ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
1848
1863
  });
1849
1864
  } catch (ackErr) {
@@ -75,6 +75,7 @@ import {
75
75
  import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
76
76
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
77
77
  import { buildMemoryPreamble } from '../memory-bridge.js';
78
+ import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
78
79
 
79
80
  // See codex.js for the rationale on bumping the default + env override.
80
81
  // Keeping both adapters in lockstep so any wrapper agent runtime has the
@@ -234,7 +235,7 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns) => {
234
235
  ];
235
236
  };
236
237
 
237
- const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
238
+ const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
238
239
  const proc = spawnImpl(cmd, args, {
239
240
  cwd,
240
241
  env,
@@ -265,8 +266,19 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) =
265
266
  // at all because of this. It is not only a diagnosability problem: the
266
267
  // circuit breaker classifies from the error message, so a blank message
267
268
  // downgrades a hard quota failure to RUNTIME and its shortest backoff.
269
+ //
270
+ // TASK-103: the tail goes through the same reader pi's refusal log uses.
271
+ // A non-JSON tail (which this normally is — the usage-limit line above is
272
+ // prose) is kept whole and only exact-match redacted; a JSON body is
273
+ // reduced to one named field. The status, when the tail names one, is
274
+ // attached as `error.status` so a refusal classifies by field rather than
275
+ // by regex.
268
276
  const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join(' | ');
269
- return reject(new Error(`claude exited with code ${code}: ${detail.slice(0, 2000)}`));
277
+ return reject(adapterFailure('claude', detail, {
278
+ credentials,
279
+ exitCode: code,
280
+ limit: 2000,
281
+ }));
270
282
  }
271
283
  resolve(stdout);
272
284
  });
@@ -688,6 +700,11 @@ export default {
688
700
  cwd: ctx.cwd,
689
701
  env,
690
702
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
703
+ // The values this spawn was handed, for the failure tail's exact-match
704
+ // check. Snapshot before the spawn: claude is subscription-authenticated
705
+ // and its provider key is not an env var here, so the seat credential is
706
+ // the value a tail can echo back.
707
+ credentials: spawnCredentials(spawnCtx),
691
708
  spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
692
709
  });
693
710
  return { text: stdout.trim(), newSessionId: sessionId };
@@ -703,7 +720,7 @@ export default {
703
720
  // session that misses the durable-state cue.
704
721
  const freshPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: true });
705
722
  const retryBase = ['-p', freshPrompt, '--output-format', 'text', '--session-id', freshId, ...modelArgs];
706
- const retry = await prepareArgv(retryBase, {
723
+ const retryCtx = {
707
724
  ...ctx,
708
725
  mcpConfigPath: mcpConfig?.file || null,
709
726
  mcpConfigDir: mcpConfig?.dir || null,
@@ -714,13 +731,15 @@ export default {
714
731
  mcpConfig?.expansionEnv,
715
732
  mcpConfig?.credential?.path || null,
716
733
  ),
717
- });
734
+ };
735
+ const retry = await prepareArgv(retryBase, retryCtx);
718
736
  const stdout = await runClaude({
719
737
  cmd: retry.cmd,
720
738
  args: retry.args,
721
739
  cwd: ctx.cwd,
722
740
  env: retry.env,
723
741
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
742
+ credentials: spawnCredentials(retryCtx),
724
743
  spawnImpl: ctx._spawnImpl,
725
744
  });
726
745
  return { text: stdout.trim(), newSessionId: freshId };
@@ -60,6 +60,7 @@ import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
60
60
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
61
61
  import { buildMemoryPreamble } from '../memory-bridge.js';
62
62
  import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js';
63
+ import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
63
64
 
64
65
  // Default timeout for a single codex spawn (exec mode).
65
66
  //
@@ -412,8 +413,21 @@ const makeEventParser = () => {
412
413
  };
413
414
  };
414
415
 
415
- const runCodex = ({ args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
416
- // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
416
+ /**
417
+ * Provider keys this adapter's route can authenticate with. `env_key` in
418
+ * `~/.codex/config.toml` names one of them (ADR-014 — the HTTPS layer is
419
+ * redirected, the CLI's semantics are not), and the adapter cannot know which
420
+ * one this seat uses, so it snapshots whichever are present. A failure tail that
421
+ * echoes a key must not reach a pod (TASK-103).
422
+ */
423
+ const CODEX_PROVIDER_KEY_ENVS = [
424
+ 'LITELLM_API_KEY',
425
+ 'LITELLM_MASTER_KEY',
426
+ 'OPENAI_API_KEY',
427
+ 'CODEX_API_KEY',
428
+ ];
429
+
430
+ const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
417
431
  // defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
418
432
  // `Reading additional input from stdin...` because it sees an open pipe
419
433
  // and waits for input that never arrives. Interactive runs are fine because
@@ -442,11 +456,18 @@ const runCodex = ({ args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new
442
456
  if (events.turnFailedMessage) {
443
457
  // Surface the model-side failure message verbatim — the run loop posts
444
458
  // it as the agent's reply so the user sees what went wrong rather than
445
- // a generic "non-zero exit" error.
446
- return reject(new Error(`codex turn failed: ${events.turnFailedMessage}`));
459
+ // a generic "non-zero exit" error. Verbatim is about the WORDING: the
460
+ // tail still goes through the shared reader (TASK-103), so a message that
461
+ // echoed a credential this spawn was handed is redacted before it can be
462
+ // posted into a pod, and a status it names is attached for the circuit
463
+ // breaker rather than left to a regex.
464
+ return reject(adapterFailure('codex turn failed', events.turnFailedMessage, {
465
+ credentials,
466
+ limit: 2000,
467
+ }));
447
468
  }
448
469
  if (code !== 0) {
449
- return reject(new Error(`codex exited with code ${code}: ${stderr.trim().slice(0, 500)}`));
470
+ return reject(adapterFailure('codex', stderr.trim(), { credentials, exitCode: code, limit: 500 }));
450
471
  }
451
472
  resolve({ threadId: events.threadId });
452
473
  });
@@ -554,6 +575,12 @@ export default {
554
575
  cwd: ctx.cwd,
555
576
  env: childEnv,
556
577
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
578
+ // The values this spawn was handed, for the failure tail's exact-match
579
+ // check: the seat credential and the provider key the route
580
+ // authenticates with. Snapshot HERE — `withholdRuntimeCredential` above
581
+ // already removed the token from the child copy, and after that there is
582
+ // nothing left to compare a tail against.
583
+ credentials: spawnCredentials(ctx, CODEX_PROVIDER_KEY_ENVS),
557
584
  spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
558
585
  });
559
586
 
@@ -0,0 +1,120 @@
1
+ /**
2
+ * claimReleaseFor — the ONE decision about how a finished turn releases its
3
+ * ADR-018 attention claim.
4
+ *
5
+ * It lives here, extracted from two byte-identical copies in the run loop
6
+ * (single-event and batch release), for the reason a second copy is always a
7
+ * risk: the batch path is the one `message.posted` actually reaches, so a
8
+ * change applied to only one of them would pass review and do nothing.
9
+ *
10
+ * Three outcomes, and the KERNEL decides what each one does with the wake
11
+ * (TASK-099 ruling, corrected at 71194/71195/71210):
12
+ *
13
+ * - `refused` — the lease holder could not deliver. Nothing about the
14
+ * agent's CHOICE happened: the model never ran (an upstream
15
+ * 429/502/401, named by TASK-096) or the server refused the
16
+ * post. On a HUMAN wake the kernel hands the message to one
17
+ * remaining listener exactly as it does for a decline — a
18
+ * per-seat budget failure must not make a human's message
19
+ * disappear — and records the refuser so the chain stays
20
+ * finite. On any other wake it is terminal. The routing is
21
+ * the kernel's: this module only names the outcome.
22
+ * - `declined` — a silent, normally completed human broadcast. The agent
23
+ * chose not to answer, so the kernel hands it to exactly one
24
+ * remaining original listener.
25
+ * - `completed` — everything else that produced a result: a posted reply, or
26
+ * a skip with a reason (`no-prompt`, `claim-held`,
27
+ * `duplicate-delivery`) where nothing was refused.
28
+ *
29
+ * The refusal CLASS is an enum, not free text: `upstream-refused` (with the
30
+ * HTTP status), `cascade-cap`, `delivery-refused`. The kernel stores the class
31
+ * so refusals are countable, and rejects anything else with a 400. A producer
32
+ * that cannot classify itself maps to `delivery-refused` before sending — the
33
+ * mapping is here, in one place, rather than in each producer.
34
+ *
35
+ * A thrown turn returns no turnResult and releases with no outcome — the
36
+ * legacy holder-only DELETE whose at-least-once redelivery is the correct
37
+ * behaviour for a crashed spawn (the lid closed, the process died).
38
+ */
39
+
40
+ /** The classes the kernel accepts. Mirrors `REFUSAL_REASONS` in agentsRuntime. */
41
+ export const REFUSAL_REASONS = ['upstream-refused', 'cascade-cap', 'delivery-refused'];
42
+
43
+ /** What an unclassifiable refusal is recorded as, rather than a fourth class. */
44
+ export const DEFAULT_REFUSAL_REASON = 'delivery-refused';
45
+
46
+ /**
47
+ * The range the kernel accepts for a refusal's `status` — its
48
+ * `MIN_UPSTREAM_STATUS` / `MAX_UPSTREAM_STATUS` in `messageClaimService`.
49
+ * A number outside it is not a refusal status the kernel will record: it
50
+ * answers 400, and the 400 fallback in `enforcement.release` re-releases as
51
+ * `completed` — which is terminal. So forwarding an out-of-range number does
52
+ * not merely lose the number, it loses the handoff: an upstream refusal on a
53
+ * human wake would be recorded as a clean completion. The sender's rule has to
54
+ * be the receiver's rule, which is why the bound lives here as well as there.
55
+ */
56
+ export const MIN_UPSTREAM_STATUS = 400;
57
+ export const MAX_UPSTREAM_STATUS = 599;
58
+
59
+ /**
60
+ * @param {{type?: string, payload?: {senderIsHuman?: boolean}}} event
61
+ * @param {{refused?: {reason?: string, status?: number}|true, outcome?: string, reason?: string}|undefined} turnResult
62
+ * @returns {{outcome?: 'refused'|'declined'|'completed', reason?: string, status?: number}}
63
+ */
64
+ export const claimReleaseFor = (event, turnResult) => {
65
+ if (!turnResult) return { outcome: undefined };
66
+ // `refused: true` is still honoured: it is what an older producer shape (and
67
+ // a third-party wrapper following TASK-096's docs) sends, and it means the
68
+ // same thing — `delivery-refused`, the class that claims the least.
69
+ if (turnResult.refused) {
70
+ const declared = typeof turnResult.refused === 'object' ? turnResult.refused : {};
71
+ const reason = REFUSAL_REASONS.includes(declared.reason)
72
+ ? declared.reason
73
+ : DEFAULT_REFUSAL_REASON;
74
+ // The status is the instance of the class, and only the upstream class has
75
+ // one: a cap refusal or a refused post never saw an upstream HTTP status,
76
+ // so sending its number would put a field on the record that cannot mean
77
+ // what a reader would assume. It is also bounded by what the kernel
78
+ // accepts — an out-of-range number keeps the class and drops only the
79
+ // number, so the refusal still hands off instead of falling back to
80
+ // `completed`.
81
+ const status = reason === 'upstream-refused'
82
+ && Number.isInteger(declared.status)
83
+ && declared.status >= MIN_UPSTREAM_STATUS
84
+ && declared.status <= MAX_UPSTREAM_STATUS
85
+ ? declared.status
86
+ : undefined;
87
+ return {
88
+ outcome: 'refused',
89
+ reason,
90
+ ...(status !== undefined ? { status } : {}),
91
+ };
92
+ }
93
+ if (event?.type === 'message.posted'
94
+ && event.payload?.senderIsHuman === true
95
+ && turnResult.outcome === 'no_action'
96
+ && !turnResult.reason) {
97
+ return { outcome: 'declined' };
98
+ }
99
+ return { outcome: 'completed' };
100
+ };
101
+
102
+ /**
103
+ * `refused` is a LOCAL release marker: the producer sets it so the claim
104
+ * release can name the outcome (see `claimReleaseFor`), and nothing else reads
105
+ * it. The event ack is a wire payload with its own readers, and the information
106
+ * that belongs there already reaches them as `reason` — so the marker is
107
+ * dropped at the ack boundary rather than widening the ack with an internal
108
+ * flag. Returns the result unchanged when there is no marker, so the common
109
+ * path never sees a rebuilt object.
110
+ *
111
+ * @param {object|undefined} turnResult
112
+ */
113
+ export const ackResultFor = (turnResult) => {
114
+ if (!turnResult || !turnResult.refused) return turnResult;
115
+ // ts-eslint's `arg`/`vars` ignore pattern is `^_`, and ignoreRestSiblings is
116
+ // off in this repo's config — so the dropped key is renamed rather than
117
+ // left to trip no-unused-vars.
118
+ const { refused: _refused, ...rest } = turnResult;
119
+ return rest;
120
+ };
@@ -434,7 +434,7 @@ export const createClaimKeeper = (client, {
434
434
  if (timer && typeof timer.unref === 'function') timer.unref();
435
435
  },
436
436
 
437
- async release(outcome) {
437
+ async release(outcome, { reason, status } = {}) {
438
438
  stopRenewal();
439
439
  if (!acquired || lost) return;
440
440
  try {
@@ -442,7 +442,35 @@ export const createClaimKeeper = (client, {
442
442
  // outcome. Besides keeping old clients' DELETE shape intact, callers
443
443
  // that assert their transport arguments must not see a synthetic
444
444
  // `undefined` body. An explicit outcome is the new D6.1 contract.
445
- await (outcome ? client.del(path, { outcome }) : client.del(path));
445
+ //
446
+ // `reason` and `status` ride only with a refusal (TASK-099) and only
447
+ // when the producer named them: the kernel stores the class as the
448
+ // record of why a delivery never happened, so an absent reason must
449
+ // stay absent rather than become the string "undefined".
450
+ if (!outcome) {
451
+ await client.del(path);
452
+ return;
453
+ }
454
+ const body = {
455
+ outcome,
456
+ ...(reason ? { reason } : {}),
457
+ ...(status !== undefined ? { status } : {}),
458
+ };
459
+ try {
460
+ await client.del(path, body);
461
+ } catch (err) {
462
+ // An older kernel validates the outcome against its own
463
+ // decline/completed enum and answers 400 — as it does for a reason
464
+ // it does not recognise. A CLI that ships before (or runs against a
465
+ // server earlier than) the third outcome must still release its
466
+ // lease, or the message stays claimed until it expires: one retry as
467
+ // `completed`, which is exactly what the old release did. Bounded to
468
+ // a 400 — a 500 or a timeout is not turned into a second write, and
469
+ // a second 400 is not retried again.
470
+ if (outcome !== 'refused' || err?.status !== 400) throw err;
471
+ log(`claim on message ${messageId}: kernel rejected the refused outcome (400) — releasing as completed`);
472
+ await client.del(path, { outcome: 'completed' });
473
+ }
446
474
  } catch {
447
475
  // Best-effort: a miss just means the lease already expired.
448
476
  }
@@ -29,14 +29,15 @@
29
29
  * relayed in an upstream error is not detectable here, because the one
30
30
  * inventory this module has is the set of values it was itself handed.
31
31
  *
32
- * Scope: this reader is wired into the pi adapter only. codex and claude are NOT
33
- * silent the way pi was: the claude adapter rejects on a non-zero exit and
34
- * deliberately reports stdout with it, because a `-p` run writes quota
35
- * conditions there; the codex adapter rejects on `turnFailedMessage` even at
36
- * exit 0. Their gap is the opposite one — the text they raise is the raw tail,
37
- * unscrubbed by the rules above, and carries no HTTP status for the run loop to
38
- * key a refusal on. Extending this module to them is adapter work (TASK-103),
39
- * not a line in this comment.
32
+ * Scope: this reader was wired into the pi adapter only. TASK-103 extended it
33
+ * to codex and claude, which are NOT silent the way pi was: the claude adapter
34
+ * rejects on a non-zero exit and deliberately reports stdout with it, because a
35
+ * `-p` run writes quota conditions there; the codex adapter rejects on
36
+ * `turnFailedMessage` even at exit 0. Their gap was the opposite one — the text
37
+ * they raised was the raw tail, unscrubbed by the rules above, and carried no
38
+ * HTTP status for the run loop to classify on. `scrubAdapterFailure` below is
39
+ * the shared reader they now both go through, so the two rules cannot drift
40
+ * apart between three adapters.
40
41
  */
41
42
 
42
43
  /** The kept detail is truncated, ellipsis included, to this many characters. */
@@ -179,6 +180,154 @@ export const readUpstreamRefusal = (stdout, { credentials = [] } = {}) => {
179
180
  return { status, detail: keptRefusalDetail(raw, { credentials }) };
180
181
  };
181
182
 
183
+ /**
184
+ * Is this text STRUCTURED upstream data — a JSON object or an array? The gate for
185
+ * the whole-tail fallback below, deliberately NOT `kept === null`, which is true
186
+ * for five different reasons (Vera 71350).
187
+ *
188
+ * An array counts, and that is a correction (Vera 71360, measured): `[{...}]` is
189
+ * structured data with fields this code does not know, which is exactly what rule
190
+ * 1 exists for, and treating it as prose shipped the whole body under exact-match
191
+ * alone. A JSON *string* is the opposite case and does NOT count — `"Too many
192
+ * requests"` is a whole-shape text with no fields to reduce, so it keeps its
193
+ * wording like any other prose.
194
+ */
195
+ const isStructuredBody = (raw) => {
196
+ try {
197
+ const parsed = JSON.parse(String(raw ?? ''));
198
+ return parsed !== null && typeof parsed === 'object';
199
+ } catch {
200
+ return false;
201
+ }
202
+ };
203
+
204
+ /**
205
+ * A 3-digit number is read as a status only when the tail NAMES it as one:
206
+ * `status 429`, `HTTP 429`, `code: "429"` — or when it opens the tail or a ` | `
207
+ * segment followed by a colon, which is the shape claude's `stderr | stdout`
208
+ * join produces for a body that leads with its status.
209
+ *
210
+ * A bare `\b(\d{3})\b` scan is not enough, and the false positives are not
211
+ * hypothetical: `500 tokens`, `"max_tokens": 500` and `retry in 500 seconds`
212
+ * all read as 500, and `classifySpawnFailure` tests the status FIELD before the
213
+ * message — so a count misread as a status stands the fleet down on a refusal
214
+ * that never happened, which is worse than no status at all (Vera 71352).
215
+ */
216
+ const STATUS_NAMED = /\b(?:status(?:_?code)?|code|http)\b\W{0,4}(\d{3})(?!\d)/gi;
217
+ // `HTTP/1.1 429 Too Many Requests` is the most canonical refusal shape there is,
218
+ // and the `\W{0,4}` gap above handles `HTTP 429` but cannot reach a VERSIONED
219
+ // line: the `1` in `/1.1` is a
220
+ // WORD character, so `HTTP` and `429` are not within four non-word characters of
221
+ // each other. It read as `status: null` — an unclassified runtime failure on the
222
+ // single most recognisable shape, which is the misclassification this row exists
223
+ // to remove (Vera 71359). Its own pattern, not a looser gap: widening the gap
224
+ // would start reading `/v1/models 500` as a status.
225
+ const STATUS_LINE = /\bHTTP\/\d(?:\.\d)?\s+(\d{3})(?!\d)/gi;
226
+ const STATUS_COLON = /(?:^|\|\s*)(\d{3})(?!\d)\s*:/g;
227
+
228
+ /** The first 4xx/5xx one of those shapes names, or null. */
229
+ const scanStatuses = (text, pattern) => {
230
+ for (const match of String(text ?? '').matchAll(pattern)) {
231
+ const value = Number(match[1]);
232
+ if (value >= 400 && value <= 599) return value;
233
+ }
234
+ return null;
235
+ };
236
+
237
+ /**
238
+ * Find the status AND the body boundary, because they are the same question.
239
+ *
240
+ * Anchoring the status to a ` | ` segment start is not enough on its own: the
241
+ * body has to be taken from just past that status too, or a mid-tail read
242
+ * isolates the status and then hands the WHOLE joined tail to the body rules,
243
+ * which no longer look like a JSON body and so fall through to "keep it all".
244
+ * That is the difference between dropping a credential-echoing body and shipping
245
+ * the part of it that exact-match cannot see.
246
+ *
247
+ * `STATUS_COLON` is the only shape that moves the boundary, because it is the
248
+ * same shape as the anchored prefix (`429: {body}`), just found at a segment
249
+ * boundary — claude's `stderr | stdout` join. A status that appears inside a
250
+ * body (`{"error":{"code":"429"}}`) or as prose (`HTTP 503`) names the status
251
+ * (`HTTP/1.1 429`) but marks no boundary, so the whole text stays the body.
252
+ */
253
+ const readStatusAndBody = (text) => {
254
+ const raw = String(text ?? '');
255
+ const prefixed = statusFromError(raw);
256
+ if (prefixed !== null) return { status: prefixed, body: bodyFromError(raw) };
257
+ // Only the colon shape moves the boundary — see the note above. A status LINE
258
+ // names the status but its reason phrase is prose, so the body stays the whole
259
+ // text and the exact-match rule protects it.
260
+ for (const match of raw.matchAll(STATUS_COLON)) {
261
+ const value = Number(match[1]);
262
+ if (value >= 400 && value <= 599) {
263
+ return { status: value, body: raw.slice(match.index + match[0].length).replace(/^\s*:?\s*/, '') };
264
+ }
265
+ }
266
+ const named = scanStatuses(raw, STATUS_NAMED);
267
+ return { status: named ?? scanStatuses(raw, STATUS_LINE), body: raw };
268
+ };
269
+
270
+ /**
271
+ * The shared reader for an adapter's own failure tail (TASK-103).
272
+ *
273
+ * The two rules above are about a body an adapter captured, and they apply here
274
+ * too — but the tail is a different shape from pi's refusal, and the difference
275
+ * decides how each rule lands:
276
+ *
277
+ * - The tail is often NOT structured. Claude's `-p` mode writes its terminal
278
+ * condition as prose (`Claude usage limit reached. Your limit will reset at
279
+ * 11:40pm.`), and that line is the whole diagnostic value of reporting
280
+ * stdout at all — 361 consecutive failures on 2026-08-03 carried no reason
281
+ * because of it. So the keep-list applies only when the tail parses as a
282
+ * body; when it does not, the text is KEPT as a whole shape and the
283
+ * exact-match rule is what protects it (`redactKnownCredentials`, which
284
+ * substitutes rather than drops, precisely because a whole-shape text has no
285
+ * second field to fall back to).
286
+ *
287
+ * - A body that IS structured (a JSON object or array) is reduced to one named
288
+ * field, and a body whose field echoed a value this spawn was handed is
289
+ * DROPPED WHOLE — the same trade rule 2 makes everywhere. The fallback to the
290
+ * whole tail is gated on the SHAPE (is it structured at all), never on
291
+ * "the keep-list returned null": that null has
292
+ * four other causes, and one of them is the credential echo this line exists
293
+ * to drop. Gating on `kept === null` would emit the whole body for that case,
294
+ * which is precisely what rule 2 forbids — and exact-match cannot see a
295
+ * transformed shape (limit (a) in the header), so a key that arrived
296
+ * base64'd or percent-encoded would ship (Vera 71350/71351).
297
+ *
298
+ * Returns `{ status, detail }`: `status` is the 4xx/5xx the tail named, or null.
299
+ * The caller decides where the status goes; `classifySpawnFailure` reads it off
300
+ * `error.status` or out of the message text, so both a leading `429: …` prefix
301
+ * and an attached field classify the same way. `detail` is `''` when nothing of
302
+ * the body may be shown — the status is then the whole diagnostic.
303
+ */
304
+ export const scrubAdapterFailure = (text, { credentials = [], limit = MAX_REFUSAL_DETAIL } = {}) => {
305
+ const raw = String(text ?? '').trim();
306
+ const { status, body } = readStatusAndBody(raw);
307
+ const kept = keptRefusalDetail(body, { credentials });
308
+ const detail = kept ?? (isStructuredBody(body) ? '' : redactKnownCredentials(body, credentials));
309
+ return { status, detail: detail.slice(0, limit) };
310
+ };
311
+
312
+ /**
313
+ * The Error an adapter raises, built from its own failure tail.
314
+ *
315
+ * `status` is attached as a field, not only written into the text: the run
316
+ * loop's classifier tests `error.status === 429` before it looks at the
317
+ * message, and a refusal that is only readable by regex is one wording away
318
+ * from the misclassification that cost an hour on 2026-08-18 (see
319
+ * spawn-retry.js). The message still names the status so a human log says the
320
+ * same thing as the classifier's decision.
321
+ */
322
+ export const adapterFailure = (label, text, { credentials = [], exitCode = null, limit } = {}) => {
323
+ const { status, detail } = scrubAdapterFailure(text, { credentials, limit });
324
+ const code = exitCode === null ? '' : ` exited with code ${exitCode}`;
325
+ const named = status === null ? '' : ` (upstream ${status})`;
326
+ const error = new Error(`${label}${code}${named}${detail ? `: ${detail}` : ''}`);
327
+ if (status !== null) error.status = status;
328
+ return error;
329
+ };
330
+
182
331
  /**
183
332
  * The seat-log clause. The status is always present; the detail only when the
184
333
  * keep-list kept one. `grep 'upstream refused'` finds every refusal, which is