toolroll 0.9.60 → 0.9.61

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 (118) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/agent-board.d.ts +208 -0
  3. package/dist/agent-board.js +454 -0
  4. package/dist/agent-fence.d.ts +19 -0
  5. package/dist/agent-fence.js +100 -8
  6. package/dist/agent-pulse.d.ts +43 -0
  7. package/dist/agent-pulse.js +214 -0
  8. package/dist/agent-turns.d.ts +52 -0
  9. package/dist/agent-turns.js +137 -0
  10. package/dist/agents-board.d.ts +101 -0
  11. package/dist/agents-board.js +225 -0
  12. package/dist/assignment-adapters.d.ts +1 -0
  13. package/dist/assignment-status.d.ts +1 -1
  14. package/dist/assignment.d.ts +4 -0
  15. package/dist/assignment.js +8 -6
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +29 -25
  18. package/dist/browser-crew.js +2 -0
  19. package/dist/browser-workspace.d.ts +79 -1
  20. package/dist/browser-workspace.js +1 -0
  21. package/dist/builder.d.ts +8 -0
  22. package/dist/builder.js +113 -13
  23. package/dist/chat-channel.js +9 -0
  24. package/dist/chat-core.d.ts +6 -0
  25. package/dist/chat-core.js +8 -0
  26. package/dist/chat-delivery.js +22 -0
  27. package/dist/chat-messages.js +8 -1
  28. package/dist/chat-quiet.js +1 -0
  29. package/dist/chat-voice.d.ts +2 -0
  30. package/dist/chat-voice.js +2 -0
  31. package/dist/checkout-cleanup.d.ts +3 -0
  32. package/dist/checkout-cleanup.js +11 -2
  33. package/dist/contracts/console-api.d.ts +3 -1
  34. package/dist/contracts/console-api.js +5 -1
  35. package/dist/contracts/lead-tools.d.ts +16 -2
  36. package/dist/contracts/lead-tools.js +5 -1
  37. package/dist/desktop-update.d.ts +1 -1
  38. package/dist/evidence-pack.js +1 -1
  39. package/dist/evidence.d.ts +6 -0
  40. package/dist/evidence.js +14 -3
  41. package/dist/exec.d.ts +32 -0
  42. package/dist/exec.js +229 -10
  43. package/dist/invoke.js +110 -18
  44. package/dist/landed-cleanup.d.ts +157 -0
  45. package/dist/landed-cleanup.js +707 -0
  46. package/dist/lead-cli.js +22 -19
  47. package/dist/lead-contract.js +1 -1
  48. package/dist/lead-doors.js +45 -1
  49. package/dist/lead-tools.js +40 -7
  50. package/dist/live-input.d.ts +68 -0
  51. package/dist/live-input.js +254 -0
  52. package/dist/live.d.ts +1 -1
  53. package/dist/live.js +4 -2
  54. package/dist/maintenance.d.ts +2 -0
  55. package/dist/maintenance.js +5 -2
  56. package/dist/operate-remote-arguments.js +5 -1
  57. package/dist/operate-remote.js +1 -1
  58. package/dist/operate.d.ts +1 -1
  59. package/dist/operate.js +188 -16
  60. package/dist/orphan-sweep.d.ts +2 -0
  61. package/dist/orphan-sweep.js +5 -3
  62. package/dist/project-delete-ui.d.ts +11 -0
  63. package/dist/project-delete-ui.js +29 -2
  64. package/dist/project-tools.d.ts +7 -11
  65. package/dist/project-tools.js +13 -37
  66. package/dist/provider.d.ts +65 -8
  67. package/dist/provider.js +80 -21
  68. package/dist/result-completion.d.ts +7 -0
  69. package/dist/result-completion.js +9 -0
  70. package/dist/runner.d.ts +4 -2
  71. package/dist/runner.js +3 -3
  72. package/dist/serve.js +19 -1
  73. package/dist/server/live.d.ts +3 -0
  74. package/dist/server/live.js +35 -2
  75. package/dist/server/pages.js +59 -35
  76. package/dist/server/render-chat.js +12 -0
  77. package/dist/server/render-tasks.d.ts +12 -2
  78. package/dist/server/render-tasks.js +27 -7
  79. package/dist/server/route-table.js +4 -1
  80. package/dist/server/settings.js +2 -1
  81. package/dist/server/task-views.js +9 -0
  82. package/dist/server/tasks.js +63 -0
  83. package/dist/shared-deps.d.ts +9 -4
  84. package/dist/shared-deps.js +89 -13
  85. package/dist/staged-runtimes.d.ts +16 -4
  86. package/dist/staged-runtimes.js +160 -38
  87. package/dist/steer-live.d.ts +19 -0
  88. package/dist/steer-live.js +93 -0
  89. package/dist/steer-status.d.ts +8 -2
  90. package/dist/steer-status.js +19 -5
  91. package/dist/storage-sweep-child.d.ts +1 -0
  92. package/dist/storage-sweep-child.js +76 -0
  93. package/dist/storage-sweep-process.d.ts +41 -0
  94. package/dist/storage-sweep-process.js +187 -0
  95. package/dist/storage-sweep.d.ts +30 -6
  96. package/dist/storage-sweep.js +214 -28
  97. package/dist/storage-ui.d.ts +2 -0
  98. package/dist/storage-ui.js +16 -4
  99. package/dist/storage.d.ts +6 -0
  100. package/dist/storage.js +35 -0
  101. package/dist/store.d.ts +163 -2
  102. package/dist/store.js +350 -15
  103. package/dist/surface.js +4 -2
  104. package/dist/sweep-quarantine.d.ts +28 -0
  105. package/dist/sweep-quarantine.js +153 -0
  106. package/dist/task-status.d.ts +2 -0
  107. package/dist/task-status.js +5 -1
  108. package/dist/test-temp.d.ts +6 -3
  109. package/dist/test-temp.js +15 -9
  110. package/dist/toolroll-update.js +3 -2
  111. package/dist/work-index.d.ts +1 -0
  112. package/dist/work-index.js +32 -12
  113. package/dist/work-summary.d.ts +2 -2
  114. package/dist/workspace-revision.js +3 -1
  115. package/dist/worktree.d.ts +3 -1
  116. package/dist/worktree.js +93 -10
  117. package/package.json +1 -1
  118. package/scripts/provider-canary.mjs +235 -147
@@ -656,7 +656,14 @@ export declare const LEAD_TOOL_INPUTS: {
656
656
  }, z.core.$strict>;
657
657
  readonly propose_steer: z.ZodObject<{
658
658
  task: z.ZodString;
659
- note: z.ZodString;
659
+ note: z.ZodOptional<z.ZodString>;
660
+ mode: z.ZodOptional<z.ZodEnum<{
661
+ note: "note";
662
+ pause: "pause";
663
+ "pause-and-steer": "pause-and-steer";
664
+ resume: "resume";
665
+ }>>;
666
+ question: z.ZodOptional<z.ZodNumber>;
660
667
  }, z.core.$strict>;
661
668
  readonly propose_dependency_repair: z.ZodObject<{
662
669
  task: z.ZodString;
@@ -1318,7 +1325,14 @@ export declare const LEAD_TOOL_READ_INPUTS: {
1318
1325
  }, z.core.$strict>;
1319
1326
  readonly propose_steer: z.ZodObject<{
1320
1327
  task: z.ZodString;
1321
- note: z.ZodString;
1328
+ note: z.ZodOptional<z.ZodString>;
1329
+ mode: z.ZodOptional<z.ZodEnum<{
1330
+ note: "note";
1331
+ pause: "pause";
1332
+ "pause-and-steer": "pause-and-steer";
1333
+ resume: "resume";
1334
+ }>>;
1335
+ question: z.ZodOptional<z.ZodNumber>;
1322
1336
  }, z.core.$strict>;
1323
1337
  readonly propose_dependency_repair: z.ZodObject<{
1324
1338
  task: z.ZodString;
@@ -130,7 +130,11 @@ export const LEAD_TOOL_INPUTS = {
130
130
  propose_reserve: z.strictObject({ task: taskRef, worker: text("workerName").nullable() }),
131
131
  propose_hold: z.strictObject({ task: taskRef, reason: text("proposalReason") }),
132
132
  propose_unhold: z.strictObject({ task: taskRef }),
133
- propose_steer: z.strictObject({ task: taskRef, note }),
133
+ // mode: a note (the default), Pause (stop a running Claude build's turn, keep the build), Pause and steer, or Resume
134
+ // (a paused build goes on where it stopped, without a note).
135
+ // question: a running agent's open question (Agents board); the note is its reply, sent as an ordinary note.
136
+ propose_steer: z.strictObject({ task: taskRef, note: note.optional(), mode: z.enum(["note", "pause", "pause-and-steer", "resume"]).optional(),
137
+ question: z.number().int().positive().optional().describe("A running agent's open question (get_task agentQuestions): the note becomes its reply and closes it.") }),
134
138
  propose_dependency_repair: z.strictObject({ task: taskRef, blocker: taskRef, operation: z.enum(["retry", "unlink", "replace"]), replacement: taskRef.optional() }),
135
139
  propose_scope: z.strictObject({ task: taskRef, goal: scopeText, not: scopeText.nullable().optional(), touches: touches.optional(), acceptance }),
136
140
  propose_agents: z.strictObject({
@@ -94,7 +94,6 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
94
94
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
95
95
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
96
96
  export declare function desktopUpdateStatus(stateDir: string): {
97
- version?: never;
98
97
  active: boolean;
99
98
  phase: string;
100
99
  detail: string;
@@ -108,6 +107,7 @@ export declare function desktopUpdateStatus(stateDir: string): {
108
107
  wasRunning?: never;
109
108
  backupPath?: never;
110
109
  updateId?: never;
110
+ version?: never;
111
111
  buildId?: never;
112
112
  workDir?: never;
113
113
  } | {
@@ -84,7 +84,7 @@ export function evidencePack(store, taskId, access, who, now, evidenceRoot, chec
84
84
  result: receipt === null ? null : {
85
85
  runId: receipt.runId, base: receipt.base, head: receipt.head, kind: receipt.completionKind,
86
86
  checks: { status: receipt.checks.status, command: receipt.checks.command, exitCode: receipt.checks.exitCode, detail: receipt.checks.detail },
87
- completedBy: assignment?.completion?.actor ?? null, completedAt: assignment?.completion?.at ?? null, caveats: receipt.caveats,
87
+ completedBy: assignment?.completion?.landed !== undefined ? `Toolroll, automatically: ${assignment.completion.landed}` : assignment?.completion?.actor ?? null, completedAt: assignment?.completion?.at ?? null, caveats: receipt.caveats,
88
88
  publication: assignment?.publication ?? null,
89
89
  },
90
90
  totals: { runs: allRuns.length, costUsd: Math.round(sum(run => run.costUsd) * 1e6) / 1e6, tokensIn: sum(run => run.tokensIn), tokensOut: sum(run => run.tokensOut) },
@@ -51,6 +51,9 @@ export declare const PROPOSAL_PREFIX = "STANDING-ORDERS-PROPOSAL-";
51
51
  * writes each operator note into it as NNN.json (a temp name, then a
52
52
  * rename); the agent acknowledges a note by moving it into `handled/`. */
53
53
  export declare const STEER_PREFIX = "STANDING-ORDERS-STEER-";
54
+ /** The running build's status file (Agents board): JSON lines the agent appends
55
+ * (working, needs-decision, blocked, done), read forward by the pulse. */
56
+ export declare const STATUS_PREFIX = "STANDING-ORDERS-STATUS-";
54
57
  export declare const MAILBOX_SUFFIX = ".json";
55
58
  /** v105: left in a run's evidence folder when the retention setting removed its files (retention.ts). */
56
59
  export declare const RETENTION_NOTE = "REMOVED-BY-RETENTION";
@@ -86,6 +89,9 @@ export declare function looksLikeProtocolFile(name: string): boolean;
86
89
  * `git status` names it: an untracked folder is reported with a trailing
87
90
  * slash, or file by file where a repository shows every untracked file. */
88
91
  export declare function looksLikeSteerFolder(name: string): boolean;
92
+ /** An agent status file (Agents board), by its exact nonce-shaped name. */
93
+ export declare function looksLikeStatusFile(name: string): boolean;
94
+ export declare function statusFileName(): string;
89
95
  export declare function steerFolderName(): string;
90
96
  export declare function handoffName(): string;
91
97
  export declare function planFileName(): string;
package/dist/evidence.js CHANGED
@@ -54,6 +54,9 @@ export const PROPOSAL_PREFIX = "STANDING-ORDERS-PROPOSAL-";
54
54
  * writes each operator note into it as NNN.json (a temp name, then a
55
55
  * rename); the agent acknowledges a note by moving it into `handled/`. */
56
56
  export const STEER_PREFIX = "STANDING-ORDERS-STEER-";
57
+ /** The running build's status file (Agents board): JSON lines the agent appends
58
+ * (working, needs-decision, blocked, done), read forward by the pulse. */
59
+ export const STATUS_PREFIX = "STANDING-ORDERS-STATUS-";
57
60
  export const MAILBOX_SUFFIX = ".json";
58
61
  /** v105: left in a run's evidence folder when the retention setting removed its files (retention.ts). */
59
62
  export const RETENTION_NOTE = "REMOVED-BY-RETENTION";
@@ -132,7 +135,7 @@ export function looksLikeMailbox(name) {
132
135
  * The pre-rename NIGHTORDERS- prefix stays recognized here — a worktree cut
133
136
  * down before 2026-08-13 may still hold one, and cleanup has no vintage. */
134
137
  export function looksLikeProtocolFile(name) {
135
- if (looksLikeSteerFolder(name))
138
+ if (looksLikeSteerFolder(name) || looksLikeStatusFile(name))
136
139
  return true;
137
140
  return ((name.startsWith(MAILBOX_PREFIX) ||
138
141
  name.startsWith(HANDOFF_PREFIX) ||
@@ -153,6 +156,13 @@ export function looksLikeProtocolFile(name) {
153
156
  export function looksLikeSteerFolder(name) {
154
157
  return /^STANDING-ORDERS-STEER-[0-9a-f]{16}(?:\/.*)?$/.test(name);
155
158
  }
159
+ /** An agent status file (Agents board), by its exact nonce-shaped name. */
160
+ export function looksLikeStatusFile(name) {
161
+ return /^STANDING-ORDERS-STATUS-[0-9a-f]{16}\.jsonl$/.test(name);
162
+ }
163
+ export function statusFileName() {
164
+ return `${STATUS_PREFIX}${randomBytes(8).toString("hex")}.jsonl`;
165
+ }
156
166
  export function steerFolderName() {
157
167
  return `${STEER_PREFIX}${randomBytes(8).toString("hex")}`;
158
168
  }
@@ -550,8 +560,9 @@ export function quarantineMailboxes(worktree, root, runId) {
550
560
  const path = join(worktree, name);
551
561
  // An earlier attempt's steering folder holds copies of notes the
552
562
  // database already keeps: removed whole (a symlink is removed, never
553
- // followed), never quarantined.
554
- if (looksLikeSteerFolder(name)) {
563
+ // followed), never quarantined. So is its status file: the database
564
+ // already read every line it will ever need.
565
+ if (looksLikeSteerFolder(name) || looksLikeStatusFile(name)) {
555
566
  try {
556
567
  rmSync(path, { recursive: true, force: true });
557
568
  swept.push(name);
package/dist/exec.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { type ProcessTreeObserver } from "./process-tree.js";
10
10
  import { type ContainmentBackendId } from "./containment.js";
11
11
  import type { Store } from "./store.js";
12
+ import type { LiveInput } from "./live-input.js";
12
13
  export type ExecResult = {
13
14
  /** Process exit code, or one of the synthetic codes below. */
14
15
  code: number;
@@ -112,6 +113,15 @@ export type RunOptions = ProcessTreeObserver & {
112
113
  * currently only the streaming transport can deliver this early).
113
114
  */
114
115
  onSessionId?: (id: string) => void;
116
+ /**
117
+ * Live input (live-input.ts): a Claude transport that declares `streamInput`
118
+ * keeps stdin piped and open, hands it to this session, and shows it every
119
+ * stdout event. The session sends the brief first and decides when stdin
120
+ * closes; the run then ends with the process, and the LAST primary result
121
+ * is the accounting envelope (one result per turn), with every main
122
+ * result's usage and turns added up (cumulativeLiveResult).
123
+ */
124
+ liveInput?: LiveInput;
115
125
  /** Fires once the child exists, with its pid (the process-group id when
116
126
  * processGroup is set) — the slot ledger records it (v14 finding 26). */
117
127
  onSpawn?: (pid: number) => void;
@@ -202,6 +212,17 @@ export declare function run(file: string, args: readonly string[], options?: Run
202
212
  * worst useful case while remaining a hard, per-event memory boundary.
203
213
  */
204
214
  export declare const JSONL_EVENT_HARD_CAP: number;
215
+ /**
216
+ * The accounting an oversized Claude result still carries, read by a bounded scan of its kept prefix and tail: the
217
+ * session's running `total_cost_usd`, its `usage` and `num_turns`, and whether it is a main result at all (an
218
+ * `origin` naming anything but a person makes it a background task's). Anything unreadable or seen twice is absent.
219
+ */
220
+ export declare function scanOversizedClaudeResult(prefix: Buffer, tail: Buffer | undefined): {
221
+ primary: boolean;
222
+ account: Record<string, unknown>;
223
+ };
224
+ /** Marks a Claude result whose session cost could not be read: the gateway charges the run's dollar cap instead. */
225
+ export declare const CLAUDE_COST_UNKNOWN = "standing_orders_cost_unknown";
205
226
  export declare function runStreamJsonl(file: string, args: readonly string[], options?: RunOptions): Promise<ExecResult>;
206
227
  /**
207
228
  * The streaming transport for gemini's stream-json dialect (Phase 3 D1/A7).
@@ -225,4 +246,15 @@ export declare function runStreamJsonl(file: string, args: readonly string[], op
225
246
  * semantics match `runStreamJsonl`.
226
247
  */
227
248
  export declare function runGeminiStreamJsonl(file: string, args: readonly string[], options?: RunOptions): Promise<ExecResult>;
249
+ /**
250
+ * A live session's account so far (one main result per turn, checked on Claude Code 2.1.290): the latest result's
251
+ * session, ending, words and cost — `total_cost_usd` is already the session's running total — with the usage token
252
+ * fields and `num_turns`, which count only their own message, added up across every main result. A field that is not
253
+ * a non-negative number adds nothing.
254
+ */
255
+ export declare function cumulativeLiveResult(previous: Record<string, unknown>, latest: Record<string, unknown>): Record<string, unknown>;
256
+ /** This transport can keep stdin open for a live session (RunOptions.liveInput). */
257
+ export declare const STREAM_INPUT: unique symbol;
258
+ /** Whether a runner declares it keeps stdin open for a live session; an injected runner opts in by carrying the mark. */
259
+ export declare const streamsInput: (runner: unknown) => boolean;
228
260
  export declare function runClaudeStreamJsonl(file: string, args: readonly string[], options?: RunOptions): Promise<ExecResult>;
package/dist/exec.js CHANGED
@@ -420,7 +420,10 @@ export const DEFAULT_MAX_BUFFER = 8 * 1024 * 1024;
420
420
  * watchdog with no wall-clock deadline. Both roads share the same kill and
421
421
  * settlement semantics.
422
422
  */
423
- function streamWatchdog(options, onTimeout) {
423
+ function streamWatchdog(options, onTimeout,
424
+ /** The idle limit was reached: true when the caller ended the wait its own way (a paused live session closes
425
+ * stdin), so the same limit runs again for the process to exit before it is killed. */
426
+ onIdle) {
424
427
  const hardMs = options.timeoutMs ?? (options.idleTimeoutMs === undefined ? DEFAULT_TIMEOUT_MS : undefined);
425
428
  const idleMs = options.idleTimeoutMs;
426
429
  let fired = false;
@@ -432,12 +435,25 @@ function streamWatchdog(options, onTimeout) {
432
435
  fired = true;
433
436
  onTimeout();
434
437
  };
438
+ const idle = () => {
439
+ let handled = false;
440
+ try {
441
+ handled = onIdle?.() === true;
442
+ }
443
+ catch {
444
+ handled = false;
445
+ }
446
+ if (handled)
447
+ armIdle();
448
+ else
449
+ fire();
450
+ };
435
451
  const armIdle = () => {
436
452
  if (idleMs === undefined)
437
453
  return;
438
454
  if (idleTimer !== undefined)
439
455
  clearTimeout(idleTimer);
440
- idleTimer = setTimeout(fire, idleMs);
456
+ idleTimer = setTimeout(idle, idleMs);
441
457
  idleTimer.unref?.();
442
458
  };
443
459
  if (hardMs !== undefined) {
@@ -725,14 +741,36 @@ const JSONL_OVERFLOW_PREFIX_CAP = 4 * 1024;
725
741
  * storage can be reused for the next record. Counting the original bytes
726
742
  * (rather than JavaScript characters) makes the overflow receipt exact.
727
743
  */
728
- function boundedJsonlFramer(cap, onLine, inspectDiscriminants = false) {
744
+ function boundedJsonlFramer(cap, onLine, inspectDiscriminants = false, tailCap = 0) {
729
745
  const retained = Buffer.allocUnsafe(cap);
730
746
  let retainedBytes = 0;
731
747
  let originalBytes = 0;
732
748
  let nonWhitespace = false;
733
749
  let discriminants = inspectDiscriminants ? jsonlDiscriminants() : null;
750
+ // An oversized line's last tailCap bytes, kept only once the line passes the cap (Claude's result carries its cost
751
+ // after the words that made it too large). Seeded from the retained prefix's end, so it is always the line's true end.
752
+ const tailSize = Math.max(0, Math.min(tailCap, cap));
753
+ const tail = tailSize === 0 ? null : Buffer.allocUnsafe(tailSize);
754
+ let tailBytes = 0;
755
+ let tailSeeded = false;
756
+ const keepTail = (part) => {
757
+ if (tail === null)
758
+ return;
759
+ if (!tailSeeded) {
760
+ tailSeeded = true;
761
+ tailBytes = retained.copy(tail, 0, retainedBytes - tailSize, retainedBytes);
762
+ }
763
+ if (part.length >= tailSize) {
764
+ tailBytes = part.copy(tail, 0, part.length - tailSize);
765
+ return;
766
+ }
767
+ const keepOld = Math.min(tailBytes, tailSize - part.length);
768
+ tail.copyWithin(0, tailBytes - keepOld, tailBytes);
769
+ tailBytes = keepOld + part.copy(tail, keepOld);
770
+ };
734
771
  const append = (part) => {
735
772
  discriminants?.push(part);
773
+ const before = originalBytes;
736
774
  originalBytes += part.length;
737
775
  if (!nonWhitespace) {
738
776
  for (const byte of part) {
@@ -745,6 +783,9 @@ function boundedJsonlFramer(cap, onLine, inspectDiscriminants = false) {
745
783
  if (retainedBytes < cap) {
746
784
  retainedBytes += part.copy(retained, retainedBytes, 0, cap - retainedBytes);
747
785
  }
786
+ // The bytes up to the cap are in the prefix; only what runs past it moves the tail.
787
+ if (tail !== null && originalBytes > cap)
788
+ keepTail(before >= cap ? part : part.subarray(cap - before));
748
789
  };
749
790
  const emit = () => {
750
791
  onLine({
@@ -752,7 +793,10 @@ function boundedJsonlFramer(cap, onLine, inspectDiscriminants = false) {
752
793
  bytesOriginal: originalBytes,
753
794
  overflowed: originalBytes > cap,
754
795
  ...(discriminants === null ? {} : { discriminants: discriminants.finish() }),
796
+ ...(tail !== null && originalBytes > cap ? { tail: tail.subarray(0, tailBytes) } : {}),
755
797
  });
798
+ tailBytes = 0;
799
+ tailSeeded = false;
756
800
  retainedBytes = 0;
757
801
  originalBytes = 0;
758
802
  nonWhitespace = false;
@@ -866,6 +910,85 @@ function leadingClaudeSystemSubtype(prefix) {
866
910
  const match = /^\s*\{\s*"type"\s*:\s*"system"\s*,\s*"subtype"\s*:\s*"([A-Za-z0-9._-]+)"/.exec(prefix.toString("utf8", 0, Math.min(prefix.length, 512)));
867
911
  return match?.[1] ?? null;
868
912
  }
913
+ /** How much of an oversized Claude result's end is kept to read its cost and usage (they follow the words). */
914
+ const CLAUDE_RESULT_TAIL_CAP = 64 * 1024;
915
+ /**
916
+ * Where a key's value starts in a fragment of JSON text, once and only once. `"key":` never occurs inside a JSON
917
+ * string (every quote there is escaped, so `y":` cannot appear), so each match is a real key, though possibly of a
918
+ * nested object. A key seen more than once is ambiguous and reads as absent.
919
+ */
920
+ function soleKeyValueAt(texts, key) {
921
+ const pattern = new RegExp(`"${key}"\\s*:\\s*`, "g");
922
+ let found = null;
923
+ for (const text of texts) {
924
+ for (const match of text.matchAll(pattern)) {
925
+ if (found !== null)
926
+ return null;
927
+ found = { text, at: match.index + match[0].length };
928
+ }
929
+ }
930
+ return found;
931
+ }
932
+ /** The JSON object starting at `at`, read to its matching brace (strings and escapes respected), or null. */
933
+ function jsonObjectAt(text, at) {
934
+ if (text[at] !== "{")
935
+ return null;
936
+ let depth = 0, inString = false;
937
+ for (let i = at; i < text.length && i - at < CLAUDE_RESULT_TAIL_CAP; i += 1) {
938
+ const c = text[i];
939
+ if (inString) {
940
+ if (c === "\\")
941
+ i += 1;
942
+ else if (c === '"')
943
+ inString = false;
944
+ }
945
+ else if (c === '"')
946
+ inString = true;
947
+ else if (c === "{")
948
+ depth += 1;
949
+ else if (c === "}" && --depth === 0) {
950
+ try {
951
+ const value = JSON.parse(text.slice(at, i + 1));
952
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
953
+ }
954
+ catch {
955
+ return null;
956
+ }
957
+ }
958
+ }
959
+ return null;
960
+ }
961
+ /**
962
+ * The accounting an oversized Claude result still carries, read by a bounded scan of its kept prefix and tail: the
963
+ * session's running `total_cost_usd`, its `usage` and `num_turns`, and whether it is a main result at all (an
964
+ * `origin` naming anything but a person makes it a background task's). Anything unreadable or seen twice is absent.
965
+ */
966
+ export function scanOversizedClaudeResult(prefix, tail) {
967
+ const texts = [prefix.toString("utf8"), ...(tail === undefined ? [] : [tail.toString("utf8")])];
968
+ const origin = soleKeyValueAt(texts, "origin");
969
+ const originObject = origin === null ? null : jsonObjectAt(origin.text, origin.at);
970
+ const primary = originObject === null || String(originObject["kind"] ?? "") === "human";
971
+ const account = {};
972
+ const number = (key) => {
973
+ const at = soleKeyValueAt(texts, key);
974
+ const match = at === null ? null : /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(at.text.slice(at.at, at.at + 64));
975
+ const value = match === null ? NaN : Number(match[0]);
976
+ return Number.isFinite(value) && value >= 0 ? value : null;
977
+ };
978
+ const cost = number("total_cost_usd");
979
+ if (cost !== null)
980
+ account["total_cost_usd"] = cost;
981
+ const turns = number("num_turns");
982
+ if (turns !== null)
983
+ account["num_turns"] = turns;
984
+ const usageAt = soleKeyValueAt(texts, "usage");
985
+ const usage = usageAt === null ? null : jsonObjectAt(usageAt.text, usageAt.at);
986
+ if (usage !== null)
987
+ account["usage"] = usage;
988
+ return { primary, account };
989
+ }
990
+ /** Marks a Claude result whose session cost could not be read: the gateway charges the run's dollar cap instead. */
991
+ export const CLAUDE_COST_UNKNOWN = "standing_orders_cost_unknown";
869
992
  function malformedCodexEventIsLoadBearing(prefix) {
870
993
  const type = leadingJsonObjectType(prefix);
871
994
  if (type === "thread.started" || type === "turn.completed" || type === "turn.failed")
@@ -1331,15 +1454,45 @@ function primaryResultOrigin(event) {
1331
1454
  return true;
1332
1455
  return false; // task-notification, channel, peer, coordinator, unknown: never the envelope
1333
1456
  }
1457
+ /** The usage fields Claude reports per message (not per session): a live session's account adds them up. */
1458
+ const LIVE_SUMMED_USAGE = ["input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"];
1459
+ /**
1460
+ * A live session's account so far (one main result per turn, checked on Claude Code 2.1.290): the latest result's
1461
+ * session, ending, words and cost — `total_cost_usd` is already the session's running total — with the usage token
1462
+ * fields and `num_turns`, which count only their own message, added up across every main result. A field that is not
1463
+ * a non-negative number adds nothing.
1464
+ */
1465
+ export function cumulativeLiveResult(previous, latest) {
1466
+ const count = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
1467
+ const usageOf = (event) => event["usage"] !== null && typeof event["usage"] === "object" && !Array.isArray(event["usage"]) ? event["usage"] : null;
1468
+ const before = usageOf(previous), now = usageOf(latest);
1469
+ const usage = { ...(now ?? {}) };
1470
+ for (const key of LIVE_SUMMED_USAGE) {
1471
+ const a = before === null ? null : count(before[key]), b = now === null ? null : count(now[key]);
1472
+ if (a !== null || b !== null)
1473
+ usage[key] = (a ?? 0) + (b ?? 0);
1474
+ }
1475
+ const turnsBefore = count(previous["num_turns"]), turnsNow = count(latest["num_turns"]);
1476
+ return {
1477
+ ...latest,
1478
+ ...(before === null && now === null ? {} : { usage }),
1479
+ ...(turnsBefore === null && turnsNow === null ? {} : { num_turns: (turnsBefore ?? 0) + (turnsNow ?? 0) }),
1480
+ };
1481
+ }
1482
+ /** This transport can keep stdin open for a live session (RunOptions.liveInput). */
1483
+ export const STREAM_INPUT = Symbol.for("toolroll.streamInput");
1484
+ /** Whether a runner declares it keeps stdin open for a live session; an injected runner opts in by carrying the mark. */
1485
+ export const streamsInput = (runner) => typeof runner === "function" && runner[STREAM_INPUT] === true;
1334
1486
  export function runClaudeStreamJsonl(file, args, options = {}) {
1335
1487
  const { cwd } = options;
1336
1488
  const childEnv = resolveChildEnv(options);
1489
+ const live = options.liveInput ?? null;
1337
1490
  return new Promise(resolve => {
1338
1491
  let child;
1339
1492
  let container = null;
1340
1493
  let attachFailure = null;
1341
1494
  try {
1342
- const spawned = spawnStream(file, args, options, ["ignore", "pipe", "pipe"], childEnv);
1495
+ const spawned = spawnStream(file, args, options, [live === null ? "ignore" : "pipe", "pipe", "pipe"], childEnv);
1343
1496
  child = spawned.child;
1344
1497
  container = spawned.container;
1345
1498
  void spawned.attached.then(outcome => { if (!outcome.ok)
@@ -1359,6 +1512,12 @@ export function runClaudeStreamJsonl(file, args, options = {}) {
1359
1512
  let conflictingInitLine = null;
1360
1513
  let initSessionId = null;
1361
1514
  let resultLine = null;
1515
+ let resultOverflowed = false;
1516
+ // The run's account: the first main result, or for a live session every main result added up (each oversized
1517
+ // one's scanned cost and usage too). Its cost is the highest running total seen; costUnknown when the latest main
1518
+ // result's cost could not be read (an oversized one), which only a later readable result can clear.
1519
+ let account = null;
1520
+ let costUnknown = false;
1362
1521
  let overflowLine = null;
1363
1522
  let receiptFired = false;
1364
1523
  let stderr = "";
@@ -1380,6 +1539,26 @@ export function runClaudeStreamJsonl(file, args, options = {}) {
1380
1539
  // note stays honestly unreceipted and re-attaches later.
1381
1540
  }
1382
1541
  };
1542
+ const tally = (event, line) => {
1543
+ const before = account;
1544
+ account = account === null || live === null ? event : { ...(line === null ? account : {}), ...cumulativeLiveResult(account, event) };
1545
+ const cost = event["total_cost_usd"];
1546
+ const readable = typeof cost === "number" && Number.isFinite(cost) && cost >= 0;
1547
+ // The running total only grows: an earlier, higher total stands (a session's cost is never given back).
1548
+ const prior = before?.["total_cost_usd"];
1549
+ if (readable && typeof prior === "number" && prior > cost)
1550
+ account = { ...account, total_cost_usd: prior };
1551
+ if (line === null)
1552
+ costUnknown = !readable;
1553
+ else if (readable)
1554
+ costUnknown = false;
1555
+ if (resultOverflowed)
1556
+ return;
1557
+ if (line !== null && account === event)
1558
+ resultLine = line;
1559
+ else if (line !== null || resultLine !== null)
1560
+ resultLine = JSON.stringify(account);
1561
+ };
1383
1562
  const keep = (framed) => {
1384
1563
  if (framed.overflowed) {
1385
1564
  // An oversized result is the first primary candidate and therefore
@@ -1404,10 +1583,22 @@ export function runClaudeStreamJsonl(file, args, options = {}) {
1404
1583
  overflowLine = claudeOverflowLine(framed);
1405
1584
  return;
1406
1585
  }
1407
- if (resultLine === null)
1586
+ // Nothing inside an oversized result is trusted: the agent shapes its bytes (tool inputs in permission_denials
1587
+ // are real JSON objects), so a planted total_cost_usd or origin could read as genuine. It counts as the main
1588
+ // result with an unknown cost, charged at the run's cap, and a live session's input closes.
1589
+ const scanned = { primary: true, account: {} };
1590
+ if (live !== null || resultLine === null)
1591
+ tally(scanned.account, null);
1592
+ if (resultLine === null) {
1408
1593
  resultLine = claudeOverflowLine(framed);
1594
+ resultOverflowed = true;
1595
+ }
1409
1596
  else if (overflowLine === null)
1410
1597
  overflowLine = claudeOverflowLine(framed);
1598
+ // A live session whose main result was too large to read cannot be steered safely: close its input, so the
1599
+ // process ends instead of running further turns nobody can account for.
1600
+ if (live !== null && scanned.primary)
1601
+ live.close("settled");
1411
1602
  return;
1412
1603
  }
1413
1604
  const line = framed.prefix.toString("utf8");
@@ -1458,8 +1649,11 @@ export function runClaudeStreamJsonl(file, args, options = {}) {
1458
1649
  // v105: the plan's usage windows, as Claude says them each turn (the Tasks page's limits).
1459
1650
  noteLimits(claudeLimitsOf(event));
1460
1651
  }
1461
- else if (type === "result" && resultLine === null && primaryResultOrigin(event)) {
1462
- resultLine = line;
1652
+ else if (type === "result" && (resultLine === null || live !== null) && primaryResultOrigin(event)) {
1653
+ // A live session ends a turn with a result each time: the run's account is the last one, with every main
1654
+ // result's usage and turns added up (account). After an oversized main result the retained result stays the
1655
+ // fail-closed overflow, but later results still count toward its cost.
1656
+ tally(event, line);
1463
1657
  // Fallback receipt (finding 11): a SUCCESSFUL main-query completion
1464
1658
  // entails the prompt ran even if the stream elided assistant
1465
1659
  // events. Error results never fire — startup death is not delivery.
@@ -1474,15 +1668,32 @@ export function runClaudeStreamJsonl(file, args, options = {}) {
1474
1668
  // Observational only: a broken listener never touches the run.
1475
1669
  }
1476
1670
  }
1671
+ // After the run's own bookkeeping: the session may close stdin on this event.
1672
+ if (live !== null)
1673
+ live.observe(event);
1477
1674
  };
1478
- const framing = boundedJsonlFramer(JSONL_EVENT_HARD_CAP, keep);
1675
+ // The retained main result as the run's account: an oversized one stays the fail-closed overflow but carries the
1676
+ // cost and usage read from it and from later results; a cost that could not be read says so (CLAUDE_COST_UNKNOWN).
1677
+ const accountedResultLine = () => {
1678
+ if (resultLine === null || (!resultOverflowed && !costUnknown))
1679
+ return resultLine;
1680
+ const kept = JSON.parse(resultLine);
1681
+ const counted = {};
1682
+ if (resultOverflowed && account !== null) {
1683
+ for (const key of ["total_cost_usd", "usage", "num_turns"])
1684
+ if (account[key] !== undefined)
1685
+ counted[key] = account[key];
1686
+ }
1687
+ return JSON.stringify({ ...kept, ...counted, ...(costUnknown ? { [CLAUDE_COST_UNKNOWN]: true } : {}) });
1688
+ };
1689
+ const framing = boundedJsonlFramer(JSONL_EVENT_HARD_CAP, keep, false, CLAUDE_RESULT_TAIL_CAP);
1479
1690
  const watchdog = streamWatchdog(options, () => {
1480
1691
  timedOut = true;
1481
1692
  if (options.processGroup === true)
1482
1693
  killGroup(child);
1483
1694
  else
1484
1695
  child.kill("SIGKILL");
1485
- });
1696
+ }, live === null ? undefined : () => live.idleLimit());
1486
1697
  child.stdout?.on("data", (chunk) => {
1487
1698
  watchdog.touch();
1488
1699
  framing.push(chunk);
@@ -1501,7 +1712,8 @@ export function runClaudeStreamJsonl(file, args, options = {}) {
1501
1712
  watchdog.stop();
1502
1713
  liveProviders.delete(child);
1503
1714
  framing.finish();
1504
- const lines = [initLine, initIdentityLine, conflictingInitLine, resultLine, overflowLine]
1715
+ live?.detach();
1716
+ const lines = [initLine, initIdentityLine, conflictingInitLine, accountedResultLine(), overflowLine]
1505
1717
  .filter((one) => one !== null)
1506
1718
  .filter((line, index, all) => all.indexOf(line) === index);
1507
1719
  const settlement = container === null ? Promise.resolve(null) : settleContainer(container, options);
@@ -1521,5 +1733,12 @@ export function runClaudeStreamJsonl(file, args, options = {}) {
1521
1733
  finish(null);
1522
1734
  });
1523
1735
  child.on("close", code => finish(code));
1736
+ if (live !== null && child.stdin !== null) {
1737
+ const input = child.stdin;
1738
+ // A pipe the child closed (it exited) ends live input; the run settles on the process, as always.
1739
+ input.on("error", () => live.detach("input-failed"));
1740
+ live.attach({ write: line => { input.write(line, "utf8"); }, end: () => { input.end(); } });
1741
+ }
1524
1742
  });
1525
1743
  }
1744
+ runClaudeStreamJsonl[STREAM_INPUT] = true;