@dungle-scrubs/harness-cli-normalizer 0.4.1 → 0.4.3

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 (78) hide show
  1. package/README.md +30 -3
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +8 -0
  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 +4 -1
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/exit-codes.js +1 -1
  9. package/dist/cli/exit-codes.js.map +1 -1
  10. package/dist/cli/help.d.ts +3 -3
  11. package/dist/cli/help.d.ts.map +1 -1
  12. package/dist/cli/help.js +27 -5
  13. package/dist/cli/help.js.map +1 -1
  14. package/dist/cli/render.d.ts.map +1 -1
  15. package/dist/cli/render.js +15 -1
  16. package/dist/cli/render.js.map +1 -1
  17. package/dist/cli/run.d.ts.map +1 -1
  18. package/dist/cli/run.js +49 -15
  19. package/dist/cli/run.js.map +1 -1
  20. package/dist/cli/session.d.ts.map +1 -1
  21. package/dist/cli/session.js +107 -9
  22. package/dist/cli/session.js.map +1 -1
  23. package/dist/execution/events.d.ts +12 -1
  24. package/dist/execution/events.d.ts.map +1 -1
  25. package/dist/execution/events.js.map +1 -1
  26. package/dist/execution/failure.d.ts.map +1 -1
  27. package/dist/execution/failure.js.map +1 -1
  28. package/dist/execution/open-session.d.ts +5 -0
  29. package/dist/execution/open-session.d.ts.map +1 -1
  30. package/dist/execution/open-session.js +136 -4
  31. package/dist/execution/open-session.js.map +1 -1
  32. package/dist/execution/stream-turn.d.ts +7 -0
  33. package/dist/execution/stream-turn.d.ts.map +1 -1
  34. package/dist/execution/stream-turn.js +67 -13
  35. package/dist/execution/stream-turn.js.map +1 -1
  36. package/dist/interpretation/argv.d.ts +5 -0
  37. package/dist/interpretation/argv.d.ts.map +1 -1
  38. package/dist/interpretation/argv.js +4 -3
  39. package/dist/interpretation/argv.js.map +1 -1
  40. package/dist/interpretation/presence.d.ts.map +1 -1
  41. package/dist/interpretation/presence.js +3 -1
  42. package/dist/interpretation/presence.js.map +1 -1
  43. package/dist/interpretation/question.d.ts +54 -0
  44. package/dist/interpretation/question.d.ts.map +1 -0
  45. package/dist/interpretation/question.js +126 -0
  46. package/dist/interpretation/question.js.map +1 -0
  47. package/dist/interpretation/session-input.d.ts.map +1 -1
  48. package/dist/interpretation/session-input.js +5 -0
  49. package/dist/interpretation/session-input.js.map +1 -1
  50. package/dist/knowledge/claude-code.d.ts.map +1 -1
  51. package/dist/knowledge/claude-code.js +2 -0
  52. package/dist/knowledge/claude-code.js.map +1 -1
  53. package/dist/knowledge/descriptor.d.ts +16 -3
  54. package/dist/knowledge/descriptor.d.ts.map +1 -1
  55. package/dist/knowledge/descriptor.js +1 -1
  56. package/dist/knowledge/descriptor.js.map +1 -1
  57. package/dist/knowledge/pi.d.ts.map +1 -1
  58. package/dist/knowledge/pi.js +21 -7
  59. package/dist/knowledge/pi.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/cli/args.ts +6 -0
  62. package/src/cli/config.ts +4 -1
  63. package/src/cli/exit-codes.ts +1 -1
  64. package/src/cli/help.ts +27 -5
  65. package/src/cli/render.ts +16 -1
  66. package/src/cli/run.ts +54 -19
  67. package/src/cli/session.ts +113 -11
  68. package/src/execution/events.ts +19 -1
  69. package/src/execution/failure.ts +1 -6
  70. package/src/execution/open-session.ts +146 -4
  71. package/src/execution/stream-turn.ts +74 -13
  72. package/src/interpretation/argv.ts +9 -3
  73. package/src/interpretation/presence.ts +3 -1
  74. package/src/interpretation/question.ts +158 -0
  75. package/src/interpretation/session-input.ts +5 -0
  76. package/src/knowledge/claude-code.ts +2 -0
  77. package/src/knowledge/descriptor.ts +14 -3
  78. package/src/knowledge/pi.ts +21 -7
package/src/cli/run.ts CHANGED
@@ -2,12 +2,9 @@ import type { HarnessEvent } from "../execution/events.js";
2
2
  import { nodeRunnerDeps } from "../execution/node-deps.js";
3
3
  import { KILL_GRACE_MS, redactArgv, streamTurn } from "../execution/stream-turn.js";
4
4
  import { buildLaunchArgv, buildResumeArgv } from "../interpretation/argv.js";
5
+ import { composeEscalatedPrompt } from "../interpretation/question.js";
5
6
  import { ArgvRefusalError } from "../interpretation/refusal.js";
6
- import {
7
- FloorExceededError,
8
- type ProvenanceEntry,
9
- resolveEffectiveOptions,
10
- } from "../interpretation/resolve-options.js";
7
+ import { FloorExceededError, resolveEffectiveOptions } from "../interpretation/resolve-options.js";
11
8
  import { recognizeNativeSpelling, supportedBy } from "../interpretation/support.js";
12
9
  import { defaultDescriptors } from "../knowledge/overrides.js";
13
10
  import { parseRunExtra, parseTurnOptions, resolvePromptAsync } from "./args.js";
@@ -213,15 +210,18 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
213
210
 
214
211
  // Defaults profile + user config: LAUNCH-ONLY. A resumed session keeps
215
212
  // its own settings; the resolver never runs on resume paths.
216
- let resolvedProvenance: readonly ProvenanceEntry[] = [];
217
- let resolvedUnrenderable: readonly string[] = [];
218
213
  let effectiveTurnOpts: ReturnType<typeof parseTurnOptions> = turnOpts;
219
214
  const resolvedTiers: {
220
215
  user?: Partial<ReturnType<typeof parseTurnOptions>>;
221
216
  project?: Partial<ReturnType<typeof parseTurnOptions>>;
222
217
  } = {};
223
- if (extra.resume === undefined) {
224
- const tiers = resolvedTiers;
218
+ // Config files load on EVERY run, launch or resume: the tiers feed the
219
+ // defaults profile on launch, and issue #41's escalateQuestions (a
220
+ // behavior instruction, not a turn option) resolves from them on resume
221
+ // too - otherwise a no-escalate session would flip its preamble on the
222
+ // answer turn. Resolution of TURN options stays launch-only.
223
+ const tiers = resolvedTiers;
224
+ {
225
225
  const { loadUserConfig, loadProjectConfig, ConfigError } = await import("./config.js");
226
226
  try {
227
227
  const loaded = loadUserConfig();
@@ -236,6 +236,8 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
236
236
  }
237
237
  throw configErr;
238
238
  }
239
+ }
240
+ if (extra.resume === undefined) {
239
241
  let resolved: ReturnType<typeof resolveEffectiveOptions>;
240
242
  try {
241
243
  resolved = resolveEffectiveOptions(h, { ...turnOpts, prompt } as never, tiers);
@@ -248,8 +250,6 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
248
250
  throw resErr;
249
251
  }
250
252
  const { provenance, unrenderable } = resolved;
251
- resolvedProvenance = provenance;
252
- resolvedUnrenderable = unrenderable;
253
253
  const { prompt: _p, ...rest } = resolved.options as { prompt: string };
254
254
  effectiveTurnOpts = rest as ReturnType<typeof parseTurnOptions>;
255
255
  // Provenance is diagnostic data like the spawn line - stderr in BOTH
@@ -268,12 +268,39 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
268
268
  }
269
269
  }
270
270
 
271
+ // issue #41: question-escalation precedence arg > project > user >
272
+ // default-true (a behavior instruction, not a turn option - the
273
+ // default lives OUTSIDE the profile on purpose, per the spec). It
274
+ // applies on LAUNCH AND RESUME alike: it shapes each turn's prompt
275
+ // preamble and event stream, never a session setting.
276
+ const projectEscalate = (resolvedTiers.project as { escalateQuestions?: boolean } | undefined)
277
+ ?.escalateQuestions;
278
+ const userEscalate = (resolvedTiers.user as { escalateQuestions?: boolean } | undefined)
279
+ ?.escalateQuestions;
280
+ const escalateQuestions =
281
+ turnOpts.escalateQuestions !== undefined
282
+ ? turnOpts.escalateQuestions
283
+ : projectEscalate !== undefined
284
+ ? projectEscalate
285
+ : userEscalate !== undefined
286
+ ? userEscalate
287
+ : true;
288
+ const escalateTier =
289
+ turnOpts.escalateQuestions !== undefined
290
+ ? "arg"
291
+ : projectEscalate !== undefined
292
+ ? "project-config"
293
+ : userEscalate !== undefined
294
+ ? "user-config"
295
+ : "default";
296
+
271
297
  const fullOpts = {
272
298
  ...effectiveTurnOpts,
273
- prompt,
299
+ prompt: composeEscalatedPrompt(prompt, escalateQuestions),
274
300
  cwd: extra.cwd,
275
301
  env: extra.env,
276
302
  resume: extra.resume,
303
+ escalateQuestions,
277
304
  ...(passthrough.length > 0 ? { passthrough } : {}),
278
305
  ...(isExplicit ? { __explicitPrompt: true as const } : {}),
279
306
  } as Parameters<typeof streamTurn>[1] & {
@@ -287,20 +314,23 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
287
314
  let preArgv: string[] | null = null;
288
315
  try {
289
316
  if (fullOpts.resume) {
290
- // Resume never carries profile/config resolution (launch-only rule),
291
- // so it builds from the raw turn options.
317
+ // Resume never carries TURN-option profile resolution (launch-only
318
+ // rule), so it builds from the raw turn options; hcn-owned behavior
319
+ // (escalateQuestions preamble, timeout budget) still applies.
292
320
  preArgv = buildResumeArgv(h, {
293
321
  ...(turnOpts as object),
294
- prompt,
322
+ prompt: fullOpts.prompt,
295
323
  sessionId: fullOpts.resume,
296
324
  __explicitPrompt: isExplicit,
297
325
  } as never);
298
326
  } else {
299
327
  // Launch builds from the RESOLVED options so the spawn line and the
300
- // real argv agree.
328
+ // real argv agree. The prompt here is the COMPOSED one (escalation
329
+ // preamble included) - redactArgv masks by position, so an argv
330
+ // built from the raw prompt would leak it into the spawn line.
301
331
  preArgv = buildLaunchArgv(h, {
302
332
  ...(effectiveTurnOpts as object),
303
- prompt,
333
+ prompt: fullOpts.prompt,
304
334
  __explicitPrompt: isExplicit,
305
335
  } as never);
306
336
  const claudeSkillTokens = (effectiveTurnOpts as unknown as { __claudeSkillTokens?: string[] })
@@ -330,13 +360,18 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
330
360
  // since buildLaunchArgv now respects __explicitPrompt.
331
361
 
332
362
  if (preArgv) {
333
- const redacted = redactArgv(preArgv, prompt);
363
+ const redacted = redactArgv(preArgv, fullOpts.prompt);
334
364
  if (!wantJson) {
335
365
  process.stderr.write(`spawn: ${redacted.join(" ")}\n`);
336
366
  } else {
337
367
  // In JSON mode, diagnostics to stderr only
338
368
  process.stderr.write(`spawn: ${redacted.join(" ")}\n`);
339
369
  }
370
+ // issue #41: the escalation mode rides stderr as provenance, like
371
+ // every other resolution the turn depends on.
372
+ process.stderr.write(
373
+ `provenance: escalateQuestions = ${escalateQuestions} (${escalateTier})\n`,
374
+ );
340
375
  }
341
376
 
342
377
  // Delete HERDR_ENV before spawn
@@ -413,7 +448,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
413
448
  renderEvent(event, state);
414
449
  }
415
450
  if (event.kind === "done") {
416
- if (event.cause === "clean") exitCode = 0;
451
+ if (event.cause === "clean" || event.cause === "awaiting-input") exitCode = 0;
417
452
  else exitCode = 1;
418
453
  // If failure class is rejected? But done.cause for rejected would be failed? Still 1 per mapping, but refusal before spawn is 2.
419
454
  // The RFC says limit/auth ->1, transport ->1, refusal ->2 (already handled). So done non-clean =>1.
@@ -7,22 +7,27 @@ import { createRenderState, renderEvent } from "./render.js";
7
7
  import { resolveHarness } from "./resolve-harness.js";
8
8
 
9
9
  export const session = async (harnessName: string, rawArgs: string[]): Promise<void> => {
10
- // Only claude supported
11
- if (harnessName !== "claude") {
10
+ // issue #44: the gate is the descriptor's sessionMode (claude stream-json,
11
+ // pi --mode rpc), not a hardcoded name list - a harness that grows a
12
+ // session mode is available the moment its descriptor declares one.
13
+ const h = resolveHarness(harnessName);
14
+ if (h.sessionMode === null) {
15
+ const supported = ["claude", "codex", "pi", "muse"]
16
+ .map((name) => resolveHarness(name))
17
+ .filter((d) => d.sessionMode !== null)
18
+ .map((d) => d.name);
12
19
  const err = new ArgvRefusalError({
13
20
  issue: "no-session-mode",
14
21
  harness: harnessName as "claude",
15
- supported: ["claude"],
16
- detail: `session is claude-only; ${harnessName} declares no persistent headless session mode`,
22
+ supported,
23
+ detail: `session mode is available on ${supported.join(", ")}; ${harnessName} declares no persistent headless session`,
17
24
  });
18
25
  process.stderr.write(`${err.message}\n`);
19
- process.stderr.write(`supported: claude\n`);
26
+ process.stderr.write(`supported: ${supported.join(", ")}\n`);
20
27
  process.exitCode = 2;
21
28
  return;
22
29
  }
23
30
 
24
- const h = resolveHarness(harnessName);
25
-
26
31
  if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
27
32
  const { SESSION_HELP } = await import("./help.js");
28
33
  process.stdout.write(SESSION_HELP);
@@ -48,6 +53,44 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
48
53
  const model = values.model as string | undefined;
49
54
  const cwd = values.cwd as string | undefined;
50
55
 
56
+ // issue #44: same precedence as hcn run - arg > project > user >
57
+ // default-true. A behavior instruction, so it rides every send's
58
+ // preamble, never a harness flag.
59
+ const argEscalate =
60
+ values["escalate-questions"] === true
61
+ ? true
62
+ : values["no-escalate-questions"] === true
63
+ ? false
64
+ : undefined;
65
+ let escalateQuestions: boolean;
66
+ let escalateTier: "arg" | "project-config" | "user-config" | "default";
67
+ try {
68
+ const { loadUserConfig, loadProjectConfig } = await import("./config.js");
69
+ const user = loadUserConfig()?.config as { escalateQuestions?: boolean } | undefined;
70
+ const project = loadProjectConfig()?.config as { escalateQuestions?: boolean } | undefined;
71
+ escalateQuestions =
72
+ argEscalate !== undefined
73
+ ? argEscalate
74
+ : project?.escalateQuestions !== undefined
75
+ ? project.escalateQuestions
76
+ : user?.escalateQuestions !== undefined
77
+ ? user.escalateQuestions
78
+ : true;
79
+ escalateTier =
80
+ argEscalate !== undefined
81
+ ? "arg"
82
+ : project?.escalateQuestions !== undefined
83
+ ? "project-config"
84
+ : user?.escalateQuestions !== undefined
85
+ ? "user-config"
86
+ : "default";
87
+ } catch (configErr) {
88
+ process.stderr.write(`config error: ${(configErr as Error).message}\n`);
89
+ process.exitCode = 2;
90
+ return;
91
+ }
92
+ process.stderr.write(`provenance: escalateQuestions = ${escalateQuestions} (${escalateTier})\n`);
93
+
51
94
  // Validate sessionId shape? let openSession handle via assertUsableSessionId
52
95
  delete (process.env as Record<string, string | undefined>).HERDR_ENV;
53
96
 
@@ -55,7 +98,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
55
98
 
56
99
  let handle: ReturnType<typeof openSession>;
57
100
  try {
58
- handle = openSession(h, { sessionId, model, cwd }, deps);
101
+ handle = openSession(h, { sessionId, model, cwd, escalateQuestions }, deps);
59
102
  } catch (err) {
60
103
  if (err instanceof ArgvRefusalError) {
61
104
  process.stderr.write(`${err.message}\n`);
@@ -76,6 +119,20 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
76
119
 
77
120
  const rl = createInterface({ input: process.stdin, output: process.stdout });
78
121
 
122
+ // One line source for BOTH the you-prompt and the answer menu. A
123
+ // readline interface buffers at most one pending line per question()
124
+ // call site; the answer line typically arrives while the interface sits
125
+ // BETWEEN calls (the turn is still streaming), and those bytes were
126
+ // dropped (verified live: menu rendered, buffered "2" never delivered).
127
+ // An explicit async-iterator pull never drops: the iterator parks on the
128
+ // stream until the next line exists, whenever the caller asks for it.
129
+ const lines = rl[Symbol.asyncIterator]();
130
+ const nextLine = async (prompt: string): Promise<string | null> => {
131
+ process.stdout.write(prompt);
132
+ const res = await lines.next();
133
+ return res.done ? null : (res.value as string);
134
+ };
135
+
79
136
  // Handle SIGINT to close session cleanly
80
137
  let closing = false;
81
138
  const doClose = async () => {
@@ -99,13 +156,14 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
99
156
  const turns = handle.turns[Symbol.asyncIterator]();
100
157
  try {
101
158
  while (true) {
102
- let line: string;
159
+ let line: string | null;
103
160
  try {
104
- line = await rl.question("you › ");
161
+ line = await nextLine("you › ");
105
162
  } catch {
106
- // readline closed (Ctrl-D)
163
+ // stdin closed (Ctrl-D)
107
164
  break;
108
165
  }
166
+ if (line === null) break;
109
167
  const trimmed = line.trim();
110
168
  if (trimmed === "" || trimmed === "exit") break;
111
169
 
@@ -119,8 +177,52 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
119
177
  | undefined;
120
178
  if (turn === undefined) break;
121
179
  const state = createRenderState();
180
+ // issue #44: a turn that ends awaiting-input renders its question as
181
+ // a pickable menu; the choice (or a custom answer) is the next send,
182
+ // delivered on the SAME live session - no exit, no resume.
183
+ let asked:
184
+ | (import("../execution/events.js").HarnessEvent & {
185
+ kind: "question";
186
+ })
187
+ | null = null;
122
188
  for await (const event of turn) {
123
189
  renderEvent(event, state);
190
+ if (event.kind === "question") asked = event;
191
+ }
192
+ if (asked !== null) {
193
+ const q = asked;
194
+ process.stdout.write(`\nanswer › pick a number, or type your own answer:\n`);
195
+ for (let i = 0; i < q.options.length; i++) {
196
+ const opt = q.options[i] as string;
197
+ const mark = opt === q.recommended ? " (recommended)" : "";
198
+ process.stdout.write(` ${i + 1}. ${opt}${mark}\n`);
199
+ }
200
+ let answer: string | null = null;
201
+ while (answer === null) {
202
+ const a = ((await nextLine("> ")) ?? "").trim();
203
+ if (a === "") continue;
204
+ const n = Number(a);
205
+ if (Number.isInteger(n) && n >= 1 && n <= q.options.length) {
206
+ answer = q.options[n - 1] as string;
207
+ } else {
208
+ answer = a;
209
+ }
210
+ }
211
+ handle.send(
212
+ `The user answered the question: "${q.question}" with: ${answer}. Continue accordingly.`,
213
+ );
214
+ // Drain the answer turn BEFORE prompting again - the pump's
215
+ // backpressure stalls the harness until the turn iterable is
216
+ // consumed (verified live: menu answered, you-prompt rendered, no
217
+ // answer turn ever ran).
218
+ const answerTurn = (await turns.next()).value as
219
+ | AsyncIterable<import("../execution/events.js").HarnessEvent>
220
+ | undefined;
221
+ if (answerTurn === undefined) break;
222
+ const answerState = createRenderState();
223
+ for await (const event of answerTurn) {
224
+ renderEvent(event, answerState);
225
+ }
124
226
  }
125
227
  }
126
228
  } catch (err) {
@@ -17,7 +17,16 @@
17
17
  import type { CapabilityResult } from "../interpretation/capabilities.js";
18
18
  import type { FailureSummary } from "./failure.js";
19
19
 
20
- export type ExitCause = "clean" | "limit" | "crash" | "stall" | "killed" | "failed";
20
+ export type ExitCause =
21
+ | "clean"
22
+ | "limit"
23
+ | "crash"
24
+ | "stall"
25
+ | "killed"
26
+ | "failed"
27
+ /** issue #41: the turn ended by asking (escalateQuestions) - a
28
+ * SUCCESSFUL turn (process exit 0); the caller resumes with the answer. */
29
+ | "awaiting-input";
21
30
 
22
31
  export type HarnessEvent =
23
32
  | {
@@ -31,6 +40,15 @@ export type HarnessEvent =
31
40
  | { readonly kind: "progress"; readonly label: string }
32
41
  | { readonly kind: "tool"; readonly name: string; readonly input?: unknown }
33
42
  | { readonly kind: "context"; readonly usedPct: number }
43
+ | {
44
+ /** issue #41: the worker asked the caller's user a question (the
45
+ * final message carried an hcn-question block). Structured-first:
46
+ * these fields ARE the question; prose renders from them. */
47
+ readonly kind: "question";
48
+ readonly question: string;
49
+ readonly options: readonly string[];
50
+ readonly recommended?: string;
51
+ }
34
52
  | { readonly kind: "limit"; readonly code: string; readonly message: string }
35
53
  | { readonly kind: "error"; readonly message: string }
36
54
  | ({ readonly kind: "failure" } & FailureSummary)
@@ -14,12 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { RefusalIssue } from "../interpretation/refusal.js";
17
- import type {
18
- AuthFailureKind,
19
- DiscoveryFacet,
20
- LimitCode,
21
- TurnOptionKey,
22
- } from "../knowledge/descriptor.js";
17
+ import type { AuthFailureKind, DiscoveryFacet, LimitCode } from "../knowledge/descriptor.js";
23
18
 
24
19
  export const FAILURE_CLASSES = Object.freeze([
25
20
  "rate-limit",
@@ -14,7 +14,9 @@
14
14
  * sessionId + turnId correlation.
15
15
  */
16
16
  import { buildSessionArgv } from "../interpretation/argv.js";
17
+ import { capabilitiesOf } from "../interpretation/capabilities.js";
17
18
  import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
19
+ import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
18
20
  import {
19
21
  encodeSessionInput,
20
22
  resolveSessionInput,
@@ -52,6 +54,11 @@ export interface OpenSessionOptions {
52
54
  readonly model?: string;
53
55
  /** Working directory for the spawned harness. */
54
56
  readonly cwd?: string;
57
+ /** issue #44: question escalation in session mode (behavior
58
+ * instruction, default true). True composes the session preamble onto
59
+ * every send and arms block detection at turn end; false composes the
60
+ * no-ask instruction and disarms detection. */
61
+ readonly escalateQuestions?: boolean;
55
62
  }
56
63
 
57
64
  export class SessionClosedError extends Error {
@@ -124,6 +131,8 @@ export const openSession = (
124
131
 
125
132
  const turnsChannel = new AsyncChannel<AsyncIterable<HarnessEvent>>();
126
133
  const state = freshDecodeState(opts.sessionId);
134
+ const escalateQuestions = opts.escalateQuestions !== false;
135
+ const sessionInputMode = h.sessionMode;
127
136
  const stderrTail = new StderrTail();
128
137
  let turnCounter = 0;
129
138
  let activeTurn: AsyncChannel<HarnessEvent> | null = null;
@@ -137,6 +146,11 @@ export const openSession = (
137
146
  let resultError = false;
138
147
  let turnLimitSeen = false;
139
148
  let pumpError: unknown = null;
149
+ // issue #44: the active turn's last assistant message (where the
150
+ // hcn-question block lives) and whether the turn ended by asking.
151
+ let lastAssistantText: string | null = null;
152
+ let turnAsked = false;
153
+ let identityAnnounced = false;
140
154
 
141
155
  const safeSignal = (sig: "SIGTERM" | "SIGKILL"): void => {
142
156
  if (!dead) deps.signal(proc, sig);
@@ -148,7 +162,12 @@ export const openSession = (
148
162
 
149
163
  const writeUser = (text: string): boolean => {
150
164
  try {
151
- stdin.write(encodeSessionInput(sessionInput, text));
165
+ stdin.write(
166
+ encodeSessionInput(
167
+ sessionInput,
168
+ composeEscalatedPrompt(text, escalateQuestions, "session"),
169
+ ),
170
+ );
152
171
  return true;
153
172
  } catch {
154
173
  activeTurn?.push({ kind: "error", message: "send failed: session stdin is gone" });
@@ -158,6 +177,8 @@ export const openSession = (
158
177
 
159
178
  const startTurn = (): void => {
160
179
  turnLimitSeen = false;
180
+ turnAsked = false;
181
+ lastAssistantText = null;
161
182
  activeTurn = new AsyncChannel<HarnessEvent>();
162
183
  activeTurnId = `${opts.sessionId}:turn-${++turnCounter}`;
163
184
  log({ event: "turn_start", sessionId: opts.sessionId, turnId: activeTurnId });
@@ -165,8 +186,44 @@ export const openSession = (
165
186
  turnsChannel.push(activeTurn);
166
187
  };
167
188
 
189
+ /** issue #44: at a turn boundary, scan the last assistant message for
190
+ * the hcn-question block - same structured-first discipline and
191
+ * last-message rule as streamTurn. The question event lands in the
192
+ * turn stream right before its done; a malformed block surfaces as an
193
+ * error event, never a silent no-op. */
194
+ const emitQuestionIfAsked = (): void => {
195
+ if (!escalateQuestions || lastAssistantText === null) return;
196
+ const detection = detectQuestionBlock(lastAssistantText);
197
+ if (detection === null) return;
198
+ if ("malformed" in detection) {
199
+ activeTurn?.push({ kind: "error", message: detection.malformed });
200
+ return;
201
+ }
202
+ turnAsked = true;
203
+ log({
204
+ event: "question",
205
+ sessionId: opts.sessionId,
206
+ turnId: activeTurnId,
207
+ harness: h.name,
208
+ options: detection.block.options.length,
209
+ });
210
+ activeTurn?.push({
211
+ kind: "question",
212
+ question: detection.block.question,
213
+ options: detection.block.options,
214
+ ...(detection.block.recommended !== undefined
215
+ ? { recommended: detection.block.recommended }
216
+ : {}),
217
+ });
218
+ };
219
+
168
220
  const endTurn = (done: HarnessEvent & { kind: "done" }): void => {
169
221
  if (activeTurn === null) return;
222
+ // Asking is a successful turn: the session semantic is "blocked on
223
+ // answer, session alive" - the done stays TURN-scoped (exitCode null
224
+ // in sessions) and the caller answers with the next send().
225
+ emitQuestionIfAsked();
226
+ if (turnAsked && done.cause === "clean") done = { ...done, cause: "awaiting-input" };
170
227
  activeTurn.push(done);
171
228
  activeTurn.close();
172
229
  log({
@@ -188,6 +245,9 @@ export const openSession = (
188
245
  state.limitSeen = true;
189
246
  turnLimitSeen = true;
190
247
  }
248
+ if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
249
+ lastAssistantText = event.text;
250
+ }
191
251
  if (activeTurn !== null) {
192
252
  // Awaited by the pumps: past the channel's high water mark this
193
253
  // blocks the pump, so OS pipe backpressure reaches the child.
@@ -207,6 +267,28 @@ export const openSession = (
207
267
 
208
268
  const pumpStdout = async (): Promise<void> => {
209
269
  const lines = new LineBuffer();
270
+ const matches = (
271
+ record: Record<string, unknown>,
272
+ spec: Readonly<Record<string, string>>,
273
+ ): boolean => {
274
+ for (const [key, expected] of Object.entries(spec)) {
275
+ if (record[key] !== expected) return false;
276
+ }
277
+ return true;
278
+ };
279
+ // issue #44: pi rpc is identity-silent at startup; the probe round
280
+ // trip is the only way to read the id (spike fixtures). The response
281
+ // echoes our marker id, so it cannot be confused with a user-visible
282
+ // get_state response.
283
+ if (sessionInputMode?.identityProbe !== null && sessionInputMode !== null) {
284
+ try {
285
+ stdin.write(
286
+ `${JSON.stringify({ id: "hcn-identity", type: sessionInputMode.identityProbe.command })}\n`,
287
+ );
288
+ } catch {
289
+ // stdin already gone; the exited handler will surface the death.
290
+ }
291
+ }
210
292
  const handleLine = async (line: string): Promise<void> => {
211
293
  let parsed: Record<string, unknown> | null = null;
212
294
  try {
@@ -218,10 +300,70 @@ export const openSession = (
218
300
  }
219
301
  return;
220
302
  }
221
- // The result line still feeds identity dedupe (claude includes
222
- // session_id on it - a rotation announced there must not be missed).
303
+ // pi rpc bookkeeping: the probe response announces identity; a
304
+ // failed command response is a surfaced error, never a silent drop
305
+ // (spike: mid-stream prompts fail with success:false naming the
306
+ // remedy - hcn never sends those, but any other failure shows here).
307
+ if (parsed.type === "response") {
308
+ if (
309
+ parsed.id === "hcn-identity" &&
310
+ typeof parsed.command === "string" &&
311
+ parsed.command === sessionInputMode?.identityProbe?.command &&
312
+ parsed.success === true
313
+ ) {
314
+ const data = parsed.data as Record<string, unknown> | undefined;
315
+ const announced = data?.sessionId;
316
+ if (typeof announced !== "string") {
317
+ await routeEvent({
318
+ kind: "error",
319
+ message: "identity probe response carried no sessionId",
320
+ });
321
+ } else if (sessionInputMode.idFlag === null) {
322
+ // Harness-MINTED identity (pi rpc: `--session` refuses unknown
323
+ // ids, so fresh sessions omit the flag). The minted id IS the
324
+ // identity; opts.sessionId stays the caller-side handle.
325
+ if (!identityAnnounced) {
326
+ identityAnnounced = true;
327
+ state.lastSeenId = announced;
328
+ await routeEvent({
329
+ kind: "identity",
330
+ sessionId: announced,
331
+ authority: h.identity.authority,
332
+ capabilities: capabilitiesOf(h, opts.model ?? "", "headless-session"),
333
+ });
334
+ }
335
+ } else if (announced === opts.sessionId) {
336
+ if (!identityAnnounced) {
337
+ identityAnnounced = true;
338
+ await routeEvent({
339
+ kind: "identity",
340
+ sessionId: announced,
341
+ authority: h.identity.authority,
342
+ capabilities: capabilitiesOf(h, opts.model ?? "", "headless-session"),
343
+ });
344
+ }
345
+ } else {
346
+ await routeEvent({
347
+ kind: "error",
348
+ message: `identity rotated: session announced ${JSON.stringify(announced)} but ${opts.sessionId} was requested`,
349
+ });
350
+ }
351
+ return;
352
+ }
353
+ if (parsed.success === false) {
354
+ await routeEvent({
355
+ kind: "error",
356
+ message: `rpc command failed: ${JSON.stringify(parsed.command)} - ${JSON.stringify(parsed.error ?? "unknown error")}`,
357
+ });
358
+ }
359
+ return;
360
+ }
361
+ // The turn-end record still feeds identity dedupe (claude includes
362
+ // session_id on result - a rotation announced there must not be
363
+ // missed).
223
364
  const events = decodeParsed(h, parsed, state, opts.model ?? "");
224
- if (parsed.type === "result") {
365
+ const isTurnEnd = sessionInputMode !== null && matches(parsed, sessionInputMode.turnEnd);
366
+ if (isTurnEnd) {
225
367
  // decodeParsed already surfaces the is_error case as an error event
226
368
  // (content.ts claude reader); routing the events is enough - we only
227
369
  // still track resultError here to classify the done cause.