@dungle-scrubs/harness-cli-normalizer 0.5.2 → 0.5.4

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 (67) hide show
  1. package/README.md +113 -2
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +8 -0
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/help.d.ts +2 -2
  6. package/dist/cli/help.d.ts.map +1 -1
  7. package/dist/cli/help.js +16 -0
  8. package/dist/cli/help.js.map +1 -1
  9. package/dist/cli/inspect.d.ts.map +1 -1
  10. package/dist/cli/inspect.js +21 -0
  11. package/dist/cli/inspect.js.map +1 -1
  12. package/dist/cli/refuse.d.ts +7 -1
  13. package/dist/cli/refuse.d.ts.map +1 -1
  14. package/dist/cli/refuse.js +16 -5
  15. package/dist/cli/refuse.js.map +1 -1
  16. package/dist/cli/session-json.d.ts +25 -0
  17. package/dist/cli/session-json.d.ts.map +1 -0
  18. package/dist/cli/session-json.js +195 -0
  19. package/dist/cli/session-json.js.map +1 -0
  20. package/dist/cli/session.d.ts.map +1 -1
  21. package/dist/cli/session.js +93 -11
  22. package/dist/cli/session.js.map +1 -1
  23. package/dist/execution/failure.d.ts +2 -1
  24. package/dist/execution/failure.d.ts.map +1 -1
  25. package/dist/execution/failure.js +12 -1
  26. package/dist/execution/failure.js.map +1 -1
  27. package/dist/execution/open-session.d.ts +24 -3
  28. package/dist/execution/open-session.d.ts.map +1 -1
  29. package/dist/execution/open-session.js +106 -19
  30. package/dist/execution/open-session.js.map +1 -1
  31. package/dist/execution/stream-turn.d.ts.map +1 -1
  32. package/dist/execution/stream-turn.js +8 -5
  33. package/dist/execution/stream-turn.js.map +1 -1
  34. package/dist/interpretation/argv.d.ts +3 -0
  35. package/dist/interpretation/argv.d.ts.map +1 -1
  36. package/dist/interpretation/argv.js +5 -0
  37. package/dist/interpretation/argv.js.map +1 -1
  38. package/dist/interpretation/limits.d.ts +3 -0
  39. package/dist/interpretation/limits.d.ts.map +1 -1
  40. package/dist/interpretation/limits.js +12 -13
  41. package/dist/interpretation/limits.js.map +1 -1
  42. package/dist/knowledge/descriptor.d.ts +6 -1
  43. package/dist/knowledge/descriptor.d.ts.map +1 -1
  44. package/dist/knowledge/descriptor.js.map +1 -1
  45. package/dist/knowledge/index.d.ts +1 -1
  46. package/dist/knowledge/index.d.ts.map +1 -1
  47. package/dist/knowledge/index.js +1 -1
  48. package/dist/knowledge/index.js.map +1 -1
  49. package/dist/knowledge/matchers.d.ts +2 -1
  50. package/dist/knowledge/matchers.d.ts.map +1 -1
  51. package/dist/knowledge/matchers.js +10 -0
  52. package/dist/knowledge/matchers.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/cli/args.ts +8 -0
  55. package/src/cli/help.ts +16 -0
  56. package/src/cli/inspect.ts +26 -0
  57. package/src/cli/refuse.ts +31 -12
  58. package/src/cli/session-json.ts +230 -0
  59. package/src/cli/session.ts +103 -12
  60. package/src/execution/failure.ts +16 -1
  61. package/src/execution/open-session.ts +132 -23
  62. package/src/execution/stream-turn.ts +8 -3
  63. package/src/interpretation/argv.ts +8 -0
  64. package/src/interpretation/limits.ts +17 -18
  65. package/src/knowledge/descriptor.ts +6 -1
  66. package/src/knowledge/index.ts +1 -0
  67. package/src/knowledge/matchers.ts +17 -1
@@ -9,6 +9,9 @@ import { createRenderState, renderEvent } from "./render.js";
9
9
  import { resolveHarness } from "./resolve-harness.js";
10
10
 
11
11
  export const session = async (harnessName: string, rawArgs: string[]): Promise<void> => {
12
+ // Decided before any refusal can fire: a refused --json session still owes
13
+ // the stream a failure and a terminal `closed` (RFC-01 rule 3).
14
+ const jsonMode = rawArgs.includes("--json");
12
15
  // issue #44: the gate is the descriptor's sessionMode (claude stream-json,
13
16
  // pi --mode rpc), not a hardcoded name list - a harness that grows a
14
17
  // session mode is available the moment its descriptor declares one.
@@ -23,9 +26,8 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
23
26
  supported,
24
27
  detail: `session mode is available on ${supported.join(", ")}; ${harnessName} declares no persistent headless session`,
25
28
  });
26
- process.stderr.write(`${err.message}\n`);
27
- process.stderr.write(`supported: ${supported.join(", ")}\n`);
28
- process.exitCode = 2;
29
+ const { refusalOf, refuse } = await import("./refuse.js");
30
+ refuse(refusalOf(err), jsonMode, "closed");
29
31
  return;
30
32
  }
31
33
 
@@ -42,6 +44,14 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
42
44
  } catch (err) {
43
45
  const message = err instanceof Error ? err.message : String(err);
44
46
  process.stderr.write(`unknown flag: ${message}\n`);
47
+ if (jsonMode) {
48
+ const { writeFailurePair } = await import("./refuse.js");
49
+ const { failureFromRejected } = await import("../execution/failure.js");
50
+ writeFailurePair(
51
+ failureFromRejected({ issue: "invalid-option-value", detail: `unknown flag: ${message}` }),
52
+ "closed",
53
+ );
54
+ }
45
55
  process.exitCode = 2;
46
56
  return;
47
57
  }
@@ -53,6 +63,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
53
63
  randomUUID();
54
64
  const model = values.model as string | undefined;
55
65
  const cwd = values.cwd as string | undefined;
66
+ const provider = values.provider as string | undefined;
56
67
 
57
68
  // issue #44: same precedence as hcn run - arg > project > user >
58
69
  // default-true. A behavior instruction, so it rides every send's
@@ -87,6 +98,17 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
87
98
  : "default";
88
99
  } catch (configErr) {
89
100
  process.stderr.write(`config error: ${(configErr as Error).message}\n`);
101
+ if (jsonMode) {
102
+ const { writeFailurePair } = await import("./refuse.js");
103
+ const { failureFromRejected } = await import("../execution/failure.js");
104
+ writeFailurePair(
105
+ failureFromRejected({
106
+ issue: "invalid-option-value",
107
+ detail: `config error: ${(configErr as Error).message}`,
108
+ }),
109
+ "closed",
110
+ );
111
+ }
90
112
  process.exitCode = 2;
91
113
  return;
92
114
  }
@@ -95,25 +117,92 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
95
117
  // Validate sessionId shape? let openSession handle via assertUsableSessionId
96
118
  delete (process.env as Record<string, string | undefined>).HERDR_ENV;
97
119
 
98
- const deps = nodeRunnerDeps();
120
+ const wantJson = values.json === true;
121
+ // Opt-in per-turn inactivity budget. 0 disables; no default. A session turn
122
+ // can hang with the process alive, which no exit code reports.
123
+ const rawStall = values.stall as string | undefined;
124
+ let stallMs: number | undefined;
125
+ if (rawStall !== undefined) {
126
+ const seconds = Number(rawStall);
127
+ if (!Number.isFinite(seconds) || seconds < 0) {
128
+ process.stderr.write(`invalid --stall ${JSON.stringify(rawStall)}; expected seconds >= 0\n`);
129
+ if (jsonMode) {
130
+ const { writeFailurePair } = await import("./refuse.js");
131
+ const { failureFromRejected } = await import("../execution/failure.js");
132
+ writeFailurePair(
133
+ failureFromRejected({
134
+ issue: "invalid-option-value",
135
+ detail: `invalid --stall ${JSON.stringify(rawStall)}`,
136
+ }),
137
+ "closed",
138
+ );
139
+ }
140
+ process.exitCode = 2;
141
+ return;
142
+ }
143
+ if (seconds > 0) stallMs = seconds * 1000;
144
+ }
145
+ const baseDeps = stallMs === undefined ? nodeRunnerDeps() : nodeRunnerDeps({ stallMs });
146
+ // Capture the runner's final exitCode/cause for the --json `closed` event.
147
+ const closeInfo = { exitCode: null as number | null, cause: "clean" };
148
+ const droppedIds: string[] = [];
149
+ const deps = wantJson
150
+ ? {
151
+ ...baseDeps,
152
+ log: (e: Record<string, unknown>) => {
153
+ if (e.event === "session_close") {
154
+ closeInfo.exitCode = (e.exitCode as number | null) ?? null;
155
+ closeInfo.cause = (e.cause as string) ?? "clean";
156
+ }
157
+ if (e.event === "sends_dropped" && Array.isArray(e.ids)) {
158
+ for (const id of e.ids as unknown[]) if (typeof id === "string") droppedIds.push(id);
159
+ }
160
+ baseDeps.log?.(e);
161
+ },
162
+ }
163
+ : baseDeps;
99
164
 
100
165
  let handle: ReturnType<typeof openSession>;
101
166
  try {
102
- handle = openSession(h, { sessionId, model, cwd, escalateQuestions }, deps);
167
+ handle = openSession(h, { sessionId, model, cwd, escalateQuestions, provider }, deps);
103
168
  } catch (err) {
104
169
  if (err instanceof ArgvRefusalError) {
105
- process.stderr.write(`${err.message}\n`);
106
- if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
107
- process.exitCode = 2;
170
+ const { refusalOf, refuse } = await import("./refuse.js");
171
+ refuse(refusalOf(err), jsonMode, "closed");
108
172
  return;
109
173
  }
174
+ // S002: the harness binary is missing or would not start. A transport
175
+ // failure, not a refusal - exit 1, and the stream is still owed its pair.
110
176
  process.stderr.write(
111
177
  `could not open session: ${err instanceof Error ? err.message : String(err)}\n`,
112
178
  );
179
+ if (jsonMode) {
180
+ const { writeFailurePair } = await import("./refuse.js");
181
+ const { failureFromTransport } = await import("../execution/failure.js");
182
+ writeFailurePair(
183
+ failureFromTransport(err instanceof Error ? err.message : String(err)),
184
+ "closed",
185
+ );
186
+ }
113
187
  process.exitCode = 1;
114
188
  return;
115
189
  }
116
190
 
191
+ if (wantJson) {
192
+ const { runJsonSession } = await import("./session-json.js");
193
+ const { getVersion } = await import("./version.js");
194
+ process.exitCode = await runJsonSession({
195
+ handle,
196
+ sessionId,
197
+ harness: h.name,
198
+ hcnVersion: getVersion(),
199
+ escalateQuestions,
200
+ getCloseInfo: () => closeInfo,
201
+ getDroppedIds: () => droppedIds,
202
+ });
203
+ return;
204
+ }
205
+
117
206
  process.stdout.write(
118
207
  `interactive ${h.name} session ${sessionId}\n(empty line or "exit" to quit)\n`,
119
208
  );
@@ -136,6 +225,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
136
225
 
137
226
  // Handle SIGINT to close session cleanly
138
227
  let closing = false;
228
+ let sendCount = 0;
139
229
  const doClose = async () => {
140
230
  if (closing) return;
141
231
  closing = true;
@@ -168,7 +258,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
168
258
  const trimmed = line.trim();
169
259
  if (trimmed === "" || trimmed === "exit") break;
170
260
 
171
- const result = handle.send(line);
261
+ const result = handle.send({ id: `you-${++sendCount}`, text: line });
172
262
  if (result.disposition === "queued") {
173
263
  process.stderr.write(`disposition: queued (turn in progress)\n`);
174
264
  }
@@ -209,9 +299,10 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
209
299
  answer = a;
210
300
  }
211
301
  }
212
- handle.send(
213
- `The user answered the question: "${q.question}" with: ${answer}. Continue accordingly.`,
214
- );
302
+ handle.send({
303
+ id: `you-${++sendCount}`,
304
+ text: `The user answered the question: "${q.question}" with: ${answer}. Continue accordingly.`,
305
+ });
215
306
  // Drain the answer turn BEFORE prompting again - the pump's
216
307
  // backpressure stalls the harness until the turn iterable is
217
308
  // consumed (verified live: menu answered, you-prompt rendered, no
@@ -13,7 +13,11 @@
13
13
  * options or a different harness, not a different model.
14
14
  */
15
15
 
16
- import { detectAuthFailureInLine, detectTransportInLine } from "../interpretation/limits.js";
16
+ import {
17
+ detectAuthFailureInLine,
18
+ detectTransportInLine,
19
+ detectUnavailableInLine,
20
+ } from "../interpretation/limits.js";
17
21
  import type { RefusalIssue } from "../interpretation/refusal.js";
18
22
  import type {
19
23
  AuthFailureKind,
@@ -30,6 +34,7 @@ export const FAILURE_CLASSES = Object.freeze([
30
34
  "budget",
31
35
  "task",
32
36
  "transport",
37
+ "unavailable",
33
38
  "rejected",
34
39
  "native",
35
40
  "timeout",
@@ -76,6 +81,8 @@ const messageFor = (cls: FailureClass, detail?: string): string => {
76
81
  return `Task failed${detail ? ` (${detail})` : ""} - surface to caller, do not auto-route`;
77
82
  case "transport":
78
83
  return `Transport failure${detail ? ` (${detail})` : ""} - retry or route to another provider`;
84
+ case "unavailable":
85
+ return `Provider cannot serve this route${detail ? ` (${detail})` : ""} - route to another model or provider`;
79
86
  case "rejected":
80
87
  return `Request rejected${detail ? ` (${detail})` : ""} - change options or harness`;
81
88
  case "timeout":
@@ -144,6 +151,7 @@ export const failureFromTerminalError = (h: HarnessDescriptor, message: string):
144
151
  const auth = detectAuthFailureInLine(h, message);
145
152
  if (auth !== null) return failureFromAuth(auth);
146
153
  if (detectTransportInLine(message)) return failureFromTransport(message);
154
+ if (detectUnavailableInLine(message)) return failureFromUnavailable(message);
147
155
  return failureFromTask(message);
148
156
  };
149
157
 
@@ -165,6 +173,12 @@ export const failureFromTransport = (detail?: string): FailureSummary => ({
165
173
  message: messageFor("transport", detail),
166
174
  });
167
175
 
176
+ export const failureFromUnavailable = (detail?: string): FailureSummary => ({
177
+ class: "unavailable",
178
+ retryable: true,
179
+ message: messageFor("unavailable", detail),
180
+ });
181
+
168
182
  export const failureFromRejected = (opts: {
169
183
  issue: RefusalIssue;
170
184
  option?: import("../interpretation/refusal.js").RefusalOption;
@@ -194,6 +208,7 @@ const PRECEDENCE: Record<FailureClass, number> = {
194
208
  "rate-limit": 2,
195
209
  "usage-limit": 2,
196
210
  quota: 2,
211
+ unavailable: 2,
197
212
  budget: 3,
198
213
  task: 3,
199
214
  transport: 4,
@@ -29,7 +29,7 @@ import {
29
29
  import type { HarnessDescriptor, SessionInputContract } from "../knowledge/descriptor.js";
30
30
  import { AsyncChannel } from "./channel.js";
31
31
  import { decodeParsed, freshDecodeState } from "./decode.js";
32
- import type { RunnerDeps, SpawnedProcess } from "./deps.js";
32
+ import type { RunnerDeps, SpawnedProcess, TimerHandle } from "./deps.js";
33
33
  import type { ExitCause, HarnessEvent } from "./events.js";
34
34
  import type { FailureSummary } from "./failure.js";
35
35
  import {
@@ -50,15 +50,36 @@ export const CLOSE_GRACE_MS = 5_000;
50
50
  const PRETURN_MAX = 256;
51
51
 
52
52
  export interface SessionSendResult {
53
- readonly disposition: "started" | "queued";
53
+ readonly disposition: "started" | "queued" | "rejected";
54
+ /** Present when rejected. `write-failed` is a broken stdin pipe, which is
55
+ * a different remedy from a session the caller already closed - the two
56
+ * must stay distinguishable. */
57
+ readonly reason?: "write-failed";
58
+ }
59
+
60
+ /** One turn's event stream, tagged with the id of the send that opened it.
61
+ * `inputId` is present for every turn a consumer send opened, which today is
62
+ * every turn; a turn opened by anything else (none exists yet) omits it. */
63
+ export interface SessionTurn extends AsyncIterable<HarnessEvent> {
64
+ readonly inputId?: string;
65
+ /** `${sessionId}:turn-${n}`, matching the runner's turn_start log. */
66
+ readonly turnId?: string;
67
+ }
68
+
69
+ /** A send's payload: the consumer's correlation id travels with the text
70
+ * from the moment it arrives to the turn it opens and, on death, to the
71
+ * loss report. */
72
+ export interface SessionInput {
73
+ readonly id: string;
74
+ readonly text: string;
54
75
  }
55
76
 
56
77
  export interface SessionHandle {
57
78
  /** One inner iterable per turn, each ending in a turn-scoped `done`.
58
79
  * Breaking out of THIS iterable closes the session; breaking out of a
59
80
  * single turn's iterable only stops reading that turn. */
60
- readonly turns: AsyncIterable<AsyncIterable<HarnessEvent>>;
61
- send(text: string): SessionSendResult;
81
+ readonly turns: AsyncIterable<SessionTurn>;
82
+ send(input: SessionInput): SessionSendResult;
62
83
  close(): Promise<void>;
63
84
  }
64
85
 
@@ -72,6 +93,8 @@ export interface OpenSessionOptions {
72
93
  * every send and arms block detection at turn end; false composes the
73
94
  * no-ask instruction and disarms detection. */
74
95
  readonly escalateQuestions?: boolean;
96
+ /** Provider selector (pi); refused on a harness without one. */
97
+ readonly provider?: string;
75
98
  }
76
99
 
77
100
  export class SessionClosedError extends Error {
@@ -99,6 +122,7 @@ export const openSession = (
99
122
  const argv = buildSessionArgv(h, {
100
123
  sessionId: opts.sessionId,
101
124
  ...(opts.model !== undefined ? { model: opts.model } : {}),
125
+ ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
102
126
  });
103
127
  let sessionInput: SessionInputContract;
104
128
  try {
@@ -142,7 +166,7 @@ export const openSession = (
142
166
  argv: redactArgv(argv),
143
167
  });
144
168
 
145
- const turnsChannel = new AsyncChannel<AsyncIterable<HarnessEvent>>();
169
+ const turnsChannel = new AsyncChannel<SessionTurn>();
146
170
  const state = freshDecodeState(opts.sessionId);
147
171
  const escalateQuestions = opts.escalateQuestions !== false;
148
172
  const sessionInputMode = h.sessionMode;
@@ -150,7 +174,7 @@ export const openSession = (
150
174
  let turnCounter = 0;
151
175
  let activeTurn: AsyncChannel<HarnessEvent> | null = null;
152
176
  let activeTurnId = "";
153
- const pendingSends: string[] = [];
177
+ const pendingSends: SessionInput[] = [];
154
178
  const preTurnEvents: HarnessEvent[] = [];
155
179
  let dead = false;
156
180
  let closing = false;
@@ -174,6 +198,42 @@ export const openSession = (
174
198
  deps.clock.setTimeout(() => safeSignal("SIGKILL"), KILL_GRACE_MS);
175
199
  };
176
200
 
201
+ // Per-turn inactivity budget. A session turn can hang with the process
202
+ // alive and the pipes open, which no exit code reports; without this the
203
+ // consumer waits forever. Armed at turn start, rearmed on any output
204
+ // chunk, disarmed at turn end and at exit - the same discipline
205
+ // streamTurn uses, scoped to the turn rather than the process.
206
+ let stallTimer: TimerHandle | null = null;
207
+ let stalled = false;
208
+ const disarmStall = (): void => {
209
+ if (stallTimer !== null) deps.clock.clearTimeout(stallTimer);
210
+ stallTimer = null;
211
+ };
212
+ const rearmStall = (): void => {
213
+ if (deps.stallMs === undefined || activeTurn === null) return;
214
+ disarmStall();
215
+ stallTimer = deps.clock.setTimeout(() => {
216
+ // The turn may have ended between the timer firing and this callback
217
+ // running. Without this guard a clean turn that finished near the
218
+ // budget would be reported as a stall and the child signalled.
219
+ if (activeTurn === null) return;
220
+ stalled = true;
221
+ log({
222
+ event: "stall",
223
+ sessionId: opts.sessionId,
224
+ turnId: activeTurnId,
225
+ harness: h.name,
226
+ reason: "inactivity",
227
+ budgetMs: deps.stallMs,
228
+ });
229
+ // The turn is owed its own terminal event before the process dies;
230
+ // the exit path then closes the session with the same cause.
231
+ void pushFailure(failureFromTransport("stalled: inactivity"));
232
+ endTurn({ kind: "done", exitCode: null, cause: "stall" });
233
+ escalate();
234
+ }, deps.stallMs);
235
+ };
236
+
177
237
  const writeUser = (text: string): boolean => {
178
238
  try {
179
239
  stdin.write(
@@ -184,7 +244,13 @@ export const openSession = (
184
244
  );
185
245
  return true;
186
246
  } catch {
187
- activeTurn?.push({ kind: "error", message: "send failed: session stdin is gone" });
247
+ // A broken stdin pipe ends the session: there is no way to drive the
248
+ // child any more. Surface it as its own event, stop accepting sends,
249
+ // and END the child - marking it dead here instead would suppress the
250
+ // very signal that stops it. The exit path then finalizes as usual.
251
+ void routeEvent({ kind: "error", message: "send failed: session stdin is gone" });
252
+ closing = true;
253
+ escalate();
188
254
  return false;
189
255
  }
190
256
  };
@@ -195,19 +261,25 @@ export const openSession = (
195
261
  return summary;
196
262
  };
197
263
 
198
- const startTurn = (): void => {
264
+ const startTurn = (inputId?: string): void => {
199
265
  turnLimitSeen = false;
200
266
  turnFailures = [];
201
267
  turnAsked = false;
202
268
  lastAssistantText = null;
203
269
  activeTurn = new AsyncChannel<HarnessEvent>();
204
270
  activeTurnId = `${opts.sessionId}:turn-${++turnCounter}`;
271
+ // Tag the turn with the id of the send that opened it, so the consumer
272
+ // correlates a queued input to its turn by reading the tag, not by
273
+ // shadowing the runner's delivery order.
274
+ (activeTurn as { inputId?: string; turnId?: string }).inputId = inputId;
275
+ (activeTurn as { inputId?: string; turnId?: string }).turnId = activeTurnId;
205
276
  log({ event: "turn_start", sessionId: opts.sessionId, turnId: activeTurnId });
206
277
  for (const held of preTurnEvents.splice(0)) {
207
278
  if (held.kind === "failure") turnFailures.push(summaryOf(held));
208
279
  activeTurn.push(held);
209
280
  }
210
- turnsChannel.push(activeTurn);
281
+ turnsChannel.push(activeTurn as SessionTurn);
282
+ rearmStall();
211
283
  };
212
284
 
213
285
  /** issue #44: at a turn boundary, scan the last assistant message for
@@ -246,6 +318,7 @@ export const openSession = (
246
318
 
247
319
  const endTurn = (done: HarnessEvent & { kind: "done" }): void => {
248
320
  if (activeTurn === null) return;
321
+ disarmStall();
249
322
  // Asking is a successful turn: the session semantic is "blocked on
250
323
  // answer, session alive" - the done stays TURN-scoped (exitCode null
251
324
  // in sessions) and the caller answers with the next send().
@@ -271,7 +344,21 @@ export const openSession = (
271
344
  // The boundary is the only legal delivery point for queued input.
272
345
  if (dead || closing) return;
273
346
  const next = pendingSends.shift();
274
- if (next !== undefined && writeUser(next)) startTurn();
347
+ if (next === undefined) return;
348
+ if (writeUser(next.text)) {
349
+ startTurn(next.id);
350
+ return;
351
+ }
352
+ // The queue was shifted but the write failed: report the id that was
353
+ // accepted as queued and never delivered, instead of dropping it.
354
+ log({
355
+ event: "sends_dropped",
356
+ sessionId: opts.sessionId,
357
+ count: 1,
358
+ ids: [next.id],
359
+ reason: "write-failed",
360
+ lengths: [next.text.length],
361
+ });
275
362
  };
276
363
 
277
364
  const routeEvent = (event: HarnessEvent): Promise<void> => {
@@ -434,6 +521,7 @@ export const openSession = (
434
521
  }
435
522
  };
436
523
  for await (const chunk of proc.stdout) {
524
+ rearmStall();
437
525
  for (const line of lines.push(chunk)) await handleLine(line);
438
526
  }
439
527
  const rest = lines.flush();
@@ -443,6 +531,7 @@ export const openSession = (
443
531
  const pumpStderr = async (): Promise<void> => {
444
532
  const lines = new LineBuffer();
445
533
  for await (const chunk of proc.stderr) {
534
+ rearmStall();
446
535
  for (const line of lines.push(chunk)) {
447
536
  const limit = detectLimitInLine(h, line);
448
537
  if (limit !== null) {
@@ -478,28 +567,34 @@ export const openSession = (
478
567
  const finalize = (): void => {
479
568
  if (finalized) return;
480
569
  finalized = true;
481
- const cause: ExitCause = state.limitSeen
482
- ? "limit"
483
- : exitCode === 0
484
- ? "clean"
485
- : exitCode === null
486
- ? "killed"
487
- : "crash";
570
+ // A stall killed the process on purpose, so the signal death it caused
571
+ // reports as "stall", not "killed".
572
+ const cause: ExitCause = stalled
573
+ ? "stall"
574
+ : state.limitSeen
575
+ ? "limit"
576
+ : exitCode === 0
577
+ ? "clean"
578
+ : exitCode === null
579
+ ? "killed"
580
+ : "crash";
488
581
  if (pumpError !== null) {
489
582
  void routeEvent({ kind: "error", message: `session pump failed: ${String(pumpError)}` });
490
583
  }
491
584
  if (pendingSends.length > 0) {
492
585
  // "queued" was an accepted disposition - the loss must be visible to
493
586
  // both the log and the consumer, never silent.
587
+ const droppedIds = pendingSends.map((s) => s.id);
494
588
  void routeEvent({
495
589
  kind: "error",
496
- message: `${pendingSends.length} queued send(s) died with the session`,
590
+ message: `${pendingSends.length} queued send(s) died with the session: ${droppedIds.join(", ")}`,
497
591
  });
498
592
  log({
499
593
  event: "sends_dropped",
500
594
  sessionId: opts.sessionId,
501
595
  count: pendingSends.length,
502
- lengths: pendingSends.map((s) => s.length),
596
+ ids: droppedIds,
597
+ lengths: pendingSends.map((s) => s.text.length),
503
598
  });
504
599
  pendingSends.length = 0;
505
600
  }
@@ -526,6 +621,8 @@ export const openSession = (
526
621
  void proc.exited.then((code) => {
527
622
  dead = true;
528
623
  exitCode = code;
624
+ // The process is gone: a later fire would flip a finished turn to stall.
625
+ disarmStall();
529
626
  // Pipes held open past exit (a grandchild) must not hang the session.
530
627
  const pipeGrace = deps.clock.setTimeout(() => {
531
628
  pipesOpenAtExit = true;
@@ -564,24 +661,36 @@ export const openSession = (
564
661
  if (!closing && !dead) void close();
565
662
  }
566
663
  })(),
567
- send(text: string): SessionSendResult {
664
+ send(input: SessionInput): SessionSendResult {
568
665
  if (dead || closing) throw new SessionClosedError();
569
666
  if (activeTurn !== null) {
570
- pendingSends.push(text);
667
+ pendingSends.push(input);
571
668
  log({
572
669
  event: "send",
573
670
  sessionId: opts.sessionId,
574
671
  turnId: activeTurnId,
672
+ inputId: input.id,
575
673
  disposition: "queued",
576
674
  });
577
675
  return { disposition: "queued" };
578
676
  }
579
- if (!writeUser(text)) throw new SessionClosedError();
580
- startTurn();
677
+ if (!writeUser(input.text)) {
678
+ log({
679
+ event: "send",
680
+ sessionId: opts.sessionId,
681
+ turnId: activeTurnId,
682
+ inputId: input.id,
683
+ disposition: "rejected",
684
+ reason: "write-failed",
685
+ });
686
+ return { disposition: "rejected", reason: "write-failed" };
687
+ }
688
+ startTurn(input.id);
581
689
  log({
582
690
  event: "send",
583
691
  sessionId: opts.sessionId,
584
692
  turnId: activeTurnId,
693
+ inputId: input.id,
585
694
  disposition: "started",
586
695
  });
587
696
  return { disposition: "started" };
@@ -20,6 +20,7 @@ import {
20
20
  detectAuthFailureInLine,
21
21
  detectLimitInLine,
22
22
  detectTransportInLine,
23
+ detectUnavailableInLine,
23
24
  } from "../interpretation/limits.js";
24
25
  import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
25
26
  import { ArgvRefusalError } from "../interpretation/refusal.js";
@@ -39,6 +40,7 @@ import {
39
40
  failureFromTerminalError,
40
41
  failureFromTimeout,
41
42
  failureFromTransport,
43
+ failureFromUnavailable,
42
44
  reduceFailures,
43
45
  } from "./failure.js";
44
46
  import { LineBuffer } from "./lines.js";
@@ -595,12 +597,15 @@ export async function* streamTurn(
595
597
  ) {
596
598
  const tailForNative = stderrTail.snapshot();
597
599
  const transportLine = tailForNative.find((line) => detectTransportInLine(line));
600
+ const unavailableLine = tailForNative.find((line) => detectUnavailableInLine(line));
598
601
  const f =
599
602
  transportLine !== undefined
600
603
  ? failureFromTransport(transportLine)
601
- : tailForNative.length > 0
602
- ? failureFromNative(exitCode, tailForNative)
603
- : failureFromTransport(`nonzero exit ${exitCode}`);
604
+ : unavailableLine !== undefined
605
+ ? failureFromUnavailable(unavailableLine)
606
+ : tailForNative.length > 0
607
+ ? failureFromNative(exitCode, tailForNative)
608
+ : failureFromTransport(`nonzero exit ${exitCode}`);
604
609
  failures.push(f);
605
610
  // Need to emit this failure before done, even though queue is closed
606
611
  yield { kind: "failure", ...f };
@@ -189,6 +189,9 @@ export const buildResumeArgv = (h: HarnessDescriptor, opts: ResumeOptions): stri
189
189
  export interface SessionOptions {
190
190
  readonly sessionId: string;
191
191
  readonly model?: string;
192
+ /** Provider selector (pi). A harness with no provider selector refuses,
193
+ * the same way a one-shot turn does. */
194
+ readonly provider?: string;
192
195
  }
193
196
 
194
197
  export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): string[] => {
@@ -223,6 +226,11 @@ export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): st
223
226
  }
224
227
  argv.push(h.vocabulary.modelFlag, validated.id);
225
228
  }
229
+ if (opts.provider !== undefined) {
230
+ // One dimension, rendered by the same code path a launch argv uses, so
231
+ // the flag spelling and the refusal (with supportedBy) stay identical.
232
+ argv.push(...renderTurnOptions(h, { provider: opts.provider } as TurnOptions, "launch"));
233
+ }
226
234
  return argv;
227
235
  };
228
236
 
@@ -23,9 +23,9 @@ import type {
23
23
  HarnessDescriptor,
24
24
  LimitCode,
25
25
  LimitMatcher,
26
- TransportMatcher,
26
+ PhraseMatcher,
27
27
  } from "../knowledge/descriptor.js";
28
- import { SHARED_TRANSPORT_MATCHERS } from "../knowledge/matchers.js";
28
+ import { SHARED_TRANSPORT_MATCHERS, SHARED_UNAVAILABLE_MATCHERS } from "../knowledge/matchers.js";
29
29
 
30
30
  /** Bottom-up batch scans stop after this many non-empty lines: the wall is
31
31
  * virtually always the last thing a dying turn printed, and an unbounded
@@ -155,29 +155,28 @@ export const detectAuthFailureInLine = (
155
155
  export const detectAuthFailure = (h: HarnessDescriptor, output: string): AuthFailureKind | null =>
156
156
  scanTail(output, compileAuthMatchers(h.authMatchers));
157
157
 
158
- const transportCache = new WeakMap<
159
- ReadonlyArray<TransportMatcher>,
160
- ReadonlyArray<readonly [RegExp, boolean]>
161
- >();
158
+ const phraseCache = new WeakMap<ReadonlyArray<PhraseMatcher>, ReadonlyArray<RegExp>>();
162
159
 
163
- const compileTransportMatchers = (
164
- matchers: ReadonlyArray<TransportMatcher>,
165
- ): ReadonlyArray<readonly [RegExp, boolean]> => {
166
- const cached = transportCache.get(matchers);
160
+ const compilePhraseMatchers = (matchers: ReadonlyArray<PhraseMatcher>): ReadonlyArray<RegExp> => {
161
+ const cached = phraseCache.get(matchers);
167
162
  if (cached !== undefined) return cached;
168
163
  if (matchers.length > MAX_MATCHERS_PER_KIND) {
169
164
  throw new Error(`more than ${MAX_MATCHERS_PER_KIND} matchers per harness per kind`);
170
165
  }
171
- const compiled = matchers.map((m) => [validateAndCompile(m.pattern, m.flags), true] as const);
172
- transportCache.set(matchers, compiled);
166
+ const compiled = matchers.map((m) => validateAndCompile(m.pattern, m.flags));
167
+ phraseCache.set(matchers, compiled);
173
168
  return compiled;
174
169
  };
175
170
 
176
- export const detectTransportInLine = (line: string): boolean => {
177
- const compiled = compileTransportMatchers(SHARED_TRANSPORT_MATCHERS);
171
+ const detectPhraseInLine = (matchers: ReadonlyArray<PhraseMatcher>, line: string): boolean => {
178
172
  const windowed = line.slice(0, WINDOW).trim();
179
- for (const [re] of compiled) {
180
- if (re.test(windowed)) return true;
181
- }
182
- return false;
173
+ return compilePhraseMatchers(matchers).some((re) => re.test(windowed));
183
174
  };
175
+
176
+ /** A network or gateway fault between the harness and its provider. */
177
+ export const detectTransportInLine = (line: string): boolean =>
178
+ detectPhraseInLine(SHARED_TRANSPORT_MATCHERS, line);
179
+
180
+ /** A provider that answered but cannot serve the requested model. */
181
+ export const detectUnavailableInLine = (line: string): boolean =>
182
+ detectPhraseInLine(SHARED_UNAVAILABLE_MATCHERS, line);