@dungle-scrubs/harness-cli-normalizer 0.5.7 → 0.6.1

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