toolroll 0.9.73 → 0.9.74

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 (84) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/dist/agent-turns.js +5 -0
  4. package/dist/agents-live.d.ts +31 -0
  5. package/dist/agents-live.js +43 -0
  6. package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
  7. package/dist/browser/workspace.css +2 -2
  8. package/dist/browser/workspace.js +31 -30
  9. package/dist/browser-workspace.d.ts +6 -0
  10. package/dist/builder.js +1 -1
  11. package/dist/chat-continuity.js +10 -0
  12. package/dist/chat-quiet.js +3 -3
  13. package/dist/deploy-gate.js +5 -2
  14. package/dist/desktop-update.d.ts +1 -1
  15. package/dist/dispose.js +35 -2
  16. package/dist/exec.js +13 -16
  17. package/dist/first-run.d.ts +17 -1
  18. package/dist/first-run.js +38 -0
  19. package/dist/invoke.d.ts +13 -0
  20. package/dist/invoke.js +21 -12
  21. package/dist/knowledge-ui.d.ts +4 -1
  22. package/dist/knowledge-ui.js +10 -5
  23. package/dist/lead-progress.d.ts +4 -1
  24. package/dist/lead-progress.js +3 -1
  25. package/dist/lead.js +4 -0
  26. package/dist/live-bus.d.ts +2 -0
  27. package/dist/live-bus.js +1 -0
  28. package/dist/live-children-read.d.ts +52 -0
  29. package/dist/live-children-read.js +333 -0
  30. package/dist/live-children.d.ts +73 -0
  31. package/dist/live-children.js +318 -0
  32. package/dist/live-detail.d.ts +4 -3
  33. package/dist/live-detail.js +9 -5
  34. package/dist/live-input.js +7 -8
  35. package/dist/live-main-read.d.ts +19 -0
  36. package/dist/live-main-read.js +91 -0
  37. package/dist/live-open.d.ts +28 -0
  38. package/dist/live-open.js +125 -0
  39. package/dist/live-records.d.ts +10 -1
  40. package/dist/live-records.js +16 -5
  41. package/dist/live.d.ts +54 -22
  42. package/dist/live.js +218 -69
  43. package/dist/operate.js +3 -2
  44. package/dist/peek-cli.d.ts +10 -3
  45. package/dist/peek-cli.js +17 -8
  46. package/dist/planner.js +1 -1
  47. package/dist/provider-limits.d.ts +3 -1
  48. package/dist/provider-limits.js +5 -2
  49. package/dist/provider.js +6 -12
  50. package/dist/release-adapters.js +4 -0
  51. package/dist/release.d.ts +3 -0
  52. package/dist/release.js +3 -0
  53. package/dist/repository-context-ui.js +2 -1
  54. package/dist/run-efficiency.d.ts +3 -3
  55. package/dist/run-efficiency.js +20 -8
  56. package/dist/scout.d.ts +3 -0
  57. package/dist/scout.js +5 -3
  58. package/dist/serve.js +34 -23
  59. package/dist/server/agents-live-lead.d.ts +47 -0
  60. package/dist/server/agents-live-lead.js +116 -0
  61. package/dist/server/agents-live-room.d.ts +23 -0
  62. package/dist/server/agents-live-room.js +88 -0
  63. package/dist/server/chat.js +84 -30
  64. package/dist/server/chrome.js +2 -2
  65. package/dist/server/console.css +68 -40
  66. package/dist/server/live.d.ts +4 -0
  67. package/dist/server/live.js +42 -2
  68. package/dist/server/pages.js +81 -1
  69. package/dist/server/render-chat.d.ts +15 -1
  70. package/dist/server/render-chat.js +14 -7
  71. package/dist/server/render-pages.d.ts +8 -4
  72. package/dist/server/render-pages.js +55 -50
  73. package/dist/server/render-tasks.js +10 -3
  74. package/dist/server/route-table.js +5 -1
  75. package/dist/server/runtime.d.ts +1 -1
  76. package/dist/server/settings.js +3 -3
  77. package/dist/server/task-views.js +1 -1
  78. package/dist/server/tasks.js +59 -41
  79. package/dist/store.d.ts +31 -1
  80. package/dist/store.js +75 -2
  81. package/dist/stream-scope.d.ts +18 -0
  82. package/dist/stream-scope.js +28 -0
  83. package/dist/subscription-chat.js +5 -3
  84. package/package.json +1 -1
@@ -211,6 +211,10 @@ export type BrowserTasksView = {
211
211
  newTask: BrowserLink;
212
212
  /** v105: subscription windows and monthly budgets, one tile each (an instance operator's; null when there are none). */
213
213
  limits: BrowserLimits | null;
214
+ /** The stored-state filter (Any state, Queued, Running, Done, Failed, Cancelled), each a real link starting on page one. */
215
+ states?: (BrowserLink & {
216
+ active: boolean;
217
+ })[];
214
218
  };
215
219
  /** One limit: a plan's usage window ("Claude · 5-hour, 48%") or a monthly budget ("shop · Budget, $4.20 of $10"). */
216
220
  export type BrowserTaskGroup = WorkIndexGroup;
@@ -1445,6 +1449,8 @@ export type BrowserAgentsView = {
1445
1449
  selected: BrowserAgentDetail | null;
1446
1450
  /** Shown when nothing is on the board. */
1447
1451
  empty: string | null;
1452
+ /** The Live grid (?view=live): every running agent's live output, watching only. */
1453
+ live?: boolean;
1448
1454
  };
1449
1455
  export type BrowserView = BrowserTasksView | BrowserSettingsView | BrowserTaskView | BrowserProjectsView | BrowserResultView | BrowserFlowView | BrowserAgentsView;
1450
1456
  export type BrowserWorkspace = {
package/dist/builder.js CHANGED
@@ -1240,7 +1240,7 @@ export async function build(store, request) {
1240
1240
  // Every streaming transport emits events now (peek); a file that cannot
1241
1241
  // open is a null, and a null never costs a build.
1242
1242
  // What the agent did last rides the same stream into one row (live tasks).
1243
- const liveLog = withAgentTurns(withRunActivity(openLiveLog(root, request.runId, liveLogContext(store, taskRef, worktree)), store, request.runId, clock), (state, at) => { if (attemptKey !== null)
1243
+ const liveLog = withAgentTurns(withRunActivity(openLiveLog(root, request.runId, liveLogContext(store, request.runId, taskRef, worktree, clock())), store, request.runId, clock), (state, at) => { if (attemptKey !== null)
1244
1244
  recordAgentTurn(store, { run: request.runId, attemptKey, state, at }); }, clock, (open, at) => { if (attemptKey !== null)
1245
1245
  noteOpenTool(store, request.runId, attemptKey, open, at); });
1246
1246
  // The retry base (steering fix for run 1465's proof): the SAME pinned
@@ -125,6 +125,8 @@ export const CHAT_CONTINUITY_SCRIPT = String.raw `
125
125
  if(got===null)return;
126
126
  var data=got.data||{};
127
127
  if(data.ok===true){
128
+ // A conversation shown before it existed (session 0) is started by its first send: that is this one.
129
+ if(session==='0'&&typeof data.session==='number'&&data.session>0)adopt(String(data.session));
128
130
  if(String(data.session)!==session){changed();return;}
129
131
  say(data.queued===true?'Queued. It goes to the lead after the reply in progress.':'Sent. Waiting for the reply…');later(300);return;
130
132
  }
@@ -273,6 +275,9 @@ export const CHAT_CONTINUITY_SCRIPT = String.raw `
273
275
  }
274
276
  return true;
275
277
  }
278
+ // The conversation this page's first send started: its binding and its draft move to it (a reload restores from there).
279
+ function adopt(started){session=started;document.querySelectorAll('form[action="/chat"] [name="request-session"]').forEach(function(bound){bound.value=started;});
280
+ try{sessionStorage.removeItem(key);}catch(e){}key='standing-orders:chat-draft:'+session+':'+task;save();}
276
281
  function later(ms){if(stale)return;clearTimeout(timer);timer=setTimeout(check,ms);}
277
282
  async function check(){
278
283
  if(polling||stale)return;
@@ -289,6 +294,11 @@ export const CHAT_CONTINUITY_SCRIPT = String.raw `
289
294
  if(!r.ok)throw new Error('connection');
290
295
  var data=await r.json();
291
296
  if(!valid(data))throw new Error('response');
297
+ // Not started yet (session 0): only this send's own receipt in the started conversation proves it is the one.
298
+ if(session==='0'&&typeof data.session==='number'&&data.session>0&&asked&&data.received===true)adopt(String(data.session));
299
+ // A poll that lands while the first send is on its way decides nothing: that send's answer (or the poll it
300
+ // asks for afterwards, with its request) says whether the started conversation is this page's.
301
+ if(session==='0'&&sending&&data.session!==null&&String(data.session)!=='0'){later(1000);return;}
292
302
  if(data.session===null||String(data.session)!==session){changed();return;}
293
303
  // A late answer for another lens never lands here; the poll goes on.
294
304
  if(data.task!==task){later(5000);return;}
@@ -101,7 +101,7 @@ export function quietCardView(store, taskRefs, now, root, viewer) {
101
101
  const heading = `${lines.length} tasks`;
102
102
  const projects = [...new Set(lines.map(one => projectLabel(one.project)))];
103
103
  const text = [heading, ...lines.map(one => `${one.icon} ${phoneText(one.title, 60)} · ${one.status}`), "", projects.join(", ")].join("\n");
104
- return { text, entities: [{ type: "bold", offset: 0, length: heading.length }], link: { label: "Open tasks", path: "/tasks" } };
104
+ return { text, entities: [{ type: "bold", offset: 0, length: heading.length }], link: { label: "Open tasks", path: "/work" } };
105
105
  }
106
106
  /** Facts that carry their own controls or must stand alone (a security alert, a decision, a flow card, a
107
107
  * screenshot, an acceptance packet) are never folded into a batch. */
@@ -176,7 +176,7 @@ export function finishedView(store, batch, now, root, viewer) {
176
176
  return null;
177
177
  const text = batchLine(lines.map(one => one.fact));
178
178
  if (lines.length > 1)
179
- return { text, entities: [], link: { label: "Open", path: "/tasks" } };
179
+ return { text, entities: [], link: { label: "Open", path: "/work" } };
180
180
  const target = lines[0].target ?? null;
181
181
  return { text, entities: [], link: lines[0].link, ...(lines[0].also.length === 0 ? {} : { also: lines[0].also }), ...(target === null ? {} : { target }) };
182
182
  }
@@ -242,7 +242,7 @@ export function enqueueEveningDigests(store, now, root) {
242
242
  if (body === null)
243
243
  return;
244
244
  if (store.enqueueNotification({ dedupeKey: `digest:evening:${person.account}:${day}`, kind: "evening-digest", subject: "Evening digest", body,
245
- link: "/tasks", source: { installation: true }, recipient: person.account }, now))
245
+ link: "/work", source: { installation: true }, recipient: person.account }, now))
246
246
  queued++;
247
247
  });
248
248
  }
@@ -18,8 +18,11 @@ export function deploymentGate(store, input) {
18
18
  if (run === null || ref === null || head === null)
19
19
  return refuse(`Run ${input.runId} is not a built candidate.`);
20
20
  // `task regate` reruns the approved check on the last built commit and records no-change: a candidate only for that commit.
21
- const regated = run.outcome === "no-change" && scope?.candidate === head &&
22
- store.runsFor(run.taskRef).find(one => one.id < input.runId && one.role === "builder" && one.outcome === "built")?.headRevision === head;
21
+ // A prepared candidate's build records a commit of its own holding exactly the candidate's tree; its rerun under the same
22
+ // signed scope is the same check of that commit (the digest is held to the approved one below).
23
+ const built = store.runsFor(run.taskRef).find(one => one.id < input.runId && one.role === "builder" && one.outcome === "built");
24
+ const regated = run.outcome === "no-change" && built?.headRevision === head &&
25
+ (scope?.candidate === head || ((scope?.candidate ?? null) !== null && built.scopeDigest === run.scopeDigest));
23
26
  if (run.role !== "builder" || !(run.outcome === "built" || regated))
24
27
  return refuse(`Run ${input.runId} is not a built candidate at ${head.slice(0, 7)}.`);
25
28
  if (scope === null || scope.approvedDigest !== scope.digest || scope.digest !== run.scopeDigest)
@@ -94,6 +94,7 @@ 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;
97
98
  active: boolean;
98
99
  phase: string;
99
100
  detail: string;
@@ -107,7 +108,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
107
108
  wasRunning?: never;
108
109
  backupPath?: never;
109
110
  updateId?: never;
110
- version?: never;
111
111
  buildId?: never;
112
112
  workDir?: never;
113
113
  } | {
package/dist/dispose.js CHANGED
@@ -23,7 +23,7 @@ import { nightlyTaskRef, releaseTaskRef } from "./store.js";
23
23
  import {} from "./proof.js";
24
24
  import { verificationEvidence, failedVerificationEvidence } from "./verification-evidence.js";
25
25
  import { classifyGateFailure, describeGateFailure, gateFailureSummary } from "./gate-failure.js";
26
- import { propose, approve } from "./scope.js";
26
+ import { propose, approve, liveReleaseOf } from "./scope.js";
27
27
  import { headWithin } from "./names.js";
28
28
  /** The reasons tick classifies as the attempt itself breaking. */
29
29
  const TICK_FAILURE_REASONS = new Set([
@@ -456,6 +456,35 @@ export function regateTask(store, taskId, now, approval) {
456
456
  return { ok: false, reason: "no-attempt", message: `${taskId} has no scope` };
457
457
  const head = last.headRevision;
458
458
  const actor = approval.kind === "operator" ? approval.name : `mode ${approval.name}`;
459
+ // A prepared candidate (a release gate, the nightly check) already names the approved commit, and the builder loads
460
+ // exactly its tree on every attempt. The rerun keeps that scope and its approval as they are: nothing is proposed or
461
+ // sealed again, so a regate can never swap the approved commit, its release terms or the person's yes for others
462
+ // (2026-10-27: a regate rewrote release-0.9.73's candidate to its own build commit and the release refused it).
463
+ if ((scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null) {
464
+ const candidate = scope.candidate ?? null;
465
+ if (candidate === null || scope.approvedDigest !== scope.digest || last.scopeDigest !== scope.digest) {
466
+ return { ok: false, reason: "not-approved", message: `${taskId}'s approved commit or scope changed since attempt #${last.id}, or its approval no longer stands; approve its scope again instead` };
467
+ }
468
+ // A release's task runs again only while its one approval still stands for it (the dispatch would refuse it anyway).
469
+ const release = liveReleaseOf(store, taskId, "dispatch", now);
470
+ if (release.applies && !release.ok)
471
+ return { ok: false, reason: "not-approved", message: `${taskId} can't run again: ${release.why}` };
472
+ // Any other seal stands only while its authority does (an expired mode, a revoked approver): the queue would never
473
+ // dispatch it, so the regate says so instead of queueing a rerun that can't start.
474
+ if (!store.modeApprovalLive(ref.id, now))
475
+ return { ok: false, reason: "not-approved", message: `${taskId}'s approval no longer stands; approve its scope again instead` };
476
+ return store.transact(() => {
477
+ if (store.getTask(taskId)?.state !== "queued") {
478
+ const queued = store.requeueTask(taskId, actor, now);
479
+ if (!queued.ok) {
480
+ const why = { claimed: "claimed", "accepted-result": "accepted-result", published: "published" };
481
+ return { ok: false, reason: why[queued.reason] ?? "not-rejected", message: `${taskId} could not be queued again: ${queued.reason}` };
482
+ }
483
+ }
484
+ store.addRunNote(last.id, "Toolroll", `The check will run again on the approved commit ${candidate.slice(0, 7)}, under the same approval, as a new attempt, ${approval.kind === "operator" ? `on ${approval.name}'s say-so` : "automatically once, because the failure was outside this change"}.`, now);
485
+ return { ok: true, run: last.id, head: candidate };
486
+ });
487
+ }
459
488
  return store.transact(() => {
460
489
  const proposed = propose(store, {
461
490
  taskId, goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd,
@@ -520,7 +549,11 @@ export function maybeTriggerRepair(store, repo, evidenceRoot, sourceRunId, verdi
520
549
  // to a person. No repair draft, no third pass, on either road.
521
550
  {
522
551
  const rerunTask = store.refById(run.taskRef)?.externalId ?? "";
523
- if (run.headRevision && store.getScope(rerunTask)?.candidate === run.headRevision && store.builderAttemptsAt(run.taskRef, run.headRevision) >= 2) {
552
+ // A prepared candidate's attempts each record their own commit of the candidate's tree, so a rerun under the same
553
+ // approved scope is the same commit's check even when its head isn't the candidate itself.
554
+ const rerunScope = store.getScope(rerunTask);
555
+ const sameCommit = rerunScope !== null && (rerunScope.candidate === run.headRevision || ((rerunScope.candidate ?? null) !== null && rerunScope.digest === run.scopeDigest));
556
+ if (run.headRevision && sameCommit && store.builderAttemptsAt(run.taskRef, run.headRevision) >= 2) {
524
557
  const contradicted = cause === "review";
525
558
  store.enqueueNotification({ source: { run: sourceRunId }, dedupeKey: `regate-failed:${sourceRunId}`, kind: "repair-evidence", pushClass: "attention",
526
559
  subject: contradicted ? "The rerun on the same commit was contradicted by its review" : "The project check failed again on the same commit",
package/dist/exec.js CHANGED
@@ -16,6 +16,7 @@ import { jsonlDiscriminants } from "./jsonl-discriminants.js";
16
16
  import { createContainer, currentContainment } from "./containment.js";
17
17
  import { claudeLimitsOf, noteLimits } from "./provider-limits.js";
18
18
  import { sleep as defaultSleep } from "./sleep.js";
19
+ import { primaryResultEvent, topLevelEvent } from "./stream-scope.js";
19
20
  /** Shell convention for "could not be executed": the refusal before any target ran. */
20
21
  export const CONTAINMENT_REFUSED_CODE = 126;
21
22
  /** Live provider children, for the deterministic stop. Registered only when `processGroup` was set. */
@@ -1760,23 +1761,17 @@ function syntheticMessageLine(content, truncated) {
1760
1761
  *
1761
1762
  * - a bounded identity witness for `system`/`init`: the first init signal,
1762
1763
  * the first usable session id, and one conflicting id
1763
- * - the FIRST primary `result` event, selected STRUCTURALLY (finding 10):
1764
- * origin absent or `origin.kind === "human"` — an allowlist, so a
1765
- * background task's result (or any future origin kind) can never be
1766
- * mistaken for the main query's accounting. Later results are dropped.
1764
+ * - the FIRST primary `result` event, selected STRUCTURALLY (finding 10;
1765
+ * stream-scope.ts `primaryResultEvent`): top-level, and origin absent or
1766
+ * `origin.kind === "human"` — an allowlist, so a subagent's or background
1767
+ * task's result (or any future origin kind) can never be mistaken for the
1768
+ * main query's accounting. Later results are dropped. A subagent's init
1769
+ * never enters the identity witness.
1767
1770
  *
1768
1771
  * The result's stdout is the retained lines joined — a synthetic, bounded
1769
1772
  * envelope `claudeParse` reads exactly like test fixtures. Timeout,
1770
1773
  * process-group, kill, and spawn semantics match `runStreamJsonl`.
1771
1774
  */
1772
- function primaryResultOrigin(event) {
1773
- const origin = event["origin"];
1774
- if (origin === undefined || origin === null)
1775
- return true;
1776
- if (typeof origin === "object" && String(origin["kind"] ?? "") === "human")
1777
- return true;
1778
- return false; // task-notification, channel, peer, coordinator, unknown: never the envelope
1779
- }
1780
1775
  /** The usage fields Claude reports per message (not per session): a live session's account adds them up. */
1781
1776
  const LIVE_SUMMED_USAGE = ["input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"];
1782
1777
  /**
@@ -1934,7 +1929,10 @@ export function runClaudeStreamJsonl(file, args, options = {}) {
1934
1929
  if (event === null || typeof event !== "object")
1935
1930
  return;
1936
1931
  const type = String(event["type"] ?? "");
1937
- if (type === "system" && String(event["subtype"] ?? "") === "init") {
1932
+ // A subagent's init, words and result are not the session's (stream-scope.ts): they never witness its identity,
1933
+ // name its session, fire its receipt or stand as its result. They still reach the observers below, which filter
1934
+ // for themselves.
1935
+ if (type === "system" && String(event["subtype"] ?? "") === "init" && topLevelEvent(event)) {
1938
1936
  if (initLine === null)
1939
1937
  initLine = line;
1940
1938
  const id = transportSessionId(event["session_id"]);
@@ -1963,15 +1961,14 @@ export function runClaudeStreamJsonl(file, args, options = {}) {
1963
1961
  // Top-level only (finding 11): assistant events carry no origin;
1964
1962
  // parent_tool_use_id is the correlation field, and a subagent's
1965
1963
  // words prove nothing about the main prompt.
1966
- const parent = event["parent_tool_use_id"];
1967
- if (parent === undefined || parent === null)
1964
+ if (topLevelEvent(event))
1968
1965
  fireReceipt();
1969
1966
  }
1970
1967
  else if (type === "rate_limit_event") {
1971
1968
  // v105: the plan's usage windows, as Claude says them each turn (the Tasks page's limits).
1972
1969
  noteLimits(claudeLimitsOf(event));
1973
1970
  }
1974
- else if (type === "result" && (resultLine === null || live !== null) && primaryResultOrigin(event)) {
1971
+ else if (type === "result" && (resultLine === null || live !== null) && primaryResultEvent(event)) {
1975
1972
  // A live session ends a turn with a result each time: the run's account is the last one, with every main
1976
1973
  // result's usage and turns added up (account). After an oversized main result the retained result stays the
1977
1974
  // fail-closed overflow, but later results still count toward its cost.
@@ -77,10 +77,13 @@ export declare function firstResultWords(since: string, at: string): string | nu
77
77
  export declare const JOURNEY_STEPS: readonly [readonly ["plan", "Plan"], readonly ["approve", "You approve"], readonly ["build", "Build"], readonly ["checks", "Checks"], readonly ["ready", "Ready"]];
78
78
  export type JourneyKey = (typeof JOURNEY_STEPS)[number][0];
79
79
  /** `stuck`: the step it stopped at (Failed, Stopped, or waiting on something other than the approval). */
80
+ /** `skipped`: a stage the task went past without it happening (its checks didn't run or couldn't be read). `needsYou`:
81
+ * the current or stuck step waits on a person, so it is the one step drawn amber. */
80
82
  export type JourneyStep = {
81
83
  key: JourneyKey;
82
84
  label: string;
83
- state: "done" | "current" | "stuck" | "todo";
85
+ state: "done" | "current" | "stuck" | "skipped" | "todo";
86
+ needsYou?: true;
84
87
  };
85
88
  /**
86
89
  * Where the first task stands on Plan → You approve → Build → Checks → Ready, from the one stage every surface reads
@@ -91,4 +94,17 @@ export declare function firstTaskJourney(read: {
91
94
  stage: "queued" | "planning" | "needs-you" | "waiting" | "building" | "checking" | "reviewing" | "finished" | "complete" | "failed" | "stopped";
92
95
  need?: string | undefined;
93
96
  }, approved: boolean, built?: boolean): JourneyStep[];
97
+ /** What a task's checks recorded, for its road: `null` when nothing says (the step is then shown skipped, never done). */
98
+ export type RoadChecks = "passed" | "running" | "failed" | "not-run" | "unread" | null;
99
+ /**
100
+ * Any task's road (the read-only stepper on its page, after ReUI's Stepper): the first task's five steps, told only
101
+ * from recorded facts. You approve is done only when the plan was approved (a rewritten plan waiting on approval
102
+ * stands there again); a task that never needed one shows it skipped. Checks is done only when a check passed; a
103
+ * running, failed, missing or unread check says so, and with no checks fact it is skipped. A research report has
104
+ * no Checks step: a report stopped there stops at Ready. The step that waits on a person carries `needsYou`.
105
+ */
106
+ export declare function taskRoad(read: Parameters<typeof firstTaskJourney>[0], approved: boolean, built: boolean, facts: {
107
+ report?: boolean;
108
+ checks?: RoadChecks;
109
+ }): JourneyStep[];
94
110
  export {};
package/dist/first-run.js CHANGED
@@ -141,3 +141,41 @@ export function firstTaskJourney(read, approved, built = false) {
141
141
  return JOURNEY_STEPS.map(([key, label], index) => ({ key, label,
142
142
  state: index < at.index ? "done" : index === at.index ? (at.stuck ? "stuck" : "current") : "todo" }));
143
143
  }
144
+ /**
145
+ * Any task's road (the read-only stepper on its page, after ReUI's Stepper): the first task's five steps, told only
146
+ * from recorded facts. You approve is done only when the plan was approved (a rewritten plan waiting on approval
147
+ * stands there again); a task that never needed one shows it skipped. Checks is done only when a check passed; a
148
+ * running, failed, missing or unread check says so, and with no checks fact it is skipped. A research report has
149
+ * no Checks step: a report stopped there stops at Ready. The step that waits on a person carries `needsYou`.
150
+ */
151
+ export function taskRoad(read, approved, built, facts) {
152
+ const replan = read.stage === "needs-you" && read.need === "replan";
153
+ const plainApproved = approved && !replan;
154
+ let steps = firstTaskJourney(replan ? { stage: "needs-you", need: "approval" } : read, plainApproved, built);
155
+ steps = steps.map(step => {
156
+ if (step.key === "approve" && step.state === "done" && !plainApproved)
157
+ return { ...step, state: "skipped" };
158
+ if (step.key !== "checks" || (step.state !== "done" && step.state !== "current"))
159
+ return step;
160
+ // At Checks now: only a recorded failure or a running check changes how it reads.
161
+ if (step.state === "current")
162
+ return facts.checks === "failed" ? { ...step, state: "stuck", label: "Checks failed" } : facts.checks === "running" ? { ...step, label: "Checks running" } : step;
163
+ switch (facts.checks ?? null) {
164
+ case "passed": return step;
165
+ case "running": return { ...step, state: "current", label: "Checks running" };
166
+ case "failed": return { ...step, state: "stuck", label: "Checks failed" };
167
+ case "not-run": return { ...step, state: "skipped", label: "Checks not run" };
168
+ case "unread": return { ...step, state: "skipped", label: "Checks unread" };
169
+ default: return { ...step, state: "skipped" };
170
+ }
171
+ });
172
+ if (facts.report === true) {
173
+ const checks = steps.find(step => step.key === "checks");
174
+ if (checks !== undefined && (checks.state === "current" || checks.state === "stuck")) {
175
+ steps = steps.map(step => step.key === "ready" ? { ...step, state: checks.state } : step);
176
+ }
177
+ steps = steps.filter(step => step.key !== "checks");
178
+ }
179
+ const waiting = read.stage === "needs-you";
180
+ return steps.map(step => waiting && (step.state === "current" || step.state === "stuck") ? { ...step, needsYou: true } : step);
181
+ }
package/dist/invoke.d.ts CHANGED
@@ -190,3 +190,16 @@ export declare function checkoutRunner(store: Store, repo: string, checkout: str
190
190
  export declare function checkoutSandboxOf(store: Store, repo: string, runIds: readonly number[]): LaunchSandbox;
191
191
  /** The digest of a launch's whole fence (its denied paths and grants, canonical), as stamped on its run (C11). */
192
192
  export declare function fenceDigest(fence: readonly string[], grants: FenceGrants): string;
193
+ /** What a Claude turn's own stream says about how it billed (v105). */
194
+ export type ClaudeBillingSeen = {
195
+ keySource: string | null;
196
+ model: string | null;
197
+ planWindows: boolean;
198
+ answered: boolean;
199
+ };
200
+ /**
201
+ * One stream event's billing observation: the key source and model from the session's own init, plan windows from a
202
+ * rate-limit event, and whether the main query answered. The session's own events only (stream-scope.ts): a
203
+ * subagent's init, rate-limit event or result says nothing about this turn's billing.
204
+ */
205
+ export declare function observeClaudeBilling(seen: ClaudeBillingSeen, event: Record<string, unknown>): void;
package/dist/invoke.js CHANGED
@@ -44,6 +44,7 @@ import { buildCacheEnv, buildCacheFolder, grantFolder, writeAllowlist, WRITES_DE
44
44
  import { proxyEnv, providerOnlyNetwork, repositoryEcosystems, resolveNetwork, startEgressProxy } from "./egress.js";
45
45
  import { claudeConfigDir, claudeDefaults, codexDefaults, codexHomeDir, isolatedCodexHome } from "./provider-home.js";
46
46
  import { billingOf, claudeBillingFrom, seenBilling } from "./spend.js";
47
+ import { primaryResultEvent, topLevelEvent } from "./stream-scope.js";
47
48
  /** "Within seconds": how soon a silent failed run must end for its stderr to
48
49
  * be read for the sign-in signal. */
49
50
  const AUTH_EARLY_EXIT_MS = 30_000;
@@ -507,18 +508,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
507
508
  ...(workspace === null ? {} : { cwd: workspace }),
508
509
  // v105: how the CLI really billed this turn (its key source, its plan's windows), read off its own stream.
509
510
  onStreamEvent: event => {
510
- if (event["type"] === "system" && event["subtype"] === "init") {
511
- if (typeof event["apiKeySource"] === "string")
512
- billingSeen.keySource = event["apiKeySource"].slice(0, 80);
513
- if (typeof event["model"] === "string")
514
- billingSeen.model = event["model"].slice(0, 200);
515
- }
516
- else if (event["type"] === "rate_limit_event") {
517
- billingSeen.planWindows = true;
518
- }
519
- else if (event["type"] === "result" && event["is_error"] !== true && event["subtype"] === "success") {
520
- billingSeen.answered = true;
521
- }
511
+ observeClaudeBilling(billingSeen, event);
522
512
  runOptions.onStreamEvent?.(event);
523
513
  },
524
514
  owner: runOwnerTag(store, runId),
@@ -1303,3 +1293,22 @@ export function checkoutSandboxOf(store, repo, runIds) {
1303
1293
  export function fenceDigest(fence, grants) {
1304
1294
  return createHash("sha256").update(`toolroll:fence:${canonicalJson({ fence: [...fence], grants: grants })}`).digest("hex").slice(0, 32);
1305
1295
  }
1296
+ /**
1297
+ * One stream event's billing observation: the key source and model from the session's own init, plan windows from a
1298
+ * rate-limit event, and whether the main query answered. The session's own events only (stream-scope.ts): a
1299
+ * subagent's init, rate-limit event or result says nothing about this turn's billing.
1300
+ */
1301
+ export function observeClaudeBilling(seen, event) {
1302
+ if (event["type"] === "system" && event["subtype"] === "init" && topLevelEvent(event)) {
1303
+ if (typeof event["apiKeySource"] === "string")
1304
+ seen.keySource = event["apiKeySource"].slice(0, 80);
1305
+ if (typeof event["model"] === "string")
1306
+ seen.model = event["model"].slice(0, 200);
1307
+ }
1308
+ else if (event["type"] === "rate_limit_event" && topLevelEvent(event)) {
1309
+ seen.planWindows = true;
1310
+ }
1311
+ else if (primaryResultEvent(event) && event["is_error"] !== true && event["subtype"] === "success") {
1312
+ seen.answered = true;
1313
+ }
1314
+ }
@@ -1,6 +1,9 @@
1
1
  import { type KnowledgeDraft, type KnowledgeSelection, type KnowledgeView } from './project-knowledge.js';
2
2
  import { type Html } from './html.js';
3
- export declare const KNOWLEDGE_CSS = ".knowledge{max-width:780px;min-width:0;overflow-wrap:anywhere}.knowledge .card{padding:20px;min-width:0;margin:16px 0}.knowledge h2{margin:0 0 12px}.knowledge form{display:grid;gap:12px;margin:0}.knowledge label{display:grid;gap:6px}.knowledge textarea,.knowledge input,.knowledge select{box-sizing:border-box;width:100%;min-width:0;max-width:100%}.knowledge textarea{resize:vertical;min-height:110px}.knowledge button,.knowledge summary,.knowledge .button-link{min-height:44px}.knowledge button{justify-self:start;white-space:nowrap}.knowledge summary{padding:12px 0;cursor:pointer}.knowledge pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}.knowledge .knowledge-actions{display:flex;gap:12px;flex-wrap:wrap}.knowledge .meta{font-size:.86rem}.knowledge .knowledge-source{display:block}.knowledge details+details{border-top:1px solid var(--so-line)}.memory-stats{display:flex;flex-wrap:wrap;gap:.4rem}.memory-stats span{padding:.15rem .55rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}@media(max-width:600px){.knowledge .card{padding:16px}.knowledge h2{font-size:1.1rem}}";
3
+ /** A section as a frame (after ReUI's Frame, MIT): its heading on the raised well, its body one paper panel. The
4
+ * cards inside become hairline-divided rows of that panel, never cards in a card. */
5
+ export declare function framedSection(heading: string, body: Html | string, extra?: string): Html;
6
+ export declare const KNOWLEDGE_CSS = ".knowledge{max-width:780px;min-width:0;overflow-wrap:anywhere}.knowledge .card{padding:20px;min-width:0;margin:16px 0}.knowledge h2{margin:0 0 12px}.knowledge form{display:grid;gap:12px;margin:0}.knowledge label{display:grid;gap:6px}.knowledge textarea,.knowledge input,.knowledge select{box-sizing:border-box;width:100%;min-width:0;max-width:100%}.knowledge textarea{resize:vertical;min-height:110px}.knowledge button,.knowledge summary,.knowledge .button-link{min-height:44px}.knowledge button{justify-self:start;white-space:nowrap}.knowledge summary{padding:12px 0;cursor:pointer}.knowledge pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}.knowledge .knowledge-actions{display:flex;gap:12px;flex-wrap:wrap}.knowledge .meta{font-size:.86rem}.knowledge .knowledge-source{display:block}.knowledge details+details{border-top:1px solid var(--so-line)}.memory-stats{display:flex;flex-wrap:wrap;gap:.4rem}.memory-stats span+span::before{content:\"\u00B7\";margin-right:.4rem}.knowledge.frame{margin:0 0 16px}.knowledge.frame>.frame-head h2,.knowledge.frame>summary.frame-head{margin:0;font-size:13px;font-weight:600;line-height:1.4}.knowledge.frame>summary.frame-head{min-height:44px;padding:4px 10px}.knowledge.frame>.frame-panel{min-width:0}.knowledge.frame>.frame-panel>:first-child{margin-top:0}.knowledge.frame>.frame-panel>:last-child{margin-bottom:0}.knowledge .frame-panel>details.card,.knowledge .frame-panel .learning .card{margin:0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.knowledge .frame-panel>details.card+details.card,.knowledge .frame-panel .learning .card+.card{border-top:1px solid var(--so-line);padding-top:4px}.knowledge.frame>summary.frame-head{justify-content:flex-start}.knowledge form.field-row{grid-template-columns:minmax(0,1fr) auto;align-items:end}.knowledge form.field-row>label,.knowledge form.field-row :is(input,select,button){margin:0}@media(max-width:600px){.knowledge .card{padding:16px}.knowledge h2{font-size:1.1rem}}";
4
7
  export declare function knowledgeHtml(view: KnowledgeView, csrf: string, canManage: boolean, draft?: KnowledgeDraft, error?: string): Html;
5
8
  export declare function knowledgeContextHtml(snapshot: KnowledgeSelection | null): Html | '';
6
9
  import type { Decision, MemoryHit } from './project-memory.js';
@@ -2,7 +2,12 @@ import { STALE_KNOWLEDGE } from './project-knowledge.js';
2
2
  import { html, postForm } from './html.js';
3
3
  // Hidden fields as they always were: a missing value is an empty field.
4
4
  const fields = (data) => Object.fromEntries(Object.entries(data).map(([k, v]) => [k, String(v ?? '')]));
5
- export const KNOWLEDGE_CSS = `.knowledge{max-width:780px;min-width:0;overflow-wrap:anywhere}.knowledge .card{padding:20px;min-width:0;margin:16px 0}.knowledge h2{margin:0 0 12px}.knowledge form{display:grid;gap:12px;margin:0}.knowledge label{display:grid;gap:6px}.knowledge textarea,.knowledge input,.knowledge select{box-sizing:border-box;width:100%;min-width:0;max-width:100%}.knowledge textarea{resize:vertical;min-height:110px}.knowledge button,.knowledge summary,.knowledge .button-link{min-height:44px}.knowledge button{justify-self:start;white-space:nowrap}.knowledge summary{padding:12px 0;cursor:pointer}.knowledge pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}.knowledge .knowledge-actions{display:flex;gap:12px;flex-wrap:wrap}.knowledge .meta{font-size:.86rem}.knowledge .knowledge-source{display:block}.knowledge details+details{border-top:1px solid var(--so-line)}.memory-stats{display:flex;flex-wrap:wrap;gap:.4rem}.memory-stats span{padding:.15rem .55rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}@media(max-width:600px){.knowledge .card{padding:16px}.knowledge h2{font-size:1.1rem}}`;
5
+ /** A section as a frame (after ReUI's Frame, MIT): its heading on the raised well, its body one paper panel. The
6
+ * cards inside become hairline-divided rows of that panel, never cards in a card. */
7
+ export function framedSection(heading, body, extra = '') {
8
+ return html `<section class="${`knowledge frame${extra ? ` ${extra}` : ''}`}"><div class="frame-head"><h2>${heading}</h2></div><div class="frame-panel">${body}</div></section>`;
9
+ }
10
+ export const KNOWLEDGE_CSS = `.knowledge{max-width:780px;min-width:0;overflow-wrap:anywhere}.knowledge .card{padding:20px;min-width:0;margin:16px 0}.knowledge h2{margin:0 0 12px}.knowledge form{display:grid;gap:12px;margin:0}.knowledge label{display:grid;gap:6px}.knowledge textarea,.knowledge input,.knowledge select{box-sizing:border-box;width:100%;min-width:0;max-width:100%}.knowledge textarea{resize:vertical;min-height:110px}.knowledge button,.knowledge summary,.knowledge .button-link{min-height:44px}.knowledge button{justify-self:start;white-space:nowrap}.knowledge summary{padding:12px 0;cursor:pointer}.knowledge pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}.knowledge .knowledge-actions{display:flex;gap:12px;flex-wrap:wrap}.knowledge .meta{font-size:.86rem}.knowledge .knowledge-source{display:block}.knowledge details+details{border-top:1px solid var(--so-line)}.memory-stats{display:flex;flex-wrap:wrap;gap:.4rem}.memory-stats span+span::before{content:"·";margin-right:.4rem}.knowledge.frame{margin:0 0 16px}.knowledge.frame>.frame-head h2,.knowledge.frame>summary.frame-head{margin:0;font-size:13px;font-weight:600;line-height:1.4}.knowledge.frame>summary.frame-head{min-height:44px;padding:4px 10px}.knowledge.frame>.frame-panel{min-width:0}.knowledge.frame>.frame-panel>:first-child{margin-top:0}.knowledge.frame>.frame-panel>:last-child{margin-bottom:0}.knowledge .frame-panel>details.card,.knowledge .frame-panel .learning .card{margin:0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.knowledge .frame-panel>details.card+details.card,.knowledge .frame-panel .learning .card+.card{border-top:1px solid var(--so-line);padding-top:4px}.knowledge.frame>summary.frame-head{justify-content:flex-start}.knowledge form.field-row{grid-template-columns:minmax(0,1fr) auto;align-items:end}.knowledge form.field-row>label,.knowledge form.field-row :is(input,select,button){margin:0}@media(max-width:600px){.knowledge .card{padding:16px}.knowledge h2{font-size:1.1rem}}`;
6
11
  export function knowledgeHtml(view, csrf, canManage, draft, error) {
7
12
  const manage = canManage && !!csrf;
8
13
  const base = { repo: view.repo, identity: view.identity, revision: view.revision };
@@ -18,7 +23,7 @@ export function knowledgeHtml(view, csrf, canManage, draft, error) {
18
23
  }
19
24
  const instructions = html `<pre class="knowledge-instructions">${view.knowledge.instructions || 'No project instructions yet.'}</pre>`;
20
25
  const editor = manage ? html `<details class="knowledge-editor"${draft?.instructions !== undefined ? html ` open` : ''}><summary>${view.knowledge.instructions ? 'Edit instructions' : 'Add instructions'}</summary>${form('instructions', html `<textarea aria-label="Project instructions" name="instructions" rows="5" maxlength="4000" placeholder="Goals, preferences, and what good work looks like…">${draft?.instructions ?? view.knowledge.instructions}</textarea><button>Save instructions</button>`)}</details>` : '';
21
- return html `<section class="knowledge knowledge-library">${error ? html `<p class="problem" role="alert">${error}</p>` : ''}<section class="knowledge-intro"><h2>Instructions</h2>${instructions}${editor}</section><section><h2>References</h2>${refs.length ? refs : html `<p class="meta">Add a spec, design guide, or useful project document.</p>`}${manage ? html `<details class="card"${draft?.title && !draft.id ? html ` open` : ''}><summary>Add reference</summary>${form('save', referenceFields(draft?.id ? {} : draft ?? {}))}</details>` : ''}</section>${history.length ? html `<details><summary>Change history</summary><ol>${history}</ol></details>` : ''}</section>`;
26
+ return html `<section class="knowledge knowledge-library">${error ? html `<p class="problem" role="alert">${error}</p>` : ''}${framedSection('Instructions', html `${instructions}${editor}`, 'knowledge-intro')}${framedSection('References', html `${refs.length ? refs : html `<p class="meta">Add a spec, design guide, or useful project document.</p>`}${manage ? html `<details class="card"${draft?.title && !draft.id ? html ` open` : ''}><summary>Add reference</summary>${form('save', referenceFields(draft?.id ? {} : draft ?? {}))}</details>` : ''}`)}${history.length ? html `<details class="knowledge frame"><summary class="frame-head">Change history</summary><div class="frame-panel"><ol>${history}</ol></div></details>` : ''}</section>`;
22
27
  }
23
28
  export function knowledgeContextHtml(snapshot) {
24
29
  if (!snapshot)
@@ -32,17 +37,17 @@ export function decisionsHtml(repo, decisions, csrf, canManage, draft, error) {
32
37
  const form = (action, body, extra = {}) => postForm('/settings/knowledge/decision', body, { hidden: fields({ ...base, action, ...extra }) });
33
38
  const rows = decisions.map(d => html `<details class="card decision" id="decision-${d.id}"><summary><strong>${d.claim}</strong> <span class="meta">#${d.id} · ${d.decidedBy} · ${d.decidedAt.slice(0, 10)}</span></summary><p>${d.why}</p><p class="meta">${d.sourceKind}${d.sourceRef ? ` · ${d.sourceRef}` : ''}${d.supersedes === null ? '' : ` · replaces #${d.supersedes}`}</p>${manage ? form('retire', html `<label>Why it no longer holds<input name="reason" maxlength="500" required></label><button type="submit">Retire</button>`, { decision: d.id }) : ''}</details>`);
34
39
  const add = manage ? html `<details class="knowledge-editor"${draft?.claim !== undefined ? html ` open` : ''}><summary>Record a decision</summary>${form('record', html `<label>The choice, in one sentence<input name="claim" maxlength="240" value="${draft?.claim}" required></label><label>Why<textarea name="why" rows="3" maxlength="2000" required>${draft?.why}</textarea></label><button type="submit">Record</button>`)}</details>` : '';
35
- return html `<section class="knowledge"><h2>Decisions</h2>${error ? html `<p class="problem" role="alert">${error}</p>` : ''}${rows.length ? rows : html `<p class="meta">No decisions yet. The lead suggests one when a conversation settles a choice.</p>`}${add}</section>`;
40
+ return framedSection('Decisions', html `${error ? html `<p class="problem" role="alert">${error}</p>` : ''}${rows.length ? rows : html `<p class="meta">No decisions yet. The lead suggests one when a conversation settles a choice.</p>`}${add}`);
36
41
  }
37
42
  export function memorySearchHtml(repo, query, hits) {
38
43
  const kind = (h) => h.kind === 'decision' ? html `<a href="#decision-${h.ref}">decision #${h.ref}</a>` : h.kind === 'conversation' ? 'conversation' : h.kind;
39
44
  const results = hits === null ? '' : hits.length === 0 ? html `<p class="meta">Nothing in project memory matches.</p>` : html `<ul class="memory-hits">${hits.map(h => html `<li><strong>${kind(h)}</strong> · ${h.title}<br><span class="meta">${h.snippet}</span></li>`)}</ul>`;
40
- return html `<section class="knowledge memory-search"><form method="get" action="/settings/knowledge"><input type="hidden" name="repo" value="${repo}"><label>Search project memory<input name="memory" value="${query}" maxlength="300" placeholder="Search decisions, references, lessons…" type="search" autocomplete="off"></label><button type="submit">Search</button></form>${results}</section>`;
45
+ return framedSection('Search project memory', html `<form class="field-row" method="get" action="/settings/knowledge"><input type="hidden" name="repo" value="${repo}"><label><span class="visually-hidden">Search project memory</span><input name="memory" value="${query}" maxlength="300" placeholder="Search decisions, references, lessons…" type="search" autocomplete="off"></label><button type="submit">Search</button></form>${results}`, 'memory-search');
41
46
  }
42
47
  export function proposalsHtml(repo, proposals, status, csrf, canManage) {
43
48
  const manage = canManage && !!csrf;
44
49
  const line = status === null ? '' : html `<p class="meta memory-stats"><span>${status.sessions.analyzed}/${status.sessions.total} sessions read${status.sessions.failed ? ` · ${status.sessions.failed} failed` : ''}</span><span>${status.openGaps} open gap${status.openGaps === 1 ? '' : 's'}</span><span>Instructions ${status.instructionBytes}/${status.budgetBytes} bytes</span></p><details class="settings-more"><summary>Read new sessions</summary><p class="meta">Run <code>toolroll memory propose --repo ${repo}</code> on this computer.</p></details>`;
45
50
  const decide = (p, decision, label) => postForm('/settings/knowledge/proposal', html `<button type="submit">${label}</button>`, { attrs: { class: 'inline' }, hidden: fields({ repo, proposal: p.id, decision }) });
46
51
  const cards = proposals.map(p => html `<details class="card proposal" open><summary><strong>${p.title}</strong> <span class="meta">#${p.id} · ${p.kind} · ${p.sessions} sessions</span></summary><p>${p.rationale}</p>${p.beforeText ? html `<pre class="knowledge-instructions">- ${p.beforeText}</pre>` : ''}${p.afterText ? html `<pre class="knowledge-instructions">+ ${p.afterText}</pre>` : ''}<ul class="memory-evidence">${p.evidence.map(ev => html `<li>“${ev.quote.slice(0, 200)}” <span class="meta">${ev.session.split(':')[0] ?? ''}</span></li>`)}</ul>${manage ? html `${decide(p, 'accept', 'Accept')} ${decide(p, 'reject', 'Reject')}` : ''}</details>`);
47
- return html `<section class="knowledge"><h2>Proposed from sessions</h2>${line}${cards.length ? cards : html `<p class="meta">Nothing to review. Suggestions appear when the same mistake shows up in two sessions.</p>`}</section>`;
52
+ return framedSection('Proposed from sessions', html `${line}${cards.length ? cards : html `<p class="meta">Nothing to review. Suggestions appear when the same mistake shows up in two sessions.</p>`}`);
48
53
  }
@@ -20,11 +20,14 @@ export type LeadProgress = {
20
20
  step: number;
21
21
  id: string;
22
22
  outcome: LeadToolOutcome;
23
- } | {
23
+ }
24
+ /** `final`: the provider's finished answer for this step, sent once and secret-checked whole. */
25
+ | {
24
26
  kind: "text";
25
27
  turn: number;
26
28
  step: number;
27
29
  text: string;
30
+ final?: true;
28
31
  };
29
32
  export type LeadToolOutcome = {
30
33
  state: "succeeded";
@@ -1,6 +1,7 @@
1
1
  /** Live progress of one lead turn (chat streaming): what the lead is doing
2
2
  * and the reply as it is written. Nothing here is durable or authoritative —
3
3
  * the finished turn's saved message and cards remain the record. */
4
+ import { topLevelEvent } from "./stream-scope.js";
4
5
  /** Plain words for each tool the lead uses, as the person watching reads them. */
5
6
  const TOOL_LABELS = {
6
7
  get_brief: "Catching up on your work",
@@ -127,7 +128,8 @@ export function claudeStreamReader(onText, capBytes = 1_048_576) {
127
128
  catch {
128
129
  continue;
129
130
  }
130
- if (!record(event) || event["type"] !== "stream_event" || !record(event["event"]))
131
+ // A subagent's frames (parent_tool_use_id set) are not the lead's reply: they never start, feed or end it.
132
+ if (!record(event) || !topLevelEvent(event) || event["type"] !== "stream_event" || !record(event["event"]))
131
133
  continue;
132
134
  const inner = event["event"];
133
135
  if (inner["type"] === "content_block_start") {
package/dist/lead.js CHANGED
@@ -486,6 +486,10 @@ export async function runLeadTurn(input) {
486
486
  return fail("malformed-reply", LEAD_FAILURE_COPY.unreadableUnknownCost, true);
487
487
  }
488
488
  const answer = result.answer;
489
+ // The streamed reply's finished text, once, secret-checked whole: only now may its last word show live.
490
+ if (!direct && input.onProgress !== undefined && !controller.signal.aborted && answer.text !== "" && scanForSecrets(answer.text).length === 0) {
491
+ progress({ kind: "text", turn: turnId, step: steps, text: answer.text, final: true });
492
+ }
489
493
  // Usage that cannot be true — more input tokens than bytes sent — is a
490
494
  // malformed reply with unknown cost, never a number to settle by.
491
495
  if (direct && answer.tokensIn > requestBytes) {
@@ -128,6 +128,8 @@ export type LiveRoomOptions = {
128
128
  /** A room whose read is costly reads at most once per this window after a signal; every viewer is still re-proved
129
129
  * the moment a signal arrives (and again at the read). */
130
130
  readSettleMs?: number;
131
+ /** Told when a room's last viewer has left and the room is gone (state kept per room can go with it). */
132
+ onEmpty?: (key: unknown) => void;
131
133
  };
132
134
  /**
133
135
  * Rooms of open pages that hear the bus. `presence` says who else is here, as
package/dist/live-bus.js CHANGED
@@ -309,6 +309,7 @@ export function createLiveRooms(fingerprint, presence, options = {}) {
309
309
  if (room.reading !== null)
310
310
  clearTimeout(room.reading);
311
311
  rooms.delete(key);
312
+ options.onEmpty?.(key);
312
313
  if (rooms.size === 0 && unsubscribe !== null) {
313
314
  unsubscribe();
314
315
  unsubscribe = null;
@@ -0,0 +1,52 @@
1
+ import { type LiveRecord } from "./live-records.js";
2
+ export declare const CURSOR_CAP: number;
3
+ /** Longer than any real key block: past this much of a stream's text with no END marker, its key state is known again. */
4
+ export declare const UNKNOWN_KEY_BYTES: number;
5
+ export type ChildView = {
6
+ k: "child";
7
+ s: number;
8
+ at?: string;
9
+ parent: string | null;
10
+ via?: number;
11
+ desc?: string;
12
+ } | {
13
+ k: "end";
14
+ s: number;
15
+ at?: string;
16
+ ok: boolean | null;
17
+ } | {
18
+ k: "over";
19
+ }
20
+ /** The file stopped at its size cap: nothing more will be written. */
21
+ | {
22
+ k: "cap";
23
+ } | (LiveRecord & {
24
+ s: number;
25
+ });
26
+ export type ChildWindow = {
27
+ ok: true;
28
+ records: ChildView[];
29
+ cursor: string;
30
+ /** A cursor was given but didn't hold (forged, stale, another run's or file's): this read started over. */
31
+ restarted: boolean;
32
+ /** Output before this read's start isn't shown (the read began at a checkpoint, or waits for one). */
33
+ earlier: boolean;
34
+ /** Records that failed a check: counted, never shown. */
35
+ quarantined: number;
36
+ /** Lines too long to show that were skipped. */
37
+ skipped: number;
38
+ /** More is already written: read again. */
39
+ more: boolean;
40
+ } | {
41
+ ok: false;
42
+ reason: "missing" | "unavailable";
43
+ };
44
+ /** Seal a reader's state under the caller's key (the same form for the run's own file and its subagents'). */
45
+ export declare function sealCursor(state: object, key: Buffer): string;
46
+ /** A sealed state's object when the seal holds and it is within the cap; null otherwise (the caller re-checks it). */
47
+ export declare function openCursor(cursor: string, key: Buffer): Record<string, unknown> | null;
48
+ /** One window of a run's subagents' file. `commands` is the run's own captured answer (store.ts runLiveCommands). */
49
+ export declare function readChildWindow(root: string, runId: number, cursor: string | null, options: {
50
+ key: Buffer;
51
+ commands: boolean;
52
+ }): ChildWindow;