@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.
Files changed (136) hide show
  1. package/README.md +23 -8
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +16 -0
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/config.d.ts.map +1 -1
  6. package/dist/cli/config.js +4 -0
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/help.d.ts +1 -1
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +10 -0
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/cli/inspect.d.ts.map +1 -1
  13. package/dist/cli/inspect.js +74 -24
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/provenance.d.ts +3 -0
  16. package/dist/cli/provenance.d.ts.map +1 -0
  17. package/dist/cli/provenance.js +11 -0
  18. package/dist/cli/provenance.js.map +1 -0
  19. package/dist/cli/run.d.ts.map +1 -1
  20. package/dist/cli/run.js +135 -85
  21. package/dist/cli/run.js.map +1 -1
  22. package/dist/cli/session.d.ts.map +1 -1
  23. package/dist/cli/session.js +3 -3
  24. package/dist/cli/session.js.map +1 -1
  25. package/dist/cli/skills-root.d.ts +2 -1
  26. package/dist/cli/skills-root.d.ts.map +1 -1
  27. package/dist/cli/skills-root.js +6 -4
  28. package/dist/cli/skills-root.js.map +1 -1
  29. package/dist/execution/decode.d.ts +3 -3
  30. package/dist/execution/decode.d.ts.map +1 -1
  31. package/dist/execution/decode.js +46 -13
  32. package/dist/execution/decode.js.map +1 -1
  33. package/dist/execution/deps.d.ts +4 -1
  34. package/dist/execution/deps.d.ts.map +1 -1
  35. package/dist/execution/deps.js +1 -1
  36. package/dist/execution/events.d.ts +7 -2
  37. package/dist/execution/events.d.ts.map +1 -1
  38. package/dist/execution/events.js.map +1 -1
  39. package/dist/execution/failure.d.ts +5 -1
  40. package/dist/execution/failure.d.ts.map +1 -1
  41. package/dist/execution/failure.js +12 -0
  42. package/dist/execution/failure.js.map +1 -1
  43. package/dist/execution/index.d.ts +2 -2
  44. package/dist/execution/index.js +2 -2
  45. package/dist/execution/node-deps.d.ts.map +1 -1
  46. package/dist/execution/node-deps.js +1 -0
  47. package/dist/execution/node-deps.js.map +1 -1
  48. package/dist/execution/open-session.d.ts.map +1 -1
  49. package/dist/execution/open-session.js +54 -8
  50. package/dist/execution/open-session.js.map +1 -1
  51. package/dist/execution/stream-turn.d.ts +4 -0
  52. package/dist/execution/stream-turn.d.ts.map +1 -1
  53. package/dist/execution/stream-turn.js +147 -33
  54. package/dist/execution/stream-turn.js.map +1 -1
  55. package/dist/interpretation/argv.d.ts +7 -0
  56. package/dist/interpretation/argv.d.ts.map +1 -1
  57. package/dist/interpretation/argv.js +28 -5
  58. package/dist/interpretation/argv.js.map +1 -1
  59. package/dist/interpretation/capabilities.d.ts.map +1 -1
  60. package/dist/interpretation/capabilities.js +4 -1
  61. package/dist/interpretation/capabilities.js.map +1 -1
  62. package/dist/interpretation/content.d.ts +9 -1
  63. package/dist/interpretation/content.d.ts.map +1 -1
  64. package/dist/interpretation/content.js +16 -4
  65. package/dist/interpretation/content.js.map +1 -1
  66. package/dist/interpretation/hints.d.ts.map +1 -1
  67. package/dist/interpretation/hints.js +5 -0
  68. package/dist/interpretation/hints.js.map +1 -1
  69. package/dist/interpretation/limits.d.ts +1 -0
  70. package/dist/interpretation/limits.d.ts.map +1 -1
  71. package/dist/interpretation/limits.js +22 -0
  72. package/dist/interpretation/limits.js.map +1 -1
  73. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  74. package/dist/interpretation/resolve-options.js +16 -0
  75. package/dist/interpretation/resolve-options.js.map +1 -1
  76. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  77. package/dist/interpretation/tool-selection.js +25 -0
  78. package/dist/interpretation/tool-selection.js.map +1 -1
  79. package/dist/interpretation/turn-options.d.ts.map +1 -1
  80. package/dist/interpretation/turn-options.js +35 -3
  81. package/dist/interpretation/turn-options.js.map +1 -1
  82. package/dist/interpretation/versions.d.ts +1 -1
  83. package/dist/interpretation/versions.d.ts.map +1 -1
  84. package/dist/knowledge/claude-code.d.ts.map +1 -1
  85. package/dist/knowledge/claude-code.js +21 -0
  86. package/dist/knowledge/claude-code.js.map +1 -1
  87. package/dist/knowledge/codex.d.ts.map +1 -1
  88. package/dist/knowledge/codex.js +9 -0
  89. package/dist/knowledge/codex.js.map +1 -1
  90. package/dist/knowledge/descriptor.d.ts +25 -10
  91. package/dist/knowledge/descriptor.d.ts.map +1 -1
  92. package/dist/knowledge/descriptor.js +6 -0
  93. package/dist/knowledge/descriptor.js.map +1 -1
  94. package/dist/knowledge/index.d.ts +1 -1
  95. package/dist/knowledge/index.d.ts.map +1 -1
  96. package/dist/knowledge/index.js +1 -1
  97. package/dist/knowledge/index.js.map +1 -1
  98. package/dist/knowledge/matchers.d.ts +2 -1
  99. package/dist/knowledge/matchers.d.ts.map +1 -1
  100. package/dist/knowledge/matchers.js +23 -1
  101. package/dist/knowledge/matchers.js.map +1 -1
  102. package/dist/knowledge/pi.d.ts.map +1 -1
  103. package/dist/knowledge/pi.js +24 -1
  104. package/dist/knowledge/pi.js.map +1 -1
  105. package/package.json +1 -1
  106. package/src/cli/args.ts +15 -0
  107. package/src/cli/config.ts +4 -0
  108. package/src/cli/help.ts +10 -0
  109. package/src/cli/inspect.ts +70 -22
  110. package/src/cli/provenance.ts +19 -0
  111. package/src/cli/run.ts +172 -91
  112. package/src/cli/session.ts +4 -3
  113. package/src/cli/skills-root.ts +11 -4
  114. package/src/execution/decode.ts +46 -12
  115. package/src/execution/deps.ts +4 -1
  116. package/src/execution/events.ts +5 -2
  117. package/src/execution/failure.ts +17 -1
  118. package/src/execution/index.ts +2 -2
  119. package/src/execution/node-deps.ts +1 -0
  120. package/src/execution/open-session.ts +63 -6
  121. package/src/execution/stream-turn.ts +150 -31
  122. package/src/interpretation/argv.ts +34 -5
  123. package/src/interpretation/capabilities.ts +4 -1
  124. package/src/interpretation/content.ts +22 -5
  125. package/src/interpretation/hints.ts +8 -0
  126. package/src/interpretation/limits.ts +29 -0
  127. package/src/interpretation/resolve-options.ts +17 -0
  128. package/src/interpretation/tool-selection.ts +25 -0
  129. package/src/interpretation/turn-options.ts +31 -3
  130. package/src/interpretation/versions.ts +1 -1
  131. package/src/knowledge/claude-code.ts +21 -0
  132. package/src/knowledge/codex.ts +9 -0
  133. package/src/knowledge/descriptor.ts +33 -10
  134. package/src/knowledge/index.ts +5 -1
  135. package/src/knowledge/matchers.ts +25 -2
  136. 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 { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
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)) activeTurn.push(held);
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: h.identity.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: h.identity.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) await routeEvent(event);
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) await routeEvent(event);
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 { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
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
- if ((event as unknown as { kind: string }).kind === "failure") {
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
- if ((event as unknown as { kind: string }).kind === "failure") {
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
- tailForNative.length > 0
496
- ? failureFromNative(exitCode, tailForNative)
497
- : failureFromTransport(`nonzero exit ${exitCode}`);
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
- : killedByWatchdog && exitCode !== 0
517
- ? watchdogReason === "turn-deadline"
518
- ? "killed" // D11: the run was killed on budget, not stalled
519
- : "stall"
520
- : exitCode === 0
521
- ? asked
522
- ? "awaiting-input" // issue #41: asking SUCCEEDED the turn
523
- : "clean"
524
- : exitCode === null
525
- ? "killed"
526
- : "crash";
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
- if ((cause === "crash" || cause === "killed") && tail.length > 0) {
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
- assertUsableSessionId(opts.sessionId);
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: ["session is available where sessionMode is declared"],
197
+ supported,
168
198
  });
169
199
  }
170
- assertUsableSessionId(opts.sessionId);
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
- if (!resolveModel(h, model).curated) {
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
- | { readonly kind: "error"; readonly message: string };
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
- { kind: "error", message: "pi turn ended with stopReason error (provider/auth failure)" },
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
- return [{ kind: "error", message: `muse run failed: ${reason}` }];
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