@dungle-scrubs/harness-cli-normalizer 0.4.4 → 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 (136) hide show
  1. package/README.md +23 -8
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +16 -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 +10 -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 +74 -24
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/provenance.d.ts +3 -0
  16. package/dist/cli/provenance.d.ts.map +1 -0
  17. package/dist/cli/provenance.js +11 -0
  18. package/dist/cli/provenance.js.map +1 -0
  19. package/dist/cli/run.d.ts.map +1 -1
  20. package/dist/cli/run.js +135 -85
  21. package/dist/cli/run.js.map +1 -1
  22. package/dist/cli/session.d.ts.map +1 -1
  23. package/dist/cli/session.js +3 -3
  24. package/dist/cli/session.js.map +1 -1
  25. package/dist/cli/skills-root.d.ts +2 -1
  26. package/dist/cli/skills-root.d.ts.map +1 -1
  27. package/dist/cli/skills-root.js +6 -4
  28. package/dist/cli/skills-root.js.map +1 -1
  29. package/dist/execution/decode.d.ts +3 -3
  30. package/dist/execution/decode.d.ts.map +1 -1
  31. package/dist/execution/decode.js +46 -13
  32. package/dist/execution/decode.js.map +1 -1
  33. package/dist/execution/deps.d.ts +4 -1
  34. package/dist/execution/deps.d.ts.map +1 -1
  35. package/dist/execution/deps.js +1 -1
  36. package/dist/execution/events.d.ts +7 -2
  37. package/dist/execution/events.d.ts.map +1 -1
  38. package/dist/execution/events.js.map +1 -1
  39. package/dist/execution/failure.d.ts +5 -1
  40. package/dist/execution/failure.d.ts.map +1 -1
  41. package/dist/execution/failure.js +12 -0
  42. package/dist/execution/failure.js.map +1 -1
  43. package/dist/execution/index.d.ts +2 -2
  44. package/dist/execution/index.js +2 -2
  45. package/dist/execution/node-deps.d.ts.map +1 -1
  46. package/dist/execution/node-deps.js +1 -0
  47. package/dist/execution/node-deps.js.map +1 -1
  48. package/dist/execution/open-session.d.ts.map +1 -1
  49. package/dist/execution/open-session.js +54 -8
  50. package/dist/execution/open-session.js.map +1 -1
  51. package/dist/execution/stream-turn.d.ts +4 -0
  52. package/dist/execution/stream-turn.d.ts.map +1 -1
  53. package/dist/execution/stream-turn.js +147 -33
  54. package/dist/execution/stream-turn.js.map +1 -1
  55. package/dist/interpretation/argv.d.ts +7 -0
  56. package/dist/interpretation/argv.d.ts.map +1 -1
  57. package/dist/interpretation/argv.js +28 -5
  58. package/dist/interpretation/argv.js.map +1 -1
  59. package/dist/interpretation/capabilities.d.ts.map +1 -1
  60. package/dist/interpretation/capabilities.js +4 -1
  61. package/dist/interpretation/capabilities.js.map +1 -1
  62. package/dist/interpretation/content.d.ts +9 -1
  63. package/dist/interpretation/content.d.ts.map +1 -1
  64. package/dist/interpretation/content.js +16 -4
  65. package/dist/interpretation/content.js.map +1 -1
  66. package/dist/interpretation/hints.d.ts.map +1 -1
  67. package/dist/interpretation/hints.js +5 -0
  68. package/dist/interpretation/hints.js.map +1 -1
  69. package/dist/interpretation/limits.d.ts +1 -0
  70. package/dist/interpretation/limits.d.ts.map +1 -1
  71. package/dist/interpretation/limits.js +22 -0
  72. package/dist/interpretation/limits.js.map +1 -1
  73. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  74. package/dist/interpretation/resolve-options.js +16 -0
  75. package/dist/interpretation/resolve-options.js.map +1 -1
  76. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  77. package/dist/interpretation/tool-selection.js +25 -0
  78. package/dist/interpretation/tool-selection.js.map +1 -1
  79. package/dist/interpretation/turn-options.d.ts.map +1 -1
  80. package/dist/interpretation/turn-options.js +35 -3
  81. package/dist/interpretation/turn-options.js.map +1 -1
  82. package/dist/interpretation/versions.d.ts +1 -1
  83. package/dist/interpretation/versions.d.ts.map +1 -1
  84. package/dist/knowledge/claude-code.d.ts.map +1 -1
  85. package/dist/knowledge/claude-code.js +21 -0
  86. package/dist/knowledge/claude-code.js.map +1 -1
  87. package/dist/knowledge/codex.d.ts.map +1 -1
  88. package/dist/knowledge/codex.js +9 -0
  89. package/dist/knowledge/codex.js.map +1 -1
  90. package/dist/knowledge/descriptor.d.ts +25 -10
  91. package/dist/knowledge/descriptor.d.ts.map +1 -1
  92. package/dist/knowledge/descriptor.js +6 -0
  93. package/dist/knowledge/descriptor.js.map +1 -1
  94. package/dist/knowledge/index.d.ts +1 -1
  95. package/dist/knowledge/index.d.ts.map +1 -1
  96. package/dist/knowledge/index.js +1 -1
  97. package/dist/knowledge/index.js.map +1 -1
  98. package/dist/knowledge/matchers.d.ts +2 -1
  99. package/dist/knowledge/matchers.d.ts.map +1 -1
  100. package/dist/knowledge/matchers.js +23 -1
  101. package/dist/knowledge/matchers.js.map +1 -1
  102. package/dist/knowledge/pi.d.ts.map +1 -1
  103. package/dist/knowledge/pi.js +24 -1
  104. package/dist/knowledge/pi.js.map +1 -1
  105. package/package.json +1 -1
  106. package/src/cli/args.ts +15 -0
  107. package/src/cli/config.ts +4 -0
  108. package/src/cli/help.ts +10 -0
  109. package/src/cli/inspect.ts +70 -22
  110. package/src/cli/provenance.ts +19 -0
  111. package/src/cli/run.ts +172 -91
  112. package/src/cli/session.ts +4 -3
  113. package/src/cli/skills-root.ts +11 -4
  114. package/src/execution/decode.ts +46 -12
  115. package/src/execution/deps.ts +4 -1
  116. package/src/execution/events.ts +5 -2
  117. package/src/execution/failure.ts +17 -1
  118. package/src/execution/index.ts +2 -2
  119. package/src/execution/node-deps.ts +1 -0
  120. package/src/execution/open-session.ts +63 -6
  121. package/src/execution/stream-turn.ts +150 -31
  122. package/src/interpretation/argv.ts +34 -5
  123. package/src/interpretation/capabilities.ts +4 -1
  124. package/src/interpretation/content.ts +22 -5
  125. package/src/interpretation/hints.ts +8 -0
  126. package/src/interpretation/limits.ts +29 -0
  127. package/src/interpretation/resolve-options.ts +17 -0
  128. package/src/interpretation/tool-selection.ts +25 -0
  129. package/src/interpretation/turn-options.ts +31 -3
  130. package/src/interpretation/versions.ts +1 -1
  131. package/src/knowledge/claude-code.ts +21 -0
  132. package/src/knowledge/codex.ts +9 -0
  133. package/src/knowledge/descriptor.ts +33 -10
  134. package/src/knowledge/index.ts +5 -1
  135. package/src/knowledge/matchers.ts +25 -2
  136. package/src/knowledge/pi.ts +24 -1
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;
@@ -189,7 +238,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
189
238
  if (rawSkills !== undefined && rawSkills.length > 0) {
190
239
  try {
191
240
  const { resolveSkillNames, listKnownSkills } = await import("./skills-root.js");
192
- const resolvedSkills = resolveSkillNames(rawSkills);
241
+ const resolvedSkills = resolveSkillNames(rawSkills, h.name);
193
242
  const claudeTokens: string[] = [];
194
243
  if (h.name === "claude") {
195
244
  const { claudeSkillOverridesArg } = await import("../interpretation/skills-selection.js");
@@ -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;
@@ -254,18 +302,8 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
254
302
  effectiveTurnOpts = rest as ReturnType<typeof parseTurnOptions>;
255
303
  // Provenance is diagnostic data like the spawn line - stderr in BOTH
256
304
  // render modes, never stdout (stdout carries the NDJSON contract).
257
- if (provenance.length > 0 || unrenderable.length > 0) {
258
- for (const entry of provenance) {
259
- process.stderr.write(
260
- `provenance: ${entry.key} = ${JSON.stringify(entry.value)} (${entry.tier})\n`,
261
- );
262
- }
263
- for (const key of unrenderable) {
264
- process.stderr.write(
265
- `divergence: profile ${JSON.stringify(key)} not expressible on ${h.name}; harness default applies\n`,
266
- );
267
- }
268
- }
305
+ const { writeProvenance } = await import("./provenance.js");
306
+ writeProvenance(h.name, provenance, unrenderable);
269
307
  }
270
308
 
271
309
  // issue #41: question-escalation precedence arg > project > user >
@@ -342,15 +380,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
342
380
  _validated = true;
343
381
  } catch (err) {
344
382
  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;
383
+ refuse(refusalOf(err), wantJson);
354
384
  return;
355
385
  }
356
386
  throw err;
@@ -372,6 +402,51 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
372
402
  process.stderr.write(
373
403
  `provenance: escalateQuestions = ${escalateQuestions} (${escalateTier})\n`,
374
404
  );
405
+ // On a harness whose include flag is not a strict allowlist (claude),
406
+ // a name outside the curated set passes through ungated; say which
407
+ // ones so a wrong-case name is visible. A grant with no known name at
408
+ // all refuses inside buildLaunchArgv below, so a throw here is left
409
+ // to that path.
410
+ const grant = (fullOpts.resume ? turnOpts : effectiveTurnOpts).tools;
411
+ if (grant !== undefined && grant.length > 0 && !h.tools.includeIsStrictAllowlist) {
412
+ try {
413
+ const { renderToolSelection } = await import("../interpretation/tool-selection.js");
414
+ const { unmapped } = renderToolSelection(h, { include: [...grant] });
415
+ if (unmapped.length > 0) {
416
+ process.stderr.write(`provenance: unmapped tools = ${JSON.stringify(unmapped)}\n`);
417
+ }
418
+ } catch {
419
+ // refused below with the structured message
420
+ }
421
+ }
422
+ }
423
+
424
+ // A harness that creates a session when the id is unknown (pi, muse)
425
+ // would turn a stale --resume into a silent blank session. Refuse when
426
+ // the session store path does not exist; where the path cannot be
427
+ // computed, the runner's pre-spawn warning is the only guard.
428
+ if (fullOpts.resume !== undefined && h.resume.onMissing === "create") {
429
+ let path: string | null = null;
430
+ try {
431
+ path = storePath(h, {
432
+ home: process.env.HOME ?? process.env.USERPROFILE ?? "",
433
+ cwd: extra.cwd ?? process.cwd(),
434
+ sessionId: fullOpts.resume,
435
+ });
436
+ } catch {
437
+ path = null;
438
+ }
439
+ if (path !== null && !existsSync(path)) {
440
+ refuse(
441
+ {
442
+ message: `no ${h.name} session ${fullOpts.resume} found at ${path}`,
443
+ issue: "invalid-option-value",
444
+ supported: [`a session id that exists in ${h.name}'s store`],
445
+ },
446
+ wantJson,
447
+ );
448
+ return;
449
+ }
375
450
  }
376
451
 
377
452
  // Delete HERDR_ENV before spawn
@@ -393,7 +468,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
393
468
  const _currentProc: { signal: (sig: "SIGTERM" | "SIGKILL") => void } | null = null;
394
469
  // We'll need to track the spawned process via deps.signal; but streamTurn owns process handle.
395
470
  // Instead we intercept deps.signal via a wrapper that captures proc.
396
- // 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.
397
472
  // We'll create a wrapper deps where signal captures last proc.
398
473
  let lastProc: import("../execution/deps.js").SpawnedProcess | null = null;
399
474
  const originalSignal = deps.signal;
@@ -410,23 +485,25 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
410
485
  },
411
486
  };
412
487
 
488
+ const abortController = new AbortController();
413
489
  let interrupted = false;
490
+ let escalationTimer: ReturnType<typeof setTimeout> | null = null;
414
491
  const onSig = async () => {
415
492
  if (interrupted) return;
416
493
  interrupted = true;
494
+ abortController.abort();
417
495
  if (lastProc) {
418
496
  try {
419
497
  wrappedDeps.signal(lastProc, "SIGTERM");
420
- await new Promise<void>((resolve) => {
421
- setTimeout(() => {
422
- if (lastProc) {
423
- try {
424
- wrappedDeps.signal(lastProc, "SIGKILL");
425
- } catch {}
426
- }
427
- resolve();
428
- }, KILL_GRACE_MS);
429
- });
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?.();
430
507
  } catch {}
431
508
  }
432
509
  };
@@ -439,7 +516,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
439
516
 
440
517
  try {
441
518
  // streamTurn handles both launch and resume via TurnRunOptions
442
- const events = streamTurn(h, fullOpts, wrappedDeps);
519
+ const events = streamTurn(h, { ...fullOpts, signal: abortController.signal }, wrappedDeps);
443
520
  for await (const event of events) {
444
521
  lastEvent = event;
445
522
  if (wantJson) {
@@ -492,6 +569,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
492
569
  process.off("SIGTERM", onSig);
493
570
  return;
494
571
  } finally {
572
+ if (escalationTimer !== null) {
573
+ clearTimeout(escalationTimer);
574
+ escalationTimer = null;
575
+ }
495
576
  process.off("SIGINT", onSig);
496
577
  process.off("SIGTERM", onSig);
497
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
  }
@@ -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,27 +56,59 @@ 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) {
72
+ const authority = state.requestedId !== null ? "caller-assigned" : "harness-minted";
62
73
  events.push({
63
74
  kind: "identity",
64
75
  sessionId: decoded.identity,
65
- authority: h.identity.authority,
66
- capabilities: capabilitiesOf(h, model, "headless-turn"),
76
+ authority,
77
+ capabilities: capabilities(),
67
78
  });
68
79
  } 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}` });
80
+ if (decoded.outcome === "rotated") {
81
+ const requested = state.requestedId ?? "unknown";
82
+ const announced = decoded.sessionId ?? "unknown";
83
+ events.push({
84
+ kind: "error",
85
+ message: `identity rotated: requested ${requested} but announced ${announced}`,
86
+ });
87
+ // The harness answered under a different id: hand the consumer the
88
+ // id it can actually resume, marked as minted by the harness.
89
+ if (decoded.sessionId !== null) {
90
+ events.push({
91
+ kind: "identity",
92
+ sessionId: decoded.sessionId,
93
+ authority: "harness-minted",
94
+ capabilities: capabilities(),
95
+ });
96
+ }
97
+ } else {
98
+ events.push({ kind: "error", message: `identity ${decoded.outcome}` });
99
+ }
73
100
  }
74
101
 
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);
102
+ // Content (message/token/tool/error/budget) is per-harness; identity
103
+ // above is descriptor-driven. contentEventsOf dispatches by harness
104
+ // name. budget is not a HarnessEvent kind and must not leak out.
105
+ for (const content of contentEventsOf(h.name, raw)) {
106
+ if (content.kind === "budget") {
107
+ events.push({ kind: "failure", ...failureFromBudget(content.detail) });
108
+ } else {
109
+ events.push(content);
110
+ }
111
+ }
78
112
 
79
113
  // claude's rate_limit_event: only non-"allowed" statuses are failures.
80
114
  // overageStatus is deliberately not classified - it is a separate
@@ -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
  */
@@ -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";
@@ -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";
@@ -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;