@dungle-scrubs/harness-cli-normalizer 0.6.0 → 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 +18 -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 +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 +2 -2
  19. package/dist/cli/help.d.ts.map +1 -1
  20. package/dist/cli/help.js +6 -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 +25 -44
  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 +38 -12
  75. package/dist/interpretation/argv.d.ts.map +1 -1
  76. package/dist/interpretation/argv.js +26 -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/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 +6 -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 +34 -44
  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 +154 -228
  182. package/src/execution/stream-turn.ts +90 -193
  183. package/src/execution/supervisor.ts +199 -0
  184. package/src/interpretation/argv.ts +62 -34
  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
@@ -8,39 +8,39 @@
8
8
  * evidence, not diagnostics. An abandoned turn (consumer breaks early)
9
9
  * closes backpressure, disposes output, stops the child, and awaits both
10
10
  * pumps before it returns.
11
+ *
12
+ * The supervising policies - stall clock, signal escalation, stderr
13
+ * classification, question detection at close - are the turn supervisor's
14
+ * (RFC-02 change 5); this runner composes it and owns only what is
15
+ * particular to one-shot turns: the wall-clock deadline, the exit
16
+ * classification, and the identity-first event buffer.
11
17
  */
12
18
  import {
13
- buildLaunchArgv,
14
- buildResumeArgv,
19
+ buildSpawnArgv,
15
20
  type LaunchOptions,
21
+ promptTextOf,
16
22
  streamingGranularityOf,
23
+ withPromptText,
17
24
  } from "../interpretation/argv.js";
18
- import { stdinPolicyOf } from "../interpretation/dimensions.js";
19
- import {
20
- detectAuthFailureInLine,
21
- detectLimitInLine,
22
- detectTransportInLine,
23
- detectUnavailableInLine,
24
- } from "../interpretation/limits.js";
25
- import {
26
- composeEscalatedPrompt,
27
- detectQuestionBlock,
28
- type QuestionMode,
29
- } from "../interpretation/question.js";
25
+ import { detectTransportInLine, detectUnavailableInLine } from "../interpretation/limits.js";
26
+ import { composeEscalatedPrompt, type QuestionMode } from "../interpretation/question.js";
30
27
  import { ArgvRefusalError } from "../interpretation/refusal.js";
31
28
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
32
29
  import { matcherOverridesOf } from "../knowledge/overrides.js";
33
30
  import { AsyncChannel } from "./channel.js";
34
31
  import { decodeLine, freshDecodeState } from "./decode.js";
35
32
  import type { RunnerDeps, SpawnedProcess } from "./deps.js";
36
- import type { EscalationDetection, ExitCause, HarnessEvent } from "./events.js";
33
+ import {
34
+ DROPPABLE_KINDS,
35
+ type EscalationDetection,
36
+ type ExitCause,
37
+ type HarnessEvent,
38
+ } from "./events.js";
37
39
  import type { FailureSummary } from "./failure.js";
38
40
  import {
39
- failureFromAuth,
40
41
  failureFromLimit,
41
42
  failureFromNative,
42
43
  failureFromRejected,
43
- failureFromTask,
44
44
  failureFromTerminalError,
45
45
  failureFromTimeout,
46
46
  failureFromTransport,
@@ -48,15 +48,14 @@ import {
48
48
  reduceFailures,
49
49
  } from "./failure.js";
50
50
  import { LineBuffer } from "./lines.js";
51
+ import { StderrTail, superviseTurn } from "./supervisor.js";
51
52
 
52
53
  /** Fallback correlation when the host does not mint turn ids: monotonic per
53
54
  * process. Hosts that need cross-process uniqueness pass deps.turnId. */
54
55
  let turnCounter = 0;
55
56
 
56
- /** SIGTERM -> SIGKILL escalation budget for a child that ignores the first
57
- * signal, and the grace allowed for pipes still held open (by a grandchild)
58
- * after the process itself exited. */
59
- export const KILL_GRACE_MS = 5_000;
57
+ /** The grace allowed for pipes still held open (by a grandchild) after the
58
+ * process itself exited. */
60
59
  export const PIPE_GRACE_MS = 2_000;
61
60
 
62
61
  const OUTPUT_STREAMS = ["stdout", "stderr"] as const;
@@ -83,25 +82,6 @@ export const redactArgv = (argv: readonly string[], prompt?: string): string[] =
83
82
  });
84
83
  };
85
84
 
86
- /** Bounded tail of unmatched stderr - the crash context a nonzero exit is
87
- * explained by (v1 kept the turn's output slice for exactly this). Shared
88
- * with the session runner. */
89
- export class StderrTail {
90
- private readonly lines: string[] = [];
91
- private bytes = 0;
92
- push(line: string): void {
93
- this.lines.push(line);
94
- this.bytes += line.length;
95
- while (this.lines.length > 20 || (this.bytes > 4096 && this.lines.length > 1)) {
96
- const dropped = this.lines.shift();
97
- this.bytes -= dropped?.length ?? 0;
98
- }
99
- }
100
- snapshot(): readonly string[] {
101
- return [...this.lines];
102
- }
103
- }
104
-
105
85
  export interface TurnRunOptions extends LaunchOptions {
106
86
  /** Resume this session id instead of launching fresh - the turn spawns
107
87
  * with the descriptor's resume grammar, and identity decoding treats a
@@ -136,11 +116,9 @@ export async function* streamTurn(
136
116
  const questionMode: QuestionMode = opts.questions ?? "ask";
137
117
  const effective: TurnRunOptions = {
138
118
  ...opts,
139
- prompt: composeEscalatedPrompt(opts.prompt, questionMode),
119
+ prompt: withPromptText(opts.prompt, composeEscalatedPrompt(promptTextOf(opts), questionMode)),
140
120
  };
141
- // The turn's last assistant message - where the protocol says the
142
- // hcn-question block lives. Tracked only when detection is armed.
143
- let lastAssistantText: string | null = null;
121
+ const promptText = promptTextOf(effective);
144
122
  let asked = false;
145
123
  let escalationDetection: EscalationDetection = "none";
146
124
 
@@ -166,7 +144,7 @@ export async function* streamTurn(
166
144
  harness: h.name,
167
145
  issue: refusal.issue,
168
146
  supported: refusal.supported,
169
- argv: redactArgv([], effective.prompt),
147
+ argv: redactArgv([], promptText),
170
148
  });
171
149
  yield { kind: "failure", ...failure };
172
150
  yield {
@@ -184,19 +162,7 @@ export async function* streamTurn(
184
162
  let argv: string[];
185
163
  let granularity: import("../knowledge/descriptor.js").StreamingGranularity;
186
164
  try {
187
- argv =
188
- effective.resume === undefined
189
- ? buildLaunchArgv(h, effective)
190
- : buildResumeArgv(h, { ...effective, sessionId: effective.resume });
191
- if (effective.passthrough !== undefined && effective.passthrough.length > 0) {
192
- argv = [...argv, "--", ...effective.passthrough];
193
- }
194
- // issue #38: claude/codex render the skills allowlist as complement-off
195
- // tokens at the argv tail (claude: settings JSON; codex: -c skills.config).
196
- const skillTokens = (opts as unknown as { __skillTokens?: string[] }).__skillTokens;
197
- if (skillTokens !== undefined && skillTokens.length > 0) {
198
- argv = [...argv, ...skillTokens];
199
- }
165
+ argv = buildSpawnArgv(h, effective);
200
166
  granularity = streamingGranularityOf(h, argv);
201
167
  } catch (e) {
202
168
  if (e instanceof ArgvRefusalError) {
@@ -212,7 +178,7 @@ export async function* streamTurn(
212
178
  // No process spawned on a refusal - log rejected instead of spawn
213
179
  let argvForLog: string[] = [];
214
180
  try {
215
- argvForLog = redactArgv([], effective.prompt);
181
+ argvForLog = redactArgv([], promptText);
216
182
  } catch {}
217
183
  log({
218
184
  event: "rejected",
@@ -243,7 +209,7 @@ export async function* streamTurn(
243
209
  event: "spawn",
244
210
  turnId,
245
211
  harness: h.name,
246
- argv: redactArgv(argv, effective.prompt),
212
+ argv: redactArgv(argv, promptText),
247
213
  granularity,
248
214
  ...(matcherOverrides ? { matcherOverrides } : {}),
249
215
  ...(envKeys?.length ? { envKeys } : {}),
@@ -260,7 +226,7 @@ export async function* streamTurn(
260
226
  let proc: SpawnedProcess;
261
227
  try {
262
228
  proc = deps.spawn(argv, {
263
- stdin: stdinPolicyOf(h) === "close-required" ? "close" : "inherit",
229
+ stdin: h.stdin === "close-required" ? "close" : "inherit",
264
230
  ...(effective.cwd !== undefined ? { cwd: effective.cwd } : {}),
265
231
  ...(effective.env !== undefined ? { env: effective.env } : {}),
266
232
  });
@@ -303,34 +269,6 @@ export async function* streamTurn(
303
269
  let cancelled = false;
304
270
  let terminalEventReached = false;
305
271
 
306
- const safeSignal = (sig: "SIGTERM" | "SIGKILL"): void => {
307
- if (!exited) deps.signal(proc, sig);
308
- };
309
- let escalationTimer: number | null = null;
310
- const escalate = (): void => {
311
- if (exited || escalationTimer !== null) return;
312
- safeSignal("SIGTERM");
313
- escalationTimer = deps.clock.setTimeout(() => {
314
- escalationTimer = null;
315
- safeSignal("SIGKILL");
316
- }, KILL_GRACE_MS);
317
- };
318
- let abortHandler: (() => void) | null = null;
319
- if (opts.signal) {
320
- const onAbort = (): void => {
321
- if (killedByAbort) return;
322
- killedByAbort = true;
323
- escalate();
324
- };
325
- if (opts.signal.aborted) {
326
- killedByAbort = true;
327
- escalate();
328
- } else {
329
- opts.signal.addEventListener("abort", onAbort, { once: true });
330
- abortHandler = onAbort;
331
- }
332
- }
333
-
334
272
  const failures: FailureSummary[] = [];
335
273
  const pushFailure = async (f: FailureSummary): Promise<void> => {
336
274
  // Suppress a failure identical in class and message to the previous one
@@ -340,19 +278,26 @@ export async function* streamTurn(
340
278
  await queue.push({ kind: "failure", ...f });
341
279
  };
342
280
 
343
- let watchdog: number | null = null;
281
+ // The wall-clock deadline is this runner's own: a session has none.
344
282
  let turnDeadline: number | null = null;
345
283
  let watchdogReason: "inactivity" | "turn-deadline" | null = null;
346
- const disarm = (): void => {
347
- if (watchdog !== null) deps.clock.clearTimeout(watchdog);
348
- watchdog = null;
284
+ const disarmDeadline = (): void => {
349
285
  if (turnDeadline !== null) deps.clock.clearTimeout(turnDeadline);
350
286
  turnDeadline = null;
351
287
  };
352
- const rearm = (): void => {
353
- if (deps.stallMs === undefined) return;
354
- if (watchdog !== null) deps.clock.clearTimeout(watchdog);
355
- watchdog = deps.clock.setTimeout(() => {
288
+
289
+ const sup = superviseTurn(h, questionMode, {
290
+ clock: deps.clock,
291
+ stallMs: deps.stallMs,
292
+ signal: (sig) => deps.signal(proc, sig),
293
+ emit: async (event) => {
294
+ // A wall read on stderr counts like one decoded from stdout.
295
+ if (event.kind === "limit") state.limitSeen = true;
296
+ await queue.push(event);
297
+ },
298
+ fail: pushFailure,
299
+ tail: stderrTail,
300
+ onStall: () => {
356
301
  killedByWatchdog = true;
357
302
  watchdogReason = "inactivity";
358
303
  log({
@@ -363,15 +308,31 @@ export async function* streamTurn(
363
308
  budgetMs: deps.stallMs,
364
309
  });
365
310
  // Disarm the other budget
366
- if (turnDeadline !== null) {
367
- deps.clock.clearTimeout(turnDeadline);
368
- turnDeadline = null;
369
- }
370
- escalate();
371
- }, deps.stallMs);
372
- };
311
+ disarmDeadline();
312
+ },
313
+ onQuestion: (options) => {
314
+ log({ event: "question", turnId, harness: h.name, options });
315
+ },
316
+ });
317
+
318
+ let abortHandler: (() => void) | null = null;
319
+ if (opts.signal) {
320
+ const onAbort = (): void => {
321
+ if (killedByAbort) return;
322
+ killedByAbort = true;
323
+ sup.escalate();
324
+ };
325
+ if (opts.signal.aborted) {
326
+ killedByAbort = true;
327
+ sup.escalate();
328
+ } else {
329
+ opts.signal.addEventListener("abort", onAbort, { once: true });
330
+ abortHandler = onAbort;
331
+ }
332
+ }
333
+
373
334
  // Arm both budgets
374
- rearm();
335
+ sup.beginTurn();
375
336
  if (deps.turnTimeoutMs !== undefined) {
376
337
  turnDeadline = deps.clock.setTimeout(() => {
377
338
  killedByWatchdog = true;
@@ -383,11 +344,8 @@ export async function* streamTurn(
383
344
  reason: "turn-deadline",
384
345
  budgetMs: deps.turnTimeoutMs,
385
346
  });
386
- if (watchdog !== null) {
387
- deps.clock.clearTimeout(watchdog);
388
- watchdog = null;
389
- }
390
- escalate();
347
+ sup.disarm();
348
+ sup.escalate();
391
349
  }, deps.turnTimeoutMs);
392
350
  }
393
351
 
@@ -395,11 +353,11 @@ export async function* streamTurn(
395
353
  void proc.exited.then((code) => {
396
354
  exited = true;
397
355
  exitCode = code;
398
- if (escalationTimer !== null) deps.clock.clearTimeout(escalationTimer);
399
- escalationTimer = null;
400
- // The process is gone: the watchdog has nothing left to kill, and a
401
- // fire after this point would flip a completed turn to "stall".
402
- disarm();
356
+ // The process is gone: no signal, no pending escalation, no stall
357
+ // clock - a fire after this point would flip a completed turn to
358
+ // "stall".
359
+ sup.settle();
360
+ disarmDeadline();
403
361
  // Pipes held open past exit (a grandchild inherited the fd) must not
404
362
  // hang the turn forever - close out with the exit code in hand.
405
363
  if (cancelled) return;
@@ -414,8 +372,6 @@ export async function* streamTurn(
414
372
  let identitySeen = false;
415
373
  const droppableBuffer: HarnessEvent[] = [];
416
374
  const BUFFER_CAP = 256;
417
- const isDroppable = (kind: string): boolean =>
418
- kind === "progress" || kind === "token" || kind === "context";
419
375
  const flushDroppable = async (): Promise<void> => {
420
376
  for (const e of droppableBuffer) await queue.push(e);
421
377
  droppableBuffer.length = 0;
@@ -428,7 +384,7 @@ export async function* streamTurn(
428
384
  await flushDroppable();
429
385
  return;
430
386
  }
431
- if (isDroppable(event.kind)) {
387
+ if (DROPPABLE_KINDS.has(event.kind)) {
432
388
  if (droppableBuffer.length >= BUFFER_CAP) droppableBuffer.shift();
433
389
  droppableBuffer.push(event);
434
390
  return;
@@ -453,15 +409,13 @@ export async function* streamTurn(
453
409
  if (event.terminal === true) await pushFailure(failureFromTerminalError(h, event.message));
454
410
  return;
455
411
  }
456
- if (questionMode === "ask" && event.kind === "message" && event.role === "assistant") {
457
- lastAssistantText = event.text;
458
- }
412
+ sup.noteEvent(event);
459
413
  await queue.push(event);
460
414
  };
461
415
  for await (const chunk of proc.stdout) {
462
416
  if (cancelled) break;
463
417
  // Any output chunk rearms the inactivity budget, but not the wall-clock deadline
464
- if (deps.stallMs !== undefined) rearm();
418
+ sup.rearm();
465
419
  for (const line of lines.push(chunk)) {
466
420
  for (const event of decodeLine(h, line, state, opts.model ?? "", granularity)) {
467
421
  await handleEvent(event);
@@ -480,75 +434,12 @@ export async function* streamTurn(
480
434
  }
481
435
  };
482
436
 
483
- /** issue #41: scan the last assistant message for the hcn-question
484
- * block. Structured-first - the block's fields become the event; no
485
- * prose parsing. Runs after the pumps settle (the last message is only
486
- * last then) and only when detection is armed (questions ask).
487
- * A malformed block surfaces as an error event, never a silent
488
- * no-op. */
489
- const emitQuestionIfAsked = async (): Promise<void> => {
490
- if (questionMode !== "ask" || lastAssistantText === null) {
491
- escalationDetection = "none";
492
- return;
493
- }
494
- const detection = detectQuestionBlock(lastAssistantText);
495
- if (detection === null) {
496
- escalationDetection = "none";
497
- return;
498
- }
499
- if ("malformed" in detection) {
500
- escalationDetection = "malformed";
501
- await queue.push({ kind: "error", message: detection.malformed });
502
- await pushFailure(failureFromTask(`malformed hcn-question block: ${detection.malformed}`));
503
- return;
504
- }
505
- escalationDetection = "block";
506
- log({
507
- event: "question",
508
- turnId,
509
- harness: h.name,
510
- options: detection.block.options.length,
511
- });
512
- asked = true;
513
- await queue.push({
514
- kind: "question",
515
- question: detection.block.question,
516
- options: detection.block.options,
517
- ...(detection.block.recommended !== undefined
518
- ? { recommended: detection.block.recommended }
519
- : {}),
520
- });
521
- };
522
-
523
437
  const pumpStderr = async (): Promise<void> => {
524
438
  const lines = new LineBuffer();
525
439
  for await (const chunk of proc.stderr) {
526
440
  if (cancelled) break;
527
- if (deps.stallMs !== undefined) rearm();
528
- for (const line of lines.push(chunk)) {
529
- const limit = detectLimitInLine(h, line);
530
- if (limit !== null) {
531
- state.limitSeen = true;
532
- const failure = failureFromLimit(limit);
533
- // Emit both limit (for 0.1.3 compat) and failure
534
- await queue.push({
535
- kind: "limit",
536
- code: limit,
537
- message: `limit wall detected (${limit})`,
538
- });
539
- await pushFailure(failure);
540
- continue;
541
- }
542
- const auth = detectAuthFailureInLine(h, line);
543
- if (auth !== null) {
544
- const failure = failureFromAuth(auth);
545
- await pushFailure(failure);
546
- // Emit error alongside failure for 0.1.3 compat
547
- await queue.push({ kind: "error", message: `auth wall: ${auth}` });
548
- continue;
549
- }
550
- stderrTail.push(line);
551
- }
441
+ sup.rearm();
442
+ for (const line of lines.push(chunk)) await sup.stderrLine(line);
552
443
  }
553
444
  };
554
445
 
@@ -568,7 +459,7 @@ export async function* streamTurn(
568
459
  });
569
460
  await queue.push({ kind: "error", message: pumpFailureMessage(stream, cause) });
570
461
  await pushFailure(failureFromTransport(pumpFailureMessage(stream, cause)));
571
- escalate();
462
+ sup.escalate();
572
463
  await proc.exited;
573
464
  proc.disposeOutput();
574
465
  }
@@ -577,8 +468,14 @@ export async function* streamTurn(
577
468
  observePump("stdout", pumpStdout()),
578
469
  observePump("stderr", pumpStderr()),
579
470
  ]);
471
+ // issue #41: the turn closes after the pumps settle (the last message is
472
+ // only last then); the supervisor detects the block and emits it.
580
473
  void Promise.all([proc.exited, pumpSettlements])
581
- .then(() => emitQuestionIfAsked())
474
+ .then(() => {
475
+ const close = sup.close();
476
+ escalationDetection = close.detection;
477
+ asked = close.asked;
478
+ })
582
479
  .then(() => queue.close());
583
480
 
584
481
  try {
@@ -709,18 +606,18 @@ export async function* streamTurn(
709
606
  const abandoned = !terminalEventReached;
710
607
  cancelled = true;
711
608
  queue.close();
712
- disarm();
609
+ sup.disarm();
610
+ disarmDeadline();
713
611
  if (abandoned) {
714
612
  log({ event: "abandoned", turnId, harness: h.name });
715
- if (!exited) escalate();
613
+ if (!exited) sup.escalate();
716
614
  }
717
615
  proc.disposeOutput();
718
616
  const [settledExit] = await Promise.all([proc.exited, pumpSettlements]);
719
617
  exitCode = settledExit;
720
618
  if (pipeGrace !== null) deps.clock.clearTimeout(pipeGrace);
721
619
  pipeGrace = null;
722
- if (escalationTimer !== null) deps.clock.clearTimeout(escalationTimer);
723
- escalationTimer = null;
620
+ sup.settle();
724
621
  if (abandoned) {
725
622
  log({
726
623
  event: "abandonment_settled",
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The turn supervisor: the one owner of the supervising policies
3
+ * CONTEXT.md names (RFC-02 change 5), composed by both runners instead of
4
+ * written into each. It owns the stall clock, the SIGTERM-then-SIGKILL
5
+ * escalation, the classification of every stderr line, the tracking of
6
+ * the last assistant message, and the turn close that turns a detected
7
+ * hcn-question block into events. It emits through the callbacks its
8
+ * runner hands it and knows nothing about channels or turns.
9
+ */
10
+ import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
11
+ import {
12
+ type QuestionDetectionKind,
13
+ type QuestionMode,
14
+ questionEventOf,
15
+ } from "../interpretation/question.js";
16
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
17
+ import type { Clock, SignalName, TimerHandle } from "./deps.js";
18
+ import type { HarnessEvent } from "./events.js";
19
+ import {
20
+ type FailureSummary,
21
+ failureFromAuth,
22
+ failureFromLimit,
23
+ failureFromTask,
24
+ } from "./failure.js";
25
+
26
+ /** SIGTERM -> SIGKILL escalation budget for a child that ignores the first
27
+ * signal. */
28
+ export const KILL_GRACE_MS = 5_000;
29
+
30
+ /** Bounded tail of unmatched stderr - the crash context a nonzero exit is
31
+ * explained by (v1 kept the turn's output slice for exactly this). */
32
+ export class StderrTail {
33
+ private readonly lines: string[] = [];
34
+ private bytes = 0;
35
+ push(line: string): void {
36
+ this.lines.push(line);
37
+ this.bytes += line.length;
38
+ while (this.lines.length > 20 || (this.bytes > 4096 && this.lines.length > 1)) {
39
+ const dropped = this.lines.shift();
40
+ this.bytes -= dropped?.length ?? 0;
41
+ }
42
+ }
43
+ snapshot(): readonly string[] {
44
+ return [...this.lines];
45
+ }
46
+ }
47
+
48
+ export interface SupervisorIo {
49
+ readonly clock: Clock;
50
+ /** Inactivity budget; undefined means no stall clock at all. */
51
+ readonly stallMs: number | undefined;
52
+ /** Signal the supervised process. The supervisor never signals after
53
+ * `settle()`. */
54
+ readonly signal: (sig: SignalName) => void;
55
+ /** Deliver an event to the turn's consumer. */
56
+ readonly emit: (event: HarnessEvent) => Promise<void>;
57
+ /** Record a failure for the turn's done AND deliver it as an event. */
58
+ readonly fail: (summary: FailureSummary) => Promise<void>;
59
+ /** Where unclassified stderr goes. */
60
+ readonly tail: StderrTail;
61
+ /** The stall clock fired: the runner records its own cause and ends the
62
+ * turn; the supervisor has already begun escalation. */
63
+ readonly onStall: () => void;
64
+ /** A question was detected at close, with its option count (for the
65
+ * runner's boundary log). */
66
+ readonly onQuestion: (optionCount: number) => void;
67
+ }
68
+
69
+ export interface TurnClose {
70
+ readonly detection: QuestionDetectionKind;
71
+ /** True when the turn ended by asking: a successful turn, cause
72
+ * awaiting-input. */
73
+ readonly asked: boolean;
74
+ }
75
+
76
+ export interface TurnSupervisor {
77
+ /** Start a turn: reset per-turn state and arm the stall clock. */
78
+ beginTurn(): void;
79
+ /** Any output chunk arrived: restart the inactivity budget, if a turn is
80
+ * open and a budget exists. */
81
+ rearm(): void;
82
+ /** The turn ended, or the process did: stop the stall clock. */
83
+ disarm(): void;
84
+ /** SIGTERM now, SIGKILL after KILL_GRACE_MS unless the process exits.
85
+ * Idempotent while an escalation is pending. */
86
+ escalate(): void;
87
+ /** The process is gone: no further signals, no pending escalation, no
88
+ * stall clock. */
89
+ settle(): void;
90
+ /** Classify one stderr line: a limit wall becomes a limit event plus a
91
+ * failure, an auth wall a failure plus an error event, anything else
92
+ * goes to the tail. Returns the limit code when the line was a limit. */
93
+ stderrLine(line: string): Promise<void>;
94
+ /** A decoded event passed by: the last assistant message is where the
95
+ * protocol says the hcn-question block lives (tracked in ask mode). */
96
+ noteEvent(event: HarnessEvent): void;
97
+ /** At turn end: detect the block in the last assistant message, emit the
98
+ * question (or the malformation as an error plus a task failure), and
99
+ * report what was found. Synchronous: the events are enqueued, not
100
+ * awaited, so a runner can close a turn from a timer callback. */
101
+ close(): TurnClose;
102
+ }
103
+
104
+ export const superviseTurn = (
105
+ h: HarnessDescriptor,
106
+ questionMode: QuestionMode,
107
+ io: SupervisorIo,
108
+ ): TurnSupervisor => {
109
+ let settled = false;
110
+ let killTimer: TimerHandle | null = null;
111
+ let stallTimer: TimerHandle | null = null;
112
+ let turnOpen = false;
113
+ let lastAssistantText: string | null = null;
114
+
115
+ const signal = (sig: SignalName): void => {
116
+ if (!settled) io.signal(sig);
117
+ };
118
+
119
+ const disarm = (): void => {
120
+ if (stallTimer !== null) io.clock.clearTimeout(stallTimer);
121
+ stallTimer = null;
122
+ turnOpen = false;
123
+ };
124
+
125
+ const escalate = (): void => {
126
+ if (settled || killTimer !== null) return;
127
+ signal("SIGTERM");
128
+ killTimer = io.clock.setTimeout(() => {
129
+ killTimer = null;
130
+ signal("SIGKILL");
131
+ }, KILL_GRACE_MS);
132
+ };
133
+
134
+ const armStall = (): void => {
135
+ if (io.stallMs === undefined || !turnOpen) return;
136
+ if (stallTimer !== null) io.clock.clearTimeout(stallTimer);
137
+ stallTimer = io.clock.setTimeout(() => {
138
+ stallTimer = null;
139
+ // The turn may have ended between the timer firing and this callback
140
+ // running; a closed turn is never reported as a stall.
141
+ if (!turnOpen) return;
142
+ io.onStall();
143
+ escalate();
144
+ }, io.stallMs);
145
+ };
146
+
147
+ return {
148
+ beginTurn(): void {
149
+ lastAssistantText = null;
150
+ turnOpen = true;
151
+ armStall();
152
+ },
153
+ rearm(): void {
154
+ armStall();
155
+ },
156
+ disarm,
157
+ escalate,
158
+ settle(): void {
159
+ settled = true;
160
+ if (killTimer !== null) io.clock.clearTimeout(killTimer);
161
+ killTimer = null;
162
+ disarm();
163
+ },
164
+ async stderrLine(line: string): Promise<void> {
165
+ const limit = detectLimitInLine(h, line);
166
+ if (limit !== null) {
167
+ await io.emit({ kind: "limit", code: limit, message: `limit wall detected (${limit})` });
168
+ await io.fail(failureFromLimit(limit));
169
+ return;
170
+ }
171
+ const auth = detectAuthFailureInLine(h, line);
172
+ if (auth !== null) {
173
+ await io.fail(failureFromAuth(auth));
174
+ await io.emit({ kind: "error", message: `auth wall: ${auth}` });
175
+ return;
176
+ }
177
+ io.tail.push(line);
178
+ },
179
+ noteEvent(event: HarnessEvent): void {
180
+ if (questionMode === "ask" && event.kind === "message" && event.role === "assistant") {
181
+ lastAssistantText = event.text;
182
+ }
183
+ },
184
+ close(): TurnClose {
185
+ const found = questionEventOf(lastAssistantText, questionMode);
186
+ if (found.detection === "malformed") {
187
+ void io.emit({ kind: "error", message: found.malformed });
188
+ void io.fail(failureFromTask(`malformed hcn-question block: ${found.malformed}`));
189
+ return { detection: "malformed", asked: false };
190
+ }
191
+ if (found.detection === "block") {
192
+ io.onQuestion(found.event.options.length);
193
+ void io.emit(found.event);
194
+ return { detection: "block", asked: true };
195
+ }
196
+ return { detection: "none", asked: false };
197
+ },
198
+ };
199
+ };