@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.
- package/README.md +30 -3
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +8 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +4 -1
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/exit-codes.js +1 -1
- package/dist/cli/exit-codes.js.map +1 -1
- package/dist/cli/help.d.ts +3 -3
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +27 -5
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/render.d.ts.map +1 -1
- package/dist/cli/render.js +15 -1
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +49 -15
- package/dist/cli/run.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/events.d.ts +12 -1
- package/dist/execution/events.d.ts.map +1 -1
- package/dist/execution/events.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/execution/stream-turn.d.ts +7 -0
- package/dist/execution/stream-turn.d.ts.map +1 -1
- package/dist/execution/stream-turn.js +67 -13
- package/dist/execution/stream-turn.js.map +1 -1
- package/dist/interpretation/argv.d.ts +5 -0
- 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 +54 -0
- package/dist/interpretation/question.d.ts.map +1 -0
- package/dist/interpretation/question.js +126 -0
- package/dist/interpretation/question.js.map +1 -0
- 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/args.ts +6 -0
- package/src/cli/config.ts +4 -1
- package/src/cli/exit-codes.ts +1 -1
- package/src/cli/help.ts +27 -5
- package/src/cli/render.ts +16 -1
- package/src/cli/run.ts +54 -19
- package/src/cli/session.ts +113 -11
- package/src/execution/events.ts +19 -1
- package/src/execution/failure.ts +1 -6
- package/src/execution/open-session.ts +146 -4
- package/src/execution/stream-turn.ts +74 -13
- package/src/interpretation/argv.ts +9 -3
- package/src/interpretation/presence.ts +3 -1
- package/src/interpretation/question.ts +158 -0
- 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/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
|
-
|
|
224
|
-
|
|
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
|
|
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.
|
package/src/cli/session.ts
CHANGED
|
@@ -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
|
-
//
|
|
11
|
-
|
|
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
|
|
16
|
-
detail: `session is
|
|
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:
|
|
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
|
|
161
|
+
line = await nextLine("you › ");
|
|
105
162
|
} catch {
|
|
106
|
-
//
|
|
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) {
|
package/src/execution/events.ts
CHANGED
|
@@ -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 =
|
|
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)
|
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.
|