@dungle-scrubs/harness-cli-normalizer 0.4.4 → 0.4.6
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 +23 -8
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +16 -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 +10 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +74 -24
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/provenance.d.ts +3 -0
- package/dist/cli/provenance.d.ts.map +1 -0
- package/dist/cli/provenance.js +11 -0
- package/dist/cli/provenance.js.map +1 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +135 -85
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/session.d.ts.map +1 -1
- package/dist/cli/session.js +3 -3
- package/dist/cli/session.js.map +1 -1
- package/dist/cli/skills-root.d.ts +2 -1
- package/dist/cli/skills-root.d.ts.map +1 -1
- package/dist/cli/skills-root.js +6 -4
- package/dist/cli/skills-root.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 +46 -13
- package/dist/execution/decode.js.map +1 -1
- package/dist/execution/deps.d.ts +4 -1
- package/dist/execution/deps.d.ts.map +1 -1
- package/dist/execution/deps.js +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/failure.d.ts +5 -1
- package/dist/execution/failure.d.ts.map +1 -1
- package/dist/execution/failure.js +12 -0
- package/dist/execution/failure.js.map +1 -1
- package/dist/execution/index.d.ts +2 -2
- package/dist/execution/index.js +2 -2
- 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 +54 -8
- 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 +147 -33
- 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 +28 -5
- 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 +16 -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/limits.d.ts +1 -0
- package/dist/interpretation/limits.d.ts.map +1 -1
- package/dist/interpretation/limits.js +22 -0
- package/dist/interpretation/limits.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 +25 -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/interpretation/versions.d.ts +1 -1
- package/dist/interpretation/versions.d.ts.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 +25 -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/index.d.ts +1 -1
- package/dist/knowledge/index.d.ts.map +1 -1
- package/dist/knowledge/index.js +1 -1
- package/dist/knowledge/index.js.map +1 -1
- package/dist/knowledge/matchers.d.ts +2 -1
- package/dist/knowledge/matchers.d.ts.map +1 -1
- package/dist/knowledge/matchers.js +23 -1
- package/dist/knowledge/matchers.js.map +1 -1
- package/dist/knowledge/pi.d.ts.map +1 -1
- package/dist/knowledge/pi.js +24 -1
- package/dist/knowledge/pi.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/args.ts +15 -0
- package/src/cli/config.ts +4 -0
- package/src/cli/help.ts +10 -0
- package/src/cli/inspect.ts +70 -22
- package/src/cli/provenance.ts +19 -0
- package/src/cli/run.ts +172 -91
- package/src/cli/session.ts +4 -3
- package/src/cli/skills-root.ts +11 -4
- package/src/execution/decode.ts +46 -12
- package/src/execution/deps.ts +4 -1
- package/src/execution/events.ts +5 -2
- package/src/execution/failure.ts +17 -1
- package/src/execution/index.ts +2 -2
- package/src/execution/node-deps.ts +1 -0
- package/src/execution/open-session.ts +63 -6
- package/src/execution/stream-turn.ts +150 -31
- package/src/interpretation/argv.ts +34 -5
- package/src/interpretation/capabilities.ts +4 -1
- package/src/interpretation/content.ts +22 -5
- package/src/interpretation/hints.ts +8 -0
- package/src/interpretation/limits.ts +29 -0
- package/src/interpretation/resolve-options.ts +17 -0
- package/src/interpretation/tool-selection.ts +25 -0
- package/src/interpretation/turn-options.ts +31 -3
- package/src/interpretation/versions.ts +1 -1
- package/src/knowledge/claude-code.ts +21 -0
- package/src/knowledge/codex.ts +9 -0
- package/src/knowledge/descriptor.ts +33 -10
- package/src/knowledge/index.ts +5 -1
- package/src/knowledge/matchers.ts +25 -2
- package/src/knowledge/pi.ts +24 -1
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { buildSessionArgv } from "../interpretation/argv.js";
|
|
17
17
|
import { capabilitiesOf } from "../interpretation/capabilities.js";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
detectAuthFailureInLine,
|
|
20
|
+
detectLimitInLine,
|
|
21
|
+
detectTransportInLine,
|
|
22
|
+
} from "../interpretation/limits.js";
|
|
19
23
|
import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
|
|
20
24
|
import {
|
|
21
25
|
encodeSessionInput,
|
|
@@ -27,6 +31,15 @@ import { AsyncChannel } from "./channel.js";
|
|
|
27
31
|
import { decodeParsed, freshDecodeState } from "./decode.js";
|
|
28
32
|
import type { RunnerDeps, SpawnedProcess } from "./deps.js";
|
|
29
33
|
import type { ExitCause, HarnessEvent } from "./events.js";
|
|
34
|
+
import type { FailureSummary } from "./failure.js";
|
|
35
|
+
import {
|
|
36
|
+
failureFromAuth,
|
|
37
|
+
failureFromLimit,
|
|
38
|
+
failureFromTask,
|
|
39
|
+
failureFromTerminalError,
|
|
40
|
+
failureFromTransport,
|
|
41
|
+
reduceFailures,
|
|
42
|
+
} from "./failure.js";
|
|
30
43
|
import { LineBuffer } from "./lines.js";
|
|
31
44
|
import { KILL_GRACE_MS, PIPE_GRACE_MS, redactArgv, StderrTail } from "./stream-turn.js";
|
|
32
45
|
|
|
@@ -145,6 +158,7 @@ export const openSession = (
|
|
|
145
158
|
let exitCode: number | null = null;
|
|
146
159
|
let resultError = false;
|
|
147
160
|
let turnLimitSeen = false;
|
|
161
|
+
let turnFailures: FailureSummary[] = [];
|
|
148
162
|
let pumpError: unknown = null;
|
|
149
163
|
// issue #44: the active turn's last assistant message (where the
|
|
150
164
|
// hcn-question block lives) and whether the turn ended by asking.
|
|
@@ -175,14 +189,24 @@ export const openSession = (
|
|
|
175
189
|
}
|
|
176
190
|
};
|
|
177
191
|
|
|
192
|
+
/** A decoded failure event minus its kind: what done.failure carries. */
|
|
193
|
+
const summaryOf = (event: HarnessEvent & { kind: "failure" }): FailureSummary => {
|
|
194
|
+
const { kind: _kind, ...summary } = event;
|
|
195
|
+
return summary;
|
|
196
|
+
};
|
|
197
|
+
|
|
178
198
|
const startTurn = (): void => {
|
|
179
199
|
turnLimitSeen = false;
|
|
200
|
+
turnFailures = [];
|
|
180
201
|
turnAsked = false;
|
|
181
202
|
lastAssistantText = null;
|
|
182
203
|
activeTurn = new AsyncChannel<HarnessEvent>();
|
|
183
204
|
activeTurnId = `${opts.sessionId}:turn-${++turnCounter}`;
|
|
184
205
|
log({ event: "turn_start", sessionId: opts.sessionId, turnId: activeTurnId });
|
|
185
|
-
for (const held of preTurnEvents.splice(0))
|
|
206
|
+
for (const held of preTurnEvents.splice(0)) {
|
|
207
|
+
if (held.kind === "failure") turnFailures.push(summaryOf(held));
|
|
208
|
+
activeTurn.push(held);
|
|
209
|
+
}
|
|
186
210
|
turnsChannel.push(activeTurn);
|
|
187
211
|
};
|
|
188
212
|
|
|
@@ -197,6 +221,9 @@ export const openSession = (
|
|
|
197
221
|
if (detection === null) return;
|
|
198
222
|
if ("malformed" in detection) {
|
|
199
223
|
activeTurn?.push({ kind: "error", message: detection.malformed });
|
|
224
|
+
const failure = failureFromTask(`malformed hcn-question block: ${detection.malformed}`);
|
|
225
|
+
turnFailures.push(failure);
|
|
226
|
+
void activeTurn?.push({ kind: "failure", ...failure });
|
|
200
227
|
return;
|
|
201
228
|
}
|
|
202
229
|
turnAsked = true;
|
|
@@ -224,6 +251,13 @@ export const openSession = (
|
|
|
224
251
|
// in sessions) and the caller answers with the next send().
|
|
225
252
|
emitQuestionIfAsked();
|
|
226
253
|
if (turnAsked && done.cause === "clean") done = { ...done, cause: "awaiting-input" };
|
|
254
|
+
// Every failure was already emitted as an event through pushFailure;
|
|
255
|
+
// the turn's done carries the reduced summary, as streamTurn's does.
|
|
256
|
+
const reduced = reduceFailures(turnFailures);
|
|
257
|
+
if (reduced !== undefined) {
|
|
258
|
+
if (done.cause === "clean") done = { ...done, cause: "failed", failure: reduced };
|
|
259
|
+
else done = { ...done, failure: reduced };
|
|
260
|
+
}
|
|
227
261
|
activeTurn.push(done);
|
|
228
262
|
activeTurn.close();
|
|
229
263
|
log({
|
|
@@ -265,6 +299,20 @@ export const openSession = (
|
|
|
265
299
|
return Promise.resolve();
|
|
266
300
|
};
|
|
267
301
|
|
|
302
|
+
const pushFailure = (f: FailureSummary): Promise<void> => {
|
|
303
|
+
turnFailures.push(f);
|
|
304
|
+
return routeEvent({ kind: "failure", ...f });
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** A decoded event other than a failure: a terminal error also records
|
|
308
|
+
* the failure it stands for, the way streamTurn does. */
|
|
309
|
+
const routeDecoded = async (event: HarnessEvent): Promise<void> => {
|
|
310
|
+
await routeEvent(event);
|
|
311
|
+
if (event.kind === "error" && event.terminal === true) {
|
|
312
|
+
await pushFailure(failureFromTerminalError(h, event.message));
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
268
316
|
const pumpStdout = async (): Promise<void> => {
|
|
269
317
|
const lines = new LineBuffer();
|
|
270
318
|
const matches = (
|
|
@@ -297,6 +345,7 @@ export const openSession = (
|
|
|
297
345
|
const code = detectLimitInLine(h, line);
|
|
298
346
|
if (code !== null) {
|
|
299
347
|
await routeEvent({ kind: "limit", code, message: `limit wall detected (${code})` });
|
|
348
|
+
await pushFailure(failureFromLimit(code));
|
|
300
349
|
}
|
|
301
350
|
return;
|
|
302
351
|
}
|
|
@@ -328,7 +377,7 @@ export const openSession = (
|
|
|
328
377
|
await routeEvent({
|
|
329
378
|
kind: "identity",
|
|
330
379
|
sessionId: announced,
|
|
331
|
-
authority:
|
|
380
|
+
authority: "harness-minted",
|
|
332
381
|
capabilities: capabilitiesOf(h, opts.model ?? "", "headless-session"),
|
|
333
382
|
});
|
|
334
383
|
}
|
|
@@ -338,7 +387,7 @@ export const openSession = (
|
|
|
338
387
|
await routeEvent({
|
|
339
388
|
kind: "identity",
|
|
340
389
|
sessionId: announced,
|
|
341
|
-
authority:
|
|
390
|
+
authority: "caller-assigned",
|
|
342
391
|
capabilities: capabilitiesOf(h, opts.model ?? "", "headless-session"),
|
|
343
392
|
});
|
|
344
393
|
}
|
|
@@ -367,7 +416,10 @@ export const openSession = (
|
|
|
367
416
|
// decodeParsed already surfaces the is_error case as an error event
|
|
368
417
|
// (content.ts claude reader); routing the events is enough - we only
|
|
369
418
|
// still track resultError here to classify the done cause.
|
|
370
|
-
for (const event of events)
|
|
419
|
+
for (const event of events) {
|
|
420
|
+
if (event.kind === "failure") await pushFailure(summaryOf(event));
|
|
421
|
+
else await routeDecoded(event);
|
|
422
|
+
}
|
|
371
423
|
if (parsed.is_error === true) resultError = true;
|
|
372
424
|
endTurn({
|
|
373
425
|
kind: "done",
|
|
@@ -376,7 +428,10 @@ export const openSession = (
|
|
|
376
428
|
});
|
|
377
429
|
return;
|
|
378
430
|
}
|
|
379
|
-
for (const event of events)
|
|
431
|
+
for (const event of events) {
|
|
432
|
+
if (event.kind === "failure") await pushFailure(summaryOf(event));
|
|
433
|
+
else await routeDecoded(event);
|
|
434
|
+
}
|
|
380
435
|
};
|
|
381
436
|
for await (const chunk of proc.stdout) {
|
|
382
437
|
for (const line of lines.push(chunk)) await handleLine(line);
|
|
@@ -396,10 +451,12 @@ export const openSession = (
|
|
|
396
451
|
code: limit,
|
|
397
452
|
message: `limit wall detected (${limit})`,
|
|
398
453
|
});
|
|
454
|
+
await pushFailure(failureFromLimit(limit));
|
|
399
455
|
continue;
|
|
400
456
|
}
|
|
401
457
|
const auth = detectAuthFailureInLine(h, line);
|
|
402
458
|
if (auth !== null) {
|
|
459
|
+
await pushFailure(failureFromAuth(auth));
|
|
403
460
|
await routeEvent({ kind: "error", message: `auth wall: ${auth}` });
|
|
404
461
|
continue;
|
|
405
462
|
}
|
|
@@ -16,7 +16,11 @@ import {
|
|
|
16
16
|
streamingGranularityOf,
|
|
17
17
|
} from "../interpretation/argv.js";
|
|
18
18
|
import { stdinPolicyOf } from "../interpretation/dimensions.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
detectAuthFailureInLine,
|
|
21
|
+
detectLimitInLine,
|
|
22
|
+
detectTransportInLine,
|
|
23
|
+
} from "../interpretation/limits.js";
|
|
20
24
|
import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
|
|
21
25
|
import { ArgvRefusalError } from "../interpretation/refusal.js";
|
|
22
26
|
import type { HarnessDescriptor } from "../knowledge/descriptor.js";
|
|
@@ -31,6 +35,8 @@ import {
|
|
|
31
35
|
failureFromLimit,
|
|
32
36
|
failureFromNative,
|
|
33
37
|
failureFromRejected,
|
|
38
|
+
failureFromTask,
|
|
39
|
+
failureFromTerminalError,
|
|
34
40
|
failureFromTimeout,
|
|
35
41
|
failureFromTransport,
|
|
36
42
|
reduceFailures,
|
|
@@ -110,6 +116,10 @@ export interface TurnRunOptions extends LaunchOptions {
|
|
|
110
116
|
* disarms detection. Applies on launch AND resume: it shapes each
|
|
111
117
|
* turn's prompt and event stream, never a session setting. */
|
|
112
118
|
readonly escalateQuestions?: boolean;
|
|
119
|
+
/** F-05: caller-requested stop. When aborted, the runner escalates
|
|
120
|
+
* SIGTERM then SIGKILL and classifies the exit as killed with no
|
|
121
|
+
* transport failure for the kill itself. */
|
|
122
|
+
readonly signal?: AbortSignal;
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
export async function* streamTurn(
|
|
@@ -229,6 +239,14 @@ export async function* streamTurn(
|
|
|
229
239
|
...(envKeys?.length ? { envKeys } : {}),
|
|
230
240
|
});
|
|
231
241
|
|
|
242
|
+
// F-23: create-on-missing resume warns before spawn - the harness will
|
|
243
|
+
// accept any id and silently start a blank session, so the consumer
|
|
244
|
+
// must verify the id exists.
|
|
245
|
+
const resumeOnMissingCreate = effective.resume !== undefined && h.resume.onMissing === "create";
|
|
246
|
+
const resumeCreateWarning = resumeOnMissingCreate
|
|
247
|
+
? `${h.name} creates a new session when ${effective.resume} is unknown; verify the id exists`
|
|
248
|
+
: null;
|
|
249
|
+
|
|
232
250
|
let proc: SpawnedProcess;
|
|
233
251
|
try {
|
|
234
252
|
proc = deps.spawn(argv, {
|
|
@@ -248,6 +266,7 @@ export async function* streamTurn(
|
|
|
248
266
|
cause: "crash",
|
|
249
267
|
spawnError: message,
|
|
250
268
|
});
|
|
269
|
+
if (resumeCreateWarning !== null) yield { kind: "error", message: resumeCreateWarning };
|
|
251
270
|
yield { kind: "error", message: `spawn failed: ${message}` };
|
|
252
271
|
yield { kind: "failure", ...failure };
|
|
253
272
|
yield { kind: "done", exitCode: 127, cause: "failed", failure };
|
|
@@ -255,9 +274,13 @@ export async function* streamTurn(
|
|
|
255
274
|
}
|
|
256
275
|
|
|
257
276
|
const queue = new AsyncChannel<HarnessEvent>();
|
|
277
|
+
// F-23 warning is an early stream event, before any harness output
|
|
278
|
+
if (resumeCreateWarning !== null)
|
|
279
|
+
void queue.push({ kind: "error", message: resumeCreateWarning });
|
|
258
280
|
const state = freshDecodeState(effective.resume ?? null);
|
|
259
281
|
const stderrTail = new StderrTail();
|
|
260
282
|
let killedByWatchdog = false;
|
|
283
|
+
let killedByAbort = false;
|
|
261
284
|
let exited = false;
|
|
262
285
|
let exitCode: number | null = null;
|
|
263
286
|
let pipesOpenAtExit = false;
|
|
@@ -276,9 +299,27 @@ export async function* streamTurn(
|
|
|
276
299
|
safeSignal("SIGKILL");
|
|
277
300
|
}, KILL_GRACE_MS);
|
|
278
301
|
};
|
|
302
|
+
let abortHandler: (() => void) | null = null;
|
|
303
|
+
if (opts.signal) {
|
|
304
|
+
const onAbort = (): void => {
|
|
305
|
+
if (killedByAbort) return;
|
|
306
|
+
killedByAbort = true;
|
|
307
|
+
escalate();
|
|
308
|
+
};
|
|
309
|
+
if (opts.signal.aborted) {
|
|
310
|
+
killedByAbort = true;
|
|
311
|
+
escalate();
|
|
312
|
+
} else {
|
|
313
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
314
|
+
abortHandler = onAbort;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
279
317
|
|
|
280
318
|
const failures: FailureSummary[] = [];
|
|
281
319
|
const pushFailure = async (f: FailureSummary): Promise<void> => {
|
|
320
|
+
// Suppress a failure identical in class and message to the previous one
|
|
321
|
+
const prev = failures[failures.length - 1];
|
|
322
|
+
if (prev !== undefined && prev.class === f.class && prev.message === f.message) return;
|
|
282
323
|
failures.push(f);
|
|
283
324
|
await queue.push({ kind: "failure", ...f });
|
|
284
325
|
};
|
|
@@ -354,32 +395,73 @@ export async function* streamTurn(
|
|
|
354
395
|
|
|
355
396
|
const pumpStdout = async (): Promise<void> => {
|
|
356
397
|
const lines = new LineBuffer();
|
|
398
|
+
let identitySeen = false;
|
|
399
|
+
const droppableBuffer: HarnessEvent[] = [];
|
|
400
|
+
const BUFFER_CAP = 256;
|
|
401
|
+
const isDroppable = (kind: string): boolean =>
|
|
402
|
+
kind === "progress" || kind === "token" || kind === "context";
|
|
403
|
+
const flushDroppable = async (): Promise<void> => {
|
|
404
|
+
for (const e of droppableBuffer) await queue.push(e);
|
|
405
|
+
droppableBuffer.length = 0;
|
|
406
|
+
};
|
|
407
|
+
const handleEvent = async (event: HarnessEvent): Promise<void> => {
|
|
408
|
+
if (!identitySeen) {
|
|
409
|
+
if (event.kind === "identity") {
|
|
410
|
+
identitySeen = true;
|
|
411
|
+
await queue.push(event);
|
|
412
|
+
await flushDroppable();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (isDroppable(event.kind)) {
|
|
416
|
+
if (droppableBuffer.length >= BUFFER_CAP) droppableBuffer.shift();
|
|
417
|
+
droppableBuffer.push(event);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// Lossless events other than identity flush the buffer before themselves
|
|
421
|
+
await flushDroppable();
|
|
422
|
+
}
|
|
423
|
+
if (event.kind === "failure") {
|
|
424
|
+
const { kind: _kind, ...summary } = event;
|
|
425
|
+
await pushFailure(summary);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (event.kind === "limit") {
|
|
429
|
+
// A wall decoded from stdout counts like one read on stderr: the
|
|
430
|
+
// turn's done must carry it, not only the limit event.
|
|
431
|
+
await queue.push(event);
|
|
432
|
+
await pushFailure(failureFromLimit(event.code));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (event.kind === "error") {
|
|
436
|
+
await queue.push(event);
|
|
437
|
+
if (event.terminal === true) await pushFailure(failureFromTerminalError(h, event.message));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
|
|
441
|
+
lastAssistantText = event.text;
|
|
442
|
+
}
|
|
443
|
+
await queue.push(event);
|
|
444
|
+
};
|
|
357
445
|
for await (const chunk of proc.stdout) {
|
|
358
446
|
if (cancelled) break;
|
|
359
447
|
// Any output chunk rearms the inactivity budget, but not the wall-clock deadline
|
|
360
448
|
if (deps.stallMs !== undefined) rearm();
|
|
361
449
|
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);
|
|
450
|
+
for (const event of decodeLine(h, line, state, opts.model ?? "", granularity)) {
|
|
451
|
+
await handleEvent(event);
|
|
371
452
|
}
|
|
372
453
|
}
|
|
373
454
|
}
|
|
374
455
|
const rest = lines.flush();
|
|
375
456
|
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);
|
|
457
|
+
for (const event of decodeLine(h, rest, state, opts.model ?? "", granularity)) {
|
|
458
|
+
await handleEvent(event);
|
|
381
459
|
}
|
|
382
460
|
}
|
|
461
|
+
// Flush at exit if no identity ever arrived
|
|
462
|
+
if (!identitySeen && droppableBuffer.length > 0) {
|
|
463
|
+
await flushDroppable();
|
|
464
|
+
}
|
|
383
465
|
};
|
|
384
466
|
|
|
385
467
|
/** issue #41: scan the last assistant message for the hcn-question
|
|
@@ -394,6 +476,7 @@ export async function* streamTurn(
|
|
|
394
476
|
if (detection === null) return;
|
|
395
477
|
if ("malformed" in detection) {
|
|
396
478
|
await queue.push({ kind: "error", message: detection.malformed });
|
|
479
|
+
await pushFailure(failureFromTask(`malformed hcn-question block: ${detection.malformed}`));
|
|
397
480
|
return;
|
|
398
481
|
}
|
|
399
482
|
log({
|
|
@@ -477,6 +560,24 @@ export async function* streamTurn(
|
|
|
477
560
|
try {
|
|
478
561
|
for await (const event of queue) yield event;
|
|
479
562
|
|
|
563
|
+
// F-04: a harness binary that is not installed surfaces as an
|
|
564
|
+
// async ENOENT. The adapter records it in startupError and resolves
|
|
565
|
+
// exited with 127 while appending `spawn failed:` to stderr. Treat
|
|
566
|
+
// it like the synchronous-throw branch: transport failure, retryable,
|
|
567
|
+
// done cause failed with the real exit code.
|
|
568
|
+
const startupMessage = proc.startupError?.() ?? null;
|
|
569
|
+
let startupFailed = false;
|
|
570
|
+
if (startupMessage !== null && failures.length === 0) {
|
|
571
|
+
const f = failureFromTransport(`spawn failed: ${startupMessage}`);
|
|
572
|
+
failures.push(f);
|
|
573
|
+
startupFailed = true;
|
|
574
|
+
// The stderr pump appends the spawn line to the tail but does not
|
|
575
|
+
// emit an error event for it; emit the error here to match the sync
|
|
576
|
+
// branch, and suppress the later tail-error path for this case.
|
|
577
|
+
yield { kind: "error", message: `spawn failed: ${startupMessage}` };
|
|
578
|
+
yield { kind: "failure", ...f };
|
|
579
|
+
}
|
|
580
|
+
|
|
480
581
|
// Post-queue failure sources. Nonzero exit with no other failure and a
|
|
481
582
|
// non-empty stderr tail is a NATIVE failure (D6): the harness rejected
|
|
482
583
|
// its own arguments or crashed on them - verbatim stderr, native exit
|
|
@@ -484,6 +585,8 @@ export async function* streamTurn(
|
|
|
484
585
|
// (a silent nonzero exit reads as an environment problem, not a
|
|
485
586
|
// harness judgment).
|
|
486
587
|
if (
|
|
588
|
+
!startupFailed &&
|
|
589
|
+
!killedByAbort &&
|
|
487
590
|
failures.length === 0 &&
|
|
488
591
|
exitCode !== 0 &&
|
|
489
592
|
exitCode !== null &&
|
|
@@ -491,16 +594,19 @@ export async function* streamTurn(
|
|
|
491
594
|
!state.limitSeen
|
|
492
595
|
) {
|
|
493
596
|
const tailForNative = stderrTail.snapshot();
|
|
597
|
+
const transportLine = tailForNative.find((line) => detectTransportInLine(line));
|
|
494
598
|
const f =
|
|
495
|
-
|
|
496
|
-
?
|
|
497
|
-
:
|
|
599
|
+
transportLine !== undefined
|
|
600
|
+
? failureFromTransport(transportLine)
|
|
601
|
+
: tailForNative.length > 0
|
|
602
|
+
? failureFromNative(exitCode, tailForNative)
|
|
603
|
+
: failureFromTransport(`nonzero exit ${exitCode}`);
|
|
498
604
|
failures.push(f);
|
|
499
605
|
// Need to emit this failure before done, even though queue is closed
|
|
500
606
|
yield { kind: "failure", ...f };
|
|
501
607
|
}
|
|
502
608
|
// Stall watchdog also implies a transport failure if not already present
|
|
503
|
-
if (killedByWatchdog && failures.length === 0) {
|
|
609
|
+
if (killedByWatchdog && !killedByAbort && failures.length === 0) {
|
|
504
610
|
// D11: a wall-clock deadline kill is a timeout, not a stall - the
|
|
505
611
|
// run was not necessarily silent, it simply outlived its budget.
|
|
506
612
|
const f =
|
|
@@ -513,19 +619,29 @@ export async function* streamTurn(
|
|
|
513
619
|
|
|
514
620
|
let cause: ExitCause = state.limitSeen
|
|
515
621
|
? "limit"
|
|
516
|
-
:
|
|
517
|
-
?
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
:
|
|
622
|
+
: killedByAbort
|
|
623
|
+
? "killed"
|
|
624
|
+
: killedByWatchdog && exitCode !== 0
|
|
625
|
+
? watchdogReason === "turn-deadline"
|
|
626
|
+
? "killed" // D11: the run was killed on budget, not stalled
|
|
627
|
+
: "stall"
|
|
628
|
+
: exitCode === 0
|
|
629
|
+
? asked
|
|
630
|
+
? "awaiting-input" // issue #41: asking SUCCEEDED the turn
|
|
631
|
+
: "clean"
|
|
632
|
+
: exitCode === null
|
|
633
|
+
? "killed"
|
|
634
|
+
: startupFailed
|
|
635
|
+
? "failed"
|
|
636
|
+
: "crash";
|
|
527
637
|
const reduced = reduceFailures(failures);
|
|
528
638
|
if (reduced && cause === "clean") cause = "failed";
|
|
639
|
+
// A classified failure other than native on a nonzero exit is a failed
|
|
640
|
+
// turn, not a crash: the failure taxonomy already captured the reason.
|
|
641
|
+
// Crash stays for unclassified exits and native failures.
|
|
642
|
+
if (reduced && reduced.class !== "native" && cause === "crash") {
|
|
643
|
+
cause = "failed";
|
|
644
|
+
}
|
|
529
645
|
const tail = stderrTail.snapshot();
|
|
530
646
|
log({
|
|
531
647
|
event: "exit",
|
|
@@ -542,7 +658,9 @@ export async function* streamTurn(
|
|
|
542
658
|
// A failure with captured stderr surfaces as a stream-level error, not
|
|
543
659
|
// only in the exit log - so a crash from the real adapter's async spawn
|
|
544
660
|
// failure carries the same error-event signal as the sync-throw path.
|
|
545
|
-
|
|
661
|
+
// F-04: the startupError path already emitted the spawn error; do not
|
|
662
|
+
// duplicate it via the tail.
|
|
663
|
+
if (!startupFailed && (cause === "crash" || cause === "killed") && tail.length > 0) {
|
|
546
664
|
yield { kind: "error", message: tail.join("\n").slice(0, 4096) };
|
|
547
665
|
}
|
|
548
666
|
terminalEventReached = true;
|
|
@@ -559,6 +677,7 @@ export async function* streamTurn(
|
|
|
559
677
|
...(reduced ? { failure: reduced } : {}),
|
|
560
678
|
};
|
|
561
679
|
} finally {
|
|
680
|
+
if (abortHandler !== null) opts.signal?.removeEventListener("abort", abortHandler);
|
|
562
681
|
const abandoned = !terminalEventReached;
|
|
563
682
|
cancelled = true;
|
|
564
683
|
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:
|
|
@@ -161,16 +188,18 @@ export interface SessionOptions {
|
|
|
161
188
|
|
|
162
189
|
export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): string[] => {
|
|
163
190
|
if (!h.sessionMode) {
|
|
191
|
+
const supported = Object.values(defaultDescriptors())
|
|
192
|
+
.filter((d): d is HarnessDescriptor => d !== undefined && d.sessionMode !== null)
|
|
193
|
+
.map((d) => d.name);
|
|
164
194
|
throw new ArgvRefusalError({
|
|
165
195
|
issue: "no-session-mode",
|
|
166
196
|
harness: h.name,
|
|
167
|
-
supported
|
|
197
|
+
supported,
|
|
168
198
|
});
|
|
169
199
|
}
|
|
170
|
-
|
|
200
|
+
refuseUnusableSessionId(h, opts.sessionId);
|
|
171
201
|
const argv = [
|
|
172
202
|
h.bin,
|
|
173
|
-
...h.launch.baseFlags,
|
|
174
203
|
...h.sessionMode.flags,
|
|
175
204
|
// idFlag null = the harness refuses unknown ids and mints its own
|
|
176
205
|
// (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
|
};
|
|
@@ -126,8 +130,16 @@ const pi = (r: Record<string, unknown>): ContentEvent[] => {
|
|
|
126
130
|
// an expired minimax token: empty content, stopReason error, clean
|
|
127
131
|
// exit). Without this the failure is invisible - a silent empty turn.
|
|
128
132
|
if (message.stopReason === "error") {
|
|
133
|
+
const msg =
|
|
134
|
+
typeof message.errorMessage === "string"
|
|
135
|
+
? `pi turn ended with stopReason error: ${message.errorMessage}`
|
|
136
|
+
: "pi turn ended with stopReason error (provider/auth failure)";
|
|
129
137
|
return [
|
|
130
|
-
{
|
|
138
|
+
{
|
|
139
|
+
kind: "error",
|
|
140
|
+
message: msg,
|
|
141
|
+
terminal: true,
|
|
142
|
+
},
|
|
131
143
|
];
|
|
132
144
|
}
|
|
133
145
|
const text = textOfBlocks(message.content);
|
|
@@ -171,7 +183,12 @@ const muse = (r: Record<string, unknown>): ContentEvent[] => {
|
|
|
171
183
|
}
|
|
172
184
|
if (payload.terminal === "failed") {
|
|
173
185
|
const reason = typeof payload.reason === "string" ? payload.reason : "run failed";
|
|
174
|
-
|
|
186
|
+
// The muse reader is the muse-specific seam; no descriptor field
|
|
187
|
+
// carries budget phrasings yet, so the pattern lives here.
|
|
188
|
+
if (/did not reach a terminal state within \d+ step/i.test(reason)) {
|
|
189
|
+
return [{ kind: "budget", detail: reason }];
|
|
190
|
+
}
|
|
191
|
+
return [{ kind: "error", message: `muse run failed: ${reason}`, terminal: true }];
|
|
175
192
|
}
|
|
176
193
|
}
|
|
177
194
|
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
|
|