@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
@@ -17,6 +17,7 @@ import {
17
17
  } from "../interpretation/argv.js";
18
18
  import { stdinPolicyOf } from "../interpretation/dimensions.js";
19
19
  import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
20
+ import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
20
21
  import { ArgvRefusalError } from "../interpretation/refusal.js";
21
22
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
22
23
  import { matcherOverridesOf } from "../knowledge/overrides.js";
@@ -102,6 +103,13 @@ export interface TurnRunOptions extends LaunchOptions {
102
103
  * normalized argv. Wrong-harness flags here fail in the harness itself
103
104
  * and surface as native errors - hcn never validates them. */
104
105
  readonly passthrough?: readonly string[];
106
+ /** issue #41: question escalation (behavior instruction, NOT a turn
107
+ * option - no flag ever reaches the harness). True (the default when
108
+ * undefined) prepends the protocol preamble and arms question-block
109
+ * detection; false prepends the state-the-assumption instruction and
110
+ * disarms detection. Applies on launch AND resume: it shapes each
111
+ * turn's prompt and event stream, never a session setting. */
112
+ readonly escalateQuestions?: boolean;
105
113
  }
106
114
 
107
115
  export async function* streamTurn(
@@ -112,6 +120,21 @@ export async function* streamTurn(
112
120
  const turnId = deps.turnId ?? `turn-${++turnCounter}`;
113
121
  const log = deps.log ?? (() => {});
114
122
 
123
+ // issue #41: compose the escalation preamble onto the prompt (the
124
+ // transport IS the prompt - no harness has native question conveyance)
125
+ // and arm detection in the true mode. Composition is idempotent, so a
126
+ // caller that already composed (the CLI does, for spawn-line truth)
127
+ // never double-prepends.
128
+ const escalateQuestions = opts.escalateQuestions !== false;
129
+ const effective: TurnRunOptions = {
130
+ ...opts,
131
+ prompt: composeEscalatedPrompt(opts.prompt, escalateQuestions),
132
+ };
133
+ // The turn's last assistant message - where the protocol says the
134
+ // hcn-question block lives. Tracked only when detection is armed.
135
+ let lastAssistantText: string | null = null;
136
+ let asked = false;
137
+
115
138
  // Validate env before building argv so an invalid env is a refusal, not a spawn
116
139
  if (opts.env !== undefined) {
117
140
  for (const [k, v] of Object.entries(opts.env)) {
@@ -134,7 +157,7 @@ export async function* streamTurn(
134
157
  harness: h.name,
135
158
  issue: refusal.issue,
136
159
  supported: refusal.supported,
137
- argv: redactArgv([], opts.prompt),
160
+ argv: redactArgv([], effective.prompt),
138
161
  });
139
162
  yield { kind: "failure", ...failure };
140
163
  yield { kind: "done", exitCode: null, cause: "failed", failure };
@@ -147,11 +170,11 @@ export async function* streamTurn(
147
170
  let granularity: import("../knowledge/descriptor.js").StreamingGranularity;
148
171
  try {
149
172
  argv =
150
- opts.resume === undefined
151
- ? buildLaunchArgv(h, opts)
152
- : buildResumeArgv(h, { ...opts, sessionId: opts.resume });
153
- if (opts.passthrough !== undefined && opts.passthrough.length > 0) {
154
- argv = [...argv, "--", ...opts.passthrough];
173
+ effective.resume === undefined
174
+ ? buildLaunchArgv(h, effective)
175
+ : buildResumeArgv(h, { ...effective, sessionId: effective.resume });
176
+ if (effective.passthrough !== undefined && effective.passthrough.length > 0) {
177
+ argv = [...argv, "--", ...effective.passthrough];
155
178
  }
156
179
  // issue #38: claude renders the skills allowlist as settings JSON at
157
180
  // the argv tail (the complement-off form).
@@ -175,7 +198,7 @@ export async function* streamTurn(
175
198
  // No process spawned on a refusal - log rejected instead of spawn
176
199
  let argvForLog: string[] = [];
177
200
  try {
178
- argvForLog = redactArgv([], opts.prompt);
201
+ argvForLog = redactArgv([], effective.prompt);
179
202
  } catch {}
180
203
  log({
181
204
  event: "rejected",
@@ -200,7 +223,7 @@ export async function* streamTurn(
200
223
  event: "spawn",
201
224
  turnId,
202
225
  harness: h.name,
203
- argv: redactArgv(argv, opts.prompt),
226
+ argv: redactArgv(argv, effective.prompt),
204
227
  granularity,
205
228
  ...(matcherOverrides ? { matcherOverrides } : {}),
206
229
  ...(envKeys?.length ? { envKeys } : {}),
@@ -210,8 +233,8 @@ export async function* streamTurn(
210
233
  try {
211
234
  proc = deps.spawn(argv, {
212
235
  stdin: stdinPolicyOf(h) === "close-required" ? "close" : "inherit",
213
- ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
214
- ...(opts.env !== undefined ? { env: opts.env } : {}),
236
+ ...(effective.cwd !== undefined ? { cwd: effective.cwd } : {}),
237
+ ...(effective.env !== undefined ? { env: effective.env } : {}),
215
238
  });
216
239
  } catch (cause) {
217
240
  // Spawn failure is a transport failure, not merely a crash
@@ -232,7 +255,7 @@ export async function* streamTurn(
232
255
  }
233
256
 
234
257
  const queue = new AsyncChannel<HarnessEvent>();
235
- const state = freshDecodeState(opts.resume ?? null);
258
+ const state = freshDecodeState(effective.resume ?? null);
236
259
  const stderrTail = new StderrTail();
237
260
  let killedByWatchdog = false;
238
261
  let exited = false;
@@ -341,6 +364,9 @@ export async function* streamTurn(
341
364
  // Directly from decode's rate_limit_event handling - track for reduction
342
365
  failures.push(event as unknown as FailureSummary);
343
366
  }
367
+ if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
368
+ lastAssistantText = event.text;
369
+ }
344
370
  await queue.push(event);
345
371
  }
346
372
  }
@@ -356,6 +382,37 @@ export async function* streamTurn(
356
382
  }
357
383
  };
358
384
 
385
+ /** issue #41: scan the last assistant message for the hcn-question
386
+ * block. Structured-first - the block's fields become the event; no
387
+ * prose parsing. Runs after the pumps settle (the last message is only
388
+ * last then) and only when detection is armed (escalateQuestions
389
+ * true). A malformed block surfaces as an error event, never a silent
390
+ * no-op. */
391
+ const emitQuestionIfAsked = async (): Promise<void> => {
392
+ if (!escalateQuestions || lastAssistantText === null) return;
393
+ const detection = detectQuestionBlock(lastAssistantText);
394
+ if (detection === null) return;
395
+ if ("malformed" in detection) {
396
+ await queue.push({ kind: "error", message: detection.malformed });
397
+ return;
398
+ }
399
+ log({
400
+ event: "question",
401
+ turnId,
402
+ harness: h.name,
403
+ options: detection.block.options.length,
404
+ });
405
+ asked = true;
406
+ await queue.push({
407
+ kind: "question",
408
+ question: detection.block.question,
409
+ options: detection.block.options,
410
+ ...(detection.block.recommended !== undefined
411
+ ? { recommended: detection.block.recommended }
412
+ : {}),
413
+ });
414
+ };
415
+
359
416
  const pumpStderr = async (): Promise<void> => {
360
417
  const lines = new LineBuffer();
361
418
  for await (const chunk of proc.stderr) {
@@ -413,7 +470,9 @@ export async function* streamTurn(
413
470
  observePump("stdout", pumpStdout()),
414
471
  observePump("stderr", pumpStderr()),
415
472
  ]);
416
- void Promise.all([proc.exited, pumpSettlements]).then(() => queue.close());
473
+ void Promise.all([proc.exited, pumpSettlements])
474
+ .then(() => emitQuestionIfAsked())
475
+ .then(() => queue.close());
417
476
 
418
477
  try {
419
478
  for await (const event of queue) yield event;
@@ -459,7 +518,9 @@ export async function* streamTurn(
459
518
  ? "killed" // D11: the run was killed on budget, not stalled
460
519
  : "stall"
461
520
  : exitCode === 0
462
- ? "clean"
521
+ ? asked
522
+ ? "awaiting-input" // issue #41: asking SUCCEEDED the turn
523
+ : "clean"
463
524
  : exitCode === null
464
525
  ? "killed"
465
526
  : "crash";
@@ -69,6 +69,11 @@ export interface TurnOptions {
69
69
  readonly write?: boolean;
70
70
  readonly shell?: boolean;
71
71
  readonly maxSteps?: number;
72
+ /** issue #41: question escalation - a BEHAVIOR INSTRUCTION, not a turn
73
+ * option. It never renders into any harness argv; the CLI layer turns
74
+ * it into the prompt preamble and arms question-block detection.
75
+ * Undefined means the default: true. */
76
+ readonly escalateQuestions?: boolean;
72
77
  /** Internal: set by CLI when prompt came from --prompt/--prompt-file to bypass leading '-' guard */
73
78
  readonly __explicitPrompt?: boolean;
74
79
  }
@@ -159,7 +164,7 @@ export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): st
159
164
  throw new ArgvRefusalError({
160
165
  issue: "no-session-mode",
161
166
  harness: h.name,
162
- supported: ["session is claude-only"],
167
+ supported: ["session is available where sessionMode is declared"],
163
168
  });
164
169
  }
165
170
  assertUsableSessionId(opts.sessionId);
@@ -167,8 +172,9 @@ export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): st
167
172
  h.bin,
168
173
  ...h.launch.baseFlags,
169
174
  ...h.sessionMode.flags,
170
- h.sessionMode.idFlag,
171
- opts.sessionId,
175
+ // idFlag null = the harness refuses unknown ids and mints its own
176
+ // (pi rpc); the caller-side sessionId stays a correlation handle.
177
+ ...(h.sessionMode.idFlag !== null ? [h.sessionMode.idFlag, opts.sessionId] : []),
172
178
  ];
173
179
  if (opts.model !== undefined) {
174
180
  const validated = validateModel(h, opts.model);
@@ -18,7 +18,9 @@ export interface ProcessRow {
18
18
  const idBearingFlags = (h: HarnessDescriptor): readonly string[] => [
19
19
  h.resume.flag,
20
20
  ...h.resume.aliases,
21
- ...(h.sessionMode === null ? [] : [h.sessionMode.idFlag]),
21
+ // idFlag null = the harness mints session ids and accepts no id flag
22
+ // (pi rpc); such processes never carry a caller id to match anyway.
23
+ ...(h.sessionMode === null || h.sessionMode.idFlag === null ? [] : [h.sessionMode.idFlag]),
22
24
  // muse's interactive process IS `muse resume <id>` - the parse-only
23
25
  // spelling is exactly what a live interactive argv looks like.
24
26
  ...(h.resume.positionalParseWord === undefined ? [] : [h.resume.positionalParseWord]),
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Question escalation (issue #41): the protocol that lets a headless
3
+ * worker ask the CALLER's user a question. Transport is the prompt - no
4
+ * harness ships native question conveyance - so hcn prepends a protocol
5
+ * contract (escalateQuestions true, the default) or the state-the-
6
+ * assumption instruction (false). The worker's final message then carries
7
+ * a fenced `hcn-question` block; detection is structured-first (fields
8
+ * parsed from the block, prose rendered downstream from them).
9
+ *
10
+ * Ratified design note (2026-08-19): autonomy and escalateQuestions are
11
+ * independent flags carving the same substrate by ORIGIN - autonomy =
12
+ * interrupts the harness raises (permission gates), escalateQuestions =
13
+ * interrupts the model raises (judgment gaps). The true-mode preamble
14
+ * must therefore never conflate asking with permission: "you may ask" is
15
+ * never "you lack permission." Both preambles open with the same marker
16
+ * so composition is idempotent (a composed prompt is never re-composed).
17
+ */
18
+
19
+ /** The shared first line of both preambles - also the idempotence marker
20
+ * for composeEscalatedPrompt. */
21
+ export const QUESTION_PREAMBLE_MARKER = "[hcn question protocol]";
22
+
23
+ export const ESCALATION_PREAMBLE = `${QUESTION_PREAMBLE_MARKER}
24
+ You are running headless: no one is watching this session live, but a caller relays answers between turns. This protocol never changes your permissions - asking is not how you obtain permission, and it never removes any permission this run already has. Use the tools you have exactly as granted.
25
+ If and only if a genuine decision you cannot make defensibly blocks correct progress, ask by ending your turn: emit one fenced code block tagged hcn-question, as the last content of your final message, containing a single JSON object:
26
+
27
+ \`\`\`hcn-question
28
+ {"question": "<the decision you need made>", "options": ["<option 1>", "<option 2>"], "recommended": "<one of options>"}
29
+ \`\`\`
30
+
31
+ Say nothing after the block. Your turn ends there; the caller's user will answer, and your next turn continues from that answer. For every choice you can make defensibly yourself, do not ask - decide, act, and state the decision you made.`;
32
+
33
+ export const NO_ESCALATION_PREAMBLE = `${QUESTION_PREAMBLE_MARKER}
34
+ You are running headless and no one will answer you in this session. Never ask a question, never request input or confirmation, and never end your turn awaiting a reply. When a decision is ambiguous, pick the most defensible reading, state the assumption you proceeded under in one sentence, and continue to completion.`;
35
+
36
+ /** Session-mode variant (issue #44): the worker lives in ONE persistent
37
+ * session with a live stdin - the ask stops generation but the session
38
+ * stays open, and the answer arrives as the next user message on the
39
+ * same session. No exit, no resume. */
40
+ export const SESSION_ESCALATION_PREAMBLE = `${QUESTION_PREAMBLE_MARKER}
41
+ You are running in a persistent session: no one is watching live, but the caller relays answers back into this same session. This protocol never changes your permissions - asking is not how you obtain permission, and it never removes any permission this run already has. Use the tools you have exactly as granted.
42
+ If and only if a genuine decision you cannot make defensibly blocks correct progress, ask by stopping: emit one fenced code block tagged hcn-question, as the last content of your reply, containing a single JSON object:
43
+
44
+ \`\`\`hcn-question
45
+ {"question": "<the decision you need made>", "options": ["<option 1>", "<option 2>"], "recommended": "<one of options>"}
46
+ \`\`\`
47
+
48
+ Say nothing after the block and stop generating. The caller's user will answer, and the answer arrives as the next user message in this session - continue from it. For every choice you can make defensibly yourself, do not ask - decide, act, and state the decision you made.`;
49
+
50
+ /** Compose the transport preamble onto a prompt. `mode` selects the
51
+ * contract wording: "turn" (exit-and-resume transport, hcn run) or
52
+ * "session" (live channel, hcn session). Idempotent: a prompt that
53
+ * already carries any preamble passes through unchanged - a turn-mode
54
+ * preamble is never re-composed into a session one (the marker matches
55
+ * both; the already-composed contract stands). */
56
+ export const composeEscalatedPrompt = (
57
+ prompt: string,
58
+ escalate: boolean,
59
+ mode: "turn" | "session" = "turn",
60
+ ): string =>
61
+ prompt.startsWith(QUESTION_PREAMBLE_MARKER)
62
+ ? prompt
63
+ : `${mode === "session" && escalate ? SESSION_ESCALATION_PREAMBLE : escalate ? ESCALATION_PREAMBLE : NO_ESCALATION_PREAMBLE}\n\n${prompt}`;
64
+
65
+ /** The structured question a worker asks (the block's fields). */
66
+ export interface QuestionBlock {
67
+ readonly question: string;
68
+ readonly options: readonly string[];
69
+ readonly recommended?: string;
70
+ }
71
+
72
+ /** Detection result: a parsed block, a named malformation (the worker
73
+ * tried to ask but botched the shape - surfaced, never swallowed), or
74
+ * null when no hcn-question block is present. */
75
+ export type QuestionDetection = { readonly block: QuestionBlock } | { readonly malformed: string };
76
+
77
+ /** One fenced-block candidate: the body text plus whether the fence was
78
+ * properly closed (an unclosed opener at end-of-text is still examined -
79
+ * the most likely formatting slip is forgetting the closing fence). */
80
+ interface FenceCandidate {
81
+ readonly body: string;
82
+ readonly closed: boolean;
83
+ }
84
+
85
+ const FENCE_OPEN = /(?:^|\n)[ \t]*```[ \t]*hcn-question[ \t]*(?=\n)/g;
86
+
87
+ /** All hcn-question fence bodies in a text, in order. */
88
+ const fenceBodies = (text: string): FenceCandidate[] => {
89
+ const out: FenceCandidate[] = [];
90
+ FENCE_OPEN.lastIndex = 0;
91
+ for (let m = FENCE_OPEN.exec(text); m !== null; m = FENCE_OPEN.exec(text)) {
92
+ const bodyStart = m.index + m[0].length;
93
+ // The closing fence may be indented like the opener (probe evidence:
94
+ // indented blocks occur), so match newline + optional spaces + ```.
95
+ const close = text.slice(bodyStart).search(/\n[ \t]*```/);
96
+ if (close === -1) {
97
+ // Unclosed fence: the body runs to end-of-text. Only meaningful
98
+ // when nothing follows the opener - take it as a candidate anyway.
99
+ out.push({ body: text.slice(bodyStart), closed: false });
100
+ break;
101
+ }
102
+ const bodyEnd = bodyStart + close;
103
+ out.push({ body: text.slice(bodyStart, bodyEnd), closed: true });
104
+ FENCE_OPEN.lastIndex = bodyEnd;
105
+ }
106
+ return out;
107
+ };
108
+
109
+ const parseBlock = (body: string): QuestionDetection => {
110
+ let raw: unknown;
111
+ try {
112
+ raw = JSON.parse(body);
113
+ } catch (e) {
114
+ return { malformed: `hcn-question block is not valid JSON: ${(e as Error).message}` };
115
+ }
116
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
117
+ return { malformed: "hcn-question block must be a JSON object" };
118
+ }
119
+ const obj = raw as Record<string, unknown>;
120
+ const { question, options, recommended } = obj;
121
+ if (typeof question !== "string" || question.trim() === "") {
122
+ return { malformed: 'hcn-question block field "question" must be a non-empty string' };
123
+ }
124
+ if (
125
+ !Array.isArray(options) ||
126
+ options.length === 0 ||
127
+ options.some((o) => typeof o !== "string" || o.trim() === "")
128
+ ) {
129
+ return {
130
+ malformed: 'hcn-question block field "options" must be an array of non-empty strings',
131
+ };
132
+ }
133
+ if (recommended !== undefined && typeof recommended !== "string") {
134
+ return { malformed: 'hcn-question block field "recommended" must be a string' };
135
+ }
136
+ return {
137
+ block: {
138
+ question,
139
+ options,
140
+ ...(recommended !== undefined ? { recommended } : {}),
141
+ },
142
+ };
143
+ };
144
+
145
+ /** Detect the hcn-question block in a message text. The LAST block wins
146
+ * (the protocol makes the block the turn's final content; a corrected
147
+ * re-emit supersedes an earlier one). An empty candidate body (a bare
148
+ * unclosed opener with nothing after it) is not a detection. */
149
+ export const detectQuestionBlock = (text: string): QuestionDetection | null => {
150
+ const candidates = fenceBodies(text);
151
+ for (let i = candidates.length - 1; i >= 0; i--) {
152
+ const candidate = candidates[i];
153
+ if (candidate === undefined) continue;
154
+ if (candidate.body.trim() === "") continue;
155
+ return parseBlock(candidate.body);
156
+ }
157
+ return null;
158
+ };
@@ -41,5 +41,10 @@ export const encodeSessionInput = (input: SessionInputContract, text: string): s
41
41
  type: "user",
42
42
  message: { role: "user", content: [{ type: "text", text }] },
43
43
  })}\n`;
44
+ case "pi-rpc-prompt":
45
+ // Verified against pi 0.84.2 rpc (spike fixtures): a prompt command
46
+ // while idle; hcn never writes mid-run (it queues sends itself), so
47
+ // no streamingBehavior field is ever needed.
48
+ return `${JSON.stringify({ id: "hcn-send", type: "prompt", message: text })}\n`;
44
49
  }
45
50
  };
@@ -61,6 +61,8 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
61
61
  ],
62
62
  idFlag: "--session-id",
63
63
  input: { kind: "claude-sdk-user-message" },
64
+ turnEnd: { type: "result" },
65
+ identityProbe: null,
64
66
  },
65
67
  output: {
66
68
  // --output-format/--include-partial-messages only work with --print, so
@@ -30,7 +30,7 @@ export type StreamingGranularity = "token" | "message" | "none";
30
30
 
31
31
  export type HarnessMode = "headless-turn" | "headless-session" | "interactive";
32
32
 
33
- export const SESSION_INPUT_KINDS = ["claude-sdk-user-message"] as const;
33
+ export const SESSION_INPUT_KINDS = ["claude-sdk-user-message", "pi-rpc-prompt"] as const;
34
34
  export type SessionInputKind = (typeof SESSION_INPUT_KINDS)[number];
35
35
 
36
36
  export interface SessionInputContract {
@@ -241,11 +241,22 @@ export interface HarnessDescriptor {
241
241
  };
242
242
  /** Persistent headless session support: the exact flag set that opens one
243
243
  * lucid-owned process serving many turns, or null when the harness has no
244
- * such mode. `idFlag` pins the caller-assigned session identity. */
244
+ * such mode. `idFlag` pins the caller-assigned session identity.
245
+ * `turnEnd` is the stdout record that delimits one turn (claude: the
246
+ * `result` record; pi rpc: `agent_settled`). `identityProbe`, when
247
+ * present, names a command the runner writes at spawn whose response
248
+ * carries the session id - pi rpc is identity-silent at startup (spike
249
+ * evidence: test/fixtures/pi-rpc-spike), so identity needs a round trip. */
245
250
  readonly sessionMode: {
246
251
  readonly flags: readonly string[];
247
- readonly idFlag: string;
252
+ /** Pin an EXISTING session id; null when the harness only accepts
253
+ * caller ids that already exist (pi rpc: `--session` refuses unknown
254
+ * ids - spike evidence), so fresh sessions omit the flag and the
255
+ * harness mints the id, readable via `identityProbe`. */
256
+ readonly idFlag: string | null;
248
257
  readonly input: SessionInputContract;
258
+ readonly turnEnd: Readonly<Record<string, string>>;
259
+ readonly identityProbe: { readonly command: string } | null;
249
260
  } | null;
250
261
  /** Streaming is a property of the INVOCATION, not the harness: each pin
251
262
  * names the flag set that unlocks a granularity, checked in order, first
@@ -37,11 +37,23 @@ export const piCli: HarnessDescriptor = deepFreeze({
37
37
  onMissing: "create",
38
38
  extraFlags: ["-p", "--mode", "json"],
39
39
  },
40
- // --mode rpc exists on 0.84.2 but its session semantics are unverified
41
- // against a live run; per the truthfulness rule it stays null until a
42
- // spike proves the contract (the claude slice is the proven vertical -
43
- // D-003).
44
- sessionMode: null,
40
+ // --mode rpc exists on 0.84.2 and its session semantics are now VERIFIED
41
+ // against a live run (2026-08-19 spike, evidence at
42
+ // test/fixtures/pi-rpc-spike): JSONL both directions, agent_settled
43
+ // delimits turns, steer/follow_up queue mid-run (hcn never needs them -
44
+ // it queues sends itself), identity is silent at startup and readable
45
+ // only via a get_state round trip, stdin EOF exits rc=0. The claude
46
+ // slice remains the proven vertical (D-003); this entry is the second.
47
+ sessionMode: {
48
+ flags: ["--mode", "rpc"],
49
+ // `--session <id>` requires an EXISTING id ("No session found" on a
50
+ // fresh uuid - live-verified); fresh sessions omit it and pi mints
51
+ // the id, readable only through the get_state probe.
52
+ idFlag: null,
53
+ input: { kind: "pi-rpc-prompt" },
54
+ turnEnd: { type: "agent_settled" },
55
+ identityProbe: { command: "get_state" },
56
+ },
45
57
  output: {
46
58
  // pi -p prints plain text; --mode json emits structured v3 records
47
59
  // INCLUDING assistantMessageEvent text_delta tokens (verified 0.84.2),
@@ -88,10 +100,12 @@ export const piCli: HarnessDescriptor = deepFreeze({
88
100
  images: false,
89
101
  streamingByMode: {
90
102
  "headless-turn": "token",
91
- "headless-session": "none",
103
+ // rpc mode streams the same assistantMessageEvent deltas json mode
104
+ // does (spike fixture 02: text/thinking deltas under message_update).
105
+ "headless-session": "token",
92
106
  interactive: "none",
93
107
  },
94
- session: false,
108
+ session: true,
95
109
  },
96
110
  turnOptions: {
97
111
  effort: { kind: "effort", render: { kind: "flag-value", flag: "--thinking" } },