@dungle-scrubs/harness-cli-normalizer 0.4.5 → 0.4.6

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.
Files changed (94) hide show
  1. package/README.md +10 -4
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +8 -0
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/help.d.ts +1 -1
  6. package/dist/cli/help.d.ts.map +1 -1
  7. package/dist/cli/help.js +3 -0
  8. package/dist/cli/help.js.map +1 -1
  9. package/dist/cli/inspect.d.ts.map +1 -1
  10. package/dist/cli/inspect.js +18 -1
  11. package/dist/cli/inspect.js.map +1 -1
  12. package/dist/cli/provenance.d.ts +3 -0
  13. package/dist/cli/provenance.d.ts.map +1 -0
  14. package/dist/cli/provenance.js +11 -0
  15. package/dist/cli/provenance.js.map +1 -0
  16. package/dist/cli/run.d.ts.map +1 -1
  17. package/dist/cli/run.js +18 -28
  18. package/dist/cli/run.js.map +1 -1
  19. package/dist/cli/session.d.ts.map +1 -1
  20. package/dist/cli/session.js +3 -3
  21. package/dist/cli/session.js.map +1 -1
  22. package/dist/cli/skills-root.d.ts +2 -1
  23. package/dist/cli/skills-root.d.ts.map +1 -1
  24. package/dist/cli/skills-root.js +6 -4
  25. package/dist/cli/skills-root.js.map +1 -1
  26. package/dist/execution/decode.d.ts.map +1 -1
  27. package/dist/execution/decode.js +2 -1
  28. package/dist/execution/decode.js.map +1 -1
  29. package/dist/execution/deps.d.ts +1 -1
  30. package/dist/execution/deps.js +1 -1
  31. package/dist/execution/failure.d.ts +5 -1
  32. package/dist/execution/failure.d.ts.map +1 -1
  33. package/dist/execution/failure.js +12 -0
  34. package/dist/execution/failure.js.map +1 -1
  35. package/dist/execution/index.d.ts +2 -2
  36. package/dist/execution/index.js +2 -2
  37. package/dist/execution/open-session.d.ts.map +1 -1
  38. package/dist/execution/open-session.js +23 -8
  39. package/dist/execution/open-session.js.map +1 -1
  40. package/dist/execution/stream-turn.d.ts.map +1 -1
  41. package/dist/execution/stream-turn.js +18 -10
  42. package/dist/execution/stream-turn.js.map +1 -1
  43. package/dist/interpretation/argv.d.ts.map +1 -1
  44. package/dist/interpretation/argv.js +4 -1
  45. package/dist/interpretation/argv.js.map +1 -1
  46. package/dist/interpretation/content.d.ts.map +1 -1
  47. package/dist/interpretation/content.js +4 -1
  48. package/dist/interpretation/content.js.map +1 -1
  49. package/dist/interpretation/limits.d.ts +1 -0
  50. package/dist/interpretation/limits.d.ts.map +1 -1
  51. package/dist/interpretation/limits.js +22 -0
  52. package/dist/interpretation/limits.js.map +1 -1
  53. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  54. package/dist/interpretation/tool-selection.js +16 -0
  55. package/dist/interpretation/tool-selection.js.map +1 -1
  56. package/dist/interpretation/versions.d.ts +1 -1
  57. package/dist/interpretation/versions.d.ts.map +1 -1
  58. package/dist/knowledge/descriptor.d.ts +4 -0
  59. package/dist/knowledge/descriptor.d.ts.map +1 -1
  60. package/dist/knowledge/descriptor.js.map +1 -1
  61. package/dist/knowledge/index.d.ts +1 -1
  62. package/dist/knowledge/index.d.ts.map +1 -1
  63. package/dist/knowledge/index.js +1 -1
  64. package/dist/knowledge/index.js.map +1 -1
  65. package/dist/knowledge/matchers.d.ts +2 -1
  66. package/dist/knowledge/matchers.d.ts.map +1 -1
  67. package/dist/knowledge/matchers.js +15 -0
  68. package/dist/knowledge/matchers.js.map +1 -1
  69. package/dist/knowledge/pi.d.ts.map +1 -1
  70. package/dist/knowledge/pi.js +4 -1
  71. package/dist/knowledge/pi.js.map +1 -1
  72. package/package.json +1 -1
  73. package/src/cli/args.ts +8 -0
  74. package/src/cli/help.ts +3 -0
  75. package/src/cli/inspect.ts +17 -1
  76. package/src/cli/provenance.ts +19 -0
  77. package/src/cli/run.ts +18 -36
  78. package/src/cli/session.ts +4 -3
  79. package/src/cli/skills-root.ts +11 -4
  80. package/src/execution/decode.ts +2 -1
  81. package/src/execution/deps.ts +1 -1
  82. package/src/execution/failure.ts +17 -1
  83. package/src/execution/index.ts +2 -2
  84. package/src/execution/open-session.ts +33 -7
  85. package/src/execution/stream-turn.ts +21 -9
  86. package/src/interpretation/argv.ts +4 -1
  87. package/src/interpretation/content.ts +5 -1
  88. package/src/interpretation/limits.ts +29 -0
  89. package/src/interpretation/tool-selection.ts +16 -0
  90. package/src/interpretation/versions.ts +1 -1
  91. package/src/knowledge/descriptor.ts +5 -0
  92. package/src/knowledge/index.ts +5 -1
  93. package/src/knowledge/matchers.ts +17 -1
  94. package/src/knowledge/pi.ts +4 -1
package/src/cli/run.ts CHANGED
@@ -238,7 +238,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
238
238
  if (rawSkills !== undefined && rawSkills.length > 0) {
239
239
  try {
240
240
  const { resolveSkillNames, listKnownSkills } = await import("./skills-root.js");
241
- const resolvedSkills = resolveSkillNames(rawSkills);
241
+ const resolvedSkills = resolveSkillNames(rawSkills, h.name);
242
242
  const claudeTokens: string[] = [];
243
243
  if (h.name === "claude") {
244
244
  const { claudeSkillOverridesArg } = await import("../interpretation/skills-selection.js");
@@ -302,30 +302,8 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
302
302
  effectiveTurnOpts = rest as ReturnType<typeof parseTurnOptions>;
303
303
  // Provenance is diagnostic data like the spawn line - stderr in BOTH
304
304
  // render modes, never stdout (stdout carries the NDJSON contract).
305
- if (provenance.length > 0 || unrenderable.length > 0) {
306
- for (const entry of provenance) {
307
- process.stderr.write(
308
- `provenance: ${entry.key} = ${JSON.stringify(entry.value)} (${entry.tier})\n`,
309
- );
310
- }
311
- for (const key of unrenderable) {
312
- process.stderr.write(
313
- `divergence: profile ${JSON.stringify(key)} not expressible on ${h.name}; harness default applies\n`,
314
- );
315
- }
316
- // issue #48 (ratified): when claude's instruction-files isolation
317
- // rides (the --setting-sources project spelling), surface its known
318
- // cost as a divergence line - legal to use, priced in stderr.
319
- if (
320
- h.name === "claude" &&
321
- (resolved.options as { discovery?: { instructionFiles?: boolean } }).discovery
322
- ?.instructionFiles === false
323
- ) {
324
- process.stderr.write(
325
- "divergence: setting-sources isolation also skips hooks, LSP and keychain reads on claude - weighed, not refused\n",
326
- );
327
- }
328
- }
305
+ const { writeProvenance } = await import("./provenance.js");
306
+ writeProvenance(h.name, provenance, unrenderable);
329
307
  }
330
308
 
331
309
  // issue #41: question-escalation precedence arg > project > user >
@@ -490,7 +468,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
490
468
  const _currentProc: { signal: (sig: "SIGTERM" | "SIGKILL") => void } | null = null;
491
469
  // We'll need to track the spawned process via deps.signal; but streamTurn owns process handle.
492
470
  // Instead we intercept deps.signal via a wrapper that captures proc.
493
- // Simpler: use deps directly and handle SIGINT by calling process.kill? But spec says via injected signal.
471
+ // Simpler: use deps directly and handle SIGINT via injected signal.
494
472
  // We'll create a wrapper deps where signal captures last proc.
495
473
  let lastProc: import("../execution/deps.js").SpawnedProcess | null = null;
496
474
  const originalSignal = deps.signal;
@@ -509,6 +487,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
509
487
 
510
488
  const abortController = new AbortController();
511
489
  let interrupted = false;
490
+ let escalationTimer: ReturnType<typeof setTimeout> | null = null;
512
491
  const onSig = async () => {
513
492
  if (interrupted) return;
514
493
  interrupted = true;
@@ -516,16 +495,15 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
516
495
  if (lastProc) {
517
496
  try {
518
497
  wrappedDeps.signal(lastProc, "SIGTERM");
519
- await new Promise<void>((resolve) => {
520
- setTimeout(() => {
521
- if (lastProc) {
522
- try {
523
- wrappedDeps.signal(lastProc, "SIGKILL");
524
- } catch {}
525
- }
526
- resolve();
527
- }, KILL_GRACE_MS);
528
- });
498
+ escalationTimer = setTimeout(() => {
499
+ if (lastProc) {
500
+ try {
501
+ wrappedDeps.signal(lastProc, "SIGKILL");
502
+ } catch {}
503
+ }
504
+ escalationTimer = null;
505
+ }, KILL_GRACE_MS);
506
+ escalationTimer.unref?.();
529
507
  } catch {}
530
508
  }
531
509
  };
@@ -591,6 +569,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
591
569
  process.off("SIGTERM", onSig);
592
570
  return;
593
571
  } finally {
572
+ if (escalationTimer !== null) {
573
+ clearTimeout(escalationTimer);
574
+ escalationTimer = null;
575
+ }
594
576
  process.off("SIGINT", onSig);
595
577
  process.off("SIGTERM", onSig);
596
578
  }
@@ -3,6 +3,8 @@ import { createInterface } from "node:readline/promises";
3
3
  import { nodeRunnerDeps } from "../execution/node-deps.js";
4
4
  import { CLOSE_GRACE_MS, openSession } from "../execution/open-session.js";
5
5
  import { ArgvRefusalError } from "../interpretation/refusal.js";
6
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
7
+ import { defaultDescriptors } from "../knowledge/overrides.js";
6
8
  import { createRenderState, renderEvent } from "./render.js";
7
9
  import { resolveHarness } from "./resolve-harness.js";
8
10
 
@@ -12,9 +14,8 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
12
14
  // session mode is available the moment its descriptor declares one.
13
15
  const h = resolveHarness(harnessName);
14
16
  if (h.sessionMode === null) {
15
- const supported = ["claude", "codex", "pi", "muse"]
16
- .map((name) => resolveHarness(name))
17
- .filter((d) => d.sessionMode !== null)
17
+ const supported = Object.values(defaultDescriptors())
18
+ .filter((d): d is HarnessDescriptor => d !== undefined && d.sessionMode !== null)
18
19
  .map((d) => d.name);
19
20
  const err = new ArgvRefusalError({
20
21
  issue: "no-session-mode",
@@ -12,6 +12,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
13
  import { join } from "node:path";
14
14
  import { ArgvRefusalError } from "../interpretation/refusal.js";
15
+ import type { HarnessName } from "../knowledge/descriptor.js";
15
16
 
16
17
  export const skillsRoot = (): string =>
17
18
  process.env.HCN_SKILLS_ROOT ?? join(homedir(), ".agents", "skills");
@@ -30,16 +31,22 @@ export const listKnownSkills = (): string[] => {
30
31
  });
31
32
  };
32
33
 
33
- export const resolveSkillNames = (names: readonly string[]): string[] => {
34
+ export const resolveSkillNames = (
35
+ names: readonly string[],
36
+ harness: HarnessName = "claude",
37
+ ): string[] => {
34
38
  const root = skillsRoot();
35
39
  const known = new Set(listKnownSkills());
36
40
  const unknown = names.filter((n) => !known.has(n));
37
41
  if (unknown.length > 0) {
38
42
  throw new ArgvRefusalError({
39
- issue: "unknown-tool-name",
40
- harness: "claude",
43
+ issue: "invalid-option-value",
44
+ harness,
41
45
  option: "skills",
42
- supported: known.size > 0 ? [[...known].join(", ")] : ["(registry is empty)"],
46
+ supported:
47
+ known.size > 0
48
+ ? [`registry at ${root}`, [...known].join(", ")]
49
+ : [`registry at ${root} (empty)`],
43
50
  detail: `unknown skill name(s): ${unknown.join(", ")}`,
44
51
  });
45
52
  }
@@ -69,10 +69,11 @@ export const decodeParsed = (
69
69
  const decoded = decodeIdentity(h, raw, state.lastSeenId, state.requestedId);
70
70
  if (decoded.sessionId !== null) state.lastSeenId = decoded.sessionId;
71
71
  if (decoded.identity !== null) {
72
+ const authority = state.requestedId !== null ? "caller-assigned" : "harness-minted";
72
73
  events.push({
73
74
  kind: "identity",
74
75
  sessionId: decoded.identity,
75
- authority: h.identity.authority,
76
+ authority,
76
77
  capabilities: capabilities(),
77
78
  });
78
79
  } else if (decoded.outcome === "malformed" || decoded.outcome === "rotated") {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Injected runtime primitives (D-005): the execution layer never imports
3
- * child_process, never calls process.kill, and never reads a wall clock -
3
+ * child_process, never signals the process directly, and never reads a wall clock -
4
4
  * spawn, clock, and signalling arrive as data so Node/Bun is a real
5
5
  * portability boundary and tests run against fakes deterministically.
6
6
  */
@@ -13,8 +13,14 @@
13
13
  * options or a different harness, not a different model.
14
14
  */
15
15
 
16
+ import { detectAuthFailureInLine, detectTransportInLine } from "../interpretation/limits.js";
16
17
  import type { RefusalIssue } from "../interpretation/refusal.js";
17
- import type { AuthFailureKind, DiscoveryFacet, LimitCode } from "../knowledge/descriptor.js";
18
+ import type {
19
+ AuthFailureKind,
20
+ DiscoveryFacet,
21
+ HarnessDescriptor,
22
+ LimitCode,
23
+ } from "../knowledge/descriptor.js";
18
24
 
19
25
  export const FAILURE_CLASSES = Object.freeze([
20
26
  "rate-limit",
@@ -131,6 +137,16 @@ export const failureFromAuth = (kind: AuthFailureKind): FailureSummary => ({
131
137
  authKind: kind,
132
138
  });
133
139
 
140
+ /** A terminal error the harness reported on its stream: classify by what
141
+ * it says. An auth wall or a transport fault reached no verdict on the
142
+ * work (retryable); anything else is the model's own failure (task). */
143
+ export const failureFromTerminalError = (h: HarnessDescriptor, message: string): FailureSummary => {
144
+ const auth = detectAuthFailureInLine(h, message);
145
+ if (auth !== null) return failureFromAuth(auth);
146
+ if (detectTransportInLine(message)) return failureFromTransport(message);
147
+ return failureFromTask(message);
148
+ };
149
+
134
150
  export const failureFromTask = (detail?: string): FailureSummary => ({
135
151
  class: "task",
136
152
  retryable: false,
@@ -4,8 +4,8 @@
4
4
  * It exposes openSession() and streamTurn(), which drive a CLI process via
5
5
  * injected {spawn, clock, signal} primitives and emit HarnessEvent values.
6
6
  * This module knows nothing about any chat protocol. Because it must run
7
- * identically on Node and Bun, it never imports child_process and never calls
8
- * process.kill directly outside the node-deps adapter - all process I/O and
7
+ * identically on Node and Bun, it never imports child_process and never
8
+ * signals the process directly outside the node-deps adapter - all process I/O and
9
9
  * signalling flows through the injected primitives.
10
10
  */
11
11
  export { AsyncChannel } from "./channel.js";
@@ -15,7 +15,11 @@
15
15
  */
16
16
  import { buildSessionArgv } from "../interpretation/argv.js";
17
17
  import { capabilitiesOf } from "../interpretation/capabilities.js";
18
- import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
18
+ import {
19
+ detectAuthFailureInLine,
20
+ detectLimitInLine,
21
+ detectTransportInLine,
22
+ } from "../interpretation/limits.js";
19
23
  import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
20
24
  import {
21
25
  encodeSessionInput,
@@ -28,7 +32,14 @@ import { decodeParsed, freshDecodeState } from "./decode.js";
28
32
  import type { RunnerDeps, SpawnedProcess } from "./deps.js";
29
33
  import type { ExitCause, HarnessEvent } from "./events.js";
30
34
  import type { FailureSummary } from "./failure.js";
31
- import { failureFromAuth, failureFromLimit, reduceFailures } from "./failure.js";
35
+ import {
36
+ failureFromAuth,
37
+ failureFromLimit,
38
+ failureFromTask,
39
+ failureFromTerminalError,
40
+ failureFromTransport,
41
+ reduceFailures,
42
+ } from "./failure.js";
32
43
  import { LineBuffer } from "./lines.js";
33
44
  import { KILL_GRACE_MS, PIPE_GRACE_MS, redactArgv, StderrTail } from "./stream-turn.js";
34
45
 
@@ -210,6 +221,9 @@ export const openSession = (
210
221
  if (detection === null) return;
211
222
  if ("malformed" in detection) {
212
223
  activeTurn?.push({ kind: "error", message: detection.malformed });
224
+ const failure = failureFromTask(`malformed hcn-question block: ${detection.malformed}`);
225
+ turnFailures.push(failure);
226
+ void activeTurn?.push({ kind: "failure", ...failure });
213
227
  return;
214
228
  }
215
229
  turnAsked = true;
@@ -240,7 +254,10 @@ export const openSession = (
240
254
  // Every failure was already emitted as an event through pushFailure;
241
255
  // the turn's done carries the reduced summary, as streamTurn's does.
242
256
  const reduced = reduceFailures(turnFailures);
243
- if (reduced !== undefined) done = { ...done, failure: reduced };
257
+ if (reduced !== undefined) {
258
+ if (done.cause === "clean") done = { ...done, cause: "failed", failure: reduced };
259
+ else done = { ...done, failure: reduced };
260
+ }
244
261
  activeTurn.push(done);
245
262
  activeTurn.close();
246
263
  log({
@@ -287,6 +304,15 @@ export const openSession = (
287
304
  return routeEvent({ kind: "failure", ...f });
288
305
  };
289
306
 
307
+ /** A decoded event other than a failure: a terminal error also records
308
+ * the failure it stands for, the way streamTurn does. */
309
+ const routeDecoded = async (event: HarnessEvent): Promise<void> => {
310
+ await routeEvent(event);
311
+ if (event.kind === "error" && event.terminal === true) {
312
+ await pushFailure(failureFromTerminalError(h, event.message));
313
+ }
314
+ };
315
+
290
316
  const pumpStdout = async (): Promise<void> => {
291
317
  const lines = new LineBuffer();
292
318
  const matches = (
@@ -351,7 +377,7 @@ export const openSession = (
351
377
  await routeEvent({
352
378
  kind: "identity",
353
379
  sessionId: announced,
354
- authority: h.identity.authority,
380
+ authority: "harness-minted",
355
381
  capabilities: capabilitiesOf(h, opts.model ?? "", "headless-session"),
356
382
  });
357
383
  }
@@ -361,7 +387,7 @@ export const openSession = (
361
387
  await routeEvent({
362
388
  kind: "identity",
363
389
  sessionId: announced,
364
- authority: h.identity.authority,
390
+ authority: "caller-assigned",
365
391
  capabilities: capabilitiesOf(h, opts.model ?? "", "headless-session"),
366
392
  });
367
393
  }
@@ -392,7 +418,7 @@ export const openSession = (
392
418
  // still track resultError here to classify the done cause.
393
419
  for (const event of events) {
394
420
  if (event.kind === "failure") await pushFailure(summaryOf(event));
395
- else await routeEvent(event);
421
+ else await routeDecoded(event);
396
422
  }
397
423
  if (parsed.is_error === true) resultError = true;
398
424
  endTurn({
@@ -404,7 +430,7 @@ export const openSession = (
404
430
  }
405
431
  for (const event of events) {
406
432
  if (event.kind === "failure") await pushFailure(summaryOf(event));
407
- else await routeEvent(event);
433
+ else await routeDecoded(event);
408
434
  }
409
435
  };
410
436
  for await (const chunk of proc.stdout) {
@@ -16,7 +16,11 @@ import {
16
16
  streamingGranularityOf,
17
17
  } from "../interpretation/argv.js";
18
18
  import { stdinPolicyOf } from "../interpretation/dimensions.js";
19
- import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
19
+ import {
20
+ detectAuthFailureInLine,
21
+ detectLimitInLine,
22
+ detectTransportInLine,
23
+ } from "../interpretation/limits.js";
20
24
  import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
21
25
  import { ArgvRefusalError } from "../interpretation/refusal.js";
22
26
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
@@ -32,6 +36,7 @@ import {
32
36
  failureFromNative,
33
37
  failureFromRejected,
34
38
  failureFromTask,
39
+ failureFromTerminalError,
35
40
  failureFromTimeout,
36
41
  failureFromTransport,
37
42
  reduceFailures,
@@ -312,6 +317,9 @@ export async function* streamTurn(
312
317
 
313
318
  const failures: FailureSummary[] = [];
314
319
  const pushFailure = async (f: FailureSummary): Promise<void> => {
320
+ // Suppress a failure identical in class and message to the previous one
321
+ const prev = failures[failures.length - 1];
322
+ if (prev !== undefined && prev.class === f.class && prev.message === f.message) return;
315
323
  failures.push(f);
316
324
  await queue.push({ kind: "failure", ...f });
317
325
  };
@@ -426,7 +434,7 @@ export async function* streamTurn(
426
434
  }
427
435
  if (event.kind === "error") {
428
436
  await queue.push(event);
429
- if (event.terminal === true) await pushFailure(failureFromTask(event.message));
437
+ if (event.terminal === true) await pushFailure(failureFromTerminalError(h, event.message));
430
438
  return;
431
439
  }
432
440
  if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
@@ -468,6 +476,7 @@ export async function* streamTurn(
468
476
  if (detection === null) return;
469
477
  if ("malformed" in detection) {
470
478
  await queue.push({ kind: "error", message: detection.malformed });
479
+ await pushFailure(failureFromTask(`malformed hcn-question block: ${detection.malformed}`));
471
480
  return;
472
481
  }
473
482
  log({
@@ -585,10 +594,13 @@ export async function* streamTurn(
585
594
  !state.limitSeen
586
595
  ) {
587
596
  const tailForNative = stderrTail.snapshot();
597
+ const transportLine = tailForNative.find((line) => detectTransportInLine(line));
588
598
  const f =
589
- tailForNative.length > 0
590
- ? failureFromNative(exitCode, tailForNative)
591
- : failureFromTransport(`nonzero exit ${exitCode}`);
599
+ transportLine !== undefined
600
+ ? failureFromTransport(transportLine)
601
+ : tailForNative.length > 0
602
+ ? failureFromNative(exitCode, tailForNative)
603
+ : failureFromTransport(`nonzero exit ${exitCode}`);
592
604
  failures.push(f);
593
605
  // Need to emit this failure before done, even though queue is closed
594
606
  yield { kind: "failure", ...f };
@@ -624,10 +636,10 @@ export async function* streamTurn(
624
636
  : "crash";
625
637
  const reduced = reduceFailures(failures);
626
638
  if (reduced && cause === "clean") cause = "failed";
627
- // A work-verdict failure (the model ran out of steps, or ended its
628
- // turn in error) is not a harness crash even when the process exits
629
- // nonzero: the cause is failed and the real exit code rides along.
630
- if ((reduced?.class === "budget" || reduced?.class === "task") && cause === "crash") {
639
+ // A classified failure other than native on a nonzero exit is a failed
640
+ // turn, not a crash: the failure taxonomy already captured the reason.
641
+ // Crash stays for unclassified exits and native failures.
642
+ if (reduced && reduced.class !== "native" && cause === "crash") {
631
643
  cause = "failed";
632
644
  }
633
645
  const tail = stderrTail.snapshot();
@@ -188,10 +188,13 @@ export interface SessionOptions {
188
188
 
189
189
  export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): string[] => {
190
190
  if (!h.sessionMode) {
191
+ const supported = Object.values(defaultDescriptors())
192
+ .filter((d): d is HarnessDescriptor => d !== undefined && d.sessionMode !== null)
193
+ .map((d) => d.name);
191
194
  throw new ArgvRefusalError({
192
195
  issue: "no-session-mode",
193
196
  harness: h.name,
194
- supported: ["session is available where sessionMode is declared"],
197
+ supported,
195
198
  });
196
199
  }
197
200
  refuseUnusableSessionId(h, opts.sessionId);
@@ -130,10 +130,14 @@ const pi = (r: Record<string, unknown>): ContentEvent[] => {
130
130
  // an expired minimax token: empty content, stopReason error, clean
131
131
  // exit). Without this the failure is invisible - a silent empty turn.
132
132
  if (message.stopReason === "error") {
133
+ const msg =
134
+ typeof message.errorMessage === "string"
135
+ ? `pi turn ended with stopReason error: ${message.errorMessage}`
136
+ : "pi turn ended with stopReason error (provider/auth failure)";
133
137
  return [
134
138
  {
135
139
  kind: "error",
136
- message: "pi turn ended with stopReason error (provider/auth failure)",
140
+ message: msg,
137
141
  terminal: true,
138
142
  },
139
143
  ];
@@ -23,7 +23,9 @@ import type {
23
23
  HarnessDescriptor,
24
24
  LimitCode,
25
25
  LimitMatcher,
26
+ TransportMatcher,
26
27
  } from "../knowledge/descriptor.js";
28
+ import { SHARED_TRANSPORT_MATCHERS } from "../knowledge/matchers.js";
27
29
 
28
30
  /** Bottom-up batch scans stop after this many non-empty lines: the wall is
29
31
  * virtually always the last thing a dying turn printed, and an unbounded
@@ -152,3 +154,30 @@ export const detectAuthFailureInLine = (
152
154
 
153
155
  export const detectAuthFailure = (h: HarnessDescriptor, output: string): AuthFailureKind | null =>
154
156
  scanTail(output, compileAuthMatchers(h.authMatchers));
157
+
158
+ const transportCache = new WeakMap<
159
+ ReadonlyArray<TransportMatcher>,
160
+ ReadonlyArray<readonly [RegExp, boolean]>
161
+ >();
162
+
163
+ const compileTransportMatchers = (
164
+ matchers: ReadonlyArray<TransportMatcher>,
165
+ ): ReadonlyArray<readonly [RegExp, boolean]> => {
166
+ const cached = transportCache.get(matchers);
167
+ if (cached !== undefined) return cached;
168
+ if (matchers.length > MAX_MATCHERS_PER_KIND) {
169
+ throw new Error(`more than ${MAX_MATCHERS_PER_KIND} matchers per harness per kind`);
170
+ }
171
+ const compiled = matchers.map((m) => [validateAndCompile(m.pattern, m.flags), true] as const);
172
+ transportCache.set(matchers, compiled);
173
+ return compiled;
174
+ };
175
+
176
+ export const detectTransportInLine = (line: string): boolean => {
177
+ const compiled = compileTransportMatchers(SHARED_TRANSPORT_MATCHERS);
178
+ const windowed = line.slice(0, WINDOW).trim();
179
+ for (const [re] of compiled) {
180
+ if (re.test(windowed)) return true;
181
+ }
182
+ return false;
183
+ };
@@ -107,6 +107,22 @@ export const renderToolSelection = (
107
107
 
108
108
  if (hasInclude) {
109
109
  const names = validateNames(h, selection.include!);
110
+ if (names.length === 0) {
111
+ // Empty include list = deny everything. On pi --tools "" has no
112
+ // defined meaning, so refuse; on claude emit the deny complement
113
+ // without an empty include token.
114
+ if (h.tools.includeIsStrictAllowlist || h.tools.excludeFlag === null) {
115
+ throw new ArgvRefusalError({
116
+ issue: "invalid-tool-grant",
117
+ harness: h.name,
118
+ option: "tools",
119
+ supported: ["non-empty tool list (empty include would deny everything)"],
120
+ detail: "empty include list",
121
+ });
122
+ }
123
+ const known = h.tools.builtins.map((t) => t.name);
124
+ return { tokens: [h.tools.excludeFlag, known.join(",")], unmapped: [] };
125
+ }
110
126
  const { mapped, unmapped } = resolveNames(h, names);
111
127
  if (!h.tools.includeIsStrictAllowlist) {
112
128
  if (mapped.length === 0 && unmapped.length > 0) {
@@ -7,7 +7,7 @@
7
7
  * precedence.
8
8
  */
9
9
 
10
- export type VersionStatus = "ok" | "behind" | "ahead" | "unknown";
10
+ export type VersionStatus = "ok" | "behind" | "ahead" | "unknown" | "drift";
11
11
 
12
12
  const parse = (v: string): number[] =>
13
13
  v
@@ -71,6 +71,11 @@ export interface AuthMatcher {
71
71
  readonly kind: AuthFailureKind;
72
72
  }
73
73
 
74
+ export interface TransportMatcher {
75
+ readonly pattern: string;
76
+ readonly flags?: string;
77
+ }
78
+
74
79
  /** The turn-option vocabulary is closed for the same reason `LimitCode` is:
75
80
  * a descriptor must not invent an option a consumer has no field for. Every
76
81
  * key here maps to a field on `TurnOptions`; adding a key without a consumer
@@ -12,7 +12,11 @@
12
12
  export { claudeCode } from "./claude-code.js";
13
13
  export { codexCli } from "./codex.js";
14
14
  export * from "./descriptor.js";
15
- export { SHARED_AUTH_MATCHERS, SHARED_LIMIT_MATCHERS } from "./matchers.js";
15
+ export {
16
+ SHARED_AUTH_MATCHERS,
17
+ SHARED_LIMIT_MATCHERS,
18
+ SHARED_TRANSPORT_MATCHERS,
19
+ } from "./matchers.js";
16
20
  export { museCode } from "./muse.js";
17
21
  export {
18
22
  type DescriptorSet,
@@ -4,7 +4,7 @@
4
4
  * or a provider 401 must classify no matter which CLI printed it. Each
5
5
  * descriptor spreads these after its harness-specific phrasings.
6
6
  */
7
- import type { AuthMatcher, LimitMatcher } from "./descriptor.js";
7
+ import type { AuthMatcher, LimitMatcher, TransportMatcher } from "./descriptor.js";
8
8
 
9
9
  export const SHARED_LIMIT_MATCHERS: ReadonlyArray<LimitMatcher> = [
10
10
  { pattern: "you'?ve hit your usage limit", flags: "i", code: "usage-limit" },
@@ -37,3 +37,19 @@ export const SHARED_AUTH_MATCHERS: ReadonlyArray<AuthMatcher> = [
37
37
  { pattern: "401 unauthorized", flags: "i", kind: "expired" },
38
38
  { pattern: "invalid api key", flags: "i", kind: "invalid-key" },
39
39
  ];
40
+
41
+ export const SHARED_TRANSPORT_MATCHERS: ReadonlyArray<TransportMatcher> = [
42
+ { pattern: "connection error", flags: "i" },
43
+ { pattern: "ECONNREFUSED", flags: "i" },
44
+ { pattern: "ECONNRESET", flags: "i" },
45
+ { pattern: "ENOTFOUND", flags: "i" },
46
+ { pattern: "EAI_AGAIN", flags: "i" },
47
+ { pattern: "ETIMEDOUT", flags: "i" },
48
+ { pattern: "fetch failed", flags: "i" },
49
+ { pattern: "socket hang up", flags: "i" },
50
+ { pattern: "network error", flags: "i" },
51
+ { pattern: "service unavailable", flags: "i" },
52
+ { pattern: "bad gateway", flags: "i" },
53
+ { pattern: "gateway time-?out", flags: "i" },
54
+ { pattern: "\\b(?:HTTP|status(?:[_ ]?code)?|code)\\b\\W*[:=]?\\W*50[234]\\b", flags: "i" },
55
+ ];
@@ -78,7 +78,10 @@ export const piCli: HarnessDescriptor = deepFreeze({
78
78
  announce: { match: { type: "session" }, idField: "id" },
79
79
  },
80
80
  limitMatchers: [...SHARED_LIMIT_MATCHERS],
81
- authMatchers: [...SHARED_AUTH_MATCHERS],
81
+ authMatchers: [
82
+ { pattern: "No API key found for", flags: "i", kind: "not-logged-in" },
83
+ ...SHARED_AUTH_MATCHERS,
84
+ ],
82
85
  autonomy: null,
83
86
  vocabulary: {
84
87
  modelFlag: "--model",