toolroll 0.9.73 → 0.9.75

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 (145) hide show
  1. package/CHANGELOG.md +63 -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/assignment.d.ts +3 -0
  7. package/dist/assignment.js +27 -8
  8. package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +31 -30
  11. package/dist/browser-workspace.d.ts +7 -1
  12. package/dist/builder.js +1 -1
  13. package/dist/chat-acceptance.d.ts +4 -1
  14. package/dist/chat-acceptance.js +13 -3
  15. package/dist/chat-continuity.js +10 -0
  16. package/dist/chat-quiet.js +3 -3
  17. package/dist/cli.js +1 -1
  18. package/dist/coding-handoff.js +4 -3
  19. package/dist/coding-ui.js +2 -2
  20. package/dist/coding-workspace.js +16 -4
  21. package/dist/contracts/console-api.d.ts +5 -1
  22. package/dist/contracts/console-api.js +10 -0
  23. package/dist/control-ui.d.ts +3 -2
  24. package/dist/control-ui.js +7 -2
  25. package/dist/demo.js +5 -3
  26. package/dist/deploy-gate.js +5 -2
  27. package/dist/desktop-update.d.ts +4 -1
  28. package/dist/desktop-update.js +43 -9
  29. package/dist/dispose.js +35 -2
  30. package/dist/exec.js +13 -16
  31. package/dist/first-run.d.ts +20 -1
  32. package/dist/first-run.js +71 -0
  33. package/dist/flow-card-task.js +3 -2
  34. package/dist/flow-unattended.d.ts +2 -0
  35. package/dist/flow-unattended.js +12 -1
  36. package/dist/integrations.d.ts +4 -3
  37. package/dist/integrations.js +5 -6
  38. package/dist/invoke.d.ts +13 -0
  39. package/dist/invoke.js +21 -12
  40. package/dist/knowledge-ui.d.ts +4 -1
  41. package/dist/knowledge-ui.js +10 -5
  42. package/dist/lead-contract.d.ts +1 -1
  43. package/dist/lead-contract.js +2 -2
  44. package/dist/lead-follow.js +3 -1
  45. package/dist/lead-progress.d.ts +4 -1
  46. package/dist/lead-progress.js +3 -1
  47. package/dist/lead.js +4 -0
  48. package/dist/live-bus.d.ts +2 -0
  49. package/dist/live-bus.js +1 -0
  50. package/dist/live-children-read.d.ts +52 -0
  51. package/dist/live-children-read.js +333 -0
  52. package/dist/live-children.d.ts +73 -0
  53. package/dist/live-children.js +318 -0
  54. package/dist/live-detail.d.ts +4 -3
  55. package/dist/live-detail.js +9 -5
  56. package/dist/live-input.js +7 -8
  57. package/dist/live-main-read.d.ts +19 -0
  58. package/dist/live-main-read.js +91 -0
  59. package/dist/live-open.d.ts +28 -0
  60. package/dist/live-open.js +125 -0
  61. package/dist/live-records.d.ts +10 -1
  62. package/dist/live-records.js +16 -5
  63. package/dist/live.d.ts +54 -22
  64. package/dist/live.js +218 -69
  65. package/dist/openrouter-models.d.ts +5 -2
  66. package/dist/openrouter-models.js +7 -4
  67. package/dist/operate.js +5 -2
  68. package/dist/peek-cli.d.ts +10 -3
  69. package/dist/peek-cli.js +17 -8
  70. package/dist/planner.js +1 -1
  71. package/dist/project-learning.js +44 -6
  72. package/dist/project-tools.js +19 -1
  73. package/dist/provider-limits.d.ts +3 -1
  74. package/dist/provider-limits.js +5 -2
  75. package/dist/provider.js +6 -12
  76. package/dist/pull-request-ui.d.ts +1 -1
  77. package/dist/pull-request-ui.js +5 -1
  78. package/dist/push.d.ts +2 -0
  79. package/dist/push.js +24 -19
  80. package/dist/read-only-request.d.ts +4 -0
  81. package/dist/read-only-request.js +15 -0
  82. package/dist/release-adapters.js +4 -0
  83. package/dist/release.d.ts +3 -0
  84. package/dist/release.js +3 -0
  85. package/dist/repository-context-ui.js +2 -1
  86. package/dist/repository-context.js +6 -1
  87. package/dist/result-follow-ups.js +20 -1
  88. package/dist/result-review.js +5 -1
  89. package/dist/run-efficiency.d.ts +3 -3
  90. package/dist/run-efficiency.js +20 -8
  91. package/dist/scout.d.ts +3 -0
  92. package/dist/scout.js +5 -3
  93. package/dist/serve.js +196 -29
  94. package/dist/server/agents-live-filed.d.ts +17 -0
  95. package/dist/server/agents-live-filed.js +97 -0
  96. package/dist/server/agents-live-lead.d.ts +47 -0
  97. package/dist/server/agents-live-lead.js +116 -0
  98. package/dist/server/agents-live-room.d.ts +23 -0
  99. package/dist/server/agents-live-room.js +91 -0
  100. package/dist/server/chat.js +115 -53
  101. package/dist/server/chrome.js +2 -2
  102. package/dist/server/console.css +68 -40
  103. package/dist/server/http.d.ts +5 -0
  104. package/dist/server/last-checks.d.ts +17 -0
  105. package/dist/server/last-checks.js +22 -0
  106. package/dist/server/live.d.ts +4 -0
  107. package/dist/server/live.js +42 -2
  108. package/dist/server/pages.js +98 -4
  109. package/dist/server/people-tokens.js +7 -4
  110. package/dist/server/render-chat.d.ts +15 -1
  111. package/dist/server/render-chat.js +14 -7
  112. package/dist/server/render-pages.d.ts +10 -5
  113. package/dist/server/render-pages.js +83 -74
  114. package/dist/server/render-results.d.ts +11 -2
  115. package/dist/server/render-results.js +54 -12
  116. package/dist/server/render-settings.js +3 -3
  117. package/dist/server/render-tasks.js +13 -3
  118. package/dist/server/route-table.js +19 -2
  119. package/dist/server/runtime.d.ts +17 -2
  120. package/dist/server/settings.js +85 -32
  121. package/dist/server/task-views.js +1 -2
  122. package/dist/server/tasks.js +70 -43
  123. package/dist/storage-ui.d.ts +2 -0
  124. package/dist/storage-ui.js +1 -1
  125. package/dist/storage.js +2 -1
  126. package/dist/store.d.ts +34 -1
  127. package/dist/store.js +108 -11
  128. package/dist/stream-scope.d.ts +18 -0
  129. package/dist/stream-scope.js +28 -0
  130. package/dist/subagent-work.js +3 -0
  131. package/dist/subscription-chat.js +5 -3
  132. package/dist/task-status.d.ts +28 -1
  133. package/dist/task-status.js +93 -11
  134. package/dist/teardown-git.d.ts +27 -1
  135. package/dist/teardown-git.js +185 -39
  136. package/dist/telegram-progress.js +4 -4
  137. package/dist/telegram-status.js +8 -3
  138. package/dist/tool-secret-store.js +8 -0
  139. package/dist/toolroll-update-ui.d.ts +2 -0
  140. package/dist/toolroll-update-ui.js +4 -3
  141. package/dist/toolroll-update.js +10 -9
  142. package/dist/work-index.js +29 -12
  143. package/dist/workspace-ui.d.ts +5 -0
  144. package/dist/workspace-ui.js +38 -10
  145. package/package.json +1 -1
package/dist/live.js CHANGED
@@ -13,17 +13,25 @@
13
13
  * Fail-soft everywhere: a live view that cannot be written must never
14
14
  * break, slow, or reclassify a build.
15
15
  */
16
- import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, statSync, unlinkSync, writeSync } from "node:fs";
16
+ import { randomBytes } from "node:crypto";
17
+ import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
17
18
  import { join } from "node:path";
18
19
  import { scanForSecrets } from "./evidence.js";
19
20
  import { createLiveRecorder, parseLiveRecords, redactLiveLines, projectLiveText, serializeLiveRecord } from "./live-records.js";
21
+ import { childLogWriter, createChildRecorder } from "./live-children.js";
22
+ import { topLevelEvent } from "./stream-scope.js";
20
23
  export const LIVE_LINE_CAP = 2 * 1024;
21
24
  export const LIVE_FILE_CAP = 4 * 1024 * 1024;
22
25
  export const LIVE_REDACTED_LINE = "[a credential-shaped line was redacted]";
23
26
  /** Finalized runs keep their live file this long for the final drain. */
24
27
  export const LIVE_RETAIN_MS = 24 * 60 * 60 * 1000;
25
- /** The bounded sweep: at most this many FINALIZED files survive a pass. */
28
+ /** The bounded sweep: at most this many FINALIZED runs' files survive a pass (a run's records and its subagents' file
29
+ * count as one). */
26
30
  export const LIVE_FILE_BOUND = 500;
31
+ /** A run's subagent file (docs/plans/agents-view.md §2.3) is never larger than this; its writer stops there. */
32
+ export const LIVE_SUB_FILE_CAP = 16 * 1024 * 1024;
33
+ /** All finished and orphan live files together stay under this; the oldest runs go first past it. */
34
+ export const LIVE_TOTAL_BYTES = 1024 * 1024 * 1024;
27
35
  export function liveDir(root) {
28
36
  return join(root, "live");
29
37
  }
@@ -33,6 +41,10 @@ export function liveDir(root) {
33
41
  export function liveLogPath(root, runId) {
34
42
  return join(liveDir(root), `${Math.trunc(runId)}.jsonl`);
35
43
  }
44
+ /** A run's subagents' records (docs/plans/agents-view.md §2.3): the run's file, swept with it. */
45
+ export function liveSubLogPath(root, runId) {
46
+ return join(liveDir(root), `${Math.trunc(runId)}.sub.jsonl`);
47
+ }
36
48
  /** The plain-text file runs wrote before the live conversation; read until it ages out. */
37
49
  export function legacyLiveLogPath(root, runId) {
38
50
  return join(liveDir(root), `${Math.trunc(runId)}.log`);
@@ -62,6 +74,9 @@ const toolKind = (name) => TOOL_KINDS.get(typeof name === "string" ? name : "")
62
74
  */
63
75
  export function liveActivityKind(event) {
64
76
  try {
77
+ // A subagent's events, of every type, are not this run's activity (stream-scope.ts).
78
+ if (!topLevelEvent(event))
79
+ return null;
65
80
  const type = String(event["type"] ?? "");
66
81
  if ((type === "system" && String(event["subtype"] ?? "") === "init") || type === "thread.started" || type === "init")
67
82
  return "session";
@@ -76,9 +91,6 @@ export function liveActivityKind(event) {
76
91
  return toolKind(event["name"] ?? event["tool"]?.["name"]);
77
92
  if (type !== "assistant")
78
93
  return null;
79
- const parent = event["parent_tool_use_id"];
80
- if (parent !== undefined && parent !== null)
81
- return null;
82
94
  const content = event["message"]?.["content"];
83
95
  if (!Array.isArray(content))
84
96
  return null;
@@ -140,7 +152,12 @@ export function openLiveLog(root, runId, options = {}) {
140
152
  catch {
141
153
  return null;
142
154
  }
143
- const recorder = createLiveRecorder({ base: options.base ?? null, commands: options.commands === true, ...(options.clock === undefined ? {} : { clock: options.clock }) });
155
+ const recorderOptions = { base: options.base ?? null, commands: options.commands === true, ...(options.clock === undefined ? {} : { clock: options.clock }) };
156
+ // The run's subagents go to their own file (live-children.ts), under the same captured answer; this file is unchanged
157
+ // (the recorder only tells the subagents' side which step it wrote for each delegation).
158
+ const children = createChildRecorder(recorderOptions);
159
+ const recorder = createLiveRecorder({ ...recorderOptions, onStep: children.parentStep });
160
+ const childLog = childLogWriter(root, runId);
144
161
  let written = 0;
145
162
  let capped = false;
146
163
  let closed = false;
@@ -178,8 +195,13 @@ export function openLiveLog(root, runId, options = {}) {
178
195
  return {
179
196
  observe(event) {
180
197
  try {
181
- for (const record of recorder.observe(event))
198
+ const mine = recorder.observe(event);
199
+ for (const record of mine)
182
200
  append(record);
201
+ if (!closed) {
202
+ const before = childLog.due() ? children.checkpoint() : null;
203
+ childLog.append(children.observe(event), before);
204
+ }
183
205
  }
184
206
  catch {
185
207
  // Recording is observational; a malformed event records nothing.
@@ -195,6 +217,12 @@ export function openLiveLog(root, runId, options = {}) {
195
217
  catch {
196
218
  // the steps simply stay open in the file
197
219
  }
220
+ try {
221
+ const before = childLog.due() ? children.checkpoint() : null;
222
+ childLog.append(children.end(), before);
223
+ }
224
+ catch { /* the subagents' steps stay open in theirs */ }
225
+ childLog.close();
198
226
  closed = true;
199
227
  try {
200
228
  closeSync(fd);
@@ -326,18 +354,42 @@ export function readLiveWindow(root, runId, from, allowTail = false) {
326
354
  closeSync(fd);
327
355
  }
328
356
  }
329
- /** A window's complete lines as the plain words the run page and `toolroll peek` show, whatever its format. */
330
- export function liveWindowLines(read, keys = { inKey: false }) {
357
+ /** A window's complete lines as the plain words the run page and `toolroll peek` show, whatever its format. `commands` is
358
+ * the run's own captured answer (store.ts runLiveCommands), never the file's say. */
359
+ export function liveWindowLines(read, commands, keys = { inKey: false }) {
331
360
  if (read.format === "records")
332
- return projectLiveText(parseLiveRecords(read.text, keys));
361
+ return projectLiveText(withholdCommands(parseLiveRecords(read.text, keys), commands));
333
362
  return cleanLegacy(read.text, keys).split("\n").filter(one => one !== "");
334
363
  }
335
- /** A window as records, whatever its format: an older plain file's lines become the agent's words. */
336
- export function liveWindowRecords(read, keys = { inKey: false }) {
364
+ /** A window as records, whatever its format: an older plain file's lines become the agent's words. Command text and
365
+ * output show only when the run's own captured answer (`commands`) is on. */
366
+ export function liveWindowRecords(read, commands, keys = { inKey: false }) {
337
367
  if (read.format === "records")
338
- return parseLiveRecords(read.text, keys);
368
+ return withholdCommands(parseLiveRecords(read.text, keys), commands);
339
369
  return cleanLegacy(read.text, keys).split("\n").filter(one => one !== "").map(line => ({ k: "say", at: "", t: [line] }));
340
370
  }
371
+ /**
372
+ * Enforce a run's captured "Show commands" answer on read: the file is build-writable, so an `open` record, a command
373
+ * step's text or a step's output may say anything. With the answer off, every `open` reads the base tier, command steps
374
+ * lose their text and steps lose their output. (The older plain-text format predates command detail and is unchanged.)
375
+ */
376
+ export function withholdCommands(records, commands) {
377
+ if (commands)
378
+ return records;
379
+ return records.map(record => {
380
+ if (record.k === "open")
381
+ return record.detail === "base" ? record : { ...record, detail: "base" };
382
+ if (record.k === "step" && record.kind === "command" && record.target !== undefined) {
383
+ const { target: _target, ...rest } = record;
384
+ return rest;
385
+ }
386
+ if (record.k === "done" && record.out !== undefined) {
387
+ const { out: _out, ...rest } = record;
388
+ return rest;
389
+ }
390
+ return record;
391
+ });
392
+ }
341
393
  /** Legacy text lines, cleaned again on read: the file may have been altered after it was written. */
342
394
  function cleanLegacy(text, keys) {
343
395
  const lines = text.split("\n");
@@ -348,10 +400,9 @@ function cleanLegacy(text, keys) {
348
400
  return ended ? `${shown.join("\n")}\n` : shown.join("\n");
349
401
  }
350
402
  /**
351
- * Enumerate the live directory for the reconcile sweep. Pure listing — the
352
- * CALLER decides which runs are finalized and when; this module only
353
- * refuses to guess. Non-run names are reported with runId null (orphans
354
- * from nothing this plane wrote; aged on mtime like everything else).
403
+ * Enumerate the live directory for the reconcile sweep: names only. Pure listing — the CALLER settles which runs are
404
+ * finalized before it measures anything; this module only refuses to guess. Non-run names are reported with runId
405
+ * null (orphans from nothing this plane wrote; aged on mtime like everything else).
355
406
  */
356
407
  export function listLiveFiles(root) {
357
408
  let names;
@@ -361,74 +412,172 @@ export function listLiveFiles(root) {
361
412
  catch {
362
413
  return [];
363
414
  }
364
- const out = [];
365
- for (const name of names) {
366
- let mtimeMs = 0;
367
- try {
368
- mtimeMs = statSync(join(liveDir(root), name)).mtimeMs;
369
- }
370
- catch {
371
- continue;
372
- }
373
- const match = /^(\d{1,15})\.(?:jsonl|log)$/.exec(name);
374
- out.push({ runId: match === null ? null : Number(match[1]), fileName: name, mtimeMs });
375
- }
376
- return out;
415
+ // A sweep's private claim (QUARANTINE_PREFIX) is never listed: it is the sweep's own, and one it couldn't restore
416
+ // stays for a person to look at rather than be aged out under a name nobody chose.
417
+ return names.filter(name => !name.startsWith(QUARANTINE_PREFIX)).map(name => {
418
+ const match = /^(\d{1,15})\.(jsonl|log|sub\.jsonl)$/.exec(name);
419
+ // The writer stops at its cap and adds one last record; anything far past it is no file this plane wrote.
420
+ const cap = match === null ? null : (match[2] === "sub.jsonl" ? LIVE_SUB_FILE_CAP : LIVE_FILE_CAP) + 64 * 1024;
421
+ return { runId: match === null ? null : Number(match[1]), fileName: name, cap };
422
+ });
377
423
  }
378
- /** Remove one live file, silently: a sweep never throws over display state. */
379
- export function removeLiveFile(root, fileName) {
424
+ /** Remove one live file, silently: a sweep never throws over display state. True when it is gone now. */
425
+ export function removeLiveFile(root, fileName, unlink = unlinkSync) {
380
426
  // The name comes from listLiveFiles' own readdir, never from user input.
381
427
  if (fileName.includes("/") || fileName.includes("\\") || fileName.includes(".."))
382
- return;
428
+ return false;
383
429
  try {
384
- unlinkSync(join(liveDir(root), fileName));
430
+ unlink(join(liveDir(root), fileName));
431
+ return true;
385
432
  }
386
- catch {
387
- // already gone
433
+ catch (error) {
434
+ return error.code === "ENOENT";
388
435
  }
389
436
  }
437
+ /** The prefix of a sweep's private claim on a file it is about to remove (never a name a writer uses). */
438
+ const QUARANTINE_PREFIX = ".sweep-claim-";
390
439
  /**
391
440
  * The reconcile sweep (arc 1 §2): ACTIVE runs are exempt ABSOLUTELY — the
392
- * transport's no-truncation assumption depends on it. Finalized runs age
393
- * from finished_at; orphans (no run row, or a name this plane never
394
- * writes) age from mtime; and the 500-file bound applies only WITHIN the
395
- * finalized-or-orphan set, oldest first.
441
+ * transport's no-truncation assumption depends on it — and a run's files
442
+ * (its records, its subagents' file, an older text file) go together, as one.
443
+ * A run is settled finished (or absent) BEFORE its files are measured.
444
+ * Finalized runs age from finished_at; orphans (no run row, or a name this
445
+ * plane never writes) age from mtime. Among the finalized-or-orphan: a file
446
+ * far past its own cap goes at once, then the day's retention, then the
447
+ * 500-run bound, then the total byte limit, oldest first, until each limit is
448
+ * really met or nothing eligible is left. Each removal re-proves, under the
449
+ * store's write lock (the one run admission takes), that the run is still
450
+ * not active and the file is the very one measured; otherwise it is kept. A
451
+ * file that can't be removed stays counted and never stops the rest.
396
452
  */
397
- export function sweepLiveLogs(store, root, now) {
453
+ export function sweepLiveLogs(store, root, now, io = {}) {
398
454
  const removed = [];
399
- const removable = [];
400
- for (const entry of listLiveFiles(root)) {
401
- if (entry.runId !== null) {
402
- const run = store.getRun(entry.runId);
403
- if (run !== null && run.outcome === null)
404
- continue; // live: untouchable
405
- const finished = run?.finishedAt === null || run?.finishedAt === undefined ? null : Date.parse(run.finishedAt);
406
- removable.push({
407
- fileName: entry.fileName,
408
- ageAnchorMs: finished === null || Number.isNaN(finished) ? entry.mtimeMs : finished,
409
- });
455
+ const settled = (runId) => {
456
+ if (runId === null)
457
+ return null;
458
+ try {
459
+ const run = store.getRun(runId);
460
+ return run !== null && run.outcome === null ? "active" : run;
461
+ }
462
+ catch {
463
+ return "unknown";
464
+ }
465
+ };
466
+ const at = (name) => join(liveDir(root), name);
467
+ const lstat = io.lstat ?? ((path) => lstatSync(path));
468
+ /** A file as it is now: measured, confirmed gone (ENOENT only), or unknown (any other failure: never taken as gone). */
469
+ const measure = (fileName, runId, path = at(fileName)) => {
470
+ try {
471
+ const stat = lstat(path);
472
+ return { fileName, runId, dev: stat.dev, ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs, bytes: stat.isFile() ? stat.size : 0 };
410
473
  }
411
- else {
412
- removable.push({ fileName: entry.fileName, ageAnchorMs: entry.mtimeMs });
474
+ catch (error) {
475
+ return error.code === "ENOENT" ? "gone" : "unknown";
413
476
  }
414
- }
415
- removable.sort((a, b) => a.ageAnchorMs - b.ageAnchorMs);
416
- const cutoff = now.getTime() - LIVE_RETAIN_MS;
417
- const survivors = [];
418
- for (const one of removable) {
419
- if (one.ageAnchorMs <= cutoff) {
420
- removeLiveFile(root, one.fileName);
421
- removed.push(one.fileName);
477
+ };
478
+ const same = (a, b) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs;
479
+ /** The run is still not active, proved under the store's write lock (the one run admission takes) — and only that. */
480
+ const stillSweepable = (runId) => {
481
+ const check = () => { const state = settled(runId); return state !== "active" && state !== "unknown"; };
482
+ try {
483
+ return store.transact === undefined ? check() : store.transact(check);
484
+ }
485
+ catch {
486
+ return false;
487
+ }
488
+ };
489
+ /** Put a claimed file back under its name without overwriting whatever may be there now. */
490
+ const restore = (claim, fileName) => {
491
+ try {
492
+ linkSync(at(claim), at(fileName));
493
+ unlinkSync(at(claim));
494
+ }
495
+ catch { /* the name is taken: the claim stays, unlisted */ }
496
+ };
497
+ /**
498
+ * Remove one measured file: its run still not active, then the name atomically claimed under a private name in the
499
+ * live directory, then the claim proved to be the very file measured (dev, inode, size, mtime) and the run still not
500
+ * active — and only then is the private claim deleted. A mismatch is put back without overwriting anything. The
501
+ * store's lock covers the run-state checks only; the filesystem work runs outside it.
502
+ */
503
+ const remove = (file) => {
504
+ if (file.failed === true)
505
+ return false;
506
+ const done = (gone) => { if (gone)
507
+ removed.push(file.fileName);
508
+ else
509
+ file.failed = true; return gone; };
510
+ io.beforeRemove?.(file.fileName);
511
+ if (file.fileName.includes("/") || file.fileName.includes("..") || !stillSweepable(file.runId))
512
+ return done(false);
513
+ io.afterCheck?.(file.fileName);
514
+ const claim = `${QUARANTINE_PREFIX}${randomBytes(12).toString("hex")}`;
515
+ try {
516
+ renameSync(at(file.fileName), at(claim));
517
+ }
518
+ catch (error) {
519
+ return done(error.code === "ENOENT");
422
520
  }
423
- else {
424
- survivors.push(one);
521
+ const claimed = measure(file.fileName, file.runId, at(claim));
522
+ if (claimed === "gone")
523
+ return done(true);
524
+ if (claimed === "unknown" || !same(claimed, file) || !stillSweepable(file.runId)) {
525
+ restore(claim, file.fileName);
526
+ return done(false);
425
527
  }
528
+ if (removeLiveFile(root, claim, io.unlink))
529
+ return done(true);
530
+ restore(claim, file.fileName);
531
+ return done(false);
532
+ };
533
+ // One group per finished run (keyed by id) or per orphan file, aged from the run's end or the file's mtime.
534
+ const groups = new Map();
535
+ // Settle every listed run first, then list again and measure: a file a run wrote (or grew) before it finished is
536
+ // seen at its final size, and one its run created after the first listing is counted with it.
537
+ const states = new Map();
538
+ for (const entry of listLiveFiles(root))
539
+ if (entry.runId !== null && !states.has(entry.runId))
540
+ states.set(entry.runId, settled(entry.runId));
541
+ for (const entry of listLiveFiles(root)) {
542
+ // A run first seen in this listing waits for the next pass: it was never settled before its files were listed.
543
+ if (entry.runId !== null && !states.has(entry.runId))
544
+ continue;
545
+ const state = entry.runId === null ? null : states.get(entry.runId);
546
+ if (state === "active" || state === "unknown")
547
+ continue; // live, or unknown this pass: kept, never guessed
548
+ // Measured only now, after the run is settled finished: its writer is done.
549
+ const file = measure(entry.fileName, entry.runId);
550
+ if (file === "gone" || file === "unknown")
551
+ continue;
552
+ if (entry.cap !== null && file.bytes > entry.cap && remove(file))
553
+ continue;
554
+ const finished = state === null || state.finishedAt === null ? null : Date.parse(state.finishedAt);
555
+ const anchor = finished === null || Number.isNaN(finished) ? file.mtimeMs : finished;
556
+ const key = entry.runId === null ? `file:${entry.fileName}` : `run:${entry.runId}`;
557
+ const group = groups.get(key) ?? { files: [], ageAnchorMs: anchor };
558
+ group.files.push(file);
559
+ group.ageAnchorMs = Math.min(group.ageAnchorMs, anchor);
560
+ groups.set(key, group);
426
561
  }
427
- // Oldest-first beyond the bound — still never touching an active run.
428
- for (let index = 0; index < survivors.length - LIVE_FILE_BOUND; index += 1) {
429
- const one = survivors[index];
430
- removeLiveFile(root, one.fileName);
431
- removed.push(one.fileName);
562
+ /** Remove a group's files; what can't be removed stays in it (and in every count after). True when it's empty. */
563
+ const removeGroup = (group) => {
564
+ group.files = group.files.filter(file => !remove(file));
565
+ return group.files.length === 0;
566
+ };
567
+ const cutoff = now.getTime() - LIVE_RETAIN_MS;
568
+ let kept = [...groups.values()].sort((a, b) => a.ageAnchorMs - b.ageAnchorMs).filter(group => group.ageAnchorMs > cutoff || !removeGroup(group));
569
+ // Oldest-first beyond the bound, continuing past any that can't go — still never touching an active run.
570
+ let excess = kept.length - LIVE_FILE_BOUND;
571
+ kept = kept.filter(group => excess <= 0 || !removeGroup(group) || (excess -= 1, false));
572
+ // Then the total, oldest first, counting only what is really still there.
573
+ const bytesOf = (group) => group.files.reduce((sum, file) => sum + file.bytes, 0);
574
+ let total = kept.reduce((sum, group) => sum + bytesOf(group), 0);
575
+ for (const group of kept) {
576
+ if (total <= LIVE_TOTAL_BYTES)
577
+ break;
578
+ const before = bytesOf(group);
579
+ removeGroup(group);
580
+ total -= before - bytesOf(group);
432
581
  }
433
582
  return { removed };
434
583
  }
@@ -24,8 +24,11 @@ export type OpenRouterModels = {
24
24
  export declare function parseOpenRouterModels(data: unknown): OpenRouterModel[] | null;
25
25
  export declare function fetchOpenRouterModels(key: string | null, fetcher?: typeof fetch): Promise<OpenRouterModels>;
26
26
  /** One bounded cache per console. A changed/removed key cannot reuse another account's list. */
27
- export declare function openRouterModelsCache(fetcher?: typeof fetch): (key: string | null, refresh?: boolean) => Promise<OpenRouterModels>;
27
+ export declare function openRouterModelsCache(fetcher?: typeof fetch): ((key: string | null, refresh?: boolean) => Promise<OpenRouterModels>) & {
28
+ known: (key: string | null) => OpenRouterModels | null;
29
+ };
28
30
  export declare function modelDollars(amount: number | null): string;
29
- export declare function openRouterPicker(catalog: OpenRouterModels, selected: string | null, refreshUrl: string): Html;
31
+ /** `refreshAction`: where the Refresh models button posts (the picker sits inside a form); a page never fetches. */
32
+ export declare function openRouterPicker(catalog: OpenRouterModels, selected: string | null, refreshAction: string): Html;
30
33
  /** Only filters already-rendered options; credentials never enter this script. */
31
34
  export declare function openRouterPickerScript(): string;
@@ -60,7 +60,9 @@ export async function fetchOpenRouterModels(key, fetcher = fetch) {
60
60
  export function openRouterModelsCache(fetcher = fetch) {
61
61
  let cache = null;
62
62
  let pending = null;
63
- return async (key, refresh = false) => {
63
+ /** The list last loaded for this key, without fetching: what a page shows (docs/plans/get-side-effects.md). */
64
+ const known = (key) => cache?.fingerprint === createHash("sha256").update(key ?? "public").digest("hex") ? cache.result : null;
65
+ return Object.assign(async (key, refresh = false) => {
64
66
  const fingerprint = createHash("sha256").update(key ?? "public").digest("hex");
65
67
  if (!refresh && cache?.fingerprint === fingerprint && cache.expires > Date.now())
66
68
  return cache.result;
@@ -75,7 +77,7 @@ export function openRouterModelsCache(fetcher = fetch) {
75
77
  pending = null;
76
78
  }
77
79
  return value;
78
- };
80
+ }, { known });
79
81
  }
80
82
  export function modelDollars(amount) {
81
83
  if (amount === null)
@@ -93,13 +95,14 @@ function details(model) {
93
95
  (model.conditionalPricing ? "Rates change with context length or other conditions. " : "") +
94
96
  (model.extraCharges ? "Additional usage charges may apply. " : "") + "Actual charges depend on routing and usage.";
95
97
  }
96
- export function openRouterPicker(catalog, selected, refreshUrl) {
98
+ /** `refreshAction`: where the Refresh models button posts (the picker sits inside a form); a page never fetches. */
99
+ export function openRouterPicker(catalog, selected, refreshAction) {
97
100
  const rows = catalog.ok ? catalog.models : [];
98
101
  const current = rows.find(row => row.id === selected);
99
102
  const manual = selected && !current ? html `<option selected value="${selected}" data-detail="This saved model is not in the loaded catalog. Availability and pricing are unverified.">${selected} · Saved choice</option>` : "";
100
103
  return html `<div data-openrouter-picker><p class="meta">${catalog.ok
101
104
  ? `${rows.length} text models · ${catalog.source === "account" ? "Filtered for your OpenRouter account" : "Public catalog · Connect a key to check your account"} · Updated ${catalog.checkedAt.slice(11, 16)} UTC`
102
- : catalog.problem} <a href="${refreshUrl}">Refresh models</a></p><label data-model-search hidden>Search models<input type="search" placeholder="Search by name or provider" autocomplete="off"></label><label>Model<select name="model" aria-describedby="openrouter-price"><option value="__custom__"${selected === null ? html ` selected` : ""}>Choose a model</option>${manual}${rows.map(row => html `<option value="${row.id}" data-detail="${details(row)}"${row.id === selected ? html ` selected` : ""}>${row.name} · ${modelDollars(row.input)} in / ${modelDollars(row.output)} out per 1M</option>`)}</select></label><p id="openrouter-price" class="meta" aria-live="polite">${current ? details(current) : selected ? "This saved model is not in the loaded catalog. Availability and pricing are unverified." : "Choose a model to see its token prices and context size."}</p><p data-model-count class="meta" aria-live="polite"></p><p class="meta"><a href="https://openrouter.ai/models" target="_blank" rel="noreferrer">Compare models on OpenRouter ↗</a> · Prices in USD; catalog browsing uses no model tokens.</p></div>`;
105
+ : catalog.problem} <button type="submit" class="secondary" formaction="${refreshAction}" formnovalidate>Refresh models</button></p><label data-model-search hidden>Search models<input type="search" placeholder="Search by name or provider" autocomplete="off"></label><label>Model<select name="model" aria-describedby="openrouter-price"><option value="__custom__"${selected === null ? html ` selected` : ""}>Choose a model</option>${manual}${rows.map(row => html `<option value="${row.id}" data-detail="${details(row)}"${row.id === selected ? html ` selected` : ""}>${row.name} · ${modelDollars(row.input)} in / ${modelDollars(row.output)} out per 1M</option>`)}</select></label><p id="openrouter-price" class="meta" aria-live="polite">${current ? details(current) : selected ? "This saved model is not in the loaded catalog. Availability and pricing are unverified." : "Choose a model to see its token prices and context size."}</p><p data-model-count class="meta" aria-live="polite"></p><p class="meta"><a href="https://openrouter.ai/models" target="_blank" rel="noreferrer">Compare models on OpenRouter ↗</a> · Prices in USD; catalog browsing uses no model tokens.</p></div>`;
103
106
  }
104
107
  /** Only filters already-rendered options; credentials never enter this script. */
105
108
  export function openRouterPickerScript() {
package/dist/operate.js CHANGED
@@ -3907,6 +3907,8 @@ async function startConsole(options) {
3907
3907
  const { context } = options;
3908
3908
  const server = createDecisionServer({
3909
3909
  store: context.store,
3910
+ // Sign-in, integration and model checks, and the tidy-ups no page visit does (docs/plans/get-side-effects.md).
3911
+ backgroundChecks: true,
3910
3912
  ...(context.desktopIdentity === undefined ? {} : { desktopIdentity: context.desktopIdentity }),
3911
3913
  ...(context.additionalProjectRepos === undefined || options.currentRepos === undefined ? {} : { additionalProjectRepos: options.currentRepos }),
3912
3914
  evidenceRoot: context.evidenceRoot,
@@ -4072,7 +4074,8 @@ async function requeueTask(positional, flags, context) {
4072
4074
  * `toolroll task regate <id>` — the approved check again on the last
4073
4075
  * attempt's exact commit (v70). A new attempt whose prepared candidate is
4074
4076
  * that commit: no agent, a fresh receipt and proof, the ordinary review.
4075
- * The operator's yes seals the rerun scope; who asked is recorded.
4077
+ * The operator's yes seals the rerun scope; who asked is recorded. A task
4078
+ * that already names a prepared candidate keeps its approved scope as it is.
4076
4079
  */
4077
4080
  async function regateTaskCommand(positional, flags, context) {
4078
4081
  const { store, write, json, clock } = context;
@@ -4087,7 +4090,7 @@ async function regateTaskCommand(positional, flags, context) {
4087
4090
  if (!result.ok)
4088
4091
  return fail(write, json, "task regate", result.reason, result.message, EXIT.refused);
4089
4092
  return succeed(write, json, "task regate", { id, run: result.run, head: result.head }, () => [
4090
- `${id} is queued again: the approved check runs on commit ${result.head.slice(0, 7)} exactly as attempt #${result.run} left it, with no agent. A fresh review follows.`,
4093
+ `${id} is queued again: the approved check runs on commit ${result.head.slice(0, 7)}, the same files attempt #${result.run} checked, with no agent. A fresh review follows.`,
4091
4094
  ]);
4092
4095
  }
4093
4096
  /**
@@ -14,7 +14,7 @@
14
14
  * Read-only by construction: nothing here writes to the store or the
15
15
  * files; a peek is a look.
16
16
  */
17
- import type { Store } from "./store.js";
17
+ import { type Store } from "./store.js";
18
18
  export declare const PEEK_TAIL_LINES = 400;
19
19
  export type PeekPane = {
20
20
  runId: number;
@@ -39,14 +39,21 @@ type Tail = {
39
39
  gen?: string;
40
40
  inKey?: boolean;
41
41
  keyTail?: string;
42
+ /** The "Show commands" answer the cached lines were made under; absent is unknown (treated as on: never trusted). */
43
+ commands?: boolean;
42
44
  };
43
45
  /**
44
46
  * Advance one run's tail from its offset; `replaced` resets visibly. The
45
47
  * offset always moves by the bytes read, even when nothing in them shows
46
48
  * (a finished step, a skipped line), and an offset is only ever used
47
- * against the file it came from.
49
+ * against the file it came from. `commands` is the run's own captured
50
+ * "Show commands" answer (store.ts runLiveCommands), read now: lines cached
51
+ * under any other answer (off, on, or unknown) are dropped and the file is
52
+ * read again from its start under this one, on every path — nothing
53
+ * projected with command detail outlives the answer turning off, and a
54
+ * recovered answer shows the detail again.
48
55
  */
49
- export declare function advanceTail(root: string, runId: number, tail: Tail): Tail;
56
+ export declare function advanceTail(root: string, runId: number, previous: Tail, commands: boolean): Tail;
50
57
  /** The stage word for a pane: the machine's own phase, or what precedes it. */
51
58
  export declare function stageWord(run: {
52
59
  phase: string | null;
package/dist/peek-cli.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import { spawn } from "node:child_process";
18
18
  import { liveWindowLines, readLiveWindow } from "./live.js";
19
19
  import { validKeyTail } from "./live-records.js";
20
+ import { runLiveCommands } from "./store.js";
20
21
  import { sleep } from "./sleep.js";
21
22
  export const PEEK_TAIL_LINES = 400;
22
23
  const ESC = "\u001b";
@@ -25,21 +26,29 @@ const REPLACED = { offset: 0, lines: ["[the live view was replaced — starting
25
26
  * Advance one run's tail from its offset; `replaced` resets visibly. The
26
27
  * offset always moves by the bytes read, even when nothing in them shows
27
28
  * (a finished step, a skipped line), and an offset is only ever used
28
- * against the file it came from.
29
+ * against the file it came from. `commands` is the run's own captured
30
+ * "Show commands" answer (store.ts runLiveCommands), read now: lines cached
31
+ * under any other answer (off, on, or unknown) are dropped and the file is
32
+ * read again from its start under this one, on every path — nothing
33
+ * projected with command detail outlives the answer turning off, and a
34
+ * recovered answer shows the detail again.
29
35
  */
30
- export function advanceTail(root, runId, tail) {
36
+ export function advanceTail(root, runId, previous, commands) {
37
+ const tail = previous.commands !== commands && (previous.lines.length > 0 || previous.offset > 0)
38
+ ? { offset: 0, lines: [], state: "not-yet" }
39
+ : previous;
31
40
  const read = readLiveWindow(root, runId, tail.offset);
32
41
  if (!read.ok) {
33
42
  if (read.reason === "replaced")
34
- return REPLACED;
35
- return { ...tail, keyTail: validKeyTail(tail.keyTail ?? "") ? tail.keyTail ?? "" : "", state: read.reason === "missing" ? (tail.lines.length === 0 ? "not-yet" : tail.state) : "unreadable" };
43
+ return { ...REPLACED, commands };
44
+ return { ...tail, commands, keyTail: validKeyTail(tail.keyTail ?? "") ? tail.keyTail ?? "" : "", state: read.reason === "missing" ? (tail.lines.length === 0 ? "not-yet" : tail.state) : "unreadable" };
36
45
  }
37
46
  if (tail.gen !== undefined && tail.gen !== read.gen)
38
- return { ...REPLACED, gen: read.gen };
47
+ return { ...REPLACED, gen: read.gen, commands };
39
48
  const keys = { inKey: tail.inKey === true, tail: validKeyTail(tail.keyTail ?? "") ? tail.keyTail ?? "" : "" };
40
- const fresh = liveWindowLines(read, keys);
49
+ const fresh = liveWindowLines(read, commands, keys);
41
50
  const lines = [...tail.lines, ...fresh].slice(-PEEK_TAIL_LINES);
42
- return { offset: read.nextOffset, lines, state: lines.length === 0 ? "not-yet" : "live", gen: read.gen, inKey: keys.inKey, keyTail: keys.tail };
51
+ return { offset: read.nextOffset, lines, state: lines.length === 0 ? "not-yet" : "live", gen: read.gen, inKey: keys.inKey, keyTail: keys.tail, commands };
43
52
  }
44
53
  /** The stage word for a pane: the machine's own phase, or what precedes it. */
45
54
  export function stageWord(run) {
@@ -58,7 +67,7 @@ export function snapshotLiveRuns(store, root, now, tails = new Map()) {
58
67
  const panes = [];
59
68
  for (const run of store.liveRuns(now)) {
60
69
  const previous = tails.get(run.id) ?? { offset: 0, lines: [], state: "not-yet" };
61
- const tail = advanceTail(root, run.id, previous);
70
+ const tail = advanceTail(root, run.id, previous, runLiveCommands(store, run.id));
62
71
  tails.set(run.id, tail);
63
72
  panes.push({
64
73
  runId: run.id,
package/dist/planner.js CHANGED
@@ -585,7 +585,7 @@ export async function plan(store, request) {
585
585
  }
586
586
  // The live window (peek): the same transcript file the builder keeps,
587
587
  // so `toolroll peek` and the run page can watch this session too.
588
- const liveLog = withRunActivity(openLiveLog(root, request.runId, liveLogContext(store, taskRef, worktree)), store, request.runId, clock);
588
+ const liveLog = withRunActivity(openLiveLog(root, request.runId, liveLogContext(store, request.runId, taskRef, worktree, clock())), store, request.runId, clock);
589
589
  // The durable run row, not a caller's duplicate options, is the route
590
590
  // every correction inherits.
591
591
  const provider = logicalRun.provider;