@dungle-scrubs/harness-cli-normalizer 0.5.4 → 0.5.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 (103) hide show
  1. package/README.md +3 -2
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +16 -8
  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 +11 -4
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/help.d.ts +3 -3
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +32 -23
  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 +10 -7
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/render.d.ts +15 -1
  16. package/dist/cli/render.d.ts.map +1 -1
  17. package/dist/cli/render.js +18 -2
  18. package/dist/cli/render.js.map +1 -1
  19. package/dist/cli/resume-guard.js +29 -2
  20. package/dist/cli/resume-guard.js.map +1 -1
  21. package/dist/cli/run.d.ts.map +1 -1
  22. package/dist/cli/run.js +35 -34
  23. package/dist/cli/run.js.map +1 -1
  24. package/dist/cli/session-json.d.ts +5 -2
  25. package/dist/cli/session-json.d.ts.map +1 -1
  26. package/dist/cli/session-json.js +36 -5
  27. package/dist/cli/session-json.js.map +1 -1
  28. package/dist/cli/session.d.ts.map +1 -1
  29. package/dist/cli/session.js +103 -36
  30. package/dist/cli/session.js.map +1 -1
  31. package/dist/execution/events.d.ts +8 -1
  32. package/dist/execution/events.d.ts.map +1 -1
  33. package/dist/execution/events.js.map +1 -1
  34. package/dist/execution/open-session.d.ts +7 -6
  35. package/dist/execution/open-session.d.ts.map +1 -1
  36. package/dist/execution/open-session.js +88 -59
  37. package/dist/execution/open-session.js.map +1 -1
  38. package/dist/execution/stream-turn.d.ts +4 -7
  39. package/dist/execution/stream-turn.d.ts.map +1 -1
  40. package/dist/execution/stream-turn.js +43 -22
  41. package/dist/execution/stream-turn.js.map +1 -1
  42. package/dist/interpretation/argv.d.ts +9 -5
  43. package/dist/interpretation/argv.d.ts.map +1 -1
  44. package/dist/interpretation/argv.js +2 -7
  45. package/dist/interpretation/argv.js.map +1 -1
  46. package/dist/interpretation/capabilities.d.ts +20 -0
  47. package/dist/interpretation/capabilities.d.ts.map +1 -1
  48. package/dist/interpretation/capabilities.js +27 -0
  49. package/dist/interpretation/capabilities.js.map +1 -1
  50. package/dist/interpretation/hints.js +1 -1
  51. package/dist/interpretation/hints.js.map +1 -1
  52. package/dist/interpretation/question.d.ts +6 -2
  53. package/dist/interpretation/question.d.ts.map +1 -1
  54. package/dist/interpretation/question.js +13 -4
  55. package/dist/interpretation/question.js.map +1 -1
  56. package/dist/interpretation/refusal.d.ts +1 -1
  57. package/dist/interpretation/refusal.d.ts.map +1 -1
  58. package/dist/interpretation/refusal.js +1 -1
  59. package/dist/interpretation/refusal.js.map +1 -1
  60. package/dist/interpretation/skills-selection.d.ts +18 -1
  61. package/dist/interpretation/skills-selection.d.ts.map +1 -1
  62. package/dist/interpretation/skills-selection.js +33 -6
  63. package/dist/interpretation/skills-selection.js.map +1 -1
  64. package/dist/knowledge/claude-code.d.ts.map +1 -1
  65. package/dist/knowledge/claude-code.js +21 -0
  66. package/dist/knowledge/claude-code.js.map +1 -1
  67. package/dist/knowledge/codex.d.ts.map +1 -1
  68. package/dist/knowledge/codex.js +16 -6
  69. package/dist/knowledge/codex.js.map +1 -1
  70. package/dist/knowledge/descriptor.d.ts +39 -12
  71. package/dist/knowledge/descriptor.d.ts.map +1 -1
  72. package/dist/knowledge/descriptor.js +1 -0
  73. package/dist/knowledge/descriptor.js.map +1 -1
  74. package/dist/knowledge/muse.d.ts.map +1 -1
  75. package/dist/knowledge/muse.js +7 -0
  76. package/dist/knowledge/muse.js.map +1 -1
  77. package/dist/knowledge/pi.d.ts.map +1 -1
  78. package/dist/knowledge/pi.js +23 -4
  79. package/dist/knowledge/pi.js.map +1 -1
  80. package/package.json +3 -2
  81. package/src/cli/args.ts +16 -6
  82. package/src/cli/config.ts +13 -4
  83. package/src/cli/help.ts +32 -23
  84. package/src/cli/inspect.ts +8 -6
  85. package/src/cli/render.ts +18 -1
  86. package/src/cli/resume-guard.ts +26 -2
  87. package/src/cli/run.ts +36 -38
  88. package/src/cli/session-json.ts +45 -7
  89. package/src/cli/session.ts +122 -40
  90. package/src/execution/events.ts +9 -1
  91. package/src/execution/open-session.ts +99 -72
  92. package/src/execution/stream-turn.ts +53 -30
  93. package/src/interpretation/argv.ts +11 -12
  94. package/src/interpretation/capabilities.ts +50 -0
  95. package/src/interpretation/hints.ts +1 -1
  96. package/src/interpretation/question.ts +17 -7
  97. package/src/interpretation/refusal.ts +2 -1
  98. package/src/interpretation/skills-selection.ts +46 -8
  99. package/src/knowledge/claude-code.ts +21 -0
  100. package/src/knowledge/codex.ts +16 -6
  101. package/src/knowledge/descriptor.ts +40 -12
  102. package/src/knowledge/muse.ts +7 -0
  103. package/src/knowledge/pi.ts +23 -4
package/src/cli/config.ts CHANGED
@@ -76,9 +76,8 @@ const KNOWN_KEYS = new Set([
76
76
  "maxSteps",
77
77
  "toolsets",
78
78
  "timeout",
79
- // issue #41: question escalation (behavior instruction, not a turn
80
- // option - it rides the prompt preamble, never a harness flag).
81
- "escalateQuestions",
79
+ // question mode: ask|assume|none (behavior instruction, not a turn option)
80
+ "questions",
82
81
  // issue #48: payload-stripping dimensions (opt-in-only, no profile
83
82
  // entry by ratification).
84
83
  "systemPrompt",
@@ -88,7 +87,8 @@ const KNOWN_KEYS = new Set([
88
87
  ]);
89
88
 
90
89
  const LIST_KEYS = new Set(["tools", "excludeTools"]);
91
- const BOOL_KEYS = new Set(["autonomy", "write", "shell", "escalateQuestions"]);
90
+ const BOOL_KEYS = new Set(["autonomy", "write", "shell"]);
91
+ const QUESTIONS_VALUES = new Set(["ask", "assume", "none"]);
92
92
 
93
93
  /** Parse + validate config text. Throws ConfigError with the offending key
94
94
  * named on any violation - never warns and continues. */
@@ -227,6 +227,15 @@ export const parseUserConfig = (text: string): Partial<TurnOptions> => {
227
227
  (out as Record<string, unknown>)[key] = value;
228
228
  continue;
229
229
  }
230
+ if (key === "questions") {
231
+ if (typeof value !== "string" || !QUESTIONS_VALUES.has(value)) {
232
+ throw new ConfigError(
233
+ `config key ${JSON.stringify(key)} must be one of ${[...QUESTIONS_VALUES].join(", ")}`,
234
+ );
235
+ }
236
+ (out as Record<string, unknown>).questions = value;
237
+ continue;
238
+ }
230
239
  if (BOOL_KEYS.has(key)) {
231
240
  if (typeof value !== "boolean") {
232
241
  throw new ConfigError(`config key ${JSON.stringify(key)} must be a boolean`);
package/src/cli/help.ts CHANGED
@@ -48,12 +48,12 @@ Options:
48
48
  --no-write Disable write
49
49
  --shell Enable shell (muse)
50
50
  --no-shell Disable shell
51
- --escalate-questions Let the worker ask the caller's user when a
52
- genuine decision blocks progress (DEFAULT;
53
- prompt-preamble transport, question event +
54
- done cause "awaiting-input", exit 0)
55
- --no-escalate-questions Worker never asks: it states the assumption it
56
- proceeded under and continues
51
+ --questions <ask|assume|none>
52
+ Which preamble to inject: ask = escalation
53
+ protocol (DEFAULT, question event +
54
+ done cause "awaiting-input", exit 0), assume =
55
+ never ask, state assumption, none = inject
56
+ nothing
57
57
  --system-prompt <text> Replace the built-in system prompt (claude, pi:
58
58
  flag; codex: -c instructions=<text-or-path>;
59
59
  muse refuses. claude pairs the dynamic-section
@@ -78,12 +78,15 @@ Options:
78
78
  --no-skills Disable skills discovery facet
79
79
  --cwd <path> Working directory for spawn
80
80
  --env KEY=VAL Environment (repeatable; KEY= deletes)
81
- --resume <uuid> Resume session id (UUID). The answer path for
82
- question escalation: resume with the chosen
83
- answer as the prompt; id continuity per
81
+ --resume <uuid> Resume session id (UUID). Continues the
82
+ conversation where it left off. The answer
83
+ path for question escalation: resume with the
84
+ chosen answer as the prompt; id continuity per
84
85
  harness (claude stable, pi/muse caller-assigned,
85
86
  codex minted via identity event)
86
- Note: pi and muse create a new session when the id is unknown - verify it exists
87
+ Note: hcn refuses an unknown id before spawn
88
+ (exit 2) for harnesses that would otherwise
89
+ create a fresh session silently (pi, muse)
87
90
  --session-id <uuid> Alias for --resume (mutually exclusive with --resume)
88
91
  -- Passthrough: native harness args verbatim
89
92
  (failures surface as labeled native errors)
@@ -102,9 +105,8 @@ Defaults with no flags:
102
105
  profile). toolMap is config-only (no flag) - canonical -> native
103
106
  mapping per harness. A bare pi run renders no --tools list: pi's list
104
107
  is a strict allowlist and would drop extension and MCP tools; name
105
- them through toolMap or native:<name> when you grant. Question
106
- escalation defaults ON (config key
107
- "escalateQuestions"; it is a prompt preamble, never a harness flag).
108
+ them through toolMap or native:<name> when you grant. Question mode defaults to ask (config keys
109
+ "questions"; prompt preamble, never a harness flag).
108
110
  Provenance prints to stderr on every run; see
109
111
  'hcn inspect <harness>' for the resolved argv of a bare run.
110
112
  `;
@@ -120,8 +122,9 @@ Options:
120
122
  --json Machine surface: NDJSON events on stdout, NDJSON
121
123
  commands on stdin ({"op":"send","id":..,"text":..},
122
124
  "answer", "close"). Every send is answered with one
123
- disposition (started | queued | rejected); a queued
124
- send's id rides to the turn that consumes it. The
125
+ disposition (started | rejected); every send is
126
+ handed to the harness, and the turn that consumes it
127
+ carries its id. The
125
128
  stream opens with a session event and ends with a
126
129
  closed event. Exit 0 clean, 1 otherwise, 2 refusal.
127
130
  --stall <seconds> Per-turn inactivity budget; the turn ends and the
@@ -129,12 +132,18 @@ Options:
129
132
  (default: no limit)
130
133
  --provider <value> Provider (pi only)
131
134
  --model <id> Model for the session
132
- --session-id <uuid> Session id (UUID, else random; re-enters an
133
- existing session)
134
- --escalate-questions Worker may ask; question renders as a pickable
135
- menu, the answer flows back into the SAME live
136
- session (DEFAULT)
137
- --no-escalate-questions Worker never asks; states its assumption
135
+ --resume <uuid> Resume session id (UUID). Continues the
136
+ conversation where it left off. --session-id
137
+ is an alias (mutually exclusive with --resume).
138
+ If no id is given, a fresh session is started
139
+ with a new id. hcn refuses an unknown id before
140
+ spawn (exit 2) for harnesses that would otherwise
141
+ create a fresh session silently (pi)
142
+ --session-id <uuid> Alias for --resume (mutually exclusive with --resume)
143
+ --questions <ask|assume|none>
144
+ Which preamble to inject: ask = worker may
145
+ ask (DEFAULT, pickable menu), assume = never
146
+ ask, none = inject nothing
138
147
  --cwd <path> Working directory
139
148
  --help Show help
140
149
  --version Show version
@@ -167,11 +176,11 @@ Options:
167
176
  --autonomy / --no-autonomy
168
177
  --write / --no-write
169
178
  --shell / --no-shell
170
- --escalate-questions / --no-escalate-questions
179
+ --questions <ask|assume|none>
171
180
  (accepted; renders nothing - rides the run prompt)
172
181
  --max-steps <n>
173
182
  --no-tools, --no-instruction-files, --no-extensions, --no-skills
174
- --escalate-questions / --no-escalate-questions
183
+ --questions <ask|assume|none>
175
184
  Accepted; renders nothing in argv (the mode
176
185
  rides the run prompt, not a harness flag)
177
186
  --cwd <path>
@@ -210,13 +210,16 @@ export const inspect = async (harnessName: string, rawArgs: string[]): Promise<v
210
210
  try {
211
211
  const { resolveSkillNames, listKnownSkills } = await import("./skills-root.js");
212
212
  const resolvedSkills = resolveSkillNames(rawSkills, h.name);
213
- const claudeTokens: string[] = [];
213
+ const skillTokens: string[] = [];
214
214
  if (h.name === "claude") {
215
215
  const { claudeSkillOverridesArg } = await import("../interpretation/skills-selection.js");
216
- claudeTokens.push(...claudeSkillOverridesArg(listKnownSkills(), resolvedSkills));
216
+ skillTokens.push(...claudeSkillOverridesArg(listKnownSkills(), resolvedSkills));
217
+ } else if (h.name === "codex") {
218
+ const { codexSkillConfigArg } = await import("../interpretation/skills-selection.js");
219
+ skillTokens.push(...codexSkillConfigArg(listKnownSkills(), resolvedSkills));
217
220
  }
218
221
  (turnOpts as unknown as Record<string, unknown>).skills = resolvedSkills;
219
- (turnOpts as unknown as Record<string, unknown>).__claudeSkillTokens = claudeTokens;
222
+ (turnOpts as unknown as Record<string, unknown>).__skillTokens = skillTokens;
220
223
  } catch (err) {
221
224
  if (err instanceof ArgvRefusalError) {
222
225
  process.stderr.write(`${err.message}\n`);
@@ -301,9 +304,8 @@ export const inspect = async (harnessName: string, rawArgs: string[]): Promise<v
301
304
  let argv: string[];
302
305
  try {
303
306
  argv = buildLaunchArgv(h, fullOpts);
304
- const claudeTokens = (effectiveRest as unknown as { __claudeSkillTokens?: string[] })
305
- .__claudeSkillTokens;
306
- if (claudeTokens !== undefined && claudeTokens.length > 0) argv.push(...claudeTokens);
307
+ const skillTokens = (effectiveRest as unknown as { __skillTokens?: string[] }).__skillTokens;
308
+ if (skillTokens !== undefined && skillTokens.length > 0) argv.push(...skillTokens);
307
309
  } catch (err) {
308
310
  if (err instanceof ArgvRefusalError) {
309
311
  refuse(refusalOf(err as ArgvRefusalError), false);
package/src/cli/render.ts CHANGED
@@ -81,6 +81,23 @@ export const renderEvent = (event: HarnessEvent, state: RenderState): void => {
81
81
  }
82
82
  };
83
83
 
84
- export const writeEventNdjson = (event: HarnessEvent): void => {
84
+ /** Write one NDJSON event, reporting whether stdout took it immediately.
85
+ *
86
+ * `false` means the kernel buffer is full and the bytes are queued in the
87
+ * process. A caller streaming a turn MUST wait for `drain` before writing
88
+ * again (see `writeEventNdjsonAsync`); a caller writing one terminal pair on
89
+ * the way out may ignore it, because Node flushes pending stdout writes
90
+ * before it exits. */
91
+ export const writeEventNdjson = (event: HarnessEvent): boolean =>
85
92
  process.stdout.write(`${JSON.stringify(event)}\n`);
93
+
94
+ /** The streaming form: resolves once stdout has taken the line.
95
+ *
96
+ * Without this a slow reader is absorbed by the process rather than pushed
97
+ * back on: hcn keeps pulling from the harness and buffering, so memory grows
98
+ * with the turn instead of the harness being stalled. RFC-01 rule 8 names
99
+ * both hops; this is the hcn-to-consumer one. */
100
+ export const writeEventNdjsonAsync = async (event: HarnessEvent): Promise<void> => {
101
+ if (writeEventNdjson(event)) return;
102
+ await new Promise<void>((resolve) => process.stdout.once("drain", resolve));
86
103
  };
@@ -1,4 +1,4 @@
1
- import { existsSync, realpathSync } from "node:fs";
1
+ import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { storePath } from "../interpretation/store.js";
3
3
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
4
4
 
@@ -31,5 +31,29 @@ export const resumeStore = (
31
31
  } catch {
32
32
  path = null;
33
33
  }
34
- return { path, exists: path !== null && existsSync(path) };
34
+ return { path, exists: path !== null && sessionExistsAt(path, opts.sessionId) };
35
+ };
36
+
37
+ /** A store template that resolves to a FILE names the session directly and
38
+ * existence is the file's. One that resolves to a DIRECTORY names where the
39
+ * harness files every session for this cwd, and the session is one entry in
40
+ * it whose name carries the id somewhere - pi writes `<timestamp>_<id>.jsonl`,
41
+ * so the id's position is not a template. Checking only that the directory
42
+ * exists reported every id as present once any session had ever run in the
43
+ * cwd, which made the guard pass unknown ids on pi and, once `origin` rode
44
+ * on it, reported a brand-new session as resumed. */
45
+ const sessionExistsAt = (path: string, sessionId: string): boolean => {
46
+ if (!existsSync(path)) return false;
47
+ let isDir = false;
48
+ try {
49
+ isDir = statSync(path).isDirectory();
50
+ } catch {
51
+ return false;
52
+ }
53
+ if (!isDir) return true;
54
+ try {
55
+ return readdirSync(path).some((name) => name.includes(sessionId));
56
+ } catch {
57
+ return false;
58
+ }
35
59
  };
package/src/cli/run.ts CHANGED
@@ -9,7 +9,7 @@ import { recognizeNativeSpelling, supportedBy } from "../interpretation/support.
9
9
  import { defaultDescriptors } from "../knowledge/overrides.js";
10
10
  import { parseRunExtra, parseTurnOptions, resolvePromptAsync } from "./args.js";
11
11
  import { refusalOf, refuse } from "./refuse.js";
12
- import { createRenderState, renderEvent, writeEventNdjson } from "./render.js";
12
+ import { createRenderState, renderEvent, writeEventNdjsonAsync } from "./render.js";
13
13
  import { resolveHarness } from "./resolve-harness.js";
14
14
  import { resumeStore } from "./resume-guard.js";
15
15
 
@@ -185,13 +185,16 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
185
185
  try {
186
186
  const { resolveSkillNames, listKnownSkills } = await import("./skills-root.js");
187
187
  const resolvedSkills = resolveSkillNames(rawSkills, h.name);
188
- const claudeTokens: string[] = [];
188
+ const skillTokens: string[] = [];
189
189
  if (h.name === "claude") {
190
190
  const { claudeSkillOverridesArg } = await import("../interpretation/skills-selection.js");
191
- claudeTokens.push(...claudeSkillOverridesArg(listKnownSkills(), resolvedSkills));
191
+ skillTokens.push(...claudeSkillOverridesArg(listKnownSkills(), resolvedSkills));
192
+ } else if (h.name === "codex") {
193
+ const { codexSkillConfigArg } = await import("../interpretation/skills-selection.js");
194
+ skillTokens.push(...codexSkillConfigArg(listKnownSkills(), resolvedSkills));
192
195
  }
193
196
  (turnOpts as unknown as Record<string, unknown>).skills = resolvedSkills;
194
- (turnOpts as unknown as Record<string, unknown>).__claudeSkillTokens = claudeTokens;
197
+ (turnOpts as unknown as Record<string, unknown>).__skillTokens = skillTokens;
195
198
  } catch (err) {
196
199
  if (err instanceof ArgvRefusalError) {
197
200
  refuse(refusalOf(err), wantJson);
@@ -209,9 +212,9 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
209
212
  project?: Partial<ReturnType<typeof parseTurnOptions>>;
210
213
  } = {};
211
214
  // Config files load on EVERY run, launch or resume: the tiers feed the
212
- // defaults profile on launch, and issue #41's escalateQuestions (a
215
+ // defaults profile on launch, and issue #41's questions (a
213
216
  // behavior instruction, not a turn option) resolves from them on resume
214
- // too - otherwise a no-escalate session would flip its preamble on the
217
+ // too - otherwise a no-ask session would flip its preamble on the
215
218
  // answer turn. Resolution of TURN options stays launch-only.
216
219
  const tiers = resolvedTiers;
217
220
  {
@@ -256,39 +259,34 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
256
259
  writeProvenance(h.name, provenance, unrenderable);
257
260
  }
258
261
 
259
- // issue #41: question-escalation precedence arg > project > user >
260
- // default-true (a behavior instruction, not a turn option - the
261
- // default lives OUTSIDE the profile on purpose, per the spec). It
262
- // applies on LAUNCH AND RESUME alike: it shapes each turn's prompt
263
- // preamble and event stream, never a session setting.
264
- const projectEscalate = (resolvedTiers.project as { escalateQuestions?: boolean } | undefined)
265
- ?.escalateQuestions;
266
- const userEscalate = (resolvedTiers.user as { escalateQuestions?: boolean } | undefined)
267
- ?.escalateQuestions;
268
- const escalateQuestions =
269
- turnOpts.escalateQuestions !== undefined
270
- ? turnOpts.escalateQuestions
271
- : projectEscalate !== undefined
272
- ? projectEscalate
273
- : userEscalate !== undefined
274
- ? userEscalate
275
- : true;
276
- const escalateTier =
277
- turnOpts.escalateQuestions !== undefined
262
+ // question mode precedence arg > project > user > default (ask)
263
+ const projectQuestions = (resolvedTiers.project as { questions?: string } | undefined)?.questions;
264
+ const userQuestions = (resolvedTiers.user as { questions?: string } | undefined)?.questions;
265
+ const rawMode = (turnOpts as { questions?: string }).questions;
266
+ const questionMode =
267
+ rawMode !== undefined
268
+ ? (rawMode as import("../interpretation/question.js").QuestionMode)
269
+ : projectQuestions !== undefined
270
+ ? (projectQuestions as import("../interpretation/question.js").QuestionMode)
271
+ : userQuestions !== undefined
272
+ ? (userQuestions as import("../interpretation/question.js").QuestionMode)
273
+ : ("ask" as const);
274
+ const questionTier =
275
+ rawMode !== undefined
278
276
  ? "arg"
279
- : projectEscalate !== undefined
277
+ : projectQuestions !== undefined
280
278
  ? "project-config"
281
- : userEscalate !== undefined
279
+ : userQuestions !== undefined
282
280
  ? "user-config"
283
281
  : "default";
284
282
 
285
283
  const fullOpts = {
286
284
  ...effectiveTurnOpts,
287
- prompt: composeEscalatedPrompt(prompt, escalateQuestions),
285
+ prompt: composeEscalatedPrompt(prompt, questionMode),
288
286
  cwd: extra.cwd,
289
287
  env: extra.env,
290
288
  resume: extra.resume,
291
- escalateQuestions,
289
+ questions: questionMode,
292
290
  ...(passthrough.length > 0 ? { passthrough } : {}),
293
291
  ...(isExplicit ? { __explicitPrompt: true as const } : {}),
294
292
  } as Parameters<typeof streamTurn>[1] & {
@@ -304,7 +302,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
304
302
  if (fullOpts.resume) {
305
303
  // Resume never carries TURN-option profile resolution (launch-only
306
304
  // rule), so it builds from the raw turn options; hcn-owned behavior
307
- // (escalateQuestions preamble, timeout budget) still applies.
305
+ // (questions preamble, timeout budget) still applies.
308
306
  preArgv = buildResumeArgv(h, {
309
307
  ...(turnOpts as object),
310
308
  prompt: fullOpts.prompt,
@@ -321,10 +319,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
321
319
  prompt: fullOpts.prompt,
322
320
  __explicitPrompt: isExplicit,
323
321
  } as never);
324
- const claudeSkillTokens = (effectiveTurnOpts as unknown as { __claudeSkillTokens?: string[] })
325
- .__claudeSkillTokens;
326
- if (claudeSkillTokens !== undefined && claudeSkillTokens.length > 0) {
327
- preArgv.push(...claudeSkillTokens);
322
+ const skillTokens = (effectiveTurnOpts as unknown as { __skillTokens?: string[] })
323
+ .__skillTokens;
324
+ if (skillTokens !== undefined && skillTokens.length > 0) {
325
+ preArgv.push(...skillTokens);
328
326
  }
329
327
  }
330
328
  _validated = true;
@@ -349,9 +347,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
349
347
  }
350
348
  // issue #41: the escalation mode rides stderr as provenance, like
351
349
  // every other resolution the turn depends on.
352
- process.stderr.write(
353
- `provenance: escalateQuestions = ${escalateQuestions} (${escalateTier})\n`,
354
- );
350
+ process.stderr.write(`provenance: questions = ${questionMode} (${questionTier})\n`);
355
351
  // On a harness whose include flag is not a strict allowlist (claude),
356
352
  // a name outside the curated set passes through ungated; say which
357
353
  // ones so a wrong-case name is visible. A grant with no known name at
@@ -465,7 +461,9 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
465
461
  for await (const event of events) {
466
462
  lastEvent = event;
467
463
  if (wantJson) {
468
- writeEventNdjson(event);
464
+ // Await the write: a consumer that stops reading must stall the
465
+ // harness, not be absorbed into this process's memory.
466
+ await writeEventNdjsonAsync(event);
469
467
  } else {
470
468
  renderEvent(event, state);
471
469
  }
@@ -4,7 +4,7 @@
4
4
  * with the control events (`session`, `turn`, `disposition`, `closed`) that
5
5
  * a program needs to drive a session it does not own the timing of. RFC-01.
6
6
  *
7
- * This owns the wire framing only. The turn lifecycle, the queue, and the id
7
+ * This owns the wire framing only. The turn lifecycle and the id
8
8
  * correlation live in `openSession`; this reads the id off the yielded turn
9
9
  * rather than shadowing the runner's delivery order.
10
10
  */
@@ -16,6 +16,7 @@ import {
16
16
  type SessionHandle,
17
17
  type SessionSendResult,
18
18
  } from "../execution/open-session.js";
19
+ import type { QuestionMode } from "../interpretation/question.js";
19
20
 
20
21
  /** What the CLI reads back after a close, captured from the runner's
21
22
  * `session_close` boundary log. */
@@ -24,15 +25,18 @@ export interface CloseInfo {
24
25
  cause: string;
25
26
  }
26
27
 
28
+ export type SessionOrigin = "fresh" | "resumed";
29
+
27
30
  export interface JsonSessionArgs {
28
31
  readonly handle: SessionHandle;
29
32
  readonly sessionId: string;
30
33
  readonly harness: string;
31
34
  readonly hcnVersion: string;
32
- readonly escalateQuestions: boolean;
35
+ readonly questions?: QuestionMode;
36
+ readonly origin: SessionOrigin;
33
37
  /** Read after close - the runner's final exitCode and cause. */
34
38
  readonly getCloseInfo: () => CloseInfo;
35
- /** Read after close - ids the runner accepted as queued and never
39
+ /** Read after close - ids the runner accepted and never
36
40
  * delivered. Each owes the consumer a rejection (RFC S003). */
37
41
  readonly getDroppedIds?: () => readonly string[];
38
42
  /** Injected for tests; defaults to process.stdin / process.stdout. */
@@ -116,12 +120,14 @@ export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
116
120
  return chain;
117
121
  };
118
122
 
123
+ const qMode: QuestionMode = a.questions ?? "ask";
119
124
  await emit({
120
125
  kind: "session",
121
126
  sessionId: a.sessionId,
122
127
  harness: a.harness,
123
128
  hcn: a.hcnVersion,
124
- escalateQuestions: a.escalateQuestions,
129
+ questions: qMode,
130
+ origin: a.origin,
125
131
  });
126
132
 
127
133
  // Shared between the pumps: the last question asked, whether the last turn
@@ -154,6 +160,17 @@ export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
154
160
  })();
155
161
 
156
162
  const rl = createInterface({ input: a.input ?? process.stdin });
163
+ // Ids accepted from stdin but not yet answered with a disposition. If the
164
+ // command stream itself fails, each is owed one: silence would leave the
165
+ // consumer waiting on an answer that can no longer arrive.
166
+ const unanswered = new Set<string>();
167
+ let stdinError: string | null = null;
168
+ const commandStream = (a.input ?? process.stdin) as {
169
+ on?: (event: string, listener: (err: NodeJS.ErrnoException) => void) => unknown;
170
+ };
171
+ commandStream.on?.("error", (err) => {
172
+ stdinError = err.code ?? err.message;
173
+ });
157
174
  const stdinPump = (async () => {
158
175
  for await (const line of rl) {
159
176
  if (line.trim() === "") continue;
@@ -177,12 +194,14 @@ export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
177
194
  text = composeAnswer(lastQuestion, cmd.text);
178
195
  }
179
196
  let sent: SessionSendResult;
197
+ unanswered.add(cmd.id);
180
198
  try {
181
199
  sent = a.handle.send({ id: cmd.id, text });
182
200
  } catch (err) {
183
201
  // A session the caller already closed, or one already dead: a
184
202
  // different remedy from a broken pipe, so a different reason.
185
203
  if (err instanceof SessionClosedError) {
204
+ unanswered.delete(cmd.id);
186
205
  await emit({
187
206
  kind: "disposition",
188
207
  id: cmd.id,
@@ -191,8 +210,10 @@ export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
191
210
  });
192
211
  continue;
193
212
  }
213
+ unanswered.delete(cmd.id);
194
214
  throw err;
195
215
  }
216
+ unanswered.delete(cmd.id);
196
217
  await emit({
197
218
  kind: "disposition",
198
219
  id: cmd.id,
@@ -201,16 +222,33 @@ export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
201
222
  });
202
223
  }
203
224
  })();
225
+ // A command stream that breaks is not a clean end of input, but it is also
226
+ // not a crash of this process: record it and close the session in order.
227
+ // The caught form is what the race waits on - the raw pump would reject
228
+ // straight out of runJsonSession.
229
+ const stdinDone = stdinPump.catch((err: unknown) => {
230
+ stdinError = err instanceof Error ? err.message : String(err);
231
+ });
204
232
 
205
233
  // Whichever ends first drives the close: a close/EOF from stdin, or the
206
234
  // session dying (the turns iterable ends). close() is idempotent; closing
207
235
  // the readline unblocks the stdin pump if the session died first.
208
- await Promise.race([stdinPump, stdoutPump]);
236
+ await Promise.race([stdinDone, stdoutPump]);
209
237
  await a.handle.close();
210
238
  rl.close();
211
- await Promise.allSettled([stdinPump, stdoutPump]);
239
+ await Promise.allSettled([stdinDone, stdoutPump]);
240
+
241
+ // A broken command stream is a fact the consumer needs, and every send it
242
+ // swallowed is owed its answer.
243
+ if (stdinError !== null) {
244
+ await emit({ kind: "error", message: `command stream failed: ${stdinError}` });
245
+ }
246
+ for (const id of unanswered) {
247
+ await emit({ kind: "disposition", id, disposition: "rejected", reason: "closed" });
248
+ }
249
+ unanswered.clear();
212
250
 
213
- // Every input the runner accepted as queued and then lost gets its own
251
+ // Every input the runner accepted and then lost gets its own
214
252
  // rejection, before the terminal line (RFC S003).
215
253
  for (const id of a.getDroppedIds?.() ?? []) {
216
254
  await emit({ kind: "disposition", id, disposition: "rejected", reason: "closed" });