@dungle-scrubs/harness-cli-normalizer 0.4.2 → 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.
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +8 -3
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/session.d.ts.map +1 -1
- package/dist/cli/session.js +107 -9
- package/dist/cli/session.js.map +1 -1
- package/dist/execution/failure.d.ts.map +1 -1
- package/dist/execution/failure.js.map +1 -1
- package/dist/execution/open-session.d.ts +5 -0
- package/dist/execution/open-session.d.ts.map +1 -1
- package/dist/execution/open-session.js +136 -4
- package/dist/execution/open-session.js.map +1 -1
- package/dist/interpretation/argv.d.ts.map +1 -1
- package/dist/interpretation/argv.js +4 -3
- package/dist/interpretation/argv.js.map +1 -1
- package/dist/interpretation/presence.d.ts.map +1 -1
- package/dist/interpretation/presence.js +3 -1
- package/dist/interpretation/presence.js.map +1 -1
- package/dist/interpretation/question.d.ts +12 -4
- package/dist/interpretation/question.d.ts.map +1 -1
- package/dist/interpretation/question.js +21 -5
- package/dist/interpretation/question.js.map +1 -1
- package/dist/interpretation/session-input.d.ts.map +1 -1
- package/dist/interpretation/session-input.js +5 -0
- package/dist/interpretation/session-input.js.map +1 -1
- package/dist/knowledge/claude-code.d.ts.map +1 -1
- package/dist/knowledge/claude-code.js +2 -0
- package/dist/knowledge/claude-code.js.map +1 -1
- package/dist/knowledge/descriptor.d.ts +16 -3
- package/dist/knowledge/descriptor.d.ts.map +1 -1
- package/dist/knowledge/descriptor.js +1 -1
- package/dist/knowledge/descriptor.js.map +1 -1
- package/dist/knowledge/pi.d.ts.map +1 -1
- package/dist/knowledge/pi.js +21 -7
- package/dist/knowledge/pi.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/help.ts +8 -3
- package/src/cli/session.ts +113 -11
- package/src/execution/failure.ts +1 -6
- package/src/execution/open-session.ts +146 -4
- package/src/interpretation/argv.ts +4 -3
- package/src/interpretation/presence.ts +3 -1
- package/src/interpretation/question.ts +26 -5
- package/src/interpretation/session-input.ts +5 -0
- package/src/knowledge/claude-code.ts +2 -0
- package/src/knowledge/descriptor.ts +14 -3
- package/src/knowledge/pi.ts +21 -7
package/src/execution/failure.ts
CHANGED
|
@@ -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(
|
|
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
|
-
//
|
|
222
|
-
//
|
|
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
|
-
|
|
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.
|
|
@@ -164,7 +164,7 @@ export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): st
|
|
|
164
164
|
throw new ArgvRefusalError({
|
|
165
165
|
issue: "no-session-mode",
|
|
166
166
|
harness: h.name,
|
|
167
|
-
supported: ["session is
|
|
167
|
+
supported: ["session is available where sessionMode is declared"],
|
|
168
168
|
});
|
|
169
169
|
}
|
|
170
170
|
assertUsableSessionId(opts.sessionId);
|
|
@@ -172,8 +172,9 @@ export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): st
|
|
|
172
172
|
h.bin,
|
|
173
173
|
...h.launch.baseFlags,
|
|
174
174
|
...h.sessionMode.flags,
|
|
175
|
-
|
|
176
|
-
|
|
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] : []),
|
|
177
178
|
];
|
|
178
179
|
if (opts.model !== undefined) {
|
|
179
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
|
-
|
|
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]),
|
|
@@ -33,13 +33,34 @@ Say nothing after the block. Your turn ends there; the caller's user will answer
|
|
|
33
33
|
export const NO_ESCALATION_PREAMBLE = `${QUESTION_PREAMBLE_MARKER}
|
|
34
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
35
|
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
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 =>
|
|
40
61
|
prompt.startsWith(QUESTION_PREAMBLE_MARKER)
|
|
41
62
|
? prompt
|
|
42
|
-
: `${escalate ? ESCALATION_PREAMBLE : NO_ESCALATION_PREAMBLE}\n\n${prompt}`;
|
|
63
|
+
: `${mode === "session" && escalate ? SESSION_ESCALATION_PREAMBLE : escalate ? ESCALATION_PREAMBLE : NO_ESCALATION_PREAMBLE}\n\n${prompt}`;
|
|
43
64
|
|
|
44
65
|
/** The structured question a worker asks (the block's fields). */
|
|
45
66
|
export interface QuestionBlock {
|
|
@@ -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
|
-
|
|
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
|
package/src/knowledge/pi.ts
CHANGED
|
@@ -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
|
|
41
|
-
// against a live run
|
|
42
|
-
// spike
|
|
43
|
-
//
|
|
44
|
-
|
|
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
|
-
|
|
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:
|
|
108
|
+
session: true,
|
|
95
109
|
},
|
|
96
110
|
turnOptions: {
|
|
97
111
|
effort: { kind: "effort", render: { kind: "flag-value", flag: "--thinking" } },
|