@dungle-scrubs/harness-cli-normalizer 0.4.4 → 0.4.5
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 +14 -5
- 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 -0
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +7 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +57 -24
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +125 -65
- package/dist/cli/run.js.map +1 -1
- package/dist/execution/decode.d.ts +3 -3
- package/dist/execution/decode.d.ts.map +1 -1
- package/dist/execution/decode.js +44 -12
- package/dist/execution/decode.js.map +1 -1
- package/dist/execution/deps.d.ts +3 -0
- package/dist/execution/deps.d.ts.map +1 -1
- package/dist/execution/events.d.ts +7 -2
- package/dist/execution/events.d.ts.map +1 -1
- package/dist/execution/events.js.map +1 -1
- package/dist/execution/node-deps.d.ts.map +1 -1
- package/dist/execution/node-deps.js +1 -0
- package/dist/execution/node-deps.js.map +1 -1
- package/dist/execution/open-session.d.ts.map +1 -1
- package/dist/execution/open-session.js +36 -5
- package/dist/execution/open-session.js.map +1 -1
- package/dist/execution/stream-turn.d.ts +4 -0
- package/dist/execution/stream-turn.d.ts.map +1 -1
- package/dist/execution/stream-turn.js +135 -29
- package/dist/execution/stream-turn.js.map +1 -1
- package/dist/interpretation/argv.d.ts +7 -0
- package/dist/interpretation/argv.d.ts.map +1 -1
- package/dist/interpretation/argv.js +24 -4
- package/dist/interpretation/argv.js.map +1 -1
- package/dist/interpretation/capabilities.d.ts.map +1 -1
- package/dist/interpretation/capabilities.js +4 -1
- package/dist/interpretation/capabilities.js.map +1 -1
- package/dist/interpretation/content.d.ts +9 -1
- package/dist/interpretation/content.d.ts.map +1 -1
- package/dist/interpretation/content.js +13 -4
- package/dist/interpretation/content.js.map +1 -1
- package/dist/interpretation/hints.d.ts.map +1 -1
- package/dist/interpretation/hints.js +5 -0
- package/dist/interpretation/hints.js.map +1 -1
- package/dist/interpretation/resolve-options.d.ts.map +1 -1
- package/dist/interpretation/resolve-options.js +16 -0
- package/dist/interpretation/resolve-options.js.map +1 -1
- package/dist/interpretation/tool-selection.d.ts.map +1 -1
- package/dist/interpretation/tool-selection.js +9 -0
- package/dist/interpretation/tool-selection.js.map +1 -1
- package/dist/interpretation/turn-options.d.ts.map +1 -1
- package/dist/interpretation/turn-options.js +35 -3
- package/dist/interpretation/turn-options.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 +9 -0
- package/dist/knowledge/codex.js.map +1 -1
- package/dist/knowledge/descriptor.d.ts +21 -10
- package/dist/knowledge/descriptor.d.ts.map +1 -1
- package/dist/knowledge/descriptor.js +6 -0
- package/dist/knowledge/descriptor.js.map +1 -1
- package/dist/knowledge/matchers.d.ts.map +1 -1
- package/dist/knowledge/matchers.js +8 -1
- package/dist/knowledge/matchers.js.map +1 -1
- package/dist/knowledge/pi.d.ts.map +1 -1
- package/dist/knowledge/pi.js +20 -0
- package/dist/knowledge/pi.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/args.ts +7 -0
- package/src/cli/config.ts +4 -0
- package/src/cli/help.ts +7 -0
- package/src/cli/inspect.ts +54 -22
- package/src/cli/run.ts +166 -67
- package/src/execution/decode.ts +44 -11
- package/src/execution/deps.ts +3 -0
- package/src/execution/events.ts +5 -2
- package/src/execution/node-deps.ts +1 -0
- package/src/execution/open-session.ts +34 -3
- package/src/execution/stream-turn.ts +134 -27
- package/src/interpretation/argv.ts +30 -4
- package/src/interpretation/capabilities.ts +4 -1
- package/src/interpretation/content.ts +18 -5
- package/src/interpretation/hints.ts +8 -0
- package/src/interpretation/resolve-options.ts +17 -0
- package/src/interpretation/tool-selection.ts +9 -0
- package/src/interpretation/turn-options.ts +31 -3
- package/src/knowledge/claude-code.ts +21 -0
- package/src/knowledge/codex.ts +9 -0
- package/src/knowledge/descriptor.ts +28 -10
- package/src/knowledge/matchers.ts +8 -1
- package/src/knowledge/pi.ts +20 -0
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
failureFromLimit,
|
|
32
32
|
failureFromNative,
|
|
33
33
|
failureFromRejected,
|
|
34
|
+
failureFromTask,
|
|
34
35
|
failureFromTimeout,
|
|
35
36
|
failureFromTransport,
|
|
36
37
|
reduceFailures,
|
|
@@ -110,6 +111,10 @@ export interface TurnRunOptions extends LaunchOptions {
|
|
|
110
111
|
* disarms detection. Applies on launch AND resume: it shapes each
|
|
111
112
|
* turn's prompt and event stream, never a session setting. */
|
|
112
113
|
readonly escalateQuestions?: boolean;
|
|
114
|
+
/** F-05: caller-requested stop. When aborted, the runner escalates
|
|
115
|
+
* SIGTERM then SIGKILL and classifies the exit as killed with no
|
|
116
|
+
* transport failure for the kill itself. */
|
|
117
|
+
readonly signal?: AbortSignal;
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
export async function* streamTurn(
|
|
@@ -229,6 +234,14 @@ export async function* streamTurn(
|
|
|
229
234
|
...(envKeys?.length ? { envKeys } : {}),
|
|
230
235
|
});
|
|
231
236
|
|
|
237
|
+
// F-23: create-on-missing resume warns before spawn - the harness will
|
|
238
|
+
// accept any id and silently start a blank session, so the consumer
|
|
239
|
+
// must verify the id exists.
|
|
240
|
+
const resumeOnMissingCreate = effective.resume !== undefined && h.resume.onMissing === "create";
|
|
241
|
+
const resumeCreateWarning = resumeOnMissingCreate
|
|
242
|
+
? `${h.name} creates a new session when ${effective.resume} is unknown; verify the id exists`
|
|
243
|
+
: null;
|
|
244
|
+
|
|
232
245
|
let proc: SpawnedProcess;
|
|
233
246
|
try {
|
|
234
247
|
proc = deps.spawn(argv, {
|
|
@@ -248,6 +261,7 @@ export async function* streamTurn(
|
|
|
248
261
|
cause: "crash",
|
|
249
262
|
spawnError: message,
|
|
250
263
|
});
|
|
264
|
+
if (resumeCreateWarning !== null) yield { kind: "error", message: resumeCreateWarning };
|
|
251
265
|
yield { kind: "error", message: `spawn failed: ${message}` };
|
|
252
266
|
yield { kind: "failure", ...failure };
|
|
253
267
|
yield { kind: "done", exitCode: 127, cause: "failed", failure };
|
|
@@ -255,9 +269,13 @@ export async function* streamTurn(
|
|
|
255
269
|
}
|
|
256
270
|
|
|
257
271
|
const queue = new AsyncChannel<HarnessEvent>();
|
|
272
|
+
// F-23 warning is an early stream event, before any harness output
|
|
273
|
+
if (resumeCreateWarning !== null)
|
|
274
|
+
void queue.push({ kind: "error", message: resumeCreateWarning });
|
|
258
275
|
const state = freshDecodeState(effective.resume ?? null);
|
|
259
276
|
const stderrTail = new StderrTail();
|
|
260
277
|
let killedByWatchdog = false;
|
|
278
|
+
let killedByAbort = false;
|
|
261
279
|
let exited = false;
|
|
262
280
|
let exitCode: number | null = null;
|
|
263
281
|
let pipesOpenAtExit = false;
|
|
@@ -276,6 +294,21 @@ export async function* streamTurn(
|
|
|
276
294
|
safeSignal("SIGKILL");
|
|
277
295
|
}, KILL_GRACE_MS);
|
|
278
296
|
};
|
|
297
|
+
let abortHandler: (() => void) | null = null;
|
|
298
|
+
if (opts.signal) {
|
|
299
|
+
const onAbort = (): void => {
|
|
300
|
+
if (killedByAbort) return;
|
|
301
|
+
killedByAbort = true;
|
|
302
|
+
escalate();
|
|
303
|
+
};
|
|
304
|
+
if (opts.signal.aborted) {
|
|
305
|
+
killedByAbort = true;
|
|
306
|
+
escalate();
|
|
307
|
+
} else {
|
|
308
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
309
|
+
abortHandler = onAbort;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
279
312
|
|
|
280
313
|
const failures: FailureSummary[] = [];
|
|
281
314
|
const pushFailure = async (f: FailureSummary): Promise<void> => {
|
|
@@ -354,32 +387,73 @@ export async function* streamTurn(
|
|
|
354
387
|
|
|
355
388
|
const pumpStdout = async (): Promise<void> => {
|
|
356
389
|
const lines = new LineBuffer();
|
|
390
|
+
let identitySeen = false;
|
|
391
|
+
const droppableBuffer: HarnessEvent[] = [];
|
|
392
|
+
const BUFFER_CAP = 256;
|
|
393
|
+
const isDroppable = (kind: string): boolean =>
|
|
394
|
+
kind === "progress" || kind === "token" || kind === "context";
|
|
395
|
+
const flushDroppable = async (): Promise<void> => {
|
|
396
|
+
for (const e of droppableBuffer) await queue.push(e);
|
|
397
|
+
droppableBuffer.length = 0;
|
|
398
|
+
};
|
|
399
|
+
const handleEvent = async (event: HarnessEvent): Promise<void> => {
|
|
400
|
+
if (!identitySeen) {
|
|
401
|
+
if (event.kind === "identity") {
|
|
402
|
+
identitySeen = true;
|
|
403
|
+
await queue.push(event);
|
|
404
|
+
await flushDroppable();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (isDroppable(event.kind)) {
|
|
408
|
+
if (droppableBuffer.length >= BUFFER_CAP) droppableBuffer.shift();
|
|
409
|
+
droppableBuffer.push(event);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// Lossless events other than identity flush the buffer before themselves
|
|
413
|
+
await flushDroppable();
|
|
414
|
+
}
|
|
415
|
+
if (event.kind === "failure") {
|
|
416
|
+
const { kind: _kind, ...summary } = event;
|
|
417
|
+
await pushFailure(summary);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (event.kind === "limit") {
|
|
421
|
+
// A wall decoded from stdout counts like one read on stderr: the
|
|
422
|
+
// turn's done must carry it, not only the limit event.
|
|
423
|
+
await queue.push(event);
|
|
424
|
+
await pushFailure(failureFromLimit(event.code));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (event.kind === "error") {
|
|
428
|
+
await queue.push(event);
|
|
429
|
+
if (event.terminal === true) await pushFailure(failureFromTask(event.message));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
|
|
433
|
+
lastAssistantText = event.text;
|
|
434
|
+
}
|
|
435
|
+
await queue.push(event);
|
|
436
|
+
};
|
|
357
437
|
for await (const chunk of proc.stdout) {
|
|
358
438
|
if (cancelled) break;
|
|
359
439
|
// Any output chunk rearms the inactivity budget, but not the wall-clock deadline
|
|
360
440
|
if (deps.stallMs !== undefined) rearm();
|
|
361
441
|
for (const line of lines.push(chunk)) {
|
|
362
|
-
for (const event of decodeLine(h, line, state, opts.model ?? "")) {
|
|
363
|
-
|
|
364
|
-
// Directly from decode's rate_limit_event handling - track for reduction
|
|
365
|
-
failures.push(event as unknown as FailureSummary);
|
|
366
|
-
}
|
|
367
|
-
if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
|
|
368
|
-
lastAssistantText = event.text;
|
|
369
|
-
}
|
|
370
|
-
await queue.push(event);
|
|
442
|
+
for (const event of decodeLine(h, line, state, opts.model ?? "", granularity)) {
|
|
443
|
+
await handleEvent(event);
|
|
371
444
|
}
|
|
372
445
|
}
|
|
373
446
|
}
|
|
374
447
|
const rest = lines.flush();
|
|
375
448
|
if (rest !== null && !cancelled) {
|
|
376
|
-
for (const event of decodeLine(h, rest, state, opts.model ?? "")) {
|
|
377
|
-
|
|
378
|
-
failures.push(event as unknown as FailureSummary);
|
|
379
|
-
}
|
|
380
|
-
await queue.push(event);
|
|
449
|
+
for (const event of decodeLine(h, rest, state, opts.model ?? "", granularity)) {
|
|
450
|
+
await handleEvent(event);
|
|
381
451
|
}
|
|
382
452
|
}
|
|
453
|
+
// Flush at exit if no identity ever arrived
|
|
454
|
+
if (!identitySeen && droppableBuffer.length > 0) {
|
|
455
|
+
await flushDroppable();
|
|
456
|
+
}
|
|
383
457
|
};
|
|
384
458
|
|
|
385
459
|
/** issue #41: scan the last assistant message for the hcn-question
|
|
@@ -477,6 +551,24 @@ export async function* streamTurn(
|
|
|
477
551
|
try {
|
|
478
552
|
for await (const event of queue) yield event;
|
|
479
553
|
|
|
554
|
+
// F-04: a harness binary that is not installed surfaces as an
|
|
555
|
+
// async ENOENT. The adapter records it in startupError and resolves
|
|
556
|
+
// exited with 127 while appending `spawn failed:` to stderr. Treat
|
|
557
|
+
// it like the synchronous-throw branch: transport failure, retryable,
|
|
558
|
+
// done cause failed with the real exit code.
|
|
559
|
+
const startupMessage = proc.startupError?.() ?? null;
|
|
560
|
+
let startupFailed = false;
|
|
561
|
+
if (startupMessage !== null && failures.length === 0) {
|
|
562
|
+
const f = failureFromTransport(`spawn failed: ${startupMessage}`);
|
|
563
|
+
failures.push(f);
|
|
564
|
+
startupFailed = true;
|
|
565
|
+
// The stderr pump appends the spawn line to the tail but does not
|
|
566
|
+
// emit an error event for it; emit the error here to match the sync
|
|
567
|
+
// branch, and suppress the later tail-error path for this case.
|
|
568
|
+
yield { kind: "error", message: `spawn failed: ${startupMessage}` };
|
|
569
|
+
yield { kind: "failure", ...f };
|
|
570
|
+
}
|
|
571
|
+
|
|
480
572
|
// Post-queue failure sources. Nonzero exit with no other failure and a
|
|
481
573
|
// non-empty stderr tail is a NATIVE failure (D6): the harness rejected
|
|
482
574
|
// its own arguments or crashed on them - verbatim stderr, native exit
|
|
@@ -484,6 +576,8 @@ export async function* streamTurn(
|
|
|
484
576
|
// (a silent nonzero exit reads as an environment problem, not a
|
|
485
577
|
// harness judgment).
|
|
486
578
|
if (
|
|
579
|
+
!startupFailed &&
|
|
580
|
+
!killedByAbort &&
|
|
487
581
|
failures.length === 0 &&
|
|
488
582
|
exitCode !== 0 &&
|
|
489
583
|
exitCode !== null &&
|
|
@@ -500,7 +594,7 @@ export async function* streamTurn(
|
|
|
500
594
|
yield { kind: "failure", ...f };
|
|
501
595
|
}
|
|
502
596
|
// Stall watchdog also implies a transport failure if not already present
|
|
503
|
-
if (killedByWatchdog && failures.length === 0) {
|
|
597
|
+
if (killedByWatchdog && !killedByAbort && failures.length === 0) {
|
|
504
598
|
// D11: a wall-clock deadline kill is a timeout, not a stall - the
|
|
505
599
|
// run was not necessarily silent, it simply outlived its budget.
|
|
506
600
|
const f =
|
|
@@ -513,19 +607,29 @@ export async function* streamTurn(
|
|
|
513
607
|
|
|
514
608
|
let cause: ExitCause = state.limitSeen
|
|
515
609
|
? "limit"
|
|
516
|
-
:
|
|
517
|
-
?
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
:
|
|
610
|
+
: killedByAbort
|
|
611
|
+
? "killed"
|
|
612
|
+
: killedByWatchdog && exitCode !== 0
|
|
613
|
+
? watchdogReason === "turn-deadline"
|
|
614
|
+
? "killed" // D11: the run was killed on budget, not stalled
|
|
615
|
+
: "stall"
|
|
616
|
+
: exitCode === 0
|
|
617
|
+
? asked
|
|
618
|
+
? "awaiting-input" // issue #41: asking SUCCEEDED the turn
|
|
619
|
+
: "clean"
|
|
620
|
+
: exitCode === null
|
|
621
|
+
? "killed"
|
|
622
|
+
: startupFailed
|
|
623
|
+
? "failed"
|
|
624
|
+
: "crash";
|
|
527
625
|
const reduced = reduceFailures(failures);
|
|
528
626
|
if (reduced && cause === "clean") cause = "failed";
|
|
627
|
+
// A work-verdict failure (the model ran out of steps, or ended its
|
|
628
|
+
// turn in error) is not a harness crash even when the process exits
|
|
629
|
+
// nonzero: the cause is failed and the real exit code rides along.
|
|
630
|
+
if ((reduced?.class === "budget" || reduced?.class === "task") && cause === "crash") {
|
|
631
|
+
cause = "failed";
|
|
632
|
+
}
|
|
529
633
|
const tail = stderrTail.snapshot();
|
|
530
634
|
log({
|
|
531
635
|
event: "exit",
|
|
@@ -542,7 +646,9 @@ export async function* streamTurn(
|
|
|
542
646
|
// A failure with captured stderr surfaces as a stream-level error, not
|
|
543
647
|
// only in the exit log - so a crash from the real adapter's async spawn
|
|
544
648
|
// failure carries the same error-event signal as the sync-throw path.
|
|
545
|
-
|
|
649
|
+
// F-04: the startupError path already emitted the spawn error; do not
|
|
650
|
+
// duplicate it via the tail.
|
|
651
|
+
if (!startupFailed && (cause === "crash" || cause === "killed") && tail.length > 0) {
|
|
546
652
|
yield { kind: "error", message: tail.join("\n").slice(0, 4096) };
|
|
547
653
|
}
|
|
548
654
|
terminalEventReached = true;
|
|
@@ -559,6 +665,7 @@ export async function* streamTurn(
|
|
|
559
665
|
...(reduced ? { failure: reduced } : {}),
|
|
560
666
|
};
|
|
561
667
|
} finally {
|
|
668
|
+
if (abortHandler !== null) opts.signal?.removeEventListener("abort", abortHandler);
|
|
562
669
|
const abandoned = !terminalEventReached;
|
|
563
670
|
cancelled = true;
|
|
564
671
|
queue.close();
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { HarnessDescriptor, StreamingGranularity } from "../knowledge/descriptor.js";
|
|
8
8
|
import { defaultDescriptors } from "../knowledge/overrides.js";
|
|
9
9
|
import { ArgvRefusalError } from "./refusal.js";
|
|
10
|
-
import { assertUsableSessionId } from "./session-id.js";
|
|
10
|
+
import { assertUsableSessionId, SESSION_ID_MAX, SessionIdRefusalError } from "./session-id.js";
|
|
11
11
|
import { renderSkillsSelection } from "./skills-selection.js";
|
|
12
12
|
import { supportedBy } from "./support.js";
|
|
13
13
|
import { renderToolSelection } from "./tool-selection.js";
|
|
@@ -69,6 +69,13 @@ export interface TurnOptions {
|
|
|
69
69
|
readonly write?: boolean;
|
|
70
70
|
readonly shell?: boolean;
|
|
71
71
|
readonly maxSteps?: number;
|
|
72
|
+
/** issue #48: replaces the harness's built-in system prompt (opt-in-only,
|
|
73
|
+
* no profile entry). claude/pi: flag-value (claude pairs the dynamic-section
|
|
74
|
+
* exclusion); codex: config-kv `instructions` (literal or path); muse:
|
|
75
|
+
* refuses with hint. */
|
|
76
|
+
readonly systemPrompt?: string;
|
|
77
|
+
/** issue #48: appends to the built-in prompt (claude/pi only). */
|
|
78
|
+
readonly appendSystemPrompt?: string;
|
|
72
79
|
/** issue #41: question escalation - a BEHAVIOR INSTRUCTION, not a turn
|
|
73
80
|
* option. It never renders into any harness argv; the CLI layer turns
|
|
74
81
|
* it into the prompt preamble and arms question-block detection.
|
|
@@ -136,8 +143,28 @@ export const buildLaunchArgv = (h: HarnessDescriptor, opts: LaunchOptions): stri
|
|
|
136
143
|
...turnTail(h, opts),
|
|
137
144
|
];
|
|
138
145
|
|
|
146
|
+
/** A session id that fails the shape rule is a spawn-boundary refusal like
|
|
147
|
+
* any other: typed, so streamTurn turns it into failure + done and the CLI
|
|
148
|
+
* exits 2, instead of a bare SessionIdRefusalError escaping the runner. */
|
|
149
|
+
const refuseUnusableSessionId = (h: HarnessDescriptor, sessionId: string): void => {
|
|
150
|
+
try {
|
|
151
|
+
assertUsableSessionId(sessionId);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
if (!(e instanceof SessionIdRefusalError)) throw e;
|
|
154
|
+
throw new ArgvRefusalError({
|
|
155
|
+
issue: "invalid-option-value",
|
|
156
|
+
harness: h.name,
|
|
157
|
+
message: `${h.name} cannot resume ${e.message}`,
|
|
158
|
+
supported: [
|
|
159
|
+
`a session id of letters, digits, '.', '_', ':', '@', '-' only, starting with a letter or digit, at most ${SESSION_ID_MAX} chars`,
|
|
160
|
+
],
|
|
161
|
+
detail: e.message,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
139
166
|
export const buildResumeArgv = (h: HarnessDescriptor, opts: ResumeOptions): string[] => {
|
|
140
|
-
|
|
167
|
+
refuseUnusableSessionId(h, opts.sessionId);
|
|
141
168
|
// Subcommands lead, then the resume token and id, then the flags the
|
|
142
169
|
// RESUME grammar accepts (never inherited launch flags - codex exec
|
|
143
170
|
// resume rejects --sandbox). One shape serves both styles:
|
|
@@ -167,10 +194,9 @@ export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): st
|
|
|
167
194
|
supported: ["session is available where sessionMode is declared"],
|
|
168
195
|
});
|
|
169
196
|
}
|
|
170
|
-
|
|
197
|
+
refuseUnusableSessionId(h, opts.sessionId);
|
|
171
198
|
const argv = [
|
|
172
199
|
h.bin,
|
|
173
|
-
...h.launch.baseFlags,
|
|
174
200
|
...h.sessionMode.flags,
|
|
175
201
|
// idFlag null = the harness refuses unknown ids and mints its own
|
|
176
202
|
// (pi rpc); the caller-side sessionId stays a correlation handle.
|
|
@@ -27,7 +27,10 @@ export const capabilitiesOf = (
|
|
|
27
27
|
model: string,
|
|
28
28
|
mode: HarnessMode,
|
|
29
29
|
): CapabilityResult => {
|
|
30
|
-
|
|
30
|
+
// F-09: an absent model means the harness default model (curated), not
|
|
31
|
+
// an unknown model. Only degrade when a model was explicitly given and is
|
|
32
|
+
// not curated.
|
|
33
|
+
if (model !== "" && !resolveModel(h, model).curated) {
|
|
31
34
|
// Curated claims cover curated models only - an extensible registry's
|
|
32
35
|
// unknown model still degrades here until runtime verification.
|
|
33
36
|
// Degrade: no raw-image claims, no streaming claims - transcribe/hold.
|
|
@@ -18,7 +18,11 @@ export type ContentEvent =
|
|
|
18
18
|
| { readonly kind: "message"; readonly role: string; readonly text: string }
|
|
19
19
|
| { readonly kind: "tool"; readonly name: string; readonly input?: unknown }
|
|
20
20
|
| { readonly kind: "progress"; readonly label: string }
|
|
21
|
-
|
|
21
|
+
/** `terminal: true` marks an error that ended the turn (a failed result
|
|
22
|
+
* record); the runner turns it into a task failure. Other errors are
|
|
23
|
+
* informational and the turn goes on. */
|
|
24
|
+
| { readonly kind: "error"; readonly message: string; readonly terminal?: boolean }
|
|
25
|
+
| { readonly kind: "budget"; readonly detail: string };
|
|
22
26
|
|
|
23
27
|
/** Text of an array of `{type:"text", text}` content blocks. */
|
|
24
28
|
const textOfBlocks = (content: unknown): string =>
|
|
@@ -70,7 +74,7 @@ const claude = (r: Record<string, unknown>): ContentEvent[] => {
|
|
|
70
74
|
// error) - surface it so a streamTurn consumer sees the failure, not a
|
|
71
75
|
// clean turn. (openSession handles result boundaries itself.)
|
|
72
76
|
const sub = typeof r.subtype === "string" ? r.subtype : "result error";
|
|
73
|
-
events.push({ kind: "error", message: `turn failed: ${sub}
|
|
77
|
+
events.push({ kind: "error", message: `turn failed: ${sub}`, terminal: true });
|
|
74
78
|
}
|
|
75
79
|
return events;
|
|
76
80
|
};
|
|
@@ -100,7 +104,7 @@ const codex = (r: Record<string, unknown>): ContentEvent[] => {
|
|
|
100
104
|
return [{ kind: "message", role: "assistant", text: item.text }];
|
|
101
105
|
}
|
|
102
106
|
if (item.type === "error" && typeof item.message === "string") {
|
|
103
|
-
return [{ kind: "error", message: item.message }];
|
|
107
|
+
return [{ kind: "error", message: item.message, terminal: true }];
|
|
104
108
|
}
|
|
105
109
|
return [];
|
|
106
110
|
};
|
|
@@ -127,7 +131,11 @@ const pi = (r: Record<string, unknown>): ContentEvent[] => {
|
|
|
127
131
|
// exit). Without this the failure is invisible - a silent empty turn.
|
|
128
132
|
if (message.stopReason === "error") {
|
|
129
133
|
return [
|
|
130
|
-
{
|
|
134
|
+
{
|
|
135
|
+
kind: "error",
|
|
136
|
+
message: "pi turn ended with stopReason error (provider/auth failure)",
|
|
137
|
+
terminal: true,
|
|
138
|
+
},
|
|
131
139
|
];
|
|
132
140
|
}
|
|
133
141
|
const text = textOfBlocks(message.content);
|
|
@@ -171,7 +179,12 @@ const muse = (r: Record<string, unknown>): ContentEvent[] => {
|
|
|
171
179
|
}
|
|
172
180
|
if (payload.terminal === "failed") {
|
|
173
181
|
const reason = typeof payload.reason === "string" ? payload.reason : "run failed";
|
|
174
|
-
|
|
182
|
+
// The muse reader is the muse-specific seam; no descriptor field
|
|
183
|
+
// carries budget phrasings yet, so the pattern lives here.
|
|
184
|
+
if (/did not reach a terminal state within \d+ step/i.test(reason)) {
|
|
185
|
+
return [{ kind: "budget", detail: reason }];
|
|
186
|
+
}
|
|
187
|
+
return [{ kind: "error", message: `muse run failed: ${reason}`, terminal: true }];
|
|
175
188
|
}
|
|
176
189
|
}
|
|
177
190
|
return [];
|
|
@@ -29,6 +29,9 @@ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFr
|
|
|
29
29
|
codex: {
|
|
30
30
|
write:
|
|
31
31
|
"codex has no write toggle; use --sandbox read-only (config: sandbox_mode) so shell commands cannot write either",
|
|
32
|
+
// issue #48: the append half has no codex spelling.
|
|
33
|
+
appendSystemPrompt:
|
|
34
|
+
"codex has no append-to-prompt flag; -c instructions=<literal or path> REPLACES the whole prompt (both accepted, live-verified) - there is no additive form",
|
|
32
35
|
shell:
|
|
33
36
|
"codex has no shell toggle; disable the shell tool via config (-c features.shell_tool=false) or use --sandbox read-only",
|
|
34
37
|
maxSteps:
|
|
@@ -65,6 +68,11 @@ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFr
|
|
|
65
68
|
"muse loads rules per workspace trust; --no-foreign-personal-context excludes foreign personal rules, and withholding --trust-workspace keeps workspace rules unloaded",
|
|
66
69
|
"discovery.skills":
|
|
67
70
|
"muse scopes skills by trust like rules; --no-foreign-personal-context drops foreign skills and untrusted workspaces stay unloaded - there is no unconditional skills-off switch",
|
|
71
|
+
// issue #48 (ratified 2026-08-20): muse cannot strip the payload at all.
|
|
72
|
+
systemPrompt:
|
|
73
|
+
"muse has no system-prompt surface; its built-in prompt always applies - there is no replacement or append spelling (structural: nothing to approximate with)",
|
|
74
|
+
appendSystemPrompt:
|
|
75
|
+
"muse has no system-prompt surface; its built-in prompt always applies - there is no replacement or append spelling (structural: nothing to approximate with)",
|
|
68
76
|
},
|
|
69
77
|
});
|
|
70
78
|
|
|
@@ -163,6 +163,23 @@ export const resolveEffectiveOptions = (
|
|
|
163
163
|
// the emit-nothing rule, recorded in provenance. On a harness with
|
|
164
164
|
// dormant built-ins (pi), it becomes the enabling include list.
|
|
165
165
|
if (key === "tools" && value === "all-known") {
|
|
166
|
+
// --no-tools containment: a tier that switched discovery.tools off
|
|
167
|
+
// must not have the profile grant switch them back on (pi reads
|
|
168
|
+
// --tools as an enabling allowlist). The tier that turned tools off
|
|
169
|
+
// owns the skip.
|
|
170
|
+
const toolsOff = (o: Partial<TurnOptions> | undefined): boolean =>
|
|
171
|
+
o?.discovery?.tools === false;
|
|
172
|
+
const offTier: ProvenanceTier | undefined = toolsOff(effectiveArgs)
|
|
173
|
+
? "arg"
|
|
174
|
+
: toolsOff(tiers.project)
|
|
175
|
+
? "project-config"
|
|
176
|
+
: toolsOff(tiers.user)
|
|
177
|
+
? "user-config"
|
|
178
|
+
: undefined;
|
|
179
|
+
if (offTier !== undefined) {
|
|
180
|
+
provenance.push({ key, value: "none (discovery.tools off)", tier: offTier });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
166
183
|
const enabled = h.tools.builtins.filter((t) => t.defaultEnabled).length;
|
|
167
184
|
const all = h.tools.builtins.length;
|
|
168
185
|
if (enabled === all) {
|
|
@@ -109,6 +109,15 @@ export const renderToolSelection = (
|
|
|
109
109
|
const names = validateNames(h, selection.include!);
|
|
110
110
|
const { mapped, unmapped } = resolveNames(h, names);
|
|
111
111
|
if (!h.tools.includeIsStrictAllowlist) {
|
|
112
|
+
if (mapped.length === 0 && unmapped.length > 0) {
|
|
113
|
+
throw new ArgvRefusalError({
|
|
114
|
+
issue: "unknown-tool-name",
|
|
115
|
+
harness: h.name,
|
|
116
|
+
option: "tools",
|
|
117
|
+
supported: [`known tool names: ${h.tools.builtins.map((t) => t.name).join(", ")}`],
|
|
118
|
+
detail: `unknown tool name(s) ${unmapped.join(", ")}`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
112
121
|
// claude: exact allowlist must reshape the visible set via the deny
|
|
113
122
|
// complement (probe 2b). The include flag also carries the granted
|
|
114
123
|
// names (curated + pass-throughs) so they skip approval prompts;
|
|
@@ -227,17 +227,22 @@ export const renderTurnOptions = (
|
|
|
227
227
|
supported: resumeSupported.length ? resumeSupported : ["(none)"],
|
|
228
228
|
});
|
|
229
229
|
}
|
|
230
|
-
// Reject config-kv for
|
|
230
|
+
// Reject config-kv for open vocabularies. Exception (issue #48):
|
|
231
|
+
// prompt-text rides config-kv VERBATIM on codex - both a literal and
|
|
232
|
+
// a path are accepted (live-verified 0.146.1); no quoting, because
|
|
233
|
+
// codex's k=v split takes the rest of the token raw and both probed
|
|
234
|
+
// forms passed unquoted.
|
|
231
235
|
if (
|
|
232
236
|
(spec as unknown as SpecBase).render.kind === "config-kv" &&
|
|
233
237
|
(spec as { kind: string }).kind !== "enum" &&
|
|
234
|
-
(spec as { kind: string }).kind !== "effort"
|
|
238
|
+
(spec as { kind: string }).kind !== "effort" &&
|
|
239
|
+
(spec as { kind: string }).kind !== "prompt-text"
|
|
235
240
|
) {
|
|
236
241
|
throw new ArgvRefusalError({
|
|
237
242
|
issue: "invalid-option-value",
|
|
238
243
|
harness: h.name,
|
|
239
244
|
option: key,
|
|
240
|
-
supported: ["config-kv only for enum and
|
|
245
|
+
supported: ["config-kv only for enum, effort, and prompt-text (issue #48)"],
|
|
241
246
|
});
|
|
242
247
|
}
|
|
243
248
|
}
|
|
@@ -320,6 +325,29 @@ export const renderTurnOptions = (
|
|
|
320
325
|
sequences.push(seq);
|
|
321
326
|
break;
|
|
322
327
|
}
|
|
328
|
+
case "prompt-text": {
|
|
329
|
+
// issue #48: free-form prompt prose (systemPrompt / appendSystemPrompt).
|
|
330
|
+
// No closed vocabulary - validate non-empty string, render verbatim.
|
|
331
|
+
// Values may be multi-line and may contain shell-hostile characters;
|
|
332
|
+
// they cross as single argv tokens, never through a shell.
|
|
333
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
334
|
+
throw new ArgvRefusalError({
|
|
335
|
+
issue: "invalid-option-value",
|
|
336
|
+
harness: h.name,
|
|
337
|
+
option: key,
|
|
338
|
+
supported: ["non-empty prompt text"],
|
|
339
|
+
detail: typeof raw === "string" ? "(empty)" : String(raw),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const r = effectiveRender!;
|
|
343
|
+
let seq: string[];
|
|
344
|
+
if (r.kind === "flag-value") seq = [...(r.extraFlags ?? []), r.flag, raw];
|
|
345
|
+
else if (r.kind === "config-kv") seq = [r.flag, `${r.key}=${raw}`];
|
|
346
|
+
else if (r.kind === "flag-list") seq = [...r.flags];
|
|
347
|
+
else seq = [];
|
|
348
|
+
sequences.push(seq);
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
323
351
|
case "selector": {
|
|
324
352
|
if (typeof raw !== "string") {
|
|
325
353
|
throw new ArgvRefusalError({
|
|
@@ -49,7 +49,10 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
|
|
|
49
49
|
// A-001: one process, many turns; `result` delimits turns; mid-turn sends
|
|
50
50
|
// queue. --setting-sources project isolates the child from user-level
|
|
51
51
|
// hooks (D-025). Token deltas require this exact output flag set.
|
|
52
|
+
// sessionMode.flags is the complete flag list after the binary, so -p
|
|
53
|
+
// lives here rather than being inherited from launch.baseFlags.
|
|
52
54
|
flags: [
|
|
55
|
+
"-p",
|
|
53
56
|
"--input-format",
|
|
54
57
|
"stream-json",
|
|
55
58
|
"--output-format",
|
|
@@ -139,6 +142,24 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
|
|
|
139
142
|
},
|
|
140
143
|
turnOptions: {
|
|
141
144
|
effort: { kind: "effort", render: { kind: "flag-value", flag: "--effort" } },
|
|
145
|
+
// issue #48, live-verified 2.1.235: --system-prompt replaces the built-in
|
|
146
|
+
// prompt; --exclude-dynamic-system-prompt-sections strips the dynamic
|
|
147
|
+
// sections (git state, directory listing) that ride even under a
|
|
148
|
+
// replacement. hcn pairs them - a payload-stripping replacement without
|
|
149
|
+
// the exclusion keeps injected sections. Verified live: the pair changes
|
|
150
|
+
// model behavior (NAKED-HAIKU probe vs BASELINE-OK).
|
|
151
|
+
systemPrompt: {
|
|
152
|
+
kind: "prompt-text",
|
|
153
|
+
render: {
|
|
154
|
+
kind: "flag-value",
|
|
155
|
+
flag: "--system-prompt",
|
|
156
|
+
extraFlags: ["--exclude-dynamic-system-prompt-sections"],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
appendSystemPrompt: {
|
|
160
|
+
kind: "prompt-text",
|
|
161
|
+
render: { kind: "flag-value", flag: "--append-system-prompt" },
|
|
162
|
+
},
|
|
142
163
|
discovery: {
|
|
143
164
|
kind: "discovery",
|
|
144
165
|
facets: {
|
package/src/knowledge/codex.ts
CHANGED
|
@@ -106,6 +106,15 @@ export const codexCli: HarnessDescriptor = deepFreeze({
|
|
|
106
106
|
kind: "effort",
|
|
107
107
|
render: { kind: "config-kv", flag: "-c", key: "model_reasoning_effort" },
|
|
108
108
|
},
|
|
109
|
+
// issue #48, live-verified 0.146.1 under --strict-config: the config key
|
|
110
|
+
// `instructions` accepts BOTH a literal string (LITERAL-OK probe) and a
|
|
111
|
+
// file path (FILE-OK probe); hcn passes the value verbatim and codex
|
|
112
|
+
// validates. `model_instructions` / `experimental_instructions_file`
|
|
113
|
+
// are refused by codex (probed) - wrong spellings, not alternates.
|
|
114
|
+
systemPrompt: {
|
|
115
|
+
kind: "prompt-text",
|
|
116
|
+
render: { kind: "config-kv", flag: "-c", key: "instructions" },
|
|
117
|
+
},
|
|
109
118
|
sandbox: {
|
|
110
119
|
kind: "enum",
|
|
111
120
|
values: ["read-only", "workspace-write", "danger-full-access"],
|
|
@@ -84,6 +84,12 @@ export const TURN_OPTION_KEYS = deepFreeze([
|
|
|
84
84
|
"write",
|
|
85
85
|
"shell",
|
|
86
86
|
"maxSteps",
|
|
87
|
+
// issue #48 (ratified 2026-08-20): the payload-stripping dimensions.
|
|
88
|
+
// systemPrompt replaces the built-in prompt; appendSystemPrompt adds to
|
|
89
|
+
// it. Both are opt-in-only (arg/config, no profile entry) - a default
|
|
90
|
+
// that strips the payload changes every bare run's semantics.
|
|
91
|
+
"systemPrompt",
|
|
92
|
+
"appendSystemPrompt",
|
|
87
93
|
] as const);
|
|
88
94
|
export type TurnOptionKey = (typeof TURN_OPTION_KEYS)[number];
|
|
89
95
|
|
|
@@ -101,8 +107,14 @@ export const DISCOVERY_FACETS = deepFreeze([
|
|
|
101
107
|
export type DiscoveryFacet = (typeof DISCOVERY_FACETS)[number];
|
|
102
108
|
|
|
103
109
|
export type OptionRender =
|
|
104
|
-
/** `--flag <value
|
|
105
|
-
|
|
110
|
+
/** `--flag <value>`; `extraFlags` are fixed companion tokens emitted
|
|
111
|
+
* before the value pair (claude's --system-prompt pairs with
|
|
112
|
+
* --exclude-dynamic-system-prompt-sections - issue #48). */
|
|
113
|
+
| {
|
|
114
|
+
readonly kind: "flag-value";
|
|
115
|
+
readonly flag: string;
|
|
116
|
+
readonly extraFlags?: readonly string[];
|
|
117
|
+
}
|
|
106
118
|
/** `-c key=value` - codex's config-override grammar. Permitted only for
|
|
107
119
|
* closed-vocabulary specs, so no value can need escaping. */
|
|
108
120
|
| { readonly kind: "config-kv"; readonly flag: string; readonly key: string }
|
|
@@ -128,6 +140,10 @@ export type TurnOptionSpec =
|
|
|
128
140
|
| (SpecBase & { readonly kind: "effort" })
|
|
129
141
|
/** Open selector, CLEAN_SELECTOR-validated. */
|
|
130
142
|
| (SpecBase & { readonly kind: "selector" })
|
|
143
|
+
/** Free-form prompt text (issue #48): systemPrompt / appendSystemPrompt.
|
|
144
|
+
* Values are prose, never a closed vocabulary - no validation beyond
|
|
145
|
+
* non-emptiness, rendering is verbatim. */
|
|
146
|
+
| (SpecBase & { readonly kind: "prompt-text" })
|
|
131
147
|
/** `polarity: "disables"` emits the render when the caller asks for FALSE. */
|
|
132
148
|
| (SpecBase & { readonly kind: "toggle"; readonly polarity: "enables" | "disables" })
|
|
133
149
|
| (SpecBase & { readonly kind: "integer"; readonly min: number; readonly max: number })
|
|
@@ -239,14 +255,16 @@ export interface HarnessDescriptor {
|
|
|
239
255
|
* harness. */
|
|
240
256
|
readonly onMissing: "error" | "create";
|
|
241
257
|
};
|
|
242
|
-
/** Persistent headless session support: the
|
|
243
|
-
* lucid-owned process serving many turns, or null
|
|
244
|
-
* such mode.
|
|
245
|
-
*
|
|
246
|
-
* `
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
258
|
+
/** Persistent headless session support: the complete flag list that follows
|
|
259
|
+
* the binary to open one lucid-owned process serving many turns, or null
|
|
260
|
+
* when the harness has no such mode. This is the entire argv prefix after
|
|
261
|
+
* the binary - no launch flags are prepended. `idFlag` pins the
|
|
262
|
+
* caller-assigned session identity. `turnEnd` is the stdout record that
|
|
263
|
+
* delimits one turn (claude: the `result` record; pi rpc:
|
|
264
|
+
* `agent_settled`). `identityProbe`, when present, names a command the
|
|
265
|
+
* runner writes at spawn whose response carries the session id - pi rpc
|
|
266
|
+
* is identity-silent at startup (spike evidence:
|
|
267
|
+
* test/fixtures/pi-rpc-spike), so identity needs a round trip. */
|
|
250
268
|
readonly sessionMode: {
|
|
251
269
|
readonly flags: readonly string[];
|
|
252
270
|
/** Pin an EXISTING session id; null when the harness only accepts
|
|
@@ -20,7 +20,14 @@ export const SHARED_LIMIT_MATCHERS: ReadonlyArray<LimitMatcher> = [
|
|
|
20
20
|
code: "quota",
|
|
21
21
|
},
|
|
22
22
|
// Rate-limit patterns are last so a line with both a usage wall and a 429 keeps usage-limit (first-match-wins, documented cost)
|
|
23
|
-
|
|
23
|
+
// 429 only next to an HTTP status word: a bare digit run inside an id, a
|
|
24
|
+
// byte count, or a timing must never read as a wall. "429 Too Many
|
|
25
|
+
// Requests" is covered by the next matcher.
|
|
26
|
+
{
|
|
27
|
+
pattern: "\\b(?:HTTP|status(?:[_ ]?code)?|code)\\b\\W*[:=]?\\W*429\\b",
|
|
28
|
+
flags: "i",
|
|
29
|
+
code: "rate-limit",
|
|
30
|
+
},
|
|
24
31
|
{ pattern: "Too Many Requests", flags: "i", code: "rate-limit" },
|
|
25
32
|
{ pattern: "rate limit(?:ed|ing)?", flags: "i", code: "rate-limit" },
|
|
26
33
|
{ pattern: "Retry-After", flags: "i", code: "rate-limit" },
|