@dungle-scrubs/harness-cli-normalizer 0.6.0 → 0.6.2

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 (219) hide show
  1. package/README.md +33 -6
  2. package/dist/cli/args.d.ts +9 -1
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +36 -26
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/check.d.ts +14 -0
  7. package/dist/cli/check.d.ts.map +1 -1
  8. package/dist/cli/check.js +10 -3
  9. package/dist/cli/check.js.map +1 -1
  10. package/dist/cli/config.d.ts +4 -4
  11. package/dist/cli/config.d.ts.map +1 -1
  12. package/dist/cli/config.js +8 -6
  13. package/dist/cli/config.js.map +1 -1
  14. package/dist/cli/exit-codes.d.ts +0 -1
  15. package/dist/cli/exit-codes.d.ts.map +1 -1
  16. package/dist/cli/exit-codes.js +0 -1
  17. package/dist/cli/exit-codes.js.map +1 -1
  18. package/dist/cli/help.d.ts +2 -2
  19. package/dist/cli/help.d.ts.map +1 -1
  20. package/dist/cli/help.js +10 -2
  21. package/dist/cli/help.js.map +1 -1
  22. package/dist/cli/index.d.ts.map +1 -1
  23. package/dist/cli/index.js +3 -1
  24. package/dist/cli/index.js.map +1 -1
  25. package/dist/cli/inspect.d.ts.map +1 -1
  26. package/dist/cli/inspect.js +84 -265
  27. package/dist/cli/inspect.js.map +1 -1
  28. package/dist/cli/plan-turn.d.ts +68 -0
  29. package/dist/cli/plan-turn.d.ts.map +1 -0
  30. package/dist/cli/plan-turn.js +268 -0
  31. package/dist/cli/plan-turn.js.map +1 -0
  32. package/dist/cli/refuse.d.ts.map +1 -1
  33. package/dist/cli/refuse.js +2 -1
  34. package/dist/cli/refuse.js.map +1 -1
  35. package/dist/cli/resolve-harness.d.ts.map +1 -1
  36. package/dist/cli/resolve-harness.js +2 -3
  37. package/dist/cli/resolve-harness.js.map +1 -1
  38. package/dist/cli/run.d.ts.map +1 -1
  39. package/dist/cli/run.js +33 -367
  40. package/dist/cli/run.js.map +1 -1
  41. package/dist/cli/session-json.d.ts +1 -1
  42. package/dist/cli/session-json.d.ts.map +1 -1
  43. package/dist/cli/session-json.js +1 -3
  44. package/dist/cli/session-json.js.map +1 -1
  45. package/dist/cli/session.d.ts.map +1 -1
  46. package/dist/cli/session.js +35 -43
  47. package/dist/cli/session.js.map +1 -1
  48. package/dist/cli/skills-root.d.ts +1 -1
  49. package/dist/cli/skills-root.d.ts.map +1 -1
  50. package/dist/cli/skills-root.js +1 -1
  51. package/dist/cli/skills-root.js.map +1 -1
  52. package/dist/execution/decode.d.ts.map +1 -1
  53. package/dist/execution/decode.js +11 -36
  54. package/dist/execution/decode.js.map +1 -1
  55. package/dist/execution/events.d.ts +4 -1
  56. package/dist/execution/events.d.ts.map +1 -1
  57. package/dist/execution/events.js.map +1 -1
  58. package/dist/execution/failure.d.ts +3 -1
  59. package/dist/execution/failure.d.ts.map +1 -1
  60. package/dist/execution/failure.js +14 -11
  61. package/dist/execution/failure.js.map +1 -1
  62. package/dist/execution/open-session.d.ts +1 -1
  63. package/dist/execution/open-session.d.ts.map +1 -1
  64. package/dist/execution/open-session.js +150 -223
  65. package/dist/execution/open-session.js.map +1 -1
  66. package/dist/execution/stream-turn.d.ts +9 -14
  67. package/dist/execution/stream-turn.d.ts.map +1 -1
  68. package/dist/execution/stream-turn.js +85 -188
  69. package/dist/execution/stream-turn.js.map +1 -1
  70. package/dist/execution/supervisor.d.ts +71 -0
  71. package/dist/execution/supervisor.d.ts.map +1 -0
  72. package/dist/execution/supervisor.js +127 -0
  73. package/dist/execution/supervisor.js.map +1 -0
  74. package/dist/interpretation/argv.d.ts +39 -12
  75. package/dist/interpretation/argv.d.ts.map +1 -1
  76. package/dist/interpretation/argv.js +28 -20
  77. package/dist/interpretation/argv.js.map +1 -1
  78. package/dist/interpretation/content.d.ts +10 -1
  79. package/dist/interpretation/content.d.ts.map +1 -1
  80. package/dist/interpretation/content.js +20 -11
  81. package/dist/interpretation/content.js.map +1 -1
  82. package/dist/interpretation/hints.d.ts +1 -1
  83. package/dist/interpretation/hints.d.ts.map +1 -1
  84. package/dist/interpretation/hints.js +10 -11
  85. package/dist/interpretation/hints.js.map +1 -1
  86. package/dist/interpretation/index.d.ts +0 -1
  87. package/dist/interpretation/index.d.ts.map +1 -1
  88. package/dist/interpretation/index.js +0 -1
  89. package/dist/interpretation/index.js.map +1 -1
  90. package/dist/interpretation/isolation.d.ts +10 -0
  91. package/dist/interpretation/isolation.d.ts.map +1 -0
  92. package/dist/interpretation/isolation.js +30 -0
  93. package/dist/interpretation/isolation.js.map +1 -0
  94. package/dist/interpretation/limits.d.ts +2 -5
  95. package/dist/interpretation/limits.d.ts.map +1 -1
  96. package/dist/interpretation/limits.js +8 -62
  97. package/dist/interpretation/limits.js.map +1 -1
  98. package/dist/interpretation/question.d.ts +27 -0
  99. package/dist/interpretation/question.d.ts.map +1 -1
  100. package/dist/interpretation/question.js +28 -0
  101. package/dist/interpretation/question.js.map +1 -1
  102. package/dist/interpretation/refusal.d.ts +10 -5
  103. package/dist/interpretation/refusal.d.ts.map +1 -1
  104. package/dist/interpretation/refusal.js +24 -15
  105. package/dist/interpretation/refusal.js.map +1 -1
  106. package/dist/interpretation/resolve-options.d.ts +36 -2
  107. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  108. package/dist/interpretation/resolve-options.js +53 -22
  109. package/dist/interpretation/resolve-options.js.map +1 -1
  110. package/dist/interpretation/session-input.d.ts +44 -1
  111. package/dist/interpretation/session-input.d.ts.map +1 -1
  112. package/dist/interpretation/session-input.js +59 -4
  113. package/dist/interpretation/session-input.js.map +1 -1
  114. package/dist/interpretation/shape.d.ts +1 -0
  115. package/dist/interpretation/shape.d.ts.map +1 -1
  116. package/dist/interpretation/shape.js +10 -0
  117. package/dist/interpretation/shape.js.map +1 -1
  118. package/dist/interpretation/skills-selection.d.ts +25 -33
  119. package/dist/interpretation/skills-selection.d.ts.map +1 -1
  120. package/dist/interpretation/skills-selection.js +41 -48
  121. package/dist/interpretation/skills-selection.js.map +1 -1
  122. package/dist/interpretation/support.d.ts.map +1 -1
  123. package/dist/interpretation/support.js +25 -8
  124. package/dist/interpretation/support.js.map +1 -1
  125. package/dist/interpretation/tool-selection.d.ts +7 -6
  126. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  127. package/dist/interpretation/tool-selection.js +54 -116
  128. package/dist/interpretation/tool-selection.js.map +1 -1
  129. package/dist/interpretation/tool-vocabulary.d.ts +31 -12
  130. package/dist/interpretation/tool-vocabulary.d.ts.map +1 -1
  131. package/dist/interpretation/tool-vocabulary.js +19 -41
  132. package/dist/interpretation/tool-vocabulary.js.map +1 -1
  133. package/dist/interpretation/turn-options.d.ts +4 -0
  134. package/dist/interpretation/turn-options.d.ts.map +1 -1
  135. package/dist/interpretation/turn-options.js +107 -241
  136. package/dist/interpretation/turn-options.js.map +1 -1
  137. package/dist/interpretation/vocabulary.d.ts +8 -6
  138. package/dist/interpretation/vocabulary.d.ts.map +1 -1
  139. package/dist/interpretation/vocabulary.js +22 -9
  140. package/dist/interpretation/vocabulary.js.map +1 -1
  141. package/dist/knowledge/claude-code.d.ts +3 -1
  142. package/dist/knowledge/claude-code.d.ts.map +1 -1
  143. package/dist/knowledge/claude-code.js +17 -4
  144. package/dist/knowledge/claude-code.js.map +1 -1
  145. package/dist/knowledge/codex.d.ts.map +1 -1
  146. package/dist/knowledge/codex.js +31 -7
  147. package/dist/knowledge/codex.js.map +1 -1
  148. package/dist/knowledge/descriptor.d.ts +67 -36
  149. package/dist/knowledge/descriptor.d.ts.map +1 -1
  150. package/dist/knowledge/descriptor.js +50 -15
  151. package/dist/knowledge/descriptor.js.map +1 -1
  152. package/dist/knowledge/matchers.d.ts +12 -0
  153. package/dist/knowledge/matchers.d.ts.map +1 -1
  154. package/dist/knowledge/matchers.js +34 -0
  155. package/dist/knowledge/matchers.js.map +1 -1
  156. package/dist/knowledge/muse.d.ts.map +1 -1
  157. package/dist/knowledge/muse.js +13 -6
  158. package/dist/knowledge/muse.js.map +1 -1
  159. package/dist/knowledge/overrides.d.ts.map +1 -1
  160. package/dist/knowledge/overrides.js +9 -20
  161. package/dist/knowledge/overrides.js.map +1 -1
  162. package/dist/knowledge/pi.d.ts.map +1 -1
  163. package/dist/knowledge/pi.js +8 -6
  164. package/dist/knowledge/pi.js.map +1 -1
  165. package/dist/knowledge/profile.d.ts +1 -0
  166. package/dist/knowledge/profile.d.ts.map +1 -1
  167. package/dist/knowledge/profile.js +1 -0
  168. package/dist/knowledge/profile.js.map +1 -1
  169. package/package.json +1 -1
  170. package/src/cli/args.ts +39 -27
  171. package/src/cli/check.ts +10 -3
  172. package/src/cli/config.ts +14 -11
  173. package/src/cli/exit-codes.ts +0 -2
  174. package/src/cli/help.ts +10 -2
  175. package/src/cli/index.ts +3 -1
  176. package/src/cli/inspect.ts +88 -269
  177. package/src/cli/plan-turn.ts +350 -0
  178. package/src/cli/refuse.ts +2 -1
  179. package/src/cli/resolve-harness.ts +4 -5
  180. package/src/cli/run.ts +36 -399
  181. package/src/cli/session-json.ts +1 -6
  182. package/src/cli/session.ts +51 -44
  183. package/src/cli/skills-root.ts +1 -4
  184. package/src/execution/decode.ts +10 -36
  185. package/src/execution/events.ts +4 -1
  186. package/src/execution/failure.ts +18 -11
  187. package/src/execution/open-session.ts +154 -228
  188. package/src/execution/stream-turn.ts +90 -193
  189. package/src/execution/supervisor.ts +199 -0
  190. package/src/interpretation/argv.ts +65 -34
  191. package/src/interpretation/content.ts +30 -13
  192. package/src/interpretation/hints.ts +2 -12
  193. package/src/interpretation/index.ts +0 -1
  194. package/src/interpretation/isolation.ts +39 -0
  195. package/src/interpretation/limits.ts +14 -75
  196. package/src/interpretation/question.ts +46 -0
  197. package/src/interpretation/refusal.ts +28 -18
  198. package/src/interpretation/resolve-options.ts +98 -33
  199. package/src/interpretation/session-input.ts +88 -5
  200. package/src/interpretation/shape.ts +10 -0
  201. package/src/interpretation/skills-selection.ts +70 -74
  202. package/src/interpretation/support.ts +21 -6
  203. package/src/interpretation/tool-selection.ts +67 -122
  204. package/src/interpretation/tool-vocabulary.ts +48 -53
  205. package/src/interpretation/turn-options.ts +125 -220
  206. package/src/interpretation/vocabulary.ts +15 -10
  207. package/src/knowledge/claude-code.ts +17 -4
  208. package/src/knowledge/codex.ts +31 -7
  209. package/src/knowledge/descriptor.ts +106 -49
  210. package/src/knowledge/matchers.ts +35 -0
  211. package/src/knowledge/muse.ts +13 -6
  212. package/src/knowledge/overrides.ts +17 -32
  213. package/src/knowledge/pi.ts +8 -6
  214. package/src/knowledge/profile.ts +1 -0
  215. package/dist/interpretation/dimensions.d.ts +0 -11
  216. package/dist/interpretation/dimensions.d.ts.map +0 -1
  217. package/dist/interpretation/dimensions.js +0 -4
  218. package/dist/interpretation/dimensions.js.map +0 -1
  219. package/src/interpretation/dimensions.ts +0 -14
@@ -10,36 +10,43 @@
10
10
  * silently dropped. Structured lifecycle events (session open/close, turn
11
11
  * start/end, send dispositions, drops) are always-on evidence with
12
12
  * sessionId + turnId correlation.
13
+ *
14
+ * The supervising policies - stall clock, signal escalation, stderr
15
+ * classification, question detection at close - are the turn supervisor's
16
+ * (RFC-02 change 5), composed once per session and begun per turn. This
17
+ * runner owns only what is particular to sessions: turn delimiting, the
18
+ * send correlation, and the close-versus-abandon distinction.
13
19
  */
14
20
  import { buildSessionArgv } from "../interpretation/argv.js";
15
21
  import { capabilitiesOf } from "../interpretation/capabilities.js";
16
- import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
17
- import {
18
- composeEscalatedPrompt,
19
- detectQuestionBlock,
20
- type QuestionMode,
21
- } from "../interpretation/question.js";
22
+ import { composeEscalatedPrompt, type QuestionMode } from "../interpretation/question.js";
22
23
  import {
24
+ decodeSessionRecord,
25
+ encodeIdentityProbe,
23
26
  encodeSessionInput,
24
27
  resolveSessionInput,
25
28
  SessionInputRefusalError,
26
29
  } from "../interpretation/session-input.js";
27
30
  import type { HarnessDescriptor, SessionInputContract } from "../knowledge/descriptor.js";
28
31
  import { AsyncChannel } from "./channel.js";
29
- import { decodeParsed, freshDecodeState } from "./decode.js";
30
- import type { RunnerDeps, SpawnedProcess, TimerHandle } from "./deps.js";
31
- import type { EscalationDetection, ExitCause, HarnessEvent } from "./events.js";
32
+ import { decodeLine, decodeParsed, freshDecodeState } from "./decode.js";
33
+ import type { RunnerDeps, SpawnedProcess } from "./deps.js";
34
+ import {
35
+ DROPPABLE_KINDS,
36
+ type EscalationDetection,
37
+ type ExitCause,
38
+ type HarnessEvent,
39
+ } from "./events.js";
32
40
  import type { FailureSummary } from "./failure.js";
33
41
  import {
34
- failureFromAuth,
35
42
  failureFromLimit,
36
- failureFromTask,
37
43
  failureFromTerminalError,
38
44
  failureFromTransport,
39
45
  reduceFailures,
40
46
  } from "./failure.js";
41
47
  import { LineBuffer } from "./lines.js";
42
- import { KILL_GRACE_MS, PIPE_GRACE_MS, redactArgv, StderrTail } from "./stream-turn.js";
48
+ import { PIPE_GRACE_MS, redactArgv } from "./stream-turn.js";
49
+ import { StderrTail, superviseTurn } from "./supervisor.js";
43
50
 
44
51
  /** Grace after stdin EOF before concluding the child will not exit on its
45
52
  * own and escalating signals. */
@@ -197,40 +204,26 @@ export const openSession = (
197
204
  let turnLimitSeen = false;
198
205
  let turnFailures: FailureSummary[] = [];
199
206
  let pumpError: unknown = null;
200
- // issue #44: the active turn's last assistant message (where the
201
- // hcn-question block lives) and whether the turn ended by asking.
202
- let lastAssistantText: string | null = null;
207
+ // issue #44: whether the active turn ended by asking, and what its close
208
+ // detected.
203
209
  let turnAsked = false;
204
210
  let turnEscalationDetection: EscalationDetection = "none";
205
211
  let identityAnnounced = false;
206
-
207
- const safeSignal = (sig: "SIGTERM" | "SIGKILL"): void => {
208
- if (!dead) deps.signal(proc, sig);
209
- };
210
- const escalate = (): void => {
211
- safeSignal("SIGTERM");
212
- deps.clock.setTimeout(() => safeSignal("SIGKILL"), KILL_GRACE_MS);
213
- };
214
-
215
- // Per-turn inactivity budget. A session turn can hang with the process
216
- // alive and the pipes open, which no exit code reports; without this the
217
- // consumer waits forever. Armed at turn start, rearmed on any output
218
- // chunk, disarmed at turn end and at exit - the same discipline
219
- // streamTurn uses, scoped to the turn rather than the process.
220
- let stallTimer: TimerHandle | null = null;
212
+ // A stall killed the process on purpose, so the signal death it caused
213
+ // reports as "stall", not "killed".
221
214
  let stalled = false;
222
- const disarmStall = (): void => {
223
- if (stallTimer !== null) deps.clock.clearTimeout(stallTimer);
224
- stallTimer = null;
225
- };
226
- const rearmStall = (): void => {
227
- if (deps.stallMs === undefined || activeTurn === null) return;
228
- disarmStall();
229
- stallTimer = deps.clock.setTimeout(() => {
230
- // The turn may have ended between the timer firing and this callback
231
- // running. Without this guard a clean turn that finished near the
232
- // budget would be reported as a stall and the child signalled.
233
- if (activeTurn === null) return;
215
+
216
+ // The supervisor spans the session; each turn begins and ends on it. A
217
+ // session turn can hang with the process alive and the pipes open, which
218
+ // no exit code reports; the stall clock is what bounds that wait.
219
+ const sup = superviseTurn(h, questionMode, {
220
+ clock: deps.clock,
221
+ stallMs: deps.stallMs,
222
+ signal: (sig) => deps.signal(proc, sig),
223
+ emit: (event) => routeEvent(event),
224
+ fail: (summary) => pushFailure(summary),
225
+ tail: stderrTail,
226
+ onStall: () => {
234
227
  stalled = true;
235
228
  log({
236
229
  event: "stall",
@@ -244,9 +237,17 @@ export const openSession = (
244
237
  // the exit path then closes the session with the same cause.
245
238
  void pushFailure(failureFromTransport("stalled: inactivity"));
246
239
  endTurn({ kind: "done", exitCode: null, cause: "stall" });
247
- escalate();
248
- }, deps.stallMs);
249
- };
240
+ },
241
+ onQuestion: (options) => {
242
+ log({
243
+ event: "question",
244
+ sessionId: opts.sessionId,
245
+ turnId: activeTurnId,
246
+ harness: h.name,
247
+ options,
248
+ });
249
+ },
250
+ });
250
251
 
251
252
  const writeUser = (text: string): boolean => {
252
253
  try {
@@ -261,7 +262,7 @@ export const openSession = (
261
262
  // very signal that stops it. The exit path then finalizes as usual.
262
263
  void routeEvent({ kind: "error", message: "send failed: session stdin is gone" });
263
264
  closing = true;
264
- escalate();
265
+ sup.escalate();
265
266
  return false;
266
267
  }
267
268
  };
@@ -277,7 +278,6 @@ export const openSession = (
277
278
  turnFailures = [];
278
279
  turnAsked = false;
279
280
  turnEscalationDetection = "none";
280
- lastAssistantText = null;
281
281
  activeTurn = new AsyncChannel<HarnessEvent>();
282
282
  activeTurnId = `${opts.sessionId}:turn-${++turnCounter}`;
283
283
  // Tag the turn with the id of the send that opened it, so the consumer
@@ -291,58 +291,21 @@ export const openSession = (
291
291
  activeTurn.push(held);
292
292
  }
293
293
  turnsChannel.push(activeTurn as SessionTurn);
294
- rearmStall();
295
- };
296
-
297
- /** issue #44: at a turn boundary, scan the last assistant message for
298
- * the hcn-question block - same structured-first discipline and
299
- * last-message rule as streamTurn. The question event lands in the
300
- * turn stream right before its done; a malformed block surfaces as an
301
- * error event, never a silent no-op. */
302
- const emitQuestionIfAsked = (): void => {
303
- if (questionMode !== "ask" || lastAssistantText === null) {
304
- turnEscalationDetection = "none";
305
- return;
306
- }
307
- const detection = detectQuestionBlock(lastAssistantText);
308
- if (detection === null) {
309
- turnEscalationDetection = "none";
310
- return;
311
- }
312
- if ("malformed" in detection) {
313
- turnEscalationDetection = "malformed";
314
- activeTurn?.push({ kind: "error", message: detection.malformed });
315
- const failure = failureFromTask(`malformed hcn-question block: ${detection.malformed}`);
316
- turnFailures.push(failure);
317
- void activeTurn?.push({ kind: "failure", ...failure });
318
- return;
319
- }
320
- turnEscalationDetection = "block";
321
- turnAsked = true;
322
- log({
323
- event: "question",
324
- sessionId: opts.sessionId,
325
- turnId: activeTurnId,
326
- harness: h.name,
327
- options: detection.block.options.length,
328
- });
329
- activeTurn?.push({
330
- kind: "question",
331
- question: detection.block.question,
332
- options: detection.block.options,
333
- ...(detection.block.recommended !== undefined
334
- ? { recommended: detection.block.recommended }
335
- : {}),
336
- });
294
+ sup.beginTurn();
337
295
  };
338
296
 
339
297
  const endTurn = (done: Omit<HarnessEvent & { kind: "done" }, "escalation">): void => {
340
298
  if (activeTurn === null) return;
341
- disarmStall();
342
- // Asking is a successful turn: the session semantic is "blocked on
343
- // answer, session alive" - the done stays TURN-scoped (exitCode null
344
- // in sessions) and the caller answers with the next send().
345
- emitQuestionIfAsked();
299
+ sup.disarm();
300
+ // issue #44: the supervisor scans the last assistant message for the
301
+ // hcn-question block; the question event lands in the turn stream
302
+ // right before its done. Asking is a successful turn: the session
303
+ // semantic is "blocked on answer, session alive" - the done stays
304
+ // TURN-scoped (exitCode null in sessions) and the caller answers with
305
+ // the next send().
306
+ const close = sup.close();
307
+ turnEscalationDetection = close.detection;
308
+ turnAsked = close.asked;
346
309
  if (turnAsked && done.cause === "clean") done = { ...done, cause: "awaiting-input" };
347
310
  // RFC-01 every turn end carries the escalation record
348
311
  let fullDone: HarnessEvent & { kind: "done" } = {
@@ -385,9 +348,7 @@ export const openSession = (
385
348
  state.limitSeen = true;
386
349
  turnLimitSeen = true;
387
350
  }
388
- if (questionMode === "ask" && event.kind === "message" && event.role === "assistant") {
389
- lastAssistantText = event.text;
390
- }
351
+ sup.noteEvent(event);
391
352
  if (activeTurn !== null) {
392
353
  // Awaited by the pumps: past the channel's high water mark this
393
354
  // blocks the pump, so OS pipe backpressure reaches the child.
@@ -397,9 +358,7 @@ export const openSession = (
397
358
  // droppable events go first, then oldest.
398
359
  preTurnEvents.push(event);
399
360
  if (preTurnEvents.length > PRETURN_MAX) {
400
- const droppableAt = preTurnEvents.findIndex(
401
- (e) => e.kind === "token" || e.kind === "progress" || e.kind === "context",
402
- );
361
+ const droppableAt = preTurnEvents.findIndex((e) => DROPPABLE_KINDS.has(e.kind));
403
362
  preTurnEvents.splice(droppableAt === -1 ? 0 : droppableAt, 1);
404
363
  }
405
364
  return Promise.resolve();
@@ -410,35 +369,63 @@ export const openSession = (
410
369
  return routeEvent({ kind: "failure", ...f });
411
370
  };
412
371
 
413
- /** A decoded event other than a failure: a terminal error also records
414
- * the failure it stands for, the way streamTurn does. */
372
+ /** A decoded event other than a failure: a limit also records the
373
+ * failure it stands for, and so does a terminal error, the way
374
+ * streamTurn does. */
415
375
  const routeDecoded = async (event: HarnessEvent): Promise<void> => {
376
+ if (event.kind === "failure") {
377
+ await pushFailure(summaryOf(event));
378
+ return;
379
+ }
416
380
  await routeEvent(event);
381
+ if (event.kind === "limit") await pushFailure(failureFromLimit(event.code));
417
382
  if (event.kind === "error" && event.terminal === true) {
418
383
  await pushFailure(failureFromTerminalError(h, event.message));
419
384
  }
420
385
  };
421
386
 
387
+ /** The probe answered: bind the announced id under the descriptor's
388
+ * authority, or surface a rotation. */
389
+ const announceIdentity = async (announced: string): Promise<void> => {
390
+ if (identityAnnounced) return;
391
+ if (sessionInputMode?.idFlag === null) {
392
+ // Harness-MINTED identity (pi rpc: `--session` refuses unknown ids,
393
+ // so fresh sessions omit the flag). The minted id IS the identity;
394
+ // opts.sessionId stays the caller-side handle.
395
+ identityAnnounced = true;
396
+ state.lastSeenId = announced;
397
+ await routeEvent({
398
+ kind: "identity",
399
+ sessionId: announced,
400
+ authority: "harness-minted",
401
+ capabilities: capabilitiesOf(h, opts.model ?? "", "headless-session"),
402
+ });
403
+ return;
404
+ }
405
+ if (announced === opts.sessionId) {
406
+ identityAnnounced = true;
407
+ await routeEvent({
408
+ kind: "identity",
409
+ sessionId: announced,
410
+ authority: "caller-assigned",
411
+ capabilities: capabilitiesOf(h, opts.model ?? "", "headless-session"),
412
+ });
413
+ return;
414
+ }
415
+ await routeEvent({
416
+ kind: "error",
417
+ message: `identity rotated: session announced ${JSON.stringify(announced)} but ${opts.sessionId} was requested`,
418
+ });
419
+ };
420
+
422
421
  const pumpStdout = async (): Promise<void> => {
423
422
  const lines = new LineBuffer();
424
- const matches = (
425
- record: Record<string, unknown>,
426
- spec: Readonly<Record<string, string>>,
427
- ): boolean => {
428
- for (const [key, expected] of Object.entries(spec)) {
429
- if (record[key] !== expected) return false;
430
- }
431
- return true;
432
- };
433
- // issue #44: pi rpc is identity-silent at startup; the probe round
434
- // trip is the only way to read the id (spike fixtures). The response
435
- // echoes our marker id, so it cannot be confused with a user-visible
436
- // get_state response.
437
- if (sessionInputMode?.identityProbe !== null && sessionInputMode !== null) {
423
+ // issue #44: a harness that is identity-silent at startup gets the
424
+ // probe its descriptor declares; interpretation encodes it (ADR 0005).
425
+ const probe = encodeIdentityProbe(h);
426
+ if (probe !== null) {
438
427
  try {
439
- stdin.write(
440
- `${JSON.stringify({ id: "hcn-identity", type: sessionInputMode.identityProbe.command })}\n`,
441
- );
428
+ stdin.write(probe);
442
429
  } catch {
443
430
  // stdin already gone; the exited handler will surface the death.
444
431
  }
@@ -448,99 +435,57 @@ export const openSession = (
448
435
  try {
449
436
  parsed = JSON.parse(line) as Record<string, unknown>;
450
437
  } catch {
451
- const code = detectLimitInLine(h, line);
452
- if (code !== null) {
453
- await routeEvent({ kind: "limit", code, message: `limit wall detected (${code})` });
454
- await pushFailure(failureFromLimit(code));
438
+ // Not a record: the shared decoder reads the one signal a plain
439
+ // line can carry (a wall) and nothing else.
440
+ for (const event of decodeLine(h, line, state, opts.model ?? "")) {
441
+ await routeDecoded(event);
455
442
  }
456
443
  return;
457
444
  }
458
- // pi rpc bookkeeping: the probe response announces identity; a
459
- // failed command response is a surfaced error, never a silent drop
460
- // (spike: mid-stream prompts fail with success:false naming the
461
- // remedy - hcn never sends those, but any other failure shows here).
462
- if (parsed.type === "response") {
463
- if (
464
- parsed.id === "hcn-identity" &&
465
- typeof parsed.command === "string" &&
466
- parsed.command === sessionInputMode?.identityProbe?.command &&
467
- parsed.success === true
468
- ) {
469
- const data = parsed.data as Record<string, unknown> | undefined;
470
- const announced = data?.sessionId;
471
- if (typeof announced !== "string") {
472
- await routeEvent({
473
- kind: "error",
474
- message: "identity probe response carried no sessionId",
475
- });
476
- } else if (sessionInputMode.idFlag === null) {
477
- // Harness-MINTED identity (pi rpc: `--session` refuses unknown
478
- // ids, so fresh sessions omit the flag). The minted id IS the
479
- // identity; opts.sessionId stays the caller-side handle.
480
- if (!identityAnnounced) {
481
- identityAnnounced = true;
482
- state.lastSeenId = announced;
483
- await routeEvent({
484
- kind: "identity",
485
- sessionId: announced,
486
- authority: "harness-minted",
487
- capabilities: capabilitiesOf(h, opts.model ?? "", "headless-session"),
488
- });
489
- }
490
- } else if (announced === opts.sessionId) {
491
- if (!identityAnnounced) {
492
- identityAnnounced = true;
493
- await routeEvent({
494
- kind: "identity",
495
- sessionId: announced,
496
- authority: "caller-assigned",
497
- capabilities: capabilitiesOf(h, opts.model ?? "", "headless-session"),
498
- });
499
- }
500
- } else {
501
- await routeEvent({
502
- kind: "error",
503
- message: `identity rotated: session announced ${JSON.stringify(announced)} but ${opts.sessionId} was requested`,
504
- });
445
+ // Interpretation says what the record means; this runner only routes
446
+ // on the kind (ADR 0005: no harness field names here).
447
+ const record = decodeSessionRecord(h, parsed);
448
+ switch (record.kind) {
449
+ case "identity":
450
+ await announceIdentity(record.sessionId);
451
+ return;
452
+ case "probe-failed":
453
+ case "command-failed":
454
+ await routeEvent({ kind: "error", message: record.message });
455
+ return;
456
+ case "ignored":
457
+ return;
458
+ case "turn-end": {
459
+ // The turn-end record still feeds identity dedupe (claude includes
460
+ // session_id on result - a rotation announced there must not be
461
+ // missed) and content decoding, which already surfaces a failed
462
+ // result as an error event; the flag here only classifies the
463
+ // done cause.
464
+ for (const event of decodeParsed(h, parsed, state, opts.model ?? "")) {
465
+ await routeDecoded(event);
505
466
  }
467
+ if (record.isError) resultError = true;
468
+ endTurn({
469
+ kind: "done",
470
+ exitCode: null,
471
+ cause: turnLimitSeen ? "limit" : resultError ? "crash" : "clean",
472
+ });
506
473
  return;
507
474
  }
508
- if (parsed.success === false) {
509
- await routeEvent({
510
- kind: "error",
511
- message: `rpc command failed: ${JSON.stringify(parsed.command)} - ${JSON.stringify(parsed.error ?? "unknown error")}`,
512
- });
475
+ case "content": {
476
+ for (const event of decodeParsed(h, parsed, state, opts.model ?? "")) {
477
+ await routeDecoded(event);
478
+ }
479
+ return;
513
480
  }
514
- return;
515
- }
516
- // The turn-end record still feeds identity dedupe (claude includes
517
- // session_id on result - a rotation announced there must not be
518
- // missed).
519
- const events = decodeParsed(h, parsed, state, opts.model ?? "");
520
- const isTurnEnd = sessionInputMode !== null && matches(parsed, sessionInputMode.turnEnd);
521
- if (isTurnEnd) {
522
- // decodeParsed already surfaces the is_error case as an error event
523
- // (content.ts claude reader); routing the events is enough - we only
524
- // still track resultError here to classify the done cause.
525
- for (const event of events) {
526
- if (event.kind === "failure") await pushFailure(summaryOf(event));
527
- else await routeDecoded(event);
481
+ default: {
482
+ const exhaustive: never = record;
483
+ return exhaustive;
528
484
  }
529
- if (parsed.is_error === true) resultError = true;
530
- endTurn({
531
- kind: "done",
532
- exitCode: null,
533
- cause: turnLimitSeen ? "limit" : resultError ? "crash" : "clean",
534
- });
535
- return;
536
- }
537
- for (const event of events) {
538
- if (event.kind === "failure") await pushFailure(summaryOf(event));
539
- else await routeDecoded(event);
540
485
  }
541
486
  };
542
487
  for await (const chunk of proc.stdout) {
543
- rearmStall();
488
+ sup.rearm();
544
489
  for (const line of lines.push(chunk)) await handleLine(line);
545
490
  }
546
491
  const rest = lines.flush();
@@ -550,26 +495,8 @@ export const openSession = (
550
495
  const pumpStderr = async (): Promise<void> => {
551
496
  const lines = new LineBuffer();
552
497
  for await (const chunk of proc.stderr) {
553
- rearmStall();
554
- for (const line of lines.push(chunk)) {
555
- const limit = detectLimitInLine(h, line);
556
- if (limit !== null) {
557
- await routeEvent({
558
- kind: "limit",
559
- code: limit,
560
- message: `limit wall detected (${limit})`,
561
- });
562
- await pushFailure(failureFromLimit(limit));
563
- continue;
564
- }
565
- const auth = detectAuthFailureInLine(h, line);
566
- if (auth !== null) {
567
- await pushFailure(failureFromAuth(auth));
568
- await routeEvent({ kind: "error", message: `auth wall: ${auth}` });
569
- continue;
570
- }
571
- stderrTail.push(line);
572
- }
498
+ sup.rearm();
499
+ for (const line of lines.push(chunk)) await sup.stderrLine(line);
573
500
  }
574
501
  };
575
502
 
@@ -586,8 +513,6 @@ export const openSession = (
586
513
  const finalize = (): void => {
587
514
  if (finalized) return;
588
515
  finalized = true;
589
- // A stall killed the process on purpose, so the signal death it caused
590
- // reports as "stall", not "killed".
591
516
  const cause: ExitCause = stalled
592
517
  ? "stall"
593
518
  : state.limitSeen
@@ -618,7 +543,7 @@ export const openSession = (
618
543
  pendingLengths.length = 0;
619
544
  }
620
545
  endTurn({ kind: "done", exitCode, cause });
621
- if (preTurnEvents.some((e) => e.kind !== "token" && e.kind !== "progress")) {
546
+ if (preTurnEvents.some((e) => !DROPPABLE_KINDS.has(e.kind))) {
622
547
  log({
623
548
  event: "preturn_events_dropped",
624
549
  sessionId: opts.sessionId,
@@ -646,8 +571,9 @@ export const openSession = (
646
571
  turnSettled = null;
647
572
  release();
648
573
  }
649
- // The process is gone: a later fire would flip a finished turn to stall.
650
- disarmStall();
574
+ // The process is gone: no signal, no pending escalation, no stall
575
+ // clock - a later fire would flip a finished turn to stall.
576
+ sup.settle();
651
577
  // Pipes held open past exit (a grandchild) must not hang the session.
652
578
  const pipeGrace = deps.clock.setTimeout(() => {
653
579
  pipesOpenAtExit = true;
@@ -683,7 +609,7 @@ export const openSession = (
683
609
  // stdin may already be gone - escalation below still bounds close.
684
610
  }
685
611
  // A child that does not exit on stdin EOF gets signalled after grace.
686
- const closeGrace = deps.clock.setTimeout(() => escalate(), CLOSE_GRACE_MS);
612
+ const closeGrace = deps.clock.setTimeout(() => sup.escalate(), CLOSE_GRACE_MS);
687
613
  await shutdown;
688
614
  deps.clock.clearTimeout(closeGrace);
689
615
  };