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

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 (99) hide show
  1. package/README.md +14 -5
  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/config.d.ts.map +1 -1
  6. package/dist/cli/config.js +4 -0
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/help.d.ts +1 -1
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +7 -0
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/cli/inspect.d.ts.map +1 -1
  13. package/dist/cli/inspect.js +57 -24
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/run.d.ts.map +1 -1
  16. package/dist/cli/run.js +125 -65
  17. package/dist/cli/run.js.map +1 -1
  18. package/dist/execution/decode.d.ts +3 -3
  19. package/dist/execution/decode.d.ts.map +1 -1
  20. package/dist/execution/decode.js +44 -12
  21. package/dist/execution/decode.js.map +1 -1
  22. package/dist/execution/deps.d.ts +3 -0
  23. package/dist/execution/deps.d.ts.map +1 -1
  24. package/dist/execution/events.d.ts +7 -2
  25. package/dist/execution/events.d.ts.map +1 -1
  26. package/dist/execution/events.js.map +1 -1
  27. package/dist/execution/node-deps.d.ts.map +1 -1
  28. package/dist/execution/node-deps.js +1 -0
  29. package/dist/execution/node-deps.js.map +1 -1
  30. package/dist/execution/open-session.d.ts.map +1 -1
  31. package/dist/execution/open-session.js +36 -5
  32. package/dist/execution/open-session.js.map +1 -1
  33. package/dist/execution/stream-turn.d.ts +4 -0
  34. package/dist/execution/stream-turn.d.ts.map +1 -1
  35. package/dist/execution/stream-turn.js +135 -29
  36. package/dist/execution/stream-turn.js.map +1 -1
  37. package/dist/interpretation/argv.d.ts +7 -0
  38. package/dist/interpretation/argv.d.ts.map +1 -1
  39. package/dist/interpretation/argv.js +24 -4
  40. package/dist/interpretation/argv.js.map +1 -1
  41. package/dist/interpretation/capabilities.d.ts.map +1 -1
  42. package/dist/interpretation/capabilities.js +4 -1
  43. package/dist/interpretation/capabilities.js.map +1 -1
  44. package/dist/interpretation/content.d.ts +9 -1
  45. package/dist/interpretation/content.d.ts.map +1 -1
  46. package/dist/interpretation/content.js +13 -4
  47. package/dist/interpretation/content.js.map +1 -1
  48. package/dist/interpretation/hints.d.ts.map +1 -1
  49. package/dist/interpretation/hints.js +5 -0
  50. package/dist/interpretation/hints.js.map +1 -1
  51. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  52. package/dist/interpretation/resolve-options.js +16 -0
  53. package/dist/interpretation/resolve-options.js.map +1 -1
  54. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  55. package/dist/interpretation/tool-selection.js +9 -0
  56. package/dist/interpretation/tool-selection.js.map +1 -1
  57. package/dist/interpretation/turn-options.d.ts.map +1 -1
  58. package/dist/interpretation/turn-options.js +35 -3
  59. package/dist/interpretation/turn-options.js.map +1 -1
  60. package/dist/knowledge/claude-code.d.ts.map +1 -1
  61. package/dist/knowledge/claude-code.js +21 -0
  62. package/dist/knowledge/claude-code.js.map +1 -1
  63. package/dist/knowledge/codex.d.ts.map +1 -1
  64. package/dist/knowledge/codex.js +9 -0
  65. package/dist/knowledge/codex.js.map +1 -1
  66. package/dist/knowledge/descriptor.d.ts +21 -10
  67. package/dist/knowledge/descriptor.d.ts.map +1 -1
  68. package/dist/knowledge/descriptor.js +6 -0
  69. package/dist/knowledge/descriptor.js.map +1 -1
  70. package/dist/knowledge/matchers.d.ts.map +1 -1
  71. package/dist/knowledge/matchers.js +8 -1
  72. package/dist/knowledge/matchers.js.map +1 -1
  73. package/dist/knowledge/pi.d.ts.map +1 -1
  74. package/dist/knowledge/pi.js +20 -0
  75. package/dist/knowledge/pi.js.map +1 -1
  76. package/package.json +1 -1
  77. package/src/cli/args.ts +7 -0
  78. package/src/cli/config.ts +4 -0
  79. package/src/cli/help.ts +7 -0
  80. package/src/cli/inspect.ts +54 -22
  81. package/src/cli/run.ts +166 -67
  82. package/src/execution/decode.ts +44 -11
  83. package/src/execution/deps.ts +3 -0
  84. package/src/execution/events.ts +5 -2
  85. package/src/execution/node-deps.ts +1 -0
  86. package/src/execution/open-session.ts +34 -3
  87. package/src/execution/stream-turn.ts +134 -27
  88. package/src/interpretation/argv.ts +30 -4
  89. package/src/interpretation/capabilities.ts +4 -1
  90. package/src/interpretation/content.ts +18 -5
  91. package/src/interpretation/hints.ts +8 -0
  92. package/src/interpretation/resolve-options.ts +17 -0
  93. package/src/interpretation/tool-selection.ts +9 -0
  94. package/src/interpretation/turn-options.ts +31 -3
  95. package/src/knowledge/claude-code.ts +21 -0
  96. package/src/knowledge/codex.ts +9 -0
  97. package/src/knowledge/descriptor.ts +28 -10
  98. package/src/knowledge/matchers.ts +8 -1
  99. package/src/knowledge/pi.ts +20 -0
package/src/cli/run.ts CHANGED
@@ -1,16 +1,72 @@
1
+ import { existsSync } from "node:fs";
1
2
  import type { HarnessEvent } from "../execution/events.js";
3
+ import { failureFromRejected } from "../execution/failure.js";
2
4
  import { nodeRunnerDeps } from "../execution/node-deps.js";
3
5
  import { KILL_GRACE_MS, redactArgv, streamTurn } from "../execution/stream-turn.js";
4
6
  import { buildLaunchArgv, buildResumeArgv } from "../interpretation/argv.js";
5
7
  import { composeEscalatedPrompt } from "../interpretation/question.js";
8
+ import type { RefusalIssue } from "../interpretation/refusal.js";
6
9
  import { ArgvRefusalError } from "../interpretation/refusal.js";
7
10
  import { FloorExceededError, resolveEffectiveOptions } from "../interpretation/resolve-options.js";
11
+ import { storePath } from "../interpretation/store.js";
8
12
  import { recognizeNativeSpelling, supportedBy } from "../interpretation/support.js";
9
13
  import { defaultDescriptors } from "../knowledge/overrides.js";
10
14
  import { parseRunExtra, parseTurnOptions, resolvePromptAsync } from "./args.js";
11
15
  import { createRenderState, renderEvent, writeEventNdjson } from "./render.js";
12
16
  import { resolveHarness } from "./resolve-harness.js";
13
17
 
18
+ /** One shape for every hcn-side refusal. Stderr keeps the prose (message,
19
+ * hint, support lists, usage pointer); under --json the same fields also
20
+ * ride stdout as a `failure` event plus a `done`, so a stream reader never
21
+ * meets an empty stream on exit 2. */
22
+ interface Refusal {
23
+ readonly message: string;
24
+ readonly issue: RefusalIssue;
25
+ readonly option?: ArgvRefusalError["option"];
26
+ readonly facet?: ArgvRefusalError["facet"];
27
+ readonly supported?: readonly string[];
28
+ readonly supportedBy?: ArgvRefusalError["supportedBy"];
29
+ readonly hint?: string;
30
+ /** Plain stderr lines written after the structured ones. */
31
+ readonly trailer?: readonly string[];
32
+ }
33
+
34
+ const refusalOf = (err: ArgvRefusalError): Refusal => ({
35
+ message: err.message,
36
+ issue: err.issue,
37
+ option: err.option,
38
+ facet: err.facet,
39
+ supported: err.supported,
40
+ supportedBy: err.supportedBy,
41
+ hint: err.hint,
42
+ });
43
+
44
+ const refuse = (r: Refusal, json: boolean): void => {
45
+ process.stderr.write(`${r.message}\n`);
46
+ if (r.hint) process.stderr.write(`hint: ${r.hint}\n`);
47
+ if (r.supportedBy?.length) {
48
+ process.stderr.write(
49
+ `supported on: ${r.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
50
+ );
51
+ }
52
+ if (r.supported?.length) process.stderr.write(`supported: ${r.supported.join(", ")}\n`);
53
+ for (const line of r.trailer ?? []) process.stderr.write(`${line}\n`);
54
+ if (json) {
55
+ const summary = failureFromRejected({
56
+ issue: r.issue,
57
+ option: r.option,
58
+ facet: r.facet,
59
+ supported: r.supported,
60
+ supportedBy: r.supportedBy,
61
+ hint: r.hint,
62
+ detail: r.message,
63
+ });
64
+ writeEventNdjson({ kind: "failure", ...summary });
65
+ writeEventNdjson({ kind: "done", exitCode: null, cause: "failed", failure: summary });
66
+ }
67
+ process.exitCode = 2;
68
+ };
69
+
14
70
  export const run = async (harnessName: string, rawArgs: string[]): Promise<void> => {
15
71
  const h = resolveHarness(harnessName);
16
72
 
@@ -23,7 +79,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
23
79
  const { parseCommonFlags, detectPositionalPromptInjection, splitPassthrough } = await import(
24
80
  "./args.js"
25
81
  );
26
- const { passthrough } = splitPassthrough(rawArgs);
82
+ const { normalized, passthrough } = splitPassthrough(rawArgs);
83
+ // Decided before any refusal can fire: a refused --json run still owes
84
+ // the stream a failure and a done.
85
+ const wantJson = normalized.includes("--json");
27
86
  const injection = detectPositionalPromptInjection(rawArgs);
28
87
  if (injection) {
29
88
  const err = new ArgvRefusalError({
@@ -32,9 +91,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
32
91
  supported: ["prompt must not start with '-'"],
33
92
  detail: `it would be parsed as a flag by ${h.bin}`,
34
93
  });
35
- process.stderr.write(`${err.message}\n`);
36
- process.stderr.write(`supported: prompt must not start with '-'\n`);
37
- process.exitCode = 2;
94
+ refuse(refusalOf(err), wantJson);
38
95
  return;
39
96
  }
40
97
  let parsed: ReturnType<typeof parseCommonFlags>;
@@ -77,19 +134,25 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
77
134
  : native.option.startsWith("discovery.")
78
135
  ? `--no-${native.option.split(".")[1] === "instructionFiles" ? "instruction-files" : native.option.split(".")[1]}`
79
136
  : `--${native.option}`;
80
- process.stderr.write(
81
- `unknown flag: ${rawFlag} is a native spelling (used by ${native.entries.map((e) => e.harness).join(", ")}) - use the normalized ${normalizedSpelling} flag instead\n`,
137
+ refuse(
138
+ {
139
+ message: `unknown flag: ${rawFlag} is a native spelling (used by ${native.entries.map((e) => e.harness).join(", ")}) - use the normalized ${normalizedSpelling} flag instead`,
140
+ issue: "invalid-option-value",
141
+ supportedBy: by,
142
+ trailer: ["Run 'hcn run --help' for usage."],
143
+ },
144
+ wantJson,
82
145
  );
83
- if (by.length > 0) {
84
- process.stderr.write(
85
- `supported on: ${by.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
86
- );
87
- }
88
- } else {
89
- process.stderr.write(`unknown flag: ${message}\n`);
146
+ return;
90
147
  }
91
- process.stderr.write(`Run 'hcn run --help' for usage.\n`);
92
- process.exitCode = 2;
148
+ refuse(
149
+ {
150
+ message: `unknown flag: ${message}`,
151
+ issue: "invalid-option-value",
152
+ trailer: ["Run 'hcn run --help' for usage."],
153
+ },
154
+ wantJson,
155
+ );
93
156
  return;
94
157
  }
95
158
 
@@ -98,18 +161,26 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
98
161
  // positionals may contain prompt if not using flag; harness already consumed so first positional is prompt
99
162
  const positionalPrompt = positionals.length > 0 ? positionals[0] : undefined;
100
163
  if (positionals.length > 1) {
101
- process.stderr.write(`too many positionals for run; expected one prompt\n`);
102
- process.exitCode = 2;
164
+ refuse(
165
+ {
166
+ message: "too many positionals for run; expected one prompt",
167
+ issue: "invalid-option-value",
168
+ },
169
+ wantJson,
170
+ );
103
171
  return;
104
172
  }
105
173
  if (passthrough.length === 0 && rawArgs.includes("--")) {
106
- process.stderr.write(`-- separator given but no passthrough tokens followed it\n`);
107
- process.exitCode = 2;
174
+ refuse(
175
+ {
176
+ message: "-- separator given but no passthrough tokens followed it",
177
+ issue: "invalid-option-value",
178
+ },
179
+ wantJson,
180
+ );
108
181
  return;
109
182
  }
110
183
 
111
- const wantJson = values.json === true;
112
-
113
184
  // Resolve prompt (async for --prompt-file -)
114
185
  let prompt: string;
115
186
  let promptSource: string;
@@ -123,20 +194,14 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
123
194
  promptSource = resolved.source;
124
195
  } catch (err) {
125
196
  if (err instanceof ArgvRefusalError) {
126
- process.stderr.write(`${err.message}\n`);
127
- if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
128
- if (err.supportedBy?.length) {
129
- process.stderr.write(
130
- `supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
131
- );
132
- }
133
- if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
134
- process.exitCode = 2;
197
+ refuse(refusalOf(err), wantJson);
135
198
  return;
136
199
  }
137
200
  if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") {
138
- process.stderr.write(`prompt file not found: ${(err as Error).message}\n`);
139
- process.exitCode = 2;
201
+ refuse(
202
+ { message: `prompt file not found: ${err.message}`, issue: "invalid-option-value" },
203
+ wantJson,
204
+ );
140
205
  return;
141
206
  }
142
207
  throw err;
@@ -148,15 +213,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
148
213
  turnOpts = parseTurnOptions(values);
149
214
  } catch (err) {
150
215
  if (err instanceof ArgvRefusalError) {
151
- process.stderr.write(`${err.message}\n`);
152
- if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
153
- if (err.supportedBy?.length) {
154
- process.stderr.write(
155
- `supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
156
- );
157
- }
158
- if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
159
- process.exitCode = 2;
216
+ refuse(refusalOf(err), wantJson);
160
217
  return;
161
218
  }
162
219
  throw err;
@@ -167,15 +224,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
167
224
  extra = parseRunExtra(values);
168
225
  } catch (err) {
169
226
  if (err instanceof ArgvRefusalError) {
170
- process.stderr.write(`${err.message}\n`);
171
- if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
172
- if (err.supportedBy?.length) {
173
- process.stderr.write(
174
- `supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
175
- );
176
- }
177
- if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
178
- process.exitCode = 2;
227
+ refuse(refusalOf(err), wantJson);
179
228
  return;
180
229
  }
181
230
  throw err;
@@ -199,9 +248,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
199
248
  (turnOpts as unknown as Record<string, unknown>).__claudeSkillTokens = claudeTokens;
200
249
  } catch (err) {
201
250
  if (err instanceof ArgvRefusalError) {
202
- process.stderr.write(`${err.message}\n`);
203
- if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
204
- process.exitCode = 2;
251
+ refuse(refusalOf(err), wantJson);
205
252
  return;
206
253
  }
207
254
  throw err;
@@ -230,8 +277,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
230
277
  if (proj !== null) tiers.project = proj.config;
231
278
  } catch (configErr) {
232
279
  if (configErr instanceof ConfigError) {
233
- process.stderr.write(`config error: ${(configErr as Error).message}\n`);
234
- process.exitCode = 2;
280
+ refuse(
281
+ { message: `config error: ${configErr.message}`, issue: "invalid-option-value" },
282
+ wantJson,
283
+ );
235
284
  return;
236
285
  }
237
286
  throw configErr;
@@ -243,8 +292,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
243
292
  resolved = resolveEffectiveOptions(h, { ...turnOpts, prompt } as never, tiers);
244
293
  } catch (resErr) {
245
294
  if (resErr instanceof FloorExceededError) {
246
- process.stderr.write(`${(resErr as Error).message}\n`);
247
- process.exitCode = 2;
295
+ refuse({ message: resErr.message, issue: "invalid-tool-grant" }, wantJson);
248
296
  return;
249
297
  }
250
298
  throw resErr;
@@ -265,6 +313,18 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
265
313
  `divergence: profile ${JSON.stringify(key)} not expressible on ${h.name}; harness default applies\n`,
266
314
  );
267
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
+ }
268
328
  }
269
329
  }
270
330
 
@@ -342,15 +402,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
342
402
  _validated = true;
343
403
  } catch (err) {
344
404
  if (err instanceof ArgvRefusalError) {
345
- process.stderr.write(`${err.message}\n`);
346
- if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
347
- if (err.supportedBy?.length) {
348
- process.stderr.write(
349
- `supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
350
- );
351
- }
352
- if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
353
- process.exitCode = 2;
405
+ refuse(refusalOf(err), wantJson);
354
406
  return;
355
407
  }
356
408
  throw err;
@@ -372,6 +424,51 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
372
424
  process.stderr.write(
373
425
  `provenance: escalateQuestions = ${escalateQuestions} (${escalateTier})\n`,
374
426
  );
427
+ // On a harness whose include flag is not a strict allowlist (claude),
428
+ // a name outside the curated set passes through ungated; say which
429
+ // ones so a wrong-case name is visible. A grant with no known name at
430
+ // all refuses inside buildLaunchArgv below, so a throw here is left
431
+ // to that path.
432
+ const grant = (fullOpts.resume ? turnOpts : effectiveTurnOpts).tools;
433
+ if (grant !== undefined && grant.length > 0 && !h.tools.includeIsStrictAllowlist) {
434
+ try {
435
+ const { renderToolSelection } = await import("../interpretation/tool-selection.js");
436
+ const { unmapped } = renderToolSelection(h, { include: [...grant] });
437
+ if (unmapped.length > 0) {
438
+ process.stderr.write(`provenance: unmapped tools = ${JSON.stringify(unmapped)}\n`);
439
+ }
440
+ } catch {
441
+ // refused below with the structured message
442
+ }
443
+ }
444
+ }
445
+
446
+ // A harness that creates a session when the id is unknown (pi, muse)
447
+ // would turn a stale --resume into a silent blank session. Refuse when
448
+ // the session store path does not exist; where the path cannot be
449
+ // computed, the runner's pre-spawn warning is the only guard.
450
+ if (fullOpts.resume !== undefined && h.resume.onMissing === "create") {
451
+ let path: string | null = null;
452
+ try {
453
+ path = storePath(h, {
454
+ home: process.env.HOME ?? process.env.USERPROFILE ?? "",
455
+ cwd: extra.cwd ?? process.cwd(),
456
+ sessionId: fullOpts.resume,
457
+ });
458
+ } catch {
459
+ path = null;
460
+ }
461
+ if (path !== null && !existsSync(path)) {
462
+ refuse(
463
+ {
464
+ message: `no ${h.name} session ${fullOpts.resume} found at ${path}`,
465
+ issue: "invalid-option-value",
466
+ supported: [`a session id that exists in ${h.name}'s store`],
467
+ },
468
+ wantJson,
469
+ );
470
+ return;
471
+ }
375
472
  }
376
473
 
377
474
  // Delete HERDR_ENV before spawn
@@ -410,10 +507,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
410
507
  },
411
508
  };
412
509
 
510
+ const abortController = new AbortController();
413
511
  let interrupted = false;
414
512
  const onSig = async () => {
415
513
  if (interrupted) return;
416
514
  interrupted = true;
515
+ abortController.abort();
417
516
  if (lastProc) {
418
517
  try {
419
518
  wrappedDeps.signal(lastProc, "SIGTERM");
@@ -439,7 +538,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
439
538
 
440
539
  try {
441
540
  // streamTurn handles both launch and resume via TurnRunOptions
442
- const events = streamTurn(h, fullOpts, wrappedDeps);
541
+ const events = streamTurn(h, { ...fullOpts, signal: abortController.signal }, wrappedDeps);
443
542
  for await (const event of events) {
444
543
  lastEvent = event;
445
544
  if (wantJson) {
@@ -4,12 +4,13 @@
4
4
  * caller threads (identity dedupe per D-022). Unparseable lines on a
5
5
  * structured stream are tolerated - scanned for walls, never fatal.
6
6
  */
7
- import { capabilitiesOf } from "../interpretation/capabilities.js";
7
+ import { type CapabilityResult, capabilitiesOf } from "../interpretation/capabilities.js";
8
8
  import { contentEventsOf } from "../interpretation/content.js";
9
9
  import { decodeIdentity } from "../interpretation/identity.js";
10
10
  import { detectLimitInLine } from "../interpretation/limits.js";
11
- import type { HarnessDescriptor } from "../knowledge/descriptor.js";
11
+ import type { HarnessDescriptor, StreamingGranularity } from "../knowledge/descriptor.js";
12
12
  import type { HarnessEvent } from "./events.js";
13
+ import { failureFromBudget } from "./failure.js";
13
14
 
14
15
  export interface DecodeState {
15
16
  lastSeenId: string | null;
@@ -30,6 +31,7 @@ export const decodeLine = (
30
31
  line: string,
31
32
  state: DecodeState,
32
33
  model: string,
34
+ streaming?: StreamingGranularity,
33
35
  ): HarnessEvent[] => {
34
36
  let raw: unknown;
35
37
  try {
@@ -43,7 +45,7 @@ export const decodeLine = (
43
45
  }
44
46
  return [];
45
47
  }
46
- return decodeParsed(h, raw, state, model);
48
+ return decodeParsed(h, raw, state, model, streaming);
47
49
  };
48
50
 
49
51
  /** The parsed-record half of decodeLine, for pumps that already parsed the
@@ -54,8 +56,16 @@ export const decodeParsed = (
54
56
  raw: unknown,
55
57
  state: DecodeState,
56
58
  model: string,
59
+ streaming?: StreamingGranularity,
57
60
  ): HarnessEvent[] => {
58
61
  const events: HarnessEvent[] = [];
62
+ // Streaming is a property of the spawned argv, not of the model, so the
63
+ // runner passes what streamingGranularityOf computed for it; the curated
64
+ // baseline supplies the rest. An unknown model stays unknown throughout.
65
+ const capabilities = (): CapabilityResult => {
66
+ const base = capabilitiesOf(h, model, "headless-turn");
67
+ return streaming !== undefined && base.source !== "unknown" ? { ...base, streaming } : base;
68
+ };
59
69
  const decoded = decodeIdentity(h, raw, state.lastSeenId, state.requestedId);
60
70
  if (decoded.sessionId !== null) state.lastSeenId = decoded.sessionId;
61
71
  if (decoded.identity !== null) {
@@ -63,18 +73,41 @@ export const decodeParsed = (
63
73
  kind: "identity",
64
74
  sessionId: decoded.identity,
65
75
  authority: h.identity.authority,
66
- capabilities: capabilitiesOf(h, model, "headless-turn"),
76
+ capabilities: capabilities(),
67
77
  });
68
78
  } else if (decoded.outcome === "malformed" || decoded.outcome === "rotated") {
69
- // The interpretation layer classified an identity anomaly; swallowing
70
- // it would leave the runner waiting for an identity that already
71
- // failed to arrive (or bind a rotated one).
72
- events.push({ kind: "error", message: `identity ${decoded.outcome}` });
79
+ if (decoded.outcome === "rotated") {
80
+ const requested = state.requestedId ?? "unknown";
81
+ const announced = decoded.sessionId ?? "unknown";
82
+ events.push({
83
+ kind: "error",
84
+ message: `identity rotated: requested ${requested} but announced ${announced}`,
85
+ });
86
+ // The harness answered under a different id: hand the consumer the
87
+ // id it can actually resume, marked as minted by the harness.
88
+ if (decoded.sessionId !== null) {
89
+ events.push({
90
+ kind: "identity",
91
+ sessionId: decoded.sessionId,
92
+ authority: "harness-minted",
93
+ capabilities: capabilities(),
94
+ });
95
+ }
96
+ } else {
97
+ events.push({ kind: "error", message: `identity ${decoded.outcome}` });
98
+ }
73
99
  }
74
100
 
75
- // Content (message/token/tool/error) is per-harness; identity above is
76
- // descriptor-driven. contentEventsOf dispatches by harness name.
77
- for (const content of contentEventsOf(h.name, raw)) events.push(content);
101
+ // Content (message/token/tool/error/budget) is per-harness; identity
102
+ // above is descriptor-driven. contentEventsOf dispatches by harness
103
+ // name. budget is not a HarnessEvent kind and must not leak out.
104
+ for (const content of contentEventsOf(h.name, raw)) {
105
+ if (content.kind === "budget") {
106
+ events.push({ kind: "failure", ...failureFromBudget(content.detail) });
107
+ } else {
108
+ events.push(content);
109
+ }
110
+ }
78
111
 
79
112
  // claude's rate_limit_event: only non-"allowed" statuses are failures.
80
113
  // overageStatus is deliberately not classified - it is a separate
@@ -16,6 +16,9 @@ export interface SpawnedProcess {
16
16
  /** Resolves with the exit code, or null when the process died to a
17
17
  * signal without one. */
18
18
  readonly exited: Promise<number | null>;
19
+ /** After `exited` settles, the spawn-time error message when the child
20
+ * never started (async ENOENT and the like), else null. */
21
+ readonly startupError?: () => string | null;
19
22
  /** Present when the spawner opened a writable stdin (session mode needs
20
23
  * it; one-shot turns close or inherit stdin instead). */
21
24
  readonly stdin?: {
@@ -15,6 +15,7 @@
15
15
  * concatenate both.
16
16
  */
17
17
  import type { CapabilityResult } from "../interpretation/capabilities.js";
18
+ import type { LimitCode } from "../knowledge/descriptor.js";
18
19
  import type { FailureSummary } from "./failure.js";
19
20
 
20
21
  export type ExitCause =
@@ -49,8 +50,10 @@ export type HarnessEvent =
49
50
  readonly options: readonly string[];
50
51
  readonly recommended?: string;
51
52
  }
52
- | { readonly kind: "limit"; readonly code: string; readonly message: string }
53
- | { readonly kind: "error"; readonly message: string }
53
+ | { readonly kind: "limit"; readonly code: LimitCode; readonly message: string }
54
+ /** `terminal: true` marks an error that ended the turn; the matching
55
+ * `failure` (class task) follows it. */
56
+ | { readonly kind: "error"; readonly message: string; readonly terminal?: boolean }
54
57
  | ({ readonly kind: "failure" } & FailureSummary)
55
58
  | {
56
59
  readonly kind: "done";
@@ -157,6 +157,7 @@ const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess
157
157
  stdout: stdoutOutput === null ? emptyStream() : stdoutOutput.stream,
158
158
  stderr: stderrWithError(),
159
159
  exited,
160
+ startupError: () => spawnError?.message ?? null,
160
161
  disposeOutput: (): void => {
161
162
  if (outputDisposed) return;
162
163
  outputDisposed = true;
@@ -27,6 +27,8 @@ import { AsyncChannel } from "./channel.js";
27
27
  import { decodeParsed, freshDecodeState } from "./decode.js";
28
28
  import type { RunnerDeps, SpawnedProcess } from "./deps.js";
29
29
  import type { ExitCause, HarnessEvent } from "./events.js";
30
+ import type { FailureSummary } from "./failure.js";
31
+ import { failureFromAuth, failureFromLimit, reduceFailures } from "./failure.js";
30
32
  import { LineBuffer } from "./lines.js";
31
33
  import { KILL_GRACE_MS, PIPE_GRACE_MS, redactArgv, StderrTail } from "./stream-turn.js";
32
34
 
@@ -145,6 +147,7 @@ export const openSession = (
145
147
  let exitCode: number | null = null;
146
148
  let resultError = false;
147
149
  let turnLimitSeen = false;
150
+ let turnFailures: FailureSummary[] = [];
148
151
  let pumpError: unknown = null;
149
152
  // issue #44: the active turn's last assistant message (where the
150
153
  // hcn-question block lives) and whether the turn ended by asking.
@@ -175,14 +178,24 @@ export const openSession = (
175
178
  }
176
179
  };
177
180
 
181
+ /** A decoded failure event minus its kind: what done.failure carries. */
182
+ const summaryOf = (event: HarnessEvent & { kind: "failure" }): FailureSummary => {
183
+ const { kind: _kind, ...summary } = event;
184
+ return summary;
185
+ };
186
+
178
187
  const startTurn = (): void => {
179
188
  turnLimitSeen = false;
189
+ turnFailures = [];
180
190
  turnAsked = false;
181
191
  lastAssistantText = null;
182
192
  activeTurn = new AsyncChannel<HarnessEvent>();
183
193
  activeTurnId = `${opts.sessionId}:turn-${++turnCounter}`;
184
194
  log({ event: "turn_start", sessionId: opts.sessionId, turnId: activeTurnId });
185
- for (const held of preTurnEvents.splice(0)) activeTurn.push(held);
195
+ for (const held of preTurnEvents.splice(0)) {
196
+ if (held.kind === "failure") turnFailures.push(summaryOf(held));
197
+ activeTurn.push(held);
198
+ }
186
199
  turnsChannel.push(activeTurn);
187
200
  };
188
201
 
@@ -224,6 +237,10 @@ export const openSession = (
224
237
  // in sessions) and the caller answers with the next send().
225
238
  emitQuestionIfAsked();
226
239
  if (turnAsked && done.cause === "clean") done = { ...done, cause: "awaiting-input" };
240
+ // Every failure was already emitted as an event through pushFailure;
241
+ // the turn's done carries the reduced summary, as streamTurn's does.
242
+ const reduced = reduceFailures(turnFailures);
243
+ if (reduced !== undefined) done = { ...done, failure: reduced };
227
244
  activeTurn.push(done);
228
245
  activeTurn.close();
229
246
  log({
@@ -265,6 +282,11 @@ export const openSession = (
265
282
  return Promise.resolve();
266
283
  };
267
284
 
285
+ const pushFailure = (f: FailureSummary): Promise<void> => {
286
+ turnFailures.push(f);
287
+ return routeEvent({ kind: "failure", ...f });
288
+ };
289
+
268
290
  const pumpStdout = async (): Promise<void> => {
269
291
  const lines = new LineBuffer();
270
292
  const matches = (
@@ -297,6 +319,7 @@ export const openSession = (
297
319
  const code = detectLimitInLine(h, line);
298
320
  if (code !== null) {
299
321
  await routeEvent({ kind: "limit", code, message: `limit wall detected (${code})` });
322
+ await pushFailure(failureFromLimit(code));
300
323
  }
301
324
  return;
302
325
  }
@@ -367,7 +390,10 @@ export const openSession = (
367
390
  // decodeParsed already surfaces the is_error case as an error event
368
391
  // (content.ts claude reader); routing the events is enough - we only
369
392
  // still track resultError here to classify the done cause.
370
- for (const event of events) await routeEvent(event);
393
+ for (const event of events) {
394
+ if (event.kind === "failure") await pushFailure(summaryOf(event));
395
+ else await routeEvent(event);
396
+ }
371
397
  if (parsed.is_error === true) resultError = true;
372
398
  endTurn({
373
399
  kind: "done",
@@ -376,7 +402,10 @@ export const openSession = (
376
402
  });
377
403
  return;
378
404
  }
379
- for (const event of events) await routeEvent(event);
405
+ for (const event of events) {
406
+ if (event.kind === "failure") await pushFailure(summaryOf(event));
407
+ else await routeEvent(event);
408
+ }
380
409
  };
381
410
  for await (const chunk of proc.stdout) {
382
411
  for (const line of lines.push(chunk)) await handleLine(line);
@@ -396,10 +425,12 @@ export const openSession = (
396
425
  code: limit,
397
426
  message: `limit wall detected (${limit})`,
398
427
  });
428
+ await pushFailure(failureFromLimit(limit));
399
429
  continue;
400
430
  }
401
431
  const auth = detectAuthFailureInLine(h, line);
402
432
  if (auth !== null) {
433
+ await pushFailure(failureFromAuth(auth));
403
434
  await routeEvent({ kind: "error", message: `auth wall: ${auth}` });
404
435
  continue;
405
436
  }