@dungle-scrubs/harness-cli-normalizer 0.5.3 → 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 (39) hide show
  1. package/README.md +111 -0
  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/open-session.d.ts +24 -3
  24. package/dist/execution/open-session.d.ts.map +1 -1
  25. package/dist/execution/open-session.js +106 -19
  26. package/dist/execution/open-session.js.map +1 -1
  27. package/dist/interpretation/argv.d.ts +3 -0
  28. package/dist/interpretation/argv.d.ts.map +1 -1
  29. package/dist/interpretation/argv.js +5 -0
  30. package/dist/interpretation/argv.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/cli/args.ts +8 -0
  33. package/src/cli/help.ts +16 -0
  34. package/src/cli/inspect.ts +26 -0
  35. package/src/cli/refuse.ts +31 -12
  36. package/src/cli/session-json.ts +230 -0
  37. package/src/cli/session.ts +103 -12
  38. package/src/execution/open-session.ts +132 -23
  39. package/src/interpretation/argv.ts +8 -0
@@ -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" };
@@ -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