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

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 (99) hide show
  1. package/README.md +3 -2
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +16 -8
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/config.d.ts.map +1 -1
  6. package/dist/cli/config.js +11 -4
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/help.d.ts +3 -3
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +32 -23
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/cli/inspect.d.ts.map +1 -1
  13. package/dist/cli/inspect.js +10 -7
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/resume-guard.d.ts.map +1 -1
  16. package/dist/cli/resume-guard.js +59 -2
  17. package/dist/cli/resume-guard.js.map +1 -1
  18. package/dist/cli/run.d.ts.map +1 -1
  19. package/dist/cli/run.js +31 -32
  20. package/dist/cli/run.js.map +1 -1
  21. package/dist/cli/session-json.d.ts +5 -2
  22. package/dist/cli/session-json.d.ts.map +1 -1
  23. package/dist/cli/session-json.js +5 -3
  24. package/dist/cli/session-json.js.map +1 -1
  25. package/dist/cli/session.d.ts.map +1 -1
  26. package/dist/cli/session.js +103 -36
  27. package/dist/cli/session.js.map +1 -1
  28. package/dist/execution/events.d.ts +8 -1
  29. package/dist/execution/events.d.ts.map +1 -1
  30. package/dist/execution/events.js.map +1 -1
  31. package/dist/execution/open-session.d.ts +7 -6
  32. package/dist/execution/open-session.d.ts.map +1 -1
  33. package/dist/execution/open-session.js +88 -59
  34. package/dist/execution/open-session.js.map +1 -1
  35. package/dist/execution/stream-turn.d.ts +4 -7
  36. package/dist/execution/stream-turn.d.ts.map +1 -1
  37. package/dist/execution/stream-turn.js +43 -22
  38. package/dist/execution/stream-turn.js.map +1 -1
  39. package/dist/interpretation/argv.d.ts +9 -5
  40. package/dist/interpretation/argv.d.ts.map +1 -1
  41. package/dist/interpretation/argv.js +2 -7
  42. package/dist/interpretation/argv.js.map +1 -1
  43. package/dist/interpretation/capabilities.d.ts +20 -0
  44. package/dist/interpretation/capabilities.d.ts.map +1 -1
  45. package/dist/interpretation/capabilities.js +27 -0
  46. package/dist/interpretation/capabilities.js.map +1 -1
  47. package/dist/interpretation/hints.js +1 -1
  48. package/dist/interpretation/hints.js.map +1 -1
  49. package/dist/interpretation/question.d.ts +6 -2
  50. package/dist/interpretation/question.d.ts.map +1 -1
  51. package/dist/interpretation/question.js +13 -4
  52. package/dist/interpretation/question.js.map +1 -1
  53. package/dist/interpretation/refusal.d.ts +1 -1
  54. package/dist/interpretation/refusal.d.ts.map +1 -1
  55. package/dist/interpretation/refusal.js +1 -1
  56. package/dist/interpretation/refusal.js.map +1 -1
  57. package/dist/interpretation/skills-selection.d.ts +18 -1
  58. package/dist/interpretation/skills-selection.d.ts.map +1 -1
  59. package/dist/interpretation/skills-selection.js +33 -6
  60. package/dist/interpretation/skills-selection.js.map +1 -1
  61. package/dist/knowledge/claude-code.d.ts.map +1 -1
  62. package/dist/knowledge/claude-code.js +21 -0
  63. package/dist/knowledge/claude-code.js.map +1 -1
  64. package/dist/knowledge/codex.d.ts.map +1 -1
  65. package/dist/knowledge/codex.js +26 -7
  66. package/dist/knowledge/codex.js.map +1 -1
  67. package/dist/knowledge/descriptor.d.ts +39 -12
  68. package/dist/knowledge/descriptor.d.ts.map +1 -1
  69. package/dist/knowledge/descriptor.js +1 -0
  70. package/dist/knowledge/descriptor.js.map +1 -1
  71. package/dist/knowledge/muse.d.ts.map +1 -1
  72. package/dist/knowledge/muse.js +7 -0
  73. package/dist/knowledge/muse.js.map +1 -1
  74. package/dist/knowledge/pi.d.ts.map +1 -1
  75. package/dist/knowledge/pi.js +23 -4
  76. package/dist/knowledge/pi.js.map +1 -1
  77. package/package.json +3 -2
  78. package/src/cli/args.ts +16 -6
  79. package/src/cli/config.ts +13 -4
  80. package/src/cli/help.ts +32 -23
  81. package/src/cli/inspect.ts +8 -6
  82. package/src/cli/resume-guard.ts +54 -2
  83. package/src/cli/run.ts +32 -36
  84. package/src/cli/session-json.ts +11 -5
  85. package/src/cli/session.ts +122 -40
  86. package/src/execution/events.ts +9 -1
  87. package/src/execution/open-session.ts +99 -72
  88. package/src/execution/stream-turn.ts +53 -30
  89. package/src/interpretation/argv.ts +11 -12
  90. package/src/interpretation/capabilities.ts +50 -0
  91. package/src/interpretation/hints.ts +1 -1
  92. package/src/interpretation/question.ts +17 -7
  93. package/src/interpretation/refusal.ts +2 -1
  94. package/src/interpretation/skills-selection.ts +46 -8
  95. package/src/knowledge/claude-code.ts +21 -0
  96. package/src/knowledge/codex.ts +26 -7
  97. package/src/knowledge/descriptor.ts +40 -12
  98. package/src/knowledge/muse.ts +7 -0
  99. package/src/knowledge/pi.ts +23 -4
@@ -1,26 +1,24 @@
1
1
  /**
2
2
  * openSession: the persistent headless session runner - ONE process, many
3
- * turns (A-001). `send` during idle writes a descriptor-encoded user record to
4
- * stdin and starts a turn; `send` during a live turn is QUEUED to the next
5
- * boundary (mid-turn stdin writes would interleave into the model's
6
- * context unpredictably - the harness itself queues, so we mirror its
7
- * disposition). `result` lines delimit turns; identity dedupe (D-022)
3
+ * turns (A-001). `send` writes a descriptor-encoded user record to stdin and starts a
4
+ * turn when idle or hands the text to the harness when a turn is live
5
+ * (the harness queues it). `result` lines delimit turns; identity dedupe (D-022)
8
6
  * spans the whole session. Lifecycle is bounded end to end: close() ends
9
7
  * stdin, escalates SIGTERM->SIGKILL if the child ignores EOF, and pipes
10
8
  * held open past exit close out after grace - a session can always be
11
- * ended. Queued sends that die with the session are surfaced, never
9
+ * ended. Pending sends that die with the session are surfaced, never
12
10
  * silently dropped. Structured lifecycle events (session open/close, turn
13
11
  * start/end, send dispositions, drops) are always-on evidence with
14
12
  * sessionId + turnId correlation.
15
13
  */
16
14
  import { buildSessionArgv } from "../interpretation/argv.js";
17
15
  import { capabilitiesOf } from "../interpretation/capabilities.js";
16
+ import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
18
17
  import {
19
- detectAuthFailureInLine,
20
- detectLimitInLine,
21
- detectTransportInLine,
22
- } from "../interpretation/limits.js";
23
- import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
18
+ composeEscalatedPrompt,
19
+ detectQuestionBlock,
20
+ type QuestionMode,
21
+ } from "../interpretation/question.js";
24
22
  import {
25
23
  encodeSessionInput,
26
24
  resolveSessionInput,
@@ -30,7 +28,7 @@ import type { HarnessDescriptor, SessionInputContract } from "../knowledge/descr
30
28
  import { AsyncChannel } from "./channel.js";
31
29
  import { decodeParsed, freshDecodeState } from "./decode.js";
32
30
  import type { RunnerDeps, SpawnedProcess, TimerHandle } from "./deps.js";
33
- import type { ExitCause, HarnessEvent } from "./events.js";
31
+ import type { EscalationDetection, ExitCause, HarnessEvent } from "./events.js";
34
32
  import type { FailureSummary } from "./failure.js";
35
33
  import {
36
34
  failureFromAuth,
@@ -50,7 +48,7 @@ export const CLOSE_GRACE_MS = 5_000;
50
48
  const PRETURN_MAX = 256;
51
49
 
52
50
  export interface SessionSendResult {
53
- readonly disposition: "started" | "queued" | "rejected";
51
+ readonly disposition: "started" | "rejected";
54
52
  /** Present when rejected. `write-failed` is a broken stdin pipe, which is
55
53
  * a different remedy from a session the caller already closed - the two
56
54
  * must stay distinguishable. */
@@ -88,13 +86,13 @@ export interface OpenSessionOptions {
88
86
  readonly model?: string;
89
87
  /** Working directory for the spawned harness. */
90
88
  readonly cwd?: string;
91
- /** issue #44: question escalation in session mode (behavior
92
- * instruction, default true). True composes the session preamble onto
93
- * every send and arms block detection at turn end; false composes the
94
- * no-ask instruction and disarms detection. */
95
- readonly escalateQuestions?: boolean;
89
+ /** question mode for session (ask/assume/none), default "ask" */
90
+ readonly questions?: QuestionMode;
96
91
  /** Provider selector (pi); refused on a harness without one. */
97
92
  readonly provider?: string;
93
+ /** True when resuming an existing conversation; controls which flag
94
+ * (resumeFlag vs idFlag) buildSessionArgv renders. */
95
+ readonly isResume?: boolean;
98
96
  }
99
97
 
100
98
  export class SessionClosedError extends Error {
@@ -123,6 +121,7 @@ export const openSession = (
123
121
  sessionId: opts.sessionId,
124
122
  ...(opts.model !== undefined ? { model: opts.model } : {}),
125
123
  ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
124
+ ...(opts.isResume !== undefined ? { isResume: opts.isResume } : {}),
126
125
  });
127
126
  let sessionInput: SessionInputContract;
128
127
  try {
@@ -168,13 +167,21 @@ export const openSession = (
168
167
 
169
168
  const turnsChannel = new AsyncChannel<SessionTurn>();
170
169
  const state = freshDecodeState(opts.sessionId);
171
- const escalateQuestions = opts.escalateQuestions !== false;
170
+ const questionMode: QuestionMode = opts.questions ?? "ask";
172
171
  const sessionInputMode = h.sessionMode;
173
172
  const stderrTail = new StderrTail();
174
173
  let turnCounter = 0;
175
174
  let activeTurn: AsyncChannel<HarnessEvent> | null = null;
176
175
  let activeTurnId = "";
177
- const pendingSends: SessionInput[] = [];
176
+ const pendingIds: string[] = [];
177
+ const pendingLengths: number[] = [];
178
+ // close() waits here while a turn is open. Ending the child's stdin
179
+ // mid-turn is fatal on pi: rpc treats EOF as "finish up and exit", so the
180
+ // prompt it has buffered never runs and the turn ends clean with no
181
+ // output (issue #99). claude happens to drain a queued turn after EOF,
182
+ // which is why this was invisible there. The README promises that a close
183
+ // after a send lets the turn finish; this is what keeps that promise.
184
+ let turnSettled: (() => void) | null = null;
178
185
  const preTurnEvents: HarnessEvent[] = [];
179
186
  let dead = false;
180
187
  let closing = false;
@@ -188,6 +195,7 @@ export const openSession = (
188
195
  // hcn-question block lives) and whether the turn ended by asking.
189
196
  let lastAssistantText: string | null = null;
190
197
  let turnAsked = false;
198
+ let turnEscalationDetection: EscalationDetection = "none";
191
199
  let identityAnnounced = false;
192
200
 
193
201
  const safeSignal = (sig: "SIGTERM" | "SIGKILL"): void => {
@@ -237,10 +245,7 @@ export const openSession = (
237
245
  const writeUser = (text: string): boolean => {
238
246
  try {
239
247
  stdin.write(
240
- encodeSessionInput(
241
- sessionInput,
242
- composeEscalatedPrompt(text, escalateQuestions, "session"),
243
- ),
248
+ encodeSessionInput(sessionInput, composeEscalatedPrompt(text, questionMode, "session")),
244
249
  );
245
250
  return true;
246
251
  } catch {
@@ -265,11 +270,12 @@ export const openSession = (
265
270
  turnLimitSeen = false;
266
271
  turnFailures = [];
267
272
  turnAsked = false;
273
+ turnEscalationDetection = "none";
268
274
  lastAssistantText = null;
269
275
  activeTurn = new AsyncChannel<HarnessEvent>();
270
276
  activeTurnId = `${opts.sessionId}:turn-${++turnCounter}`;
271
277
  // Tag the turn with the id of the send that opened it, so the consumer
272
- // correlates a queued input to its turn by reading the tag, not by
278
+ // correlates an input to its turn by reading the tag, not by
273
279
  // shadowing the runner's delivery order.
274
280
  (activeTurn as { inputId?: string; turnId?: string }).inputId = inputId;
275
281
  (activeTurn as { inputId?: string; turnId?: string }).turnId = activeTurnId;
@@ -288,16 +294,24 @@ export const openSession = (
288
294
  * turn stream right before its done; a malformed block surfaces as an
289
295
  * error event, never a silent no-op. */
290
296
  const emitQuestionIfAsked = (): void => {
291
- if (!escalateQuestions || lastAssistantText === null) return;
297
+ if (questionMode !== "ask" || lastAssistantText === null) {
298
+ turnEscalationDetection = "none";
299
+ return;
300
+ }
292
301
  const detection = detectQuestionBlock(lastAssistantText);
293
- if (detection === null) return;
302
+ if (detection === null) {
303
+ turnEscalationDetection = "none";
304
+ return;
305
+ }
294
306
  if ("malformed" in detection) {
307
+ turnEscalationDetection = "malformed";
295
308
  activeTurn?.push({ kind: "error", message: detection.malformed });
296
309
  const failure = failureFromTask(`malformed hcn-question block: ${detection.malformed}`);
297
310
  turnFailures.push(failure);
298
311
  void activeTurn?.push({ kind: "failure", ...failure });
299
312
  return;
300
313
  }
314
+ turnEscalationDetection = "block";
301
315
  turnAsked = true;
302
316
  log({
303
317
  event: "question",
@@ -316,7 +330,7 @@ export const openSession = (
316
330
  });
317
331
  };
318
332
 
319
- const endTurn = (done: HarnessEvent & { kind: "done" }): void => {
333
+ const endTurn = (done: Omit<HarnessEvent & { kind: "done" }, "escalation">): void => {
320
334
  if (activeTurn === null) return;
321
335
  disarmStall();
322
336
  // Asking is a successful turn: the session semantic is "blocked on
@@ -324,41 +338,40 @@ export const openSession = (
324
338
  // in sessions) and the caller answers with the next send().
325
339
  emitQuestionIfAsked();
326
340
  if (turnAsked && done.cause === "clean") done = { ...done, cause: "awaiting-input" };
341
+ // RFC-01 every turn end carries the escalation record
342
+ let fullDone: HarnessEvent & { kind: "done" } = {
343
+ ...done,
344
+ escalation: { mode: questionMode, detection: turnEscalationDetection },
345
+ } as HarnessEvent & { kind: "done" };
327
346
  // Every failure was already emitted as an event through pushFailure;
328
347
  // the turn's done carries the reduced summary, as streamTurn's does.
329
348
  const reduced = reduceFailures(turnFailures);
330
349
  if (reduced !== undefined) {
331
- if (done.cause === "clean") done = { ...done, cause: "failed", failure: reduced };
332
- else done = { ...done, failure: reduced };
350
+ if (fullDone.cause === "clean") fullDone = { ...fullDone, cause: "failed", failure: reduced };
351
+ else fullDone = { ...fullDone, failure: reduced };
333
352
  }
334
- activeTurn.push(done);
353
+ activeTurn.push(fullDone);
335
354
  activeTurn.close();
336
355
  log({
337
356
  event: "turn_end",
338
357
  sessionId: opts.sessionId,
339
358
  turnId: activeTurnId,
340
- cause: done.cause,
359
+ cause: fullDone.cause,
341
360
  });
342
361
  activeTurn = null;
343
362
  resultError = false;
344
- // The boundary is the only legal delivery point for queued input.
345
- if (dead || closing) return;
346
- const next = pendingSends.shift();
347
- if (next === undefined) return;
348
- if (writeUser(next.text)) {
349
- startTurn(next.id);
350
- return;
363
+ if (turnSettled !== null) {
364
+ const release = turnSettled;
365
+ turnSettled = null;
366
+ release();
351
367
  }
352
- // The queue was shifted but the write failed: report the id that was
353
- // accepted as queued and never delivered, instead of dropping it.
354
- log({
355
- event: "sends_dropped",
356
- sessionId: opts.sessionId,
357
- count: 1,
358
- ids: [next.id],
359
- reason: "write-failed",
360
- lengths: [next.text.length],
361
- });
368
+ if (dead || closing) return;
369
+ const nextId = pendingIds.shift();
370
+ if (nextId === undefined) return;
371
+ pendingLengths.shift();
372
+ // The text was already written to the harness when the send arrived;
373
+ // the id was held to correlate the next turn.
374
+ startTurn(nextId);
362
375
  };
363
376
 
364
377
  const routeEvent = (event: HarnessEvent): Promise<void> => {
@@ -366,7 +379,7 @@ export const openSession = (
366
379
  state.limitSeen = true;
367
380
  turnLimitSeen = true;
368
381
  }
369
- if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
382
+ if (questionMode === "ask" && event.kind === "message" && event.role === "assistant") {
370
383
  lastAssistantText = event.text;
371
384
  }
372
385
  if (activeTurn !== null) {
@@ -581,22 +594,22 @@ export const openSession = (
581
594
  if (pumpError !== null) {
582
595
  void routeEvent({ kind: "error", message: `session pump failed: ${String(pumpError)}` });
583
596
  }
584
- if (pendingSends.length > 0) {
585
- // "queued" was an accepted disposition - the loss must be visible to
586
- // both the log and the consumer, never silent.
587
- const droppedIds = pendingSends.map((s) => s.id);
597
+ if (pendingIds.length > 0) {
598
+ const droppedIds = [...pendingIds];
599
+ const droppedLengths = [...pendingLengths];
588
600
  void routeEvent({
589
601
  kind: "error",
590
- message: `${pendingSends.length} queued send(s) died with the session: ${droppedIds.join(", ")}`,
602
+ message: `${pendingIds.length} pending send(s) died with the session: ${droppedIds.join(", ")}`,
591
603
  });
592
604
  log({
593
605
  event: "sends_dropped",
594
606
  sessionId: opts.sessionId,
595
- count: pendingSends.length,
607
+ count: pendingIds.length,
596
608
  ids: droppedIds,
597
- lengths: pendingSends.map((s) => s.text.length),
609
+ lengths: droppedLengths,
598
610
  });
599
- pendingSends.length = 0;
611
+ pendingIds.length = 0;
612
+ pendingLengths.length = 0;
600
613
  }
601
614
  endTurn({ kind: "done", exitCode, cause });
602
615
  if (preTurnEvents.some((e) => e.kind !== "token" && e.kind !== "progress")) {
@@ -621,6 +634,12 @@ export const openSession = (
621
634
  void proc.exited.then((code) => {
622
635
  dead = true;
623
636
  exitCode = code;
637
+ // A close() waiting on an open turn must not outlive the child.
638
+ if (turnSettled !== null) {
639
+ const release = turnSettled;
640
+ turnSettled = null;
641
+ release();
642
+ }
624
643
  // The process is gone: a later fire would flip a finished turn to stall.
625
644
  disarmStall();
626
645
  // Pipes held open past exit (a grandchild) must not hang the session.
@@ -636,9 +655,22 @@ export const openSession = (
636
655
  });
637
656
  });
638
657
 
639
- const close = async (): Promise<void> => {
658
+ // Two intents share this path and must not be conflated. A CLOSE is the
659
+ // consumer asking politely: an open turn gets to finish first, because
660
+ // the consumer is still there to receive it. ABANDONMENT is the consumer
661
+ // walking away from the turns iterable: nobody is left to receive a turn,
662
+ // so stdin ends at once and the child is reaped, not drained.
663
+ const close = async (drain = true): Promise<void> => {
640
664
  if (closing) return shutdown;
641
665
  closing = true;
666
+ // Let an open turn reach its end record before stdin goes away. The
667
+ // wait is bounded twice over: the stall watchdog ends a silent turn,
668
+ // and the grace below still escalates a child that never exits.
669
+ if (drain && activeTurn !== null && !dead) {
670
+ await new Promise<void>((resolve) => {
671
+ turnSettled = resolve;
672
+ });
673
+ }
642
674
  try {
643
675
  stdin.end();
644
676
  } catch {
@@ -658,22 +690,12 @@ export const openSession = (
658
690
  try {
659
691
  for await (const turn of turnsChannel) yield turn;
660
692
  } finally {
661
- if (!closing && !dead) void close();
693
+ if (!closing && !dead) void close(false);
662
694
  }
663
695
  })(),
664
696
  send(input: SessionInput): SessionSendResult {
665
697
  if (dead || closing) throw new SessionClosedError();
666
- if (activeTurn !== null) {
667
- pendingSends.push(input);
668
- log({
669
- event: "send",
670
- sessionId: opts.sessionId,
671
- turnId: activeTurnId,
672
- inputId: input.id,
673
- disposition: "queued",
674
- });
675
- return { disposition: "queued" };
676
- }
698
+ const wasBusy = activeTurn !== null;
677
699
  if (!writeUser(input.text)) {
678
700
  log({
679
701
  event: "send",
@@ -685,7 +707,12 @@ export const openSession = (
685
707
  });
686
708
  return { disposition: "rejected", reason: "write-failed" };
687
709
  }
688
- startTurn(input.id);
710
+ if (wasBusy) {
711
+ pendingIds.push(input.id);
712
+ pendingLengths.push(input.text.length);
713
+ } else {
714
+ startTurn(input.id);
715
+ }
689
716
  log({
690
717
  event: "send",
691
718
  sessionId: opts.sessionId,
@@ -22,14 +22,18 @@ import {
22
22
  detectTransportInLine,
23
23
  detectUnavailableInLine,
24
24
  } from "../interpretation/limits.js";
25
- import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
25
+ import {
26
+ composeEscalatedPrompt,
27
+ detectQuestionBlock,
28
+ type QuestionMode,
29
+ } from "../interpretation/question.js";
26
30
  import { ArgvRefusalError } from "../interpretation/refusal.js";
27
31
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
28
32
  import { matcherOverridesOf } from "../knowledge/overrides.js";
29
33
  import { AsyncChannel } from "./channel.js";
30
34
  import { decodeLine, freshDecodeState } from "./decode.js";
31
35
  import type { RunnerDeps, SpawnedProcess } from "./deps.js";
32
- import type { ExitCause, HarnessEvent } from "./events.js";
36
+ import type { EscalationDetection, ExitCause, HarnessEvent } from "./events.js";
33
37
  import type { FailureSummary } from "./failure.js";
34
38
  import {
35
39
  failureFromAuth,
@@ -111,13 +115,9 @@ export interface TurnRunOptions extends LaunchOptions {
111
115
  * normalized argv. Wrong-harness flags here fail in the harness itself
112
116
  * and surface as native errors - hcn never validates them. */
113
117
  readonly passthrough?: readonly string[];
114
- /** issue #41: question escalation (behavior instruction, NOT a turn
115
- * option - no flag ever reaches the harness). True (the default when
116
- * undefined) prepends the protocol preamble and arms question-block
117
- * detection; false prepends the state-the-assumption instruction and
118
- * disarms detection. Applies on launch AND resume: it shapes each
119
- * turn's prompt and event stream, never a session setting. */
120
- readonly escalateQuestions?: boolean;
118
+ /** question mode: which preamble to inject (ask/assume/none).
119
+ * Behavior instruction - never a harness flag. Defaults to "ask". */
120
+ readonly questions?: QuestionMode;
121
121
  /** F-05: caller-requested stop. When aborted, the runner escalates
122
122
  * SIGTERM then SIGKILL and classifies the exit as killed with no
123
123
  * transport failure for the kill itself. */
@@ -132,20 +132,17 @@ export async function* streamTurn(
132
132
  const turnId = deps.turnId ?? `turn-${++turnCounter}`;
133
133
  const log = deps.log ?? (() => {});
134
134
 
135
- // issue #41: compose the escalation preamble onto the prompt (the
136
- // transport IS the prompt - no harness has native question conveyance)
137
- // and arm detection in the true mode. Composition is idempotent, so a
138
- // caller that already composed (the CLI does, for spawn-line truth)
139
- // never double-prepends.
140
- const escalateQuestions = opts.escalateQuestions !== false;
135
+ // compose the preamble onto the prompt based on question mode.
136
+ const questionMode: QuestionMode = opts.questions ?? "ask";
141
137
  const effective: TurnRunOptions = {
142
138
  ...opts,
143
- prompt: composeEscalatedPrompt(opts.prompt, escalateQuestions),
139
+ prompt: composeEscalatedPrompt(opts.prompt, questionMode),
144
140
  };
145
141
  // The turn's last assistant message - where the protocol says the
146
142
  // hcn-question block lives. Tracked only when detection is armed.
147
143
  let lastAssistantText: string | null = null;
148
144
  let asked = false;
145
+ let escalationDetection: EscalationDetection = "none";
149
146
 
150
147
  // Validate env before building argv so an invalid env is a refusal, not a spawn
151
148
  if (opts.env !== undefined) {
@@ -172,7 +169,13 @@ export async function* streamTurn(
172
169
  argv: redactArgv([], effective.prompt),
173
170
  });
174
171
  yield { kind: "failure", ...failure };
175
- yield { kind: "done", exitCode: null, cause: "failed", failure };
172
+ yield {
173
+ kind: "done",
174
+ exitCode: null,
175
+ cause: "failed",
176
+ failure,
177
+ escalation: { mode: questionMode, detection: "none" },
178
+ };
176
179
  return;
177
180
  }
178
181
  }
@@ -188,12 +191,11 @@ export async function* streamTurn(
188
191
  if (effective.passthrough !== undefined && effective.passthrough.length > 0) {
189
192
  argv = [...argv, "--", ...effective.passthrough];
190
193
  }
191
- // issue #38: claude renders the skills allowlist as settings JSON at
192
- // the argv tail (the complement-off form).
193
- const claudeSkillTokens = (opts as unknown as { __claudeSkillTokens?: string[] })
194
- .__claudeSkillTokens;
195
- if (claudeSkillTokens !== undefined && claudeSkillTokens.length > 0) {
196
- argv = [...argv, ...claudeSkillTokens];
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];
197
199
  }
198
200
  granularity = streamingGranularityOf(h, argv);
199
201
  } catch (e) {
@@ -223,7 +225,13 @@ export async function* streamTurn(
223
225
  argv: argvForLog,
224
226
  });
225
227
  yield { kind: "failure", ...failure };
226
- yield { kind: "done", exitCode: null, cause: "failed", failure };
228
+ yield {
229
+ kind: "done",
230
+ exitCode: null,
231
+ cause: "failed",
232
+ failure,
233
+ escalation: { mode: questionMode, detection: "none" },
234
+ };
227
235
  return;
228
236
  }
229
237
  throw e;
@@ -271,7 +279,13 @@ export async function* streamTurn(
271
279
  if (resumeCreateWarning !== null) yield { kind: "error", message: resumeCreateWarning };
272
280
  yield { kind: "error", message: `spawn failed: ${message}` };
273
281
  yield { kind: "failure", ...failure };
274
- yield { kind: "done", exitCode: 127, cause: "failed", failure };
282
+ yield {
283
+ kind: "done",
284
+ exitCode: 127,
285
+ cause: "failed",
286
+ failure,
287
+ escalation: { mode: questionMode, detection: "none" },
288
+ };
275
289
  return;
276
290
  }
277
291
 
@@ -439,7 +453,7 @@ export async function* streamTurn(
439
453
  if (event.terminal === true) await pushFailure(failureFromTerminalError(h, event.message));
440
454
  return;
441
455
  }
442
- if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
456
+ if (questionMode === "ask" && event.kind === "message" && event.role === "assistant") {
443
457
  lastAssistantText = event.text;
444
458
  }
445
459
  await queue.push(event);
@@ -469,18 +483,26 @@ export async function* streamTurn(
469
483
  /** issue #41: scan the last assistant message for the hcn-question
470
484
  * block. Structured-first - the block's fields become the event; no
471
485
  * prose parsing. Runs after the pumps settle (the last message is only
472
- * last then) and only when detection is armed (escalateQuestions
473
- * true). A malformed block surfaces as an error event, never a silent
486
+ * last then) and only when detection is armed (questions ask).
487
+ * A malformed block surfaces as an error event, never a silent
474
488
  * no-op. */
475
489
  const emitQuestionIfAsked = async (): Promise<void> => {
476
- if (!escalateQuestions || lastAssistantText === null) return;
490
+ if (questionMode !== "ask" || lastAssistantText === null) {
491
+ escalationDetection = "none";
492
+ return;
493
+ }
477
494
  const detection = detectQuestionBlock(lastAssistantText);
478
- if (detection === null) return;
495
+ if (detection === null) {
496
+ escalationDetection = "none";
497
+ return;
498
+ }
479
499
  if ("malformed" in detection) {
500
+ escalationDetection = "malformed";
480
501
  await queue.push({ kind: "error", message: detection.malformed });
481
502
  await pushFailure(failureFromTask(`malformed hcn-question block: ${detection.malformed}`));
482
503
  return;
483
504
  }
505
+ escalationDetection = "block";
484
506
  log({
485
507
  event: "question",
486
508
  turnId,
@@ -680,6 +702,7 @@ export async function* streamTurn(
680
702
  exitCode: nativeReduced ? null : exitCode,
681
703
  cause,
682
704
  ...(reduced ? { failure: reduced } : {}),
705
+ escalation: { mode: questionMode, detection: escalationDetection },
683
706
  };
684
707
  } finally {
685
708
  if (abortHandler !== null) opts.signal?.removeEventListener("abort", abortHandler);
@@ -76,11 +76,11 @@ export interface TurnOptions {
76
76
  readonly systemPrompt?: string;
77
77
  /** issue #48: appends to the built-in prompt (claude/pi only). */
78
78
  readonly appendSystemPrompt?: string;
79
- /** issue #41: question escalation - a BEHAVIOR INSTRUCTION, not a turn
80
- * option. It never renders into any harness argv; the CLI layer turns
81
- * it into the prompt preamble and arms question-block detection.
82
- * Undefined means the default: true. */
83
- readonly escalateQuestions?: boolean;
79
+ /** question mode: which preamble hcn injects (ask/assume/none).
80
+ * A BEHAVIOR INSTRUCTION, not a turn option. It never renders into any
81
+ * harness argv; the CLI layer turns it into the prompt preamble and
82
+ * arms question-block detection. Undefined means the default: "ask". */
83
+ readonly questions?: import("./question.js").QuestionMode;
84
84
  /** Internal: set by CLI when prompt came from --prompt/--prompt-file to bypass leading '-' guard */
85
85
  readonly __explicitPrompt?: boolean;
86
86
  /** toolMap extensible vocabulary per harness (issue toolMap) */
@@ -192,6 +192,10 @@ export interface SessionOptions {
192
192
  /** Provider selector (pi). A harness with no provider selector refuses,
193
193
  * the same way a one-shot turn does. */
194
194
  readonly provider?: string;
195
+ /** True when this argv should resume an existing conversation, false for a
196
+ * fresh session. Controls which descriptor flag is rendered: resumeFlag
197
+ * vs idFlag. Only consumers that alias --resume/--session-id set this. */
198
+ readonly isResume?: boolean;
195
199
  }
196
200
 
197
201
  export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): string[] => {
@@ -206,13 +210,8 @@ export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): st
206
210
  });
207
211
  }
208
212
  refuseUnusableSessionId(h, opts.sessionId);
209
- const argv = [
210
- h.bin,
211
- ...h.sessionMode.flags,
212
- // idFlag null = the harness refuses unknown ids and mints its own
213
- // (pi rpc); the caller-side sessionId stays a correlation handle.
214
- ...(h.sessionMode.idFlag !== null ? [h.sessionMode.idFlag, opts.sessionId] : []),
215
- ];
213
+ const flag = opts.isResume ? h.sessionMode.resumeFlag : h.sessionMode.idFlag;
214
+ const argv = [h.bin, ...h.sessionMode.flags, ...(flag !== null ? [flag, opts.sessionId] : [])];
216
215
  if (opts.model !== undefined) {
217
216
  const validated = validateModel(h, opts.model);
218
217
  if (!validated.ok) {
@@ -11,8 +11,30 @@ import type {
11
11
  HarnessMode,
12
12
  StreamingGranularity,
13
13
  } from "../knowledge/descriptor.js";
14
+ import { compareVersions } from "./versions.js";
14
15
  import { resolveModel } from "./vocabulary.js";
15
16
 
17
+ export interface EscalationObservedOn {
18
+ readonly harness: string;
19
+ readonly model: string;
20
+ readonly version: string;
21
+ readonly date: string;
22
+ }
23
+
24
+ /**
25
+ * Escalation claim: whether this harness and model were observed to emit
26
+ * the structured hcn-question block when instructed, NOT "this harness can
27
+ * ask" - live probes show models ask unprompted, so capability-to-ask would
28
+ * be false.
29
+ */
30
+ export interface EscalationClaim {
31
+ /** True when this harness and model were observed to emit the structured block when instructed. */
32
+ readonly supported: boolean;
33
+ readonly source: "runtime-verified" | "curated" | "unknown";
34
+ readonly confidence: "high" | "medium" | "none";
35
+ readonly observedOn?: EscalationObservedOn;
36
+ }
37
+
16
38
  export interface CapabilityResult {
17
39
  readonly vision: boolean;
18
40
  readonly images: boolean;
@@ -20,8 +42,34 @@ export interface CapabilityResult {
20
42
  readonly session: boolean;
21
43
  readonly source: "runtime-verified" | "curated" | "unknown";
22
44
  readonly confidence: "high" | "medium" | "none";
45
+ readonly escalation: EscalationClaim;
23
46
  }
24
47
 
48
+ const escalationOf = (h: HarnessDescriptor): EscalationClaim => {
49
+ const obs = h.escalation.observedOn;
50
+ if (obs !== undefined) {
51
+ const cmp = compareVersions(obs.version, h.verifiedAgainst);
52
+ if (cmp < 0) {
53
+ return {
54
+ supported: true,
55
+ source: "runtime-verified",
56
+ confidence: "medium",
57
+ observedOn: obs,
58
+ };
59
+ }
60
+ return {
61
+ supported: true,
62
+ source: "runtime-verified",
63
+ confidence: "high",
64
+ observedOn: obs,
65
+ };
66
+ }
67
+ if (h.escalation.supported) {
68
+ return { supported: true, source: "curated", confidence: "medium" };
69
+ }
70
+ return { supported: false, source: "unknown", confidence: "none" };
71
+ };
72
+
25
73
  export const capabilitiesOf = (
26
74
  h: HarnessDescriptor,
27
75
  model: string,
@@ -41,6 +89,7 @@ export const capabilitiesOf = (
41
89
  session: false,
42
90
  source: "unknown",
43
91
  confidence: "none",
92
+ escalation: { supported: false, source: "unknown", confidence: "none" },
44
93
  };
45
94
  }
46
95
  return {
@@ -50,5 +99,6 @@ export const capabilitiesOf = (
50
99
  session: h.capabilities.session,
51
100
  source: "curated",
52
101
  confidence: "medium",
102
+ escalation: escalationOf(h),
53
103
  };
54
104
  };
@@ -49,7 +49,7 @@ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFr
49
49
  "discovery.extensions":
50
50
  "codex loads MCP servers and plugins from config; disable per-server with -c or codex mcp remove rather than a call-time toggle",
51
51
  "discovery.skills":
52
- "codex discovers skills from its skills directory; no call-time toggle - remove or move the skill files instead",
52
+ 'codex has no global skills-off switch (skills.enabled is not a key); disable per skill per call with -c skills.config=[{path=".../SKILL.md", enabled=false}] and bundled skills with -c skills.bundled.enabled=false',
53
53
  },
54
54
  pi: {
55
55
  sandbox: