@dungle-scrubs/harness-cli-normalizer 0.5.5 → 0.5.7
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 +3 -2
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +16 -8
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +11 -4
- package/dist/cli/config.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 +32 -23
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +10 -7
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/resume-guard.d.ts.map +1 -1
- package/dist/cli/resume-guard.js +59 -2
- package/dist/cli/resume-guard.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +31 -32
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/session-json.d.ts +5 -2
- package/dist/cli/session-json.d.ts.map +1 -1
- package/dist/cli/session-json.js +5 -3
- package/dist/cli/session-json.js.map +1 -1
- package/dist/cli/session.d.ts.map +1 -1
- package/dist/cli/session.js +103 -36
- package/dist/cli/session.js.map +1 -1
- package/dist/execution/events.d.ts +8 -1
- package/dist/execution/events.d.ts.map +1 -1
- package/dist/execution/events.js.map +1 -1
- package/dist/execution/open-session.d.ts +7 -6
- package/dist/execution/open-session.d.ts.map +1 -1
- package/dist/execution/open-session.js +88 -59
- package/dist/execution/open-session.js.map +1 -1
- package/dist/execution/stream-turn.d.ts +4 -7
- package/dist/execution/stream-turn.d.ts.map +1 -1
- package/dist/execution/stream-turn.js +43 -22
- package/dist/execution/stream-turn.js.map +1 -1
- package/dist/interpretation/argv.d.ts +9 -5
- package/dist/interpretation/argv.d.ts.map +1 -1
- package/dist/interpretation/argv.js +2 -7
- package/dist/interpretation/argv.js.map +1 -1
- package/dist/interpretation/capabilities.d.ts +20 -0
- package/dist/interpretation/capabilities.d.ts.map +1 -1
- package/dist/interpretation/capabilities.js +27 -0
- package/dist/interpretation/capabilities.js.map +1 -1
- package/dist/interpretation/hints.js +1 -1
- package/dist/interpretation/hints.js.map +1 -1
- package/dist/interpretation/question.d.ts +6 -2
- package/dist/interpretation/question.d.ts.map +1 -1
- package/dist/interpretation/question.js +13 -4
- package/dist/interpretation/question.js.map +1 -1
- package/dist/interpretation/refusal.d.ts +1 -1
- package/dist/interpretation/refusal.d.ts.map +1 -1
- package/dist/interpretation/refusal.js +1 -1
- package/dist/interpretation/refusal.js.map +1 -1
- package/dist/interpretation/skills-selection.d.ts +18 -1
- package/dist/interpretation/skills-selection.d.ts.map +1 -1
- package/dist/interpretation/skills-selection.js +33 -6
- package/dist/interpretation/skills-selection.js.map +1 -1
- package/dist/knowledge/claude-code.d.ts.map +1 -1
- package/dist/knowledge/claude-code.js +21 -0
- package/dist/knowledge/claude-code.js.map +1 -1
- package/dist/knowledge/codex.d.ts.map +1 -1
- package/dist/knowledge/codex.js +26 -7
- package/dist/knowledge/codex.js.map +1 -1
- package/dist/knowledge/descriptor.d.ts +39 -12
- package/dist/knowledge/descriptor.d.ts.map +1 -1
- package/dist/knowledge/descriptor.js +1 -0
- package/dist/knowledge/descriptor.js.map +1 -1
- package/dist/knowledge/muse.d.ts.map +1 -1
- package/dist/knowledge/muse.js +7 -0
- package/dist/knowledge/muse.js.map +1 -1
- package/dist/knowledge/pi.d.ts.map +1 -1
- package/dist/knowledge/pi.js +23 -4
- package/dist/knowledge/pi.js.map +1 -1
- package/package.json +3 -2
- package/src/cli/args.ts +16 -6
- package/src/cli/config.ts +13 -4
- package/src/cli/help.ts +32 -23
- package/src/cli/inspect.ts +8 -6
- package/src/cli/resume-guard.ts +54 -2
- package/src/cli/run.ts +32 -36
- package/src/cli/session-json.ts +11 -5
- package/src/cli/session.ts +122 -40
- package/src/execution/events.ts +9 -1
- package/src/execution/open-session.ts +99 -72
- package/src/execution/stream-turn.ts +53 -30
- package/src/interpretation/argv.ts +11 -12
- package/src/interpretation/capabilities.ts +50 -0
- package/src/interpretation/hints.ts +1 -1
- package/src/interpretation/question.ts +17 -7
- package/src/interpretation/refusal.ts +2 -1
- package/src/interpretation/skills-selection.ts +46 -8
- package/src/knowledge/claude-code.ts +21 -0
- package/src/knowledge/codex.ts +26 -7
- package/src/knowledge/descriptor.ts +40 -12
- package/src/knowledge/muse.ts +7 -0
- package/src/knowledge/pi.ts +23 -4
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* openSession: the persistent headless session runner - ONE process, many
|
|
3
|
-
* turns (A-001). `send`
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* context unpredictably - the harness itself queues, so we mirror its
|
|
7
|
-
* disposition). `result` lines delimit turns; identity dedupe (D-022)
|
|
3
|
+
* turns (A-001). `send` writes a descriptor-encoded user record to stdin and starts a
|
|
4
|
+
* turn when idle or hands the text to the harness when a turn is live
|
|
5
|
+
* (the harness queues it). `result` lines delimit turns; identity dedupe (D-022)
|
|
8
6
|
* spans the whole session. Lifecycle is bounded end to end: close() ends
|
|
9
7
|
* stdin, escalates SIGTERM->SIGKILL if the child ignores EOF, and pipes
|
|
10
8
|
* held open past exit close out after grace - a session can always be
|
|
11
|
-
* ended.
|
|
9
|
+
* ended. Pending sends that die with the session are surfaced, never
|
|
12
10
|
* silently dropped. Structured lifecycle events (session open/close, turn
|
|
13
11
|
* start/end, send dispositions, drops) are always-on evidence with
|
|
14
12
|
* sessionId + turnId correlation.
|
|
15
13
|
*/
|
|
16
14
|
import { buildSessionArgv } from "../interpretation/argv.js";
|
|
17
15
|
import { capabilitiesOf } from "../interpretation/capabilities.js";
|
|
16
|
+
import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
|
|
18
17
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} from "../interpretation/
|
|
23
|
-
import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
|
|
18
|
+
composeEscalatedPrompt,
|
|
19
|
+
detectQuestionBlock,
|
|
20
|
+
type QuestionMode,
|
|
21
|
+
} from "../interpretation/question.js";
|
|
24
22
|
import {
|
|
25
23
|
encodeSessionInput,
|
|
26
24
|
resolveSessionInput,
|
|
@@ -30,7 +28,7 @@ import type { HarnessDescriptor, SessionInputContract } from "../knowledge/descr
|
|
|
30
28
|
import { AsyncChannel } from "./channel.js";
|
|
31
29
|
import { decodeParsed, freshDecodeState } from "./decode.js";
|
|
32
30
|
import type { RunnerDeps, SpawnedProcess, TimerHandle } from "./deps.js";
|
|
33
|
-
import type { ExitCause, HarnessEvent } from "./events.js";
|
|
31
|
+
import type { EscalationDetection, ExitCause, HarnessEvent } from "./events.js";
|
|
34
32
|
import type { FailureSummary } from "./failure.js";
|
|
35
33
|
import {
|
|
36
34
|
failureFromAuth,
|
|
@@ -50,7 +48,7 @@ export const CLOSE_GRACE_MS = 5_000;
|
|
|
50
48
|
const PRETURN_MAX = 256;
|
|
51
49
|
|
|
52
50
|
export interface SessionSendResult {
|
|
53
|
-
readonly disposition: "started" | "
|
|
51
|
+
readonly disposition: "started" | "rejected";
|
|
54
52
|
/** Present when rejected. `write-failed` is a broken stdin pipe, which is
|
|
55
53
|
* a different remedy from a session the caller already closed - the two
|
|
56
54
|
* must stay distinguishable. */
|
|
@@ -88,13 +86,13 @@ export interface OpenSessionOptions {
|
|
|
88
86
|
readonly model?: string;
|
|
89
87
|
/** Working directory for the spawned harness. */
|
|
90
88
|
readonly cwd?: string;
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
* every send and arms block detection at turn end; false composes the
|
|
94
|
-
* no-ask instruction and disarms detection. */
|
|
95
|
-
readonly escalateQuestions?: boolean;
|
|
89
|
+
/** question mode for session (ask/assume/none), default "ask" */
|
|
90
|
+
readonly questions?: QuestionMode;
|
|
96
91
|
/** Provider selector (pi); refused on a harness without one. */
|
|
97
92
|
readonly provider?: string;
|
|
93
|
+
/** True when resuming an existing conversation; controls which flag
|
|
94
|
+
* (resumeFlag vs idFlag) buildSessionArgv renders. */
|
|
95
|
+
readonly isResume?: boolean;
|
|
98
96
|
}
|
|
99
97
|
|
|
100
98
|
export class SessionClosedError extends Error {
|
|
@@ -123,6 +121,7 @@ export const openSession = (
|
|
|
123
121
|
sessionId: opts.sessionId,
|
|
124
122
|
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
125
123
|
...(opts.provider !== undefined ? { provider: opts.provider } : {}),
|
|
124
|
+
...(opts.isResume !== undefined ? { isResume: opts.isResume } : {}),
|
|
126
125
|
});
|
|
127
126
|
let sessionInput: SessionInputContract;
|
|
128
127
|
try {
|
|
@@ -168,13 +167,21 @@ export const openSession = (
|
|
|
168
167
|
|
|
169
168
|
const turnsChannel = new AsyncChannel<SessionTurn>();
|
|
170
169
|
const state = freshDecodeState(opts.sessionId);
|
|
171
|
-
const
|
|
170
|
+
const questionMode: QuestionMode = opts.questions ?? "ask";
|
|
172
171
|
const sessionInputMode = h.sessionMode;
|
|
173
172
|
const stderrTail = new StderrTail();
|
|
174
173
|
let turnCounter = 0;
|
|
175
174
|
let activeTurn: AsyncChannel<HarnessEvent> | null = null;
|
|
176
175
|
let activeTurnId = "";
|
|
177
|
-
const
|
|
176
|
+
const pendingIds: string[] = [];
|
|
177
|
+
const pendingLengths: number[] = [];
|
|
178
|
+
// close() waits here while a turn is open. Ending the child's stdin
|
|
179
|
+
// mid-turn is fatal on pi: rpc treats EOF as "finish up and exit", so the
|
|
180
|
+
// prompt it has buffered never runs and the turn ends clean with no
|
|
181
|
+
// output (issue #99). claude happens to drain a queued turn after EOF,
|
|
182
|
+
// which is why this was invisible there. The README promises that a close
|
|
183
|
+
// after a send lets the turn finish; this is what keeps that promise.
|
|
184
|
+
let turnSettled: (() => void) | null = null;
|
|
178
185
|
const preTurnEvents: HarnessEvent[] = [];
|
|
179
186
|
let dead = false;
|
|
180
187
|
let closing = false;
|
|
@@ -188,6 +195,7 @@ export const openSession = (
|
|
|
188
195
|
// hcn-question block lives) and whether the turn ended by asking.
|
|
189
196
|
let lastAssistantText: string | null = null;
|
|
190
197
|
let turnAsked = false;
|
|
198
|
+
let turnEscalationDetection: EscalationDetection = "none";
|
|
191
199
|
let identityAnnounced = false;
|
|
192
200
|
|
|
193
201
|
const safeSignal = (sig: "SIGTERM" | "SIGKILL"): void => {
|
|
@@ -237,10 +245,7 @@ export const openSession = (
|
|
|
237
245
|
const writeUser = (text: string): boolean => {
|
|
238
246
|
try {
|
|
239
247
|
stdin.write(
|
|
240
|
-
encodeSessionInput(
|
|
241
|
-
sessionInput,
|
|
242
|
-
composeEscalatedPrompt(text, escalateQuestions, "session"),
|
|
243
|
-
),
|
|
248
|
+
encodeSessionInput(sessionInput, composeEscalatedPrompt(text, questionMode, "session")),
|
|
244
249
|
);
|
|
245
250
|
return true;
|
|
246
251
|
} catch {
|
|
@@ -265,11 +270,12 @@ export const openSession = (
|
|
|
265
270
|
turnLimitSeen = false;
|
|
266
271
|
turnFailures = [];
|
|
267
272
|
turnAsked = false;
|
|
273
|
+
turnEscalationDetection = "none";
|
|
268
274
|
lastAssistantText = null;
|
|
269
275
|
activeTurn = new AsyncChannel<HarnessEvent>();
|
|
270
276
|
activeTurnId = `${opts.sessionId}:turn-${++turnCounter}`;
|
|
271
277
|
// Tag the turn with the id of the send that opened it, so the consumer
|
|
272
|
-
// correlates
|
|
278
|
+
// correlates an input to its turn by reading the tag, not by
|
|
273
279
|
// shadowing the runner's delivery order.
|
|
274
280
|
(activeTurn as { inputId?: string; turnId?: string }).inputId = inputId;
|
|
275
281
|
(activeTurn as { inputId?: string; turnId?: string }).turnId = activeTurnId;
|
|
@@ -288,16 +294,24 @@ export const openSession = (
|
|
|
288
294
|
* turn stream right before its done; a malformed block surfaces as an
|
|
289
295
|
* error event, never a silent no-op. */
|
|
290
296
|
const emitQuestionIfAsked = (): void => {
|
|
291
|
-
if (
|
|
297
|
+
if (questionMode !== "ask" || lastAssistantText === null) {
|
|
298
|
+
turnEscalationDetection = "none";
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
292
301
|
const detection = detectQuestionBlock(lastAssistantText);
|
|
293
|
-
if (detection === null)
|
|
302
|
+
if (detection === null) {
|
|
303
|
+
turnEscalationDetection = "none";
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
294
306
|
if ("malformed" in detection) {
|
|
307
|
+
turnEscalationDetection = "malformed";
|
|
295
308
|
activeTurn?.push({ kind: "error", message: detection.malformed });
|
|
296
309
|
const failure = failureFromTask(`malformed hcn-question block: ${detection.malformed}`);
|
|
297
310
|
turnFailures.push(failure);
|
|
298
311
|
void activeTurn?.push({ kind: "failure", ...failure });
|
|
299
312
|
return;
|
|
300
313
|
}
|
|
314
|
+
turnEscalationDetection = "block";
|
|
301
315
|
turnAsked = true;
|
|
302
316
|
log({
|
|
303
317
|
event: "question",
|
|
@@ -316,7 +330,7 @@ export const openSession = (
|
|
|
316
330
|
});
|
|
317
331
|
};
|
|
318
332
|
|
|
319
|
-
const endTurn = (done: HarnessEvent & { kind: "done" }): void => {
|
|
333
|
+
const endTurn = (done: Omit<HarnessEvent & { kind: "done" }, "escalation">): void => {
|
|
320
334
|
if (activeTurn === null) return;
|
|
321
335
|
disarmStall();
|
|
322
336
|
// Asking is a successful turn: the session semantic is "blocked on
|
|
@@ -324,41 +338,40 @@ export const openSession = (
|
|
|
324
338
|
// in sessions) and the caller answers with the next send().
|
|
325
339
|
emitQuestionIfAsked();
|
|
326
340
|
if (turnAsked && done.cause === "clean") done = { ...done, cause: "awaiting-input" };
|
|
341
|
+
// RFC-01 every turn end carries the escalation record
|
|
342
|
+
let fullDone: HarnessEvent & { kind: "done" } = {
|
|
343
|
+
...done,
|
|
344
|
+
escalation: { mode: questionMode, detection: turnEscalationDetection },
|
|
345
|
+
} as HarnessEvent & { kind: "done" };
|
|
327
346
|
// Every failure was already emitted as an event through pushFailure;
|
|
328
347
|
// the turn's done carries the reduced summary, as streamTurn's does.
|
|
329
348
|
const reduced = reduceFailures(turnFailures);
|
|
330
349
|
if (reduced !== undefined) {
|
|
331
|
-
if (
|
|
332
|
-
else
|
|
350
|
+
if (fullDone.cause === "clean") fullDone = { ...fullDone, cause: "failed", failure: reduced };
|
|
351
|
+
else fullDone = { ...fullDone, failure: reduced };
|
|
333
352
|
}
|
|
334
|
-
activeTurn.push(
|
|
353
|
+
activeTurn.push(fullDone);
|
|
335
354
|
activeTurn.close();
|
|
336
355
|
log({
|
|
337
356
|
event: "turn_end",
|
|
338
357
|
sessionId: opts.sessionId,
|
|
339
358
|
turnId: activeTurnId,
|
|
340
|
-
cause:
|
|
359
|
+
cause: fullDone.cause,
|
|
341
360
|
});
|
|
342
361
|
activeTurn = null;
|
|
343
362
|
resultError = false;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (writeUser(next.text)) {
|
|
349
|
-
startTurn(next.id);
|
|
350
|
-
return;
|
|
363
|
+
if (turnSettled !== null) {
|
|
364
|
+
const release = turnSettled;
|
|
365
|
+
turnSettled = null;
|
|
366
|
+
release();
|
|
351
367
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
reason: "write-failed",
|
|
360
|
-
lengths: [next.text.length],
|
|
361
|
-
});
|
|
368
|
+
if (dead || closing) return;
|
|
369
|
+
const nextId = pendingIds.shift();
|
|
370
|
+
if (nextId === undefined) return;
|
|
371
|
+
pendingLengths.shift();
|
|
372
|
+
// The text was already written to the harness when the send arrived;
|
|
373
|
+
// the id was held to correlate the next turn.
|
|
374
|
+
startTurn(nextId);
|
|
362
375
|
};
|
|
363
376
|
|
|
364
377
|
const routeEvent = (event: HarnessEvent): Promise<void> => {
|
|
@@ -366,7 +379,7 @@ export const openSession = (
|
|
|
366
379
|
state.limitSeen = true;
|
|
367
380
|
turnLimitSeen = true;
|
|
368
381
|
}
|
|
369
|
-
if (
|
|
382
|
+
if (questionMode === "ask" && event.kind === "message" && event.role === "assistant") {
|
|
370
383
|
lastAssistantText = event.text;
|
|
371
384
|
}
|
|
372
385
|
if (activeTurn !== null) {
|
|
@@ -581,22 +594,22 @@ export const openSession = (
|
|
|
581
594
|
if (pumpError !== null) {
|
|
582
595
|
void routeEvent({ kind: "error", message: `session pump failed: ${String(pumpError)}` });
|
|
583
596
|
}
|
|
584
|
-
if (
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
const droppedIds = pendingSends.map((s) => s.id);
|
|
597
|
+
if (pendingIds.length > 0) {
|
|
598
|
+
const droppedIds = [...pendingIds];
|
|
599
|
+
const droppedLengths = [...pendingLengths];
|
|
588
600
|
void routeEvent({
|
|
589
601
|
kind: "error",
|
|
590
|
-
message: `${
|
|
602
|
+
message: `${pendingIds.length} pending send(s) died with the session: ${droppedIds.join(", ")}`,
|
|
591
603
|
});
|
|
592
604
|
log({
|
|
593
605
|
event: "sends_dropped",
|
|
594
606
|
sessionId: opts.sessionId,
|
|
595
|
-
count:
|
|
607
|
+
count: pendingIds.length,
|
|
596
608
|
ids: droppedIds,
|
|
597
|
-
lengths:
|
|
609
|
+
lengths: droppedLengths,
|
|
598
610
|
});
|
|
599
|
-
|
|
611
|
+
pendingIds.length = 0;
|
|
612
|
+
pendingLengths.length = 0;
|
|
600
613
|
}
|
|
601
614
|
endTurn({ kind: "done", exitCode, cause });
|
|
602
615
|
if (preTurnEvents.some((e) => e.kind !== "token" && e.kind !== "progress")) {
|
|
@@ -621,6 +634,12 @@ export const openSession = (
|
|
|
621
634
|
void proc.exited.then((code) => {
|
|
622
635
|
dead = true;
|
|
623
636
|
exitCode = code;
|
|
637
|
+
// A close() waiting on an open turn must not outlive the child.
|
|
638
|
+
if (turnSettled !== null) {
|
|
639
|
+
const release = turnSettled;
|
|
640
|
+
turnSettled = null;
|
|
641
|
+
release();
|
|
642
|
+
}
|
|
624
643
|
// The process is gone: a later fire would flip a finished turn to stall.
|
|
625
644
|
disarmStall();
|
|
626
645
|
// Pipes held open past exit (a grandchild) must not hang the session.
|
|
@@ -636,9 +655,22 @@ export const openSession = (
|
|
|
636
655
|
});
|
|
637
656
|
});
|
|
638
657
|
|
|
639
|
-
|
|
658
|
+
// Two intents share this path and must not be conflated. A CLOSE is the
|
|
659
|
+
// consumer asking politely: an open turn gets to finish first, because
|
|
660
|
+
// the consumer is still there to receive it. ABANDONMENT is the consumer
|
|
661
|
+
// walking away from the turns iterable: nobody is left to receive a turn,
|
|
662
|
+
// so stdin ends at once and the child is reaped, not drained.
|
|
663
|
+
const close = async (drain = true): Promise<void> => {
|
|
640
664
|
if (closing) return shutdown;
|
|
641
665
|
closing = true;
|
|
666
|
+
// Let an open turn reach its end record before stdin goes away. The
|
|
667
|
+
// wait is bounded twice over: the stall watchdog ends a silent turn,
|
|
668
|
+
// and the grace below still escalates a child that never exits.
|
|
669
|
+
if (drain && activeTurn !== null && !dead) {
|
|
670
|
+
await new Promise<void>((resolve) => {
|
|
671
|
+
turnSettled = resolve;
|
|
672
|
+
});
|
|
673
|
+
}
|
|
642
674
|
try {
|
|
643
675
|
stdin.end();
|
|
644
676
|
} catch {
|
|
@@ -658,22 +690,12 @@ export const openSession = (
|
|
|
658
690
|
try {
|
|
659
691
|
for await (const turn of turnsChannel) yield turn;
|
|
660
692
|
} finally {
|
|
661
|
-
if (!closing && !dead) void close();
|
|
693
|
+
if (!closing && !dead) void close(false);
|
|
662
694
|
}
|
|
663
695
|
})(),
|
|
664
696
|
send(input: SessionInput): SessionSendResult {
|
|
665
697
|
if (dead || closing) throw new SessionClosedError();
|
|
666
|
-
|
|
667
|
-
pendingSends.push(input);
|
|
668
|
-
log({
|
|
669
|
-
event: "send",
|
|
670
|
-
sessionId: opts.sessionId,
|
|
671
|
-
turnId: activeTurnId,
|
|
672
|
-
inputId: input.id,
|
|
673
|
-
disposition: "queued",
|
|
674
|
-
});
|
|
675
|
-
return { disposition: "queued" };
|
|
676
|
-
}
|
|
698
|
+
const wasBusy = activeTurn !== null;
|
|
677
699
|
if (!writeUser(input.text)) {
|
|
678
700
|
log({
|
|
679
701
|
event: "send",
|
|
@@ -685,7 +707,12 @@ export const openSession = (
|
|
|
685
707
|
});
|
|
686
708
|
return { disposition: "rejected", reason: "write-failed" };
|
|
687
709
|
}
|
|
688
|
-
|
|
710
|
+
if (wasBusy) {
|
|
711
|
+
pendingIds.push(input.id);
|
|
712
|
+
pendingLengths.push(input.text.length);
|
|
713
|
+
} else {
|
|
714
|
+
startTurn(input.id);
|
|
715
|
+
}
|
|
689
716
|
log({
|
|
690
717
|
event: "send",
|
|
691
718
|
sessionId: opts.sessionId,
|
|
@@ -22,14 +22,18 @@ import {
|
|
|
22
22
|
detectTransportInLine,
|
|
23
23
|
detectUnavailableInLine,
|
|
24
24
|
} from "../interpretation/limits.js";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
composeEscalatedPrompt,
|
|
27
|
+
detectQuestionBlock,
|
|
28
|
+
type QuestionMode,
|
|
29
|
+
} from "../interpretation/question.js";
|
|
26
30
|
import { ArgvRefusalError } from "../interpretation/refusal.js";
|
|
27
31
|
import type { HarnessDescriptor } from "../knowledge/descriptor.js";
|
|
28
32
|
import { matcherOverridesOf } from "../knowledge/overrides.js";
|
|
29
33
|
import { AsyncChannel } from "./channel.js";
|
|
30
34
|
import { decodeLine, freshDecodeState } from "./decode.js";
|
|
31
35
|
import type { RunnerDeps, SpawnedProcess } from "./deps.js";
|
|
32
|
-
import type { ExitCause, HarnessEvent } from "./events.js";
|
|
36
|
+
import type { EscalationDetection, ExitCause, HarnessEvent } from "./events.js";
|
|
33
37
|
import type { FailureSummary } from "./failure.js";
|
|
34
38
|
import {
|
|
35
39
|
failureFromAuth,
|
|
@@ -111,13 +115,9 @@ export interface TurnRunOptions extends LaunchOptions {
|
|
|
111
115
|
* normalized argv. Wrong-harness flags here fail in the harness itself
|
|
112
116
|
* and surface as native errors - hcn never validates them. */
|
|
113
117
|
readonly passthrough?: readonly string[];
|
|
114
|
-
/**
|
|
115
|
-
*
|
|
116
|
-
|
|
117
|
-
* detection; false prepends the state-the-assumption instruction and
|
|
118
|
-
* disarms detection. Applies on launch AND resume: it shapes each
|
|
119
|
-
* turn's prompt and event stream, never a session setting. */
|
|
120
|
-
readonly escalateQuestions?: boolean;
|
|
118
|
+
/** question mode: which preamble to inject (ask/assume/none).
|
|
119
|
+
* Behavior instruction - never a harness flag. Defaults to "ask". */
|
|
120
|
+
readonly questions?: QuestionMode;
|
|
121
121
|
/** F-05: caller-requested stop. When aborted, the runner escalates
|
|
122
122
|
* SIGTERM then SIGKILL and classifies the exit as killed with no
|
|
123
123
|
* transport failure for the kill itself. */
|
|
@@ -132,20 +132,17 @@ export async function* streamTurn(
|
|
|
132
132
|
const turnId = deps.turnId ?? `turn-${++turnCounter}`;
|
|
133
133
|
const log = deps.log ?? (() => {});
|
|
134
134
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
// and arm detection in the true mode. Composition is idempotent, so a
|
|
138
|
-
// caller that already composed (the CLI does, for spawn-line truth)
|
|
139
|
-
// never double-prepends.
|
|
140
|
-
const escalateQuestions = opts.escalateQuestions !== false;
|
|
135
|
+
// compose the preamble onto the prompt based on question mode.
|
|
136
|
+
const questionMode: QuestionMode = opts.questions ?? "ask";
|
|
141
137
|
const effective: TurnRunOptions = {
|
|
142
138
|
...opts,
|
|
143
|
-
prompt: composeEscalatedPrompt(opts.prompt,
|
|
139
|
+
prompt: composeEscalatedPrompt(opts.prompt, questionMode),
|
|
144
140
|
};
|
|
145
141
|
// The turn's last assistant message - where the protocol says the
|
|
146
142
|
// hcn-question block lives. Tracked only when detection is armed.
|
|
147
143
|
let lastAssistantText: string | null = null;
|
|
148
144
|
let asked = false;
|
|
145
|
+
let escalationDetection: EscalationDetection = "none";
|
|
149
146
|
|
|
150
147
|
// Validate env before building argv so an invalid env is a refusal, not a spawn
|
|
151
148
|
if (opts.env !== undefined) {
|
|
@@ -172,7 +169,13 @@ export async function* streamTurn(
|
|
|
172
169
|
argv: redactArgv([], effective.prompt),
|
|
173
170
|
});
|
|
174
171
|
yield { kind: "failure", ...failure };
|
|
175
|
-
yield {
|
|
172
|
+
yield {
|
|
173
|
+
kind: "done",
|
|
174
|
+
exitCode: null,
|
|
175
|
+
cause: "failed",
|
|
176
|
+
failure,
|
|
177
|
+
escalation: { mode: questionMode, detection: "none" },
|
|
178
|
+
};
|
|
176
179
|
return;
|
|
177
180
|
}
|
|
178
181
|
}
|
|
@@ -188,12 +191,11 @@ export async function* streamTurn(
|
|
|
188
191
|
if (effective.passthrough !== undefined && effective.passthrough.length > 0) {
|
|
189
192
|
argv = [...argv, "--", ...effective.passthrough];
|
|
190
193
|
}
|
|
191
|
-
// issue #38: claude
|
|
192
|
-
// the argv tail (
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
argv = [...argv, ...claudeSkillTokens];
|
|
194
|
+
// issue #38: claude/codex render the skills allowlist as complement-off
|
|
195
|
+
// tokens at the argv tail (claude: settings JSON; codex: -c skills.config).
|
|
196
|
+
const skillTokens = (opts as unknown as { __skillTokens?: string[] }).__skillTokens;
|
|
197
|
+
if (skillTokens !== undefined && skillTokens.length > 0) {
|
|
198
|
+
argv = [...argv, ...skillTokens];
|
|
197
199
|
}
|
|
198
200
|
granularity = streamingGranularityOf(h, argv);
|
|
199
201
|
} catch (e) {
|
|
@@ -223,7 +225,13 @@ export async function* streamTurn(
|
|
|
223
225
|
argv: argvForLog,
|
|
224
226
|
});
|
|
225
227
|
yield { kind: "failure", ...failure };
|
|
226
|
-
yield {
|
|
228
|
+
yield {
|
|
229
|
+
kind: "done",
|
|
230
|
+
exitCode: null,
|
|
231
|
+
cause: "failed",
|
|
232
|
+
failure,
|
|
233
|
+
escalation: { mode: questionMode, detection: "none" },
|
|
234
|
+
};
|
|
227
235
|
return;
|
|
228
236
|
}
|
|
229
237
|
throw e;
|
|
@@ -271,7 +279,13 @@ export async function* streamTurn(
|
|
|
271
279
|
if (resumeCreateWarning !== null) yield { kind: "error", message: resumeCreateWarning };
|
|
272
280
|
yield { kind: "error", message: `spawn failed: ${message}` };
|
|
273
281
|
yield { kind: "failure", ...failure };
|
|
274
|
-
yield {
|
|
282
|
+
yield {
|
|
283
|
+
kind: "done",
|
|
284
|
+
exitCode: 127,
|
|
285
|
+
cause: "failed",
|
|
286
|
+
failure,
|
|
287
|
+
escalation: { mode: questionMode, detection: "none" },
|
|
288
|
+
};
|
|
275
289
|
return;
|
|
276
290
|
}
|
|
277
291
|
|
|
@@ -439,7 +453,7 @@ export async function* streamTurn(
|
|
|
439
453
|
if (event.terminal === true) await pushFailure(failureFromTerminalError(h, event.message));
|
|
440
454
|
return;
|
|
441
455
|
}
|
|
442
|
-
if (
|
|
456
|
+
if (questionMode === "ask" && event.kind === "message" && event.role === "assistant") {
|
|
443
457
|
lastAssistantText = event.text;
|
|
444
458
|
}
|
|
445
459
|
await queue.push(event);
|
|
@@ -469,18 +483,26 @@ export async function* streamTurn(
|
|
|
469
483
|
/** issue #41: scan the last assistant message for the hcn-question
|
|
470
484
|
* block. Structured-first - the block's fields become the event; no
|
|
471
485
|
* prose parsing. Runs after the pumps settle (the last message is only
|
|
472
|
-
* last then) and only when detection is armed (
|
|
473
|
-
*
|
|
486
|
+
* last then) and only when detection is armed (questions ask).
|
|
487
|
+
* A malformed block surfaces as an error event, never a silent
|
|
474
488
|
* no-op. */
|
|
475
489
|
const emitQuestionIfAsked = async (): Promise<void> => {
|
|
476
|
-
if (
|
|
490
|
+
if (questionMode !== "ask" || lastAssistantText === null) {
|
|
491
|
+
escalationDetection = "none";
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
477
494
|
const detection = detectQuestionBlock(lastAssistantText);
|
|
478
|
-
if (detection === null)
|
|
495
|
+
if (detection === null) {
|
|
496
|
+
escalationDetection = "none";
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
479
499
|
if ("malformed" in detection) {
|
|
500
|
+
escalationDetection = "malformed";
|
|
480
501
|
await queue.push({ kind: "error", message: detection.malformed });
|
|
481
502
|
await pushFailure(failureFromTask(`malformed hcn-question block: ${detection.malformed}`));
|
|
482
503
|
return;
|
|
483
504
|
}
|
|
505
|
+
escalationDetection = "block";
|
|
484
506
|
log({
|
|
485
507
|
event: "question",
|
|
486
508
|
turnId,
|
|
@@ -680,6 +702,7 @@ export async function* streamTurn(
|
|
|
680
702
|
exitCode: nativeReduced ? null : exitCode,
|
|
681
703
|
cause,
|
|
682
704
|
...(reduced ? { failure: reduced } : {}),
|
|
705
|
+
escalation: { mode: questionMode, detection: escalationDetection },
|
|
683
706
|
};
|
|
684
707
|
} finally {
|
|
685
708
|
if (abortHandler !== null) opts.signal?.removeEventListener("abort", abortHandler);
|
|
@@ -76,11 +76,11 @@ export interface TurnOptions {
|
|
|
76
76
|
readonly systemPrompt?: string;
|
|
77
77
|
/** issue #48: appends to the built-in prompt (claude/pi only). */
|
|
78
78
|
readonly appendSystemPrompt?: string;
|
|
79
|
-
/**
|
|
80
|
-
* option. It never renders into any
|
|
81
|
-
* it into the prompt preamble and
|
|
82
|
-
* Undefined means the default:
|
|
83
|
-
readonly
|
|
79
|
+
/** question mode: which preamble hcn injects (ask/assume/none).
|
|
80
|
+
* A BEHAVIOR INSTRUCTION, not a turn option. It never renders into any
|
|
81
|
+
* harness argv; the CLI layer turns it into the prompt preamble and
|
|
82
|
+
* arms question-block detection. Undefined means the default: "ask". */
|
|
83
|
+
readonly questions?: import("./question.js").QuestionMode;
|
|
84
84
|
/** Internal: set by CLI when prompt came from --prompt/--prompt-file to bypass leading '-' guard */
|
|
85
85
|
readonly __explicitPrompt?: boolean;
|
|
86
86
|
/** toolMap extensible vocabulary per harness (issue toolMap) */
|
|
@@ -192,6 +192,10 @@ export interface SessionOptions {
|
|
|
192
192
|
/** Provider selector (pi). A harness with no provider selector refuses,
|
|
193
193
|
* the same way a one-shot turn does. */
|
|
194
194
|
readonly provider?: string;
|
|
195
|
+
/** True when this argv should resume an existing conversation, false for a
|
|
196
|
+
* fresh session. Controls which descriptor flag is rendered: resumeFlag
|
|
197
|
+
* vs idFlag. Only consumers that alias --resume/--session-id set this. */
|
|
198
|
+
readonly isResume?: boolean;
|
|
195
199
|
}
|
|
196
200
|
|
|
197
201
|
export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): string[] => {
|
|
@@ -206,13 +210,8 @@ export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): st
|
|
|
206
210
|
});
|
|
207
211
|
}
|
|
208
212
|
refuseUnusableSessionId(h, opts.sessionId);
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
...h.sessionMode.flags,
|
|
212
|
-
// idFlag null = the harness refuses unknown ids and mints its own
|
|
213
|
-
// (pi rpc); the caller-side sessionId stays a correlation handle.
|
|
214
|
-
...(h.sessionMode.idFlag !== null ? [h.sessionMode.idFlag, opts.sessionId] : []),
|
|
215
|
-
];
|
|
213
|
+
const flag = opts.isResume ? h.sessionMode.resumeFlag : h.sessionMode.idFlag;
|
|
214
|
+
const argv = [h.bin, ...h.sessionMode.flags, ...(flag !== null ? [flag, opts.sessionId] : [])];
|
|
216
215
|
if (opts.model !== undefined) {
|
|
217
216
|
const validated = validateModel(h, opts.model);
|
|
218
217
|
if (!validated.ok) {
|
|
@@ -11,8 +11,30 @@ import type {
|
|
|
11
11
|
HarnessMode,
|
|
12
12
|
StreamingGranularity,
|
|
13
13
|
} from "../knowledge/descriptor.js";
|
|
14
|
+
import { compareVersions } from "./versions.js";
|
|
14
15
|
import { resolveModel } from "./vocabulary.js";
|
|
15
16
|
|
|
17
|
+
export interface EscalationObservedOn {
|
|
18
|
+
readonly harness: string;
|
|
19
|
+
readonly model: string;
|
|
20
|
+
readonly version: string;
|
|
21
|
+
readonly date: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Escalation claim: whether this harness and model were observed to emit
|
|
26
|
+
* the structured hcn-question block when instructed, NOT "this harness can
|
|
27
|
+
* ask" - live probes show models ask unprompted, so capability-to-ask would
|
|
28
|
+
* be false.
|
|
29
|
+
*/
|
|
30
|
+
export interface EscalationClaim {
|
|
31
|
+
/** True when this harness and model were observed to emit the structured block when instructed. */
|
|
32
|
+
readonly supported: boolean;
|
|
33
|
+
readonly source: "runtime-verified" | "curated" | "unknown";
|
|
34
|
+
readonly confidence: "high" | "medium" | "none";
|
|
35
|
+
readonly observedOn?: EscalationObservedOn;
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
export interface CapabilityResult {
|
|
17
39
|
readonly vision: boolean;
|
|
18
40
|
readonly images: boolean;
|
|
@@ -20,8 +42,34 @@ export interface CapabilityResult {
|
|
|
20
42
|
readonly session: boolean;
|
|
21
43
|
readonly source: "runtime-verified" | "curated" | "unknown";
|
|
22
44
|
readonly confidence: "high" | "medium" | "none";
|
|
45
|
+
readonly escalation: EscalationClaim;
|
|
23
46
|
}
|
|
24
47
|
|
|
48
|
+
const escalationOf = (h: HarnessDescriptor): EscalationClaim => {
|
|
49
|
+
const obs = h.escalation.observedOn;
|
|
50
|
+
if (obs !== undefined) {
|
|
51
|
+
const cmp = compareVersions(obs.version, h.verifiedAgainst);
|
|
52
|
+
if (cmp < 0) {
|
|
53
|
+
return {
|
|
54
|
+
supported: true,
|
|
55
|
+
source: "runtime-verified",
|
|
56
|
+
confidence: "medium",
|
|
57
|
+
observedOn: obs,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
supported: true,
|
|
62
|
+
source: "runtime-verified",
|
|
63
|
+
confidence: "high",
|
|
64
|
+
observedOn: obs,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (h.escalation.supported) {
|
|
68
|
+
return { supported: true, source: "curated", confidence: "medium" };
|
|
69
|
+
}
|
|
70
|
+
return { supported: false, source: "unknown", confidence: "none" };
|
|
71
|
+
};
|
|
72
|
+
|
|
25
73
|
export const capabilitiesOf = (
|
|
26
74
|
h: HarnessDescriptor,
|
|
27
75
|
model: string,
|
|
@@ -41,6 +89,7 @@ export const capabilitiesOf = (
|
|
|
41
89
|
session: false,
|
|
42
90
|
source: "unknown",
|
|
43
91
|
confidence: "none",
|
|
92
|
+
escalation: { supported: false, source: "unknown", confidence: "none" },
|
|
44
93
|
};
|
|
45
94
|
}
|
|
46
95
|
return {
|
|
@@ -50,5 +99,6 @@ export const capabilitiesOf = (
|
|
|
50
99
|
session: h.capabilities.session,
|
|
51
100
|
source: "curated",
|
|
52
101
|
confidence: "medium",
|
|
102
|
+
escalation: escalationOf(h),
|
|
53
103
|
};
|
|
54
104
|
};
|
|
@@ -49,7 +49,7 @@ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFr
|
|
|
49
49
|
"discovery.extensions":
|
|
50
50
|
"codex loads MCP servers and plugins from config; disable per-server with -c or codex mcp remove rather than a call-time toggle",
|
|
51
51
|
"discovery.skills":
|
|
52
|
-
|
|
52
|
+
'codex has no global skills-off switch (skills.enabled is not a key); disable per skill per call with -c skills.config=[{path=".../SKILL.md", enabled=false}] and bundled skills with -c skills.bundled.enabled=false',
|
|
53
53
|
},
|
|
54
54
|
pi: {
|
|
55
55
|
sandbox:
|