@dungle-scrubs/harness-cli-normalizer 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +3 -2
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +16 -8
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/config.d.ts.map +1 -1
  6. package/dist/cli/config.js +11 -4
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/help.d.ts +3 -3
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +32 -23
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/cli/inspect.d.ts.map +1 -1
  13. package/dist/cli/inspect.js +10 -7
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/render.d.ts +15 -1
  16. package/dist/cli/render.d.ts.map +1 -1
  17. package/dist/cli/render.js +18 -2
  18. package/dist/cli/render.js.map +1 -1
  19. package/dist/cli/resume-guard.js +29 -2
  20. package/dist/cli/resume-guard.js.map +1 -1
  21. package/dist/cli/run.d.ts.map +1 -1
  22. package/dist/cli/run.js +35 -34
  23. package/dist/cli/run.js.map +1 -1
  24. package/dist/cli/session-json.d.ts +5 -2
  25. package/dist/cli/session-json.d.ts.map +1 -1
  26. package/dist/cli/session-json.js +36 -5
  27. package/dist/cli/session-json.js.map +1 -1
  28. package/dist/cli/session.d.ts.map +1 -1
  29. package/dist/cli/session.js +103 -36
  30. package/dist/cli/session.js.map +1 -1
  31. package/dist/execution/events.d.ts +8 -1
  32. package/dist/execution/events.d.ts.map +1 -1
  33. package/dist/execution/events.js.map +1 -1
  34. package/dist/execution/open-session.d.ts +7 -6
  35. package/dist/execution/open-session.d.ts.map +1 -1
  36. package/dist/execution/open-session.js +88 -59
  37. package/dist/execution/open-session.js.map +1 -1
  38. package/dist/execution/stream-turn.d.ts +4 -7
  39. package/dist/execution/stream-turn.d.ts.map +1 -1
  40. package/dist/execution/stream-turn.js +43 -22
  41. package/dist/execution/stream-turn.js.map +1 -1
  42. package/dist/interpretation/argv.d.ts +9 -5
  43. package/dist/interpretation/argv.d.ts.map +1 -1
  44. package/dist/interpretation/argv.js +2 -7
  45. package/dist/interpretation/argv.js.map +1 -1
  46. package/dist/interpretation/capabilities.d.ts +20 -0
  47. package/dist/interpretation/capabilities.d.ts.map +1 -1
  48. package/dist/interpretation/capabilities.js +27 -0
  49. package/dist/interpretation/capabilities.js.map +1 -1
  50. package/dist/interpretation/hints.js +1 -1
  51. package/dist/interpretation/hints.js.map +1 -1
  52. package/dist/interpretation/question.d.ts +6 -2
  53. package/dist/interpretation/question.d.ts.map +1 -1
  54. package/dist/interpretation/question.js +13 -4
  55. package/dist/interpretation/question.js.map +1 -1
  56. package/dist/interpretation/refusal.d.ts +1 -1
  57. package/dist/interpretation/refusal.d.ts.map +1 -1
  58. package/dist/interpretation/refusal.js +1 -1
  59. package/dist/interpretation/refusal.js.map +1 -1
  60. package/dist/interpretation/skills-selection.d.ts +18 -1
  61. package/dist/interpretation/skills-selection.d.ts.map +1 -1
  62. package/dist/interpretation/skills-selection.js +33 -6
  63. package/dist/interpretation/skills-selection.js.map +1 -1
  64. package/dist/knowledge/claude-code.d.ts.map +1 -1
  65. package/dist/knowledge/claude-code.js +21 -0
  66. package/dist/knowledge/claude-code.js.map +1 -1
  67. package/dist/knowledge/codex.d.ts.map +1 -1
  68. package/dist/knowledge/codex.js +16 -6
  69. package/dist/knowledge/codex.js.map +1 -1
  70. package/dist/knowledge/descriptor.d.ts +39 -12
  71. package/dist/knowledge/descriptor.d.ts.map +1 -1
  72. package/dist/knowledge/descriptor.js +1 -0
  73. package/dist/knowledge/descriptor.js.map +1 -1
  74. package/dist/knowledge/muse.d.ts.map +1 -1
  75. package/dist/knowledge/muse.js +7 -0
  76. package/dist/knowledge/muse.js.map +1 -1
  77. package/dist/knowledge/pi.d.ts.map +1 -1
  78. package/dist/knowledge/pi.js +23 -4
  79. package/dist/knowledge/pi.js.map +1 -1
  80. package/package.json +3 -2
  81. package/src/cli/args.ts +16 -6
  82. package/src/cli/config.ts +13 -4
  83. package/src/cli/help.ts +32 -23
  84. package/src/cli/inspect.ts +8 -6
  85. package/src/cli/render.ts +18 -1
  86. package/src/cli/resume-guard.ts +26 -2
  87. package/src/cli/run.ts +36 -38
  88. package/src/cli/session-json.ts +45 -7
  89. package/src/cli/session.ts +122 -40
  90. package/src/execution/events.ts +9 -1
  91. package/src/execution/open-session.ts +99 -72
  92. package/src/execution/stream-turn.ts +53 -30
  93. package/src/interpretation/argv.ts +11 -12
  94. package/src/interpretation/capabilities.ts +50 -0
  95. package/src/interpretation/hints.ts +1 -1
  96. package/src/interpretation/question.ts +17 -7
  97. package/src/interpretation/refusal.ts +2 -1
  98. package/src/interpretation/skills-selection.ts +46 -8
  99. package/src/knowledge/claude-code.ts +21 -0
  100. package/src/knowledge/codex.ts +16 -6
  101. package/src/knowledge/descriptor.ts +40 -12
  102. package/src/knowledge/muse.ts +7 -0
  103. package/src/knowledge/pi.ts +23 -4
@@ -57,45 +57,96 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
57
57
  }
58
58
 
59
59
  const values = parsed.values as Record<string, unknown>;
60
- const sessionId =
61
- (values["session-id"] as string | undefined) ??
62
- (values.resume as string | undefined) ??
63
- randomUUID();
60
+ // --resume and --session-id are aliases for one concept: resume an existing
61
+ // session. Passing both is refused, matching hcn run's parseRunExtra shape
62
+ // (src/cli/args.ts) rather than inventing a second parser.
63
+ if (values.resume !== undefined && values["session-id"] !== undefined) {
64
+ const { ArgvRefusalError: AliasError } = await import("../interpretation/refusal.js");
65
+ const err = new AliasError({
66
+ issue: "mutually-exclusive-options",
67
+ harness: h.name,
68
+ supported: ["--resume or --session-id, not both (--session-id is an alias for --resume)"],
69
+ detail: "both --resume and --session-id given",
70
+ });
71
+ const { refusalOf: aliasRefusalOf, refuse: aliasRefuse } = await import("./refuse.js");
72
+ aliasRefuse(aliasRefusalOf(err), jsonMode, "closed");
73
+ return;
74
+ }
75
+ const resumeId =
76
+ (values.resume as string | undefined) ?? (values["session-id"] as string | undefined);
77
+ // The two flags are aliases for "use this session". Whether that means
78
+ // RESUME is decided by the store, not by which spelling was typed: an id
79
+ // that exists is resumed (resumeFlag rendered), an id that does not exist
80
+ // names a fresh session (idFlag rendered). --resume on an id that does not
81
+ // exist is the one combination refused, below - a caller who said "resume"
82
+ // and would silently get a fresh conversation is issue #86 exactly.
83
+ const explicitResume = values.resume !== undefined;
84
+ // A caller-supplied id must be the shape this harness's store files
85
+ // sessions under (issue #95). hcn run already holds this line through
86
+ // parse-resume; session only checked the safe-filename rule, so "bogus"
87
+ // reached the harness, rode the session and identity events, and on a
88
+ // create-on-unknown harness became a real file nobody can find again.
89
+ if (resumeId !== undefined && !h.resume.idShape.test(resumeId)) {
90
+ const flag = explicitResume ? "--resume" : "--session-id";
91
+ const { refuse: shapeRefuse } = await import("./refuse.js");
92
+ shapeRefuse(
93
+ {
94
+ message: `${flag} ${JSON.stringify(resumeId.slice(0, 64))} is not a ${h.name} session id`,
95
+ issue: "invalid-option-value",
96
+ supported: [`a session id matching ${String(h.resume.idShape)}`],
97
+ },
98
+ jsonMode,
99
+ "closed",
100
+ );
101
+ return;
102
+ }
103
+ const sessionId = resumeId ?? randomUUID();
104
+ let isResume = false;
64
105
  const model = values.model as string | undefined;
65
106
  const cwd = values.cwd as string | undefined;
66
107
  const provider = values.provider as string | undefined;
67
108
 
68
- // issue #44: same precedence as hcn run - arg > project > user >
69
- // default-true. A behavior instruction, so it rides every send's
70
- // preamble, never a harness flag.
71
- const argEscalate =
72
- values["escalate-questions"] === true
73
- ? true
74
- : values["no-escalate-questions"] === true
75
- ? false
76
- : undefined;
77
- let escalateQuestions: boolean;
78
- let escalateTier: "arg" | "project-config" | "user-config" | "default";
109
+ // question mode precedence arg > project > user > default (ask)
110
+ const rawArgMode = values.questions !== undefined ? String(values.questions) : undefined;
111
+ if (rawArgMode !== undefined && !["ask", "assume", "none"].includes(rawArgMode)) {
112
+ const { refuse, refusalOf } = await import("./refuse.js");
113
+ const { ArgvRefusalError } = await import("../interpretation/refusal.js");
114
+ refuse(
115
+ refusalOf(
116
+ new ArgvRefusalError({
117
+ issue: "invalid-option-value",
118
+ harness: h.name,
119
+ option: "questions",
120
+ supported: ["ask", "assume", "none"],
121
+ detail: rawArgMode,
122
+ }),
123
+ ),
124
+ jsonMode,
125
+ "closed",
126
+ );
127
+ return;
128
+ }
129
+ let questionMode: import("../interpretation/question.js").QuestionMode;
130
+ let questionTier: "arg" | "project-config" | "user-config" | "default";
79
131
  try {
80
132
  const { loadUserConfig, loadProjectConfig } = await import("./config.js");
81
- const user = loadUserConfig()?.config as { escalateQuestions?: boolean } | undefined;
82
- const project = loadProjectConfig()?.config as { escalateQuestions?: boolean } | undefined;
83
- escalateQuestions =
84
- argEscalate !== undefined
85
- ? argEscalate
86
- : project?.escalateQuestions !== undefined
87
- ? project.escalateQuestions
88
- : user?.escalateQuestions !== undefined
89
- ? user.escalateQuestions
90
- : true;
91
- escalateTier =
92
- argEscalate !== undefined
93
- ? "arg"
94
- : project?.escalateQuestions !== undefined
95
- ? "project-config"
96
- : user?.escalateQuestions !== undefined
97
- ? "user-config"
98
- : "default";
133
+ const user = loadUserConfig()?.config as { questions?: string } | undefined;
134
+ const project = loadProjectConfig()?.config as { questions?: string } | undefined;
135
+ const userMode = user?.questions;
136
+ const projectMode = project?.questions;
137
+ if (rawArgMode !== undefined) {
138
+ questionMode = rawArgMode as import("../interpretation/question.js").QuestionMode;
139
+ questionTier = "arg";
140
+ } else if (projectMode !== undefined) {
141
+ questionMode = projectMode as import("../interpretation/question.js").QuestionMode;
142
+ questionTier = "project-config";
143
+ } else if (userMode !== undefined) {
144
+ questionMode = userMode as import("../interpretation/question.js").QuestionMode;
145
+ questionTier = "user-config";
146
+ } else {
147
+ questionMode = "ask";
148
+ questionTier = "default";
149
+ }
99
150
  } catch (configErr) {
100
151
  process.stderr.write(`config error: ${(configErr as Error).message}\n`);
101
152
  if (jsonMode) {
@@ -112,9 +163,38 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
112
163
  process.exitCode = 2;
113
164
  return;
114
165
  }
115
- process.stderr.write(`provenance: escalateQuestions = ${escalateQuestions} (${escalateTier})\n`);
166
+ process.stderr.write(`provenance: questions = ${questionMode} (${questionTier})\n`);
116
167
 
117
168
  // Validate sessionId shape? let openSession handle via assertUsableSessionId
169
+ // Unknown-id refusal reuses the run resume guard (src/cli/resume-guard.ts):
170
+ // harnesses that create on unknown (pi, muse with onMissing === "create")
171
+ // are refused before spawn with the same message shape hcn run uses.
172
+ // claude (onMissing === "error") refuses on its own; no store check.
173
+ if (resumeId !== undefined) {
174
+ const { resumeStore: checkResumeStore } = await import("./resume-guard.js");
175
+ const { path: storePath, exists } = checkResumeStore(h, {
176
+ home: process.env.HOME ?? process.env.USERPROFILE ?? "",
177
+ cwd: cwd ?? process.cwd(),
178
+ sessionId,
179
+ });
180
+ // Exists in the store: resume it, whichever alias was typed. Absent and the
181
+ // caller only named an id: a fresh session under that id, as before.
182
+ isResume = storePath !== null && exists;
183
+ if (explicitResume && storePath !== null && !exists) {
184
+ const { refuse: guardRefuse } = await import("./refuse.js");
185
+ guardRefuse(
186
+ {
187
+ message: `no ${h.name} session ${sessionId} found at ${storePath}`,
188
+ issue: "invalid-option-value",
189
+ supported: [`a session id that exists in ${h.name}'s store`],
190
+ },
191
+ jsonMode,
192
+ "closed",
193
+ );
194
+ return;
195
+ }
196
+ }
197
+
118
198
  delete (process.env as Record<string, string | undefined>).HERDR_ENV;
119
199
 
120
200
  const wantJson = values.json === true;
@@ -164,7 +244,11 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
164
244
 
165
245
  let handle: ReturnType<typeof openSession>;
166
246
  try {
167
- handle = openSession(h, { sessionId, model, cwd, escalateQuestions, provider }, deps);
247
+ handle = openSession(
248
+ h,
249
+ { sessionId, model, cwd, questions: questionMode, provider, isResume },
250
+ deps,
251
+ );
168
252
  } catch (err) {
169
253
  if (err instanceof ArgvRefusalError) {
170
254
  const { refusalOf, refuse } = await import("./refuse.js");
@@ -196,7 +280,8 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
196
280
  sessionId,
197
281
  harness: h.name,
198
282
  hcnVersion: getVersion(),
199
- escalateQuestions,
283
+ questions: questionMode,
284
+ origin: isResume ? "resumed" : "fresh",
200
285
  getCloseInfo: () => closeInfo,
201
286
  getDroppedIds: () => droppedIds,
202
287
  });
@@ -258,10 +343,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
258
343
  const trimmed = line.trim();
259
344
  if (trimmed === "" || trimmed === "exit") break;
260
345
 
261
- const result = handle.send({ id: `you-${++sendCount}`, text: line });
262
- if (result.disposition === "queued") {
263
- process.stderr.write(`disposition: queued (turn in progress)\n`);
264
- }
346
+ handle.send({ id: `you-${++sendCount}`, text: line });
265
347
 
266
348
  const turn = (await turns.next()).value as
267
349
  | AsyncIterable<import("../execution/events.js").HarnessEvent>
@@ -25,10 +25,17 @@ export type ExitCause =
25
25
  | "stall"
26
26
  | "killed"
27
27
  | "failed"
28
- /** issue #41: the turn ended by asking (escalateQuestions) - a
28
+ /** issue #41: the turn ended by asking (questions ask) - a
29
29
  * SUCCESSFUL turn (process exit 0); the caller resumes with the answer. */
30
30
  | "awaiting-input";
31
31
 
32
+ export type EscalationMode = "ask" | "assume" | "none";
33
+ export type EscalationDetection = "block" | "malformed" | "none";
34
+ export interface EscalationRecord {
35
+ readonly mode: EscalationMode;
36
+ readonly detection: EscalationDetection;
37
+ }
38
+
32
39
  export type HarnessEvent =
33
40
  | {
34
41
  readonly kind: "identity";
@@ -60,6 +67,7 @@ export type HarnessEvent =
60
67
  readonly exitCode: number | null;
61
68
  readonly cause: ExitCause;
62
69
  readonly failure?: FailureSummary;
70
+ readonly escalation: EscalationRecord;
63
71
  };
64
72
 
65
73
  export const DROPPABLE_KINDS = new Set(["token", "progress", "context"]);
@@ -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,