toolroll 0.9.60 → 0.9.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/agent-board.d.ts +208 -0
  3. package/dist/agent-board.js +454 -0
  4. package/dist/agent-fence.d.ts +19 -0
  5. package/dist/agent-fence.js +100 -8
  6. package/dist/agent-pulse.d.ts +43 -0
  7. package/dist/agent-pulse.js +214 -0
  8. package/dist/agent-turns.d.ts +52 -0
  9. package/dist/agent-turns.js +137 -0
  10. package/dist/agents-board.d.ts +101 -0
  11. package/dist/agents-board.js +225 -0
  12. package/dist/assignment-adapters.d.ts +1 -0
  13. package/dist/assignment-status.d.ts +1 -1
  14. package/dist/assignment.d.ts +4 -0
  15. package/dist/assignment.js +8 -6
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +29 -25
  18. package/dist/browser-crew.js +2 -0
  19. package/dist/browser-workspace.d.ts +79 -1
  20. package/dist/browser-workspace.js +1 -0
  21. package/dist/builder.d.ts +8 -0
  22. package/dist/builder.js +113 -13
  23. package/dist/chat-channel.js +9 -0
  24. package/dist/chat-core.d.ts +6 -0
  25. package/dist/chat-core.js +8 -0
  26. package/dist/chat-delivery.js +22 -0
  27. package/dist/chat-messages.js +8 -1
  28. package/dist/chat-quiet.js +1 -0
  29. package/dist/chat-voice.d.ts +2 -0
  30. package/dist/chat-voice.js +2 -0
  31. package/dist/checkout-cleanup.d.ts +3 -0
  32. package/dist/checkout-cleanup.js +11 -2
  33. package/dist/contracts/console-api.d.ts +3 -1
  34. package/dist/contracts/console-api.js +5 -1
  35. package/dist/contracts/lead-tools.d.ts +16 -2
  36. package/dist/contracts/lead-tools.js +5 -1
  37. package/dist/desktop-update.d.ts +1 -1
  38. package/dist/evidence-pack.js +1 -1
  39. package/dist/evidence.d.ts +6 -0
  40. package/dist/evidence.js +14 -3
  41. package/dist/exec.d.ts +32 -0
  42. package/dist/exec.js +229 -10
  43. package/dist/invoke.js +110 -18
  44. package/dist/landed-cleanup.d.ts +157 -0
  45. package/dist/landed-cleanup.js +707 -0
  46. package/dist/lead-cli.js +22 -19
  47. package/dist/lead-contract.js +1 -1
  48. package/dist/lead-doors.js +45 -1
  49. package/dist/lead-tools.js +40 -7
  50. package/dist/live-input.d.ts +68 -0
  51. package/dist/live-input.js +254 -0
  52. package/dist/live.d.ts +1 -1
  53. package/dist/live.js +4 -2
  54. package/dist/maintenance.d.ts +2 -0
  55. package/dist/maintenance.js +5 -2
  56. package/dist/operate-remote-arguments.js +5 -1
  57. package/dist/operate-remote.js +1 -1
  58. package/dist/operate.d.ts +1 -1
  59. package/dist/operate.js +188 -16
  60. package/dist/orphan-sweep.d.ts +2 -0
  61. package/dist/orphan-sweep.js +5 -3
  62. package/dist/project-delete-ui.d.ts +11 -0
  63. package/dist/project-delete-ui.js +29 -2
  64. package/dist/project-tools.d.ts +7 -11
  65. package/dist/project-tools.js +13 -37
  66. package/dist/provider.d.ts +65 -8
  67. package/dist/provider.js +80 -21
  68. package/dist/result-completion.d.ts +7 -0
  69. package/dist/result-completion.js +9 -0
  70. package/dist/runner.d.ts +4 -2
  71. package/dist/runner.js +3 -3
  72. package/dist/serve.js +19 -1
  73. package/dist/server/live.d.ts +3 -0
  74. package/dist/server/live.js +35 -2
  75. package/dist/server/pages.js +59 -35
  76. package/dist/server/render-chat.js +12 -0
  77. package/dist/server/render-tasks.d.ts +12 -2
  78. package/dist/server/render-tasks.js +27 -7
  79. package/dist/server/route-table.js +4 -1
  80. package/dist/server/settings.js +2 -1
  81. package/dist/server/task-views.js +9 -0
  82. package/dist/server/tasks.js +63 -0
  83. package/dist/shared-deps.d.ts +9 -4
  84. package/dist/shared-deps.js +89 -13
  85. package/dist/staged-runtimes.d.ts +16 -4
  86. package/dist/staged-runtimes.js +160 -38
  87. package/dist/steer-live.d.ts +19 -0
  88. package/dist/steer-live.js +93 -0
  89. package/dist/steer-status.d.ts +8 -2
  90. package/dist/steer-status.js +19 -5
  91. package/dist/storage-sweep-child.d.ts +1 -0
  92. package/dist/storage-sweep-child.js +76 -0
  93. package/dist/storage-sweep-process.d.ts +41 -0
  94. package/dist/storage-sweep-process.js +187 -0
  95. package/dist/storage-sweep.d.ts +30 -6
  96. package/dist/storage-sweep.js +214 -28
  97. package/dist/storage-ui.d.ts +2 -0
  98. package/dist/storage-ui.js +16 -4
  99. package/dist/storage.d.ts +6 -0
  100. package/dist/storage.js +35 -0
  101. package/dist/store.d.ts +163 -2
  102. package/dist/store.js +350 -15
  103. package/dist/surface.js +4 -2
  104. package/dist/sweep-quarantine.d.ts +28 -0
  105. package/dist/sweep-quarantine.js +153 -0
  106. package/dist/task-status.d.ts +2 -0
  107. package/dist/task-status.js +5 -1
  108. package/dist/test-temp.d.ts +6 -3
  109. package/dist/test-temp.js +15 -9
  110. package/dist/toolroll-update.js +3 -2
  111. package/dist/work-index.d.ts +1 -0
  112. package/dist/work-index.js +32 -12
  113. package/dist/work-summary.d.ts +2 -2
  114. package/dist/workspace-revision.js +3 -1
  115. package/dist/worktree.d.ts +3 -1
  116. package/dist/worktree.js +93 -10
  117. package/package.json +1 -1
  118. package/scripts/provider-canary.mjs +235 -147
@@ -21,7 +21,8 @@ import { run as execRun } from "../exec.js";
21
21
  import { computeGaps } from "../gaps.js";
22
22
  import { LEDGER_CSV_HEADER, ledgerCsvRows } from "../ledger-csv.js";
23
23
  import { ledgerBody } from "../ledger-view.js";
24
- import { readLiveWindow } from "../live.js";
24
+ import { agentRowOf, agentsBoardOf, agentFactsFor, sealedDiffOf } from "../agents-board.js";
25
+ import { markAgentSeen } from "../agent-board.js";
25
26
  import { prometheusMetrics } from "../metrics.js";
26
27
  import { readMonitoring } from "../monitoring-settings.js";
27
28
  import { targetOf } from "../monitoring.js";
@@ -374,42 +375,64 @@ export function createPagesHandlers(runtime) {
374
375
  now,
375
376
  }));
376
377
  }
377
- // The multiplexer in the console (peek): one pane per live run the
378
- // ceiling admits, each fed by the run page's own transcript poller
379
- // (the byte-offset JSON protocol — text into textContent, never
380
- // markup). The pane SET is re-checked on a slow cadence; when it
381
- // changes the page reloads rather than swapping pollers mid-flight.
378
+ // Peek became the Agents board: one place to check on every running agent (agents-board.ts).
382
379
  async function peekPage(ctx) {
383
- const { url, response, project } = ctx;
384
- const live = store.liveRuns(clock()).filter(run => runVisible(run));
385
- if (url.searchParams.get("fragment") === "1") {
386
- return respond(response, 200, "application/json", JSON.stringify({ runs: live.map(run => run.id) }));
387
- }
380
+ return redirect(ctx.response, "/agents");
381
+ }
382
+ /** What the board says after a Reply or a steer, in fixed words (never text from the address). */
383
+ const AGENT_SAID = {
384
+ replied: { tone: "info", text: "Reply sent. The agent reads it at its next check." },
385
+ steered: { tone: "info", text: "Note sent. The agent reads it at its next check." },
386
+ "reply-closed": { tone: "error", text: "That question was already answered." },
387
+ "reply-stale": { tone: "error", text: "This attempt ended before your reply, so nothing was sent." },
388
+ "reply-invalid": { tone: "error", text: "Write a reply of up to 1,500 characters." },
389
+ };
390
+ // The Agents board (one place to check on running agents): rows by state, and the opened agent's live view.
391
+ async function agentsPage(ctx) {
392
+ const { url, who, response, project } = ctx;
388
393
  const now = clock();
389
- const panes = live.map(run => {
390
- const window = options.localRunner === undefined ? null : readLiveWindow(evidenceRoot, run.id, 0);
391
- const tail = window !== null && window.ok ? window.text.split("\n").filter(one => one !== "").slice(-40).join("\n") : "";
392
- const stage = run.phase !== null ? run.phase.replace(/-/g, " ") : run.providerStartedAt === null ? "preparing workspace" : "the agent is working";
393
- return joinHtml([
394
- html `<section class="card peek-pane" data-run="${run.id}">`,
395
- html `<p class="row"><a href="/r/${run.id}" class="mono">#${run.id}</a> <a href="${taskHref(run.taskId)}"><strong>${run.title}</strong></a>${projectChip(run.repo)} <span class="right meta mono">${run.runner} \u00b7 ${run.role} \u00b7 ${stage} \u00b7 ${relativeAge(run.startedAt, now)}</span></p>`,
396
- options.localRunner === undefined
397
- ? html `<p class="meta">The transcript is readable on the machine that runs the worker \u2014 start the console there with <code>toolroll up</code></p>`
398
- : html `<pre class="recap plan-doc live-pane" id="live-transcript-${run.id}" data-initial-offset="0">${tail}</pre><p class="meta" id="live-transcript-${run.id}-state"></p>`,
399
- html `</section>`,
400
- ], "\n");
401
- });
402
- const script = (options.localRunner === undefined ? "" : live.map(run => transcriptScript(`/r/${run.id}`, `live-transcript-${run.id}`)).join("")) +
403
- `(function(){var seen=${JSON.stringify(live.map(run => run.id))};function check(){if(document.hidden){setTimeout(check,12000);return;}` +
404
- `fetch("/peek?fragment=1",{redirect:"manual",cache:"no-store"}).then(function(r){return r.ok?r.json():null;})` +
405
- `.then(function(d){if(d&&Array.isArray(d.runs)&&JSON.stringify(d.runs)!==JSON.stringify(seen)){location.reload();return;}setTimeout(check,12000);})` +
406
- `.catch(function(){setTimeout(check,12000);});}setTimeout(check,12000);})();`;
407
- return sendScreen(response, 200, screen("peek", joinHtml([
408
- html `<h1>Peek</h1>`,
409
- panes.length === 0
410
- ? html `<p class="meta">No agent is working right now \u2014 this page follows them the moment one starts</p>`
411
- : joinHtml(panes, "\n"),
412
- ], "\n"), { chrome: chromeFor(project, "runs"), functional: { script, fetches: true } }));
394
+ const access = workAccess();
395
+ const asked = url.searchParams.get("run");
396
+ let selected = null;
397
+ if (asked !== null && /^[1-9][0-9]{0,9}$/.test(asked)) {
398
+ const run = store.getRun(Number(asked));
399
+ const facts = run === null || !runVisible(run) ? null : agentFactsFor(store, run.id, access, who.name);
400
+ if (facts !== null) {
401
+ // Opening a finished agent is viewing it: it leaves this person's unread Done.
402
+ if (facts.outcome !== null && markAgentSeen(store, who.name, facts.runId, now))
403
+ facts.seen = true;
404
+ const live = facts.outcome === null;
405
+ const sealed = live ? null : sealedDiffOf(store, evidenceRoot, facts.runId);
406
+ // A reply's own word first; otherwise the note that the agent asked past the questions cap.
407
+ const said = AGENT_SAID[url.searchParams.get("said") ?? ""] ?? (facts.questionNote === null ? null : { tone: "info", text: facts.questionNote });
408
+ selected = {
409
+ ...agentRowOf(store, facts, now, evidenceRoot), live,
410
+ question: live && facts.question !== null ? { ...facts.question, action: `${taskHref(facts.taskId)}/reply` } : null,
411
+ transcript: options.localRunner === undefined ? null : `/r/${facts.runId}?fragment=transcript`,
412
+ // The live look at its checkout needs this machine's runner and pool, as the run page's does.
413
+ peek: live && options.localRunner !== undefined && options.poolRoot !== undefined ? `/r/${facts.runId}?fragment=peek` : null,
414
+ files: (sealed?.files ?? []).slice(0, 30).map(one => ({ path: one.path, added: one.additions, removed: one.deletions })),
415
+ filesMore: Math.max(0, (sealed?.fileCount ?? 0) - 30),
416
+ steer: live ? { action: `${taskHref(facts.taskId)}/steer`, run: facts.runId } : null,
417
+ canAct: who.role === "approver" && !store.isDemo(),
418
+ taskHref: taskHref(facts.taskId),
419
+ // The run's own address: a finished result opens on its result page, anything else on its record.
420
+ resultHref: live ? null : `/r/${facts.runId}`,
421
+ room: `agent:${facts.runId}`, said,
422
+ };
423
+ }
424
+ }
425
+ const groups = agentsBoardOf(store, now, access, who.name, evidenceRoot);
426
+ const view = { kind: "agents", groups, selected,
427
+ empty: groups.length === 0 ? "No agents in the last day. They show here the moment one starts." : null };
428
+ const fallback = joinHtml([
429
+ html `<h1>Agents</h1>`,
430
+ groups.length === 0 ? html `<p class="meta">${view.empty ?? ""}</p>` : joinHtml(groups.map(group => joinHtml([
431
+ html `<h2>${group.label}</h2>`,
432
+ ...group.rows.map(row => html `<p class="row"><a href="${row.href}"><strong>${row.title}</strong></a> <span class="meta">${[row.label, row.line].filter(Boolean).join(" \u00b7 ")}</span></p>`),
433
+ ], "\n")), "\n"),
434
+ ], "\n");
435
+ return sendScreen(response, 200, screen("Agents", fallback, { chrome: chromeFor(project, "runs"), workspace: { view } }));
413
436
  }
414
437
  async function fleetPage(ctx) {
415
438
  const { url, who, response, project } = ctx;
@@ -901,6 +924,7 @@ export function createPagesHandlers(runtime) {
901
924
  "activity": activityPage,
902
925
  "system": systemScreen,
903
926
  "peek": peekPage,
927
+ "agents": agentsPage,
904
928
  "fleet": fleetPage,
905
929
  "ledger.export": ledgerExport,
906
930
  "menu": menuPage,
@@ -570,6 +570,18 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
570
570
  else if (view.kind === "unhold") {
571
571
  what = html `<h3>Release <a href="${taskHref(task)}">${task}</a> from its hold</h3>${facts(["project", mono(repoId)])}`;
572
572
  }
573
+ else if (view.kind === "steer" && payload["mode"] === "pause") {
574
+ const taskTitle = text("taskTitle") || task;
575
+ what = html `<h3>Pause <a href="${taskHref(task)}">${taskTitle}</a></h3>${facts(["project", mono(repoId)])}<p class="meta proposal-disclosure">Its current turn stops and the build keeps running; its next note continues it.</p>`;
576
+ }
577
+ else if (view.kind === "steer" && payload["mode"] === "resume") {
578
+ const taskTitle = text("taskTitle") || task;
579
+ what = html `<h3>Resume <a href="${taskHref(task)}">${taskTitle}</a></h3>${facts(["project", mono(repoId)])}<p class="meta proposal-disclosure">It goes on where it stopped. No note is sent.</p>`;
580
+ }
581
+ else if (view.kind === "steer" && payload["mode"] === "pause-and-steer") {
582
+ const taskTitle = text("taskTitle") || task;
583
+ what = html `<h3>Pause <a href="${taskHref(task)}">${taskTitle}</a> and send a note</h3><p class="proposal-summary">${text("note")}</p>${facts(["project", mono(repoId)])}<p class="meta proposal-disclosure">Its current turn stops first. The task’s scope doesn’t change.</p>`;
584
+ }
573
585
  else if (view.kind === "steer") {
574
586
  const taskTitle = text("taskTitle") || task;
575
587
  what = html `<h3>Guidance for <a href="${taskHref(task)}">${taskTitle}</a></h3><p class="proposal-summary">${text("note")}</p>${facts(["project", mono(repoId)])}<p class="meta proposal-disclosure">A running agent reads it when it next checks for notes; otherwise the next attempt does. The task’s scope doesn’t change.</p>`;
@@ -264,10 +264,18 @@ export declare function taskStatusCard(status: DisplayStatus & {
264
264
  diagnostics?: WorkStatus["diagnostics"];
265
265
  }, taskId: string, dispatch: DispatchDiagnosis | null, runId: number | null, approvalAction?: boolean): Html;
266
266
  export declare function checkProgressHtml(progress: CheckProgress | null, id?: string): Html;
267
- export declare function taskControlDetailsHtml(control: TaskControlView, taskId: string, csrf: string, surface: "task" | "chat", inert?: boolean, stopElsewhere?: boolean): Html;
267
+ export declare function taskControlDetailsHtml(control: TaskControlView, taskId: string, csrf: string, surface: "task" | "chat", inert?: boolean, stopElsewhere?: boolean, livePause?: LivePauseFact | null): Html;
268
268
  /** `stopElsewhere`: the Building card already carries this exact run's Stop, so the control card keeps only its details.
269
269
  * `csrf` is only a guard now: an empty one (a read-only surface) renders no forms; postForm writes the field. */
270
- export declare function taskControlHtml(control: TaskControlView, taskId: string, csrf: string, surface: "task" | "chat", inert?: boolean, stopElsewhere?: boolean): Html;
270
+ /** Live steering: the Claude build that takes Pause right now, its latest pause (null: none, or it continued), and
271
+ * until when a paused build can wait before it ends (null: no idle limit). */
272
+ export type LivePauseFact = {
273
+ run: number;
274
+ state: "pausing" | "paused" | null;
275
+ until?: string | null;
276
+ limitMs?: number | null;
277
+ };
278
+ export declare function taskControlHtml(control: TaskControlView, taskId: string, csrf: string, surface: "task" | "chat", inert?: boolean, stopElsewhere?: boolean, livePause?: LivePauseFact | null): Html;
271
279
  /** The resume confirmation (v52): the exact run restated, what resuming
272
280
  * does and does not do, the gate that would still keep work from
273
281
  * starting, and the password typed again. */
@@ -489,6 +497,8 @@ export declare function taskBodyParts(data: {
489
497
  liveRunId?: number | null;
490
498
  /** What the agent did last on that live run, and when (task-activity.ts). */
491
499
  activity?: RunActivity | null;
500
+ /** Live steering: the Claude build that takes Pause right now, and its latest pause (null: none, or it continued). */
501
+ livePause?: LivePauseFact | null;
492
502
  /** Workers that are both alive and authorized for this task's project. */
493
503
  worker?: {
494
504
  answering: number;
@@ -34,7 +34,8 @@ import { runCostWords } from "../summary.js";
34
34
  import {} from "../task-control.js";
35
35
  import { assignmentStageOf, demoChecksOf, stageOfCode, stageOfDispatch, STATUS_MORE, taskStatusOf } from "../task-status.js";
36
36
  import { buildProgressOf, dispatchActionLabel, earlierAttemptsWords, receiptPublicationWords, resultStatusOf, REVIEW_TOKENS, workStatusOf } from "../workspace-ui.js";
37
- import { createHash } from "node:crypto";
37
+ import { createHash, randomUUID } from "node:crypto";
38
+ import { TEXT_LIMITS } from "../text-limits.js";
38
39
  import { isOverdue, oneLineOf, projectChip, regionScript, safePrUrl, screen, SENSITIVE_INPUT, sentenceCase, shortDigest, transcriptScript, whenTime } from "./chrome.js";
39
40
  import { steerDestinationWords, steerStandingParts } from "../steer-status.js";
40
41
  import { reviewHref, taskChatHref, taskHref } from "./http.js";
@@ -808,14 +809,26 @@ export function checkProgressHtml(progress, id = "check-progress") {
808
809
  : Object.values(progress.suites).every(one => one.state === "passed") ? "passed" : "unknown";
809
810
  return html `<p id="${id}" class="check-progress mono" data-check-progress data-final="${state}" role="status" aria-live="polite">${progress.line}</p>`;
810
811
  }
811
- export function taskControlDetailsHtml(control, taskId, csrf, surface, inert = false, stopElsewhere = false) {
812
- const card = taskControlHtml(control, taskId, csrf, surface, inert, stopElsewhere);
812
+ export function taskControlDetailsHtml(control, taskId, csrf, surface, inert = false, stopElsewhere = false, livePause = null) {
813
+ const card = taskControlHtml(control, taskId, csrf, surface, inert, stopElsewhere, livePause);
813
814
  return control.kind === "paused" || control.kind === "review-stopped" || control.kind === "stopping"
814
815
  ? html `<details id="task-control-details"><summary>${control.kind === "paused" ? "Preserved work and resume" : "Stop details"}</summary>${card}</details>` : card;
815
816
  }
816
- /** `stopElsewhere`: the Building card already carries this exact run's Stop, so the control card keeps only its details.
817
- * `csrf` is only a guard now: an empty one (a read-only surface) renders no forms; postForm writes the field. */
818
- export function taskControlHtml(control, taskId, csrf, surface, inert = false, stopElsewhere = false) {
817
+ /** Pause and Pause and steer beside the task page's Stop (live steering): the same exact run, each form under its own
818
+ * request id so a repeated submit is the same request. Pause hides once asked; a paused build says until when it can
819
+ * wait and offers Resume, or the note to continue with. */
820
+ function pauseFormsHtml(taskId, livePause) {
821
+ const form = (fields, button, control, extra = {}) => postForm(`${taskHref(taskId)}/pause`, html `${fields}${button}`, { attrs: { class: "inline task-control-form", "data-task-control": control }, returnTo: "task", hidden: { run: livePause.run, request: `web-${randomUUID()}`, ...extra } });
822
+ const paused = livePause.state === "paused";
823
+ const until = livePause.until ?? null, limitMs = livePause.limitMs ?? null;
824
+ return joinHtml([
825
+ paused ? html `<p class="meta" role="status" data-live-pause="paused">${until === null || limitMs === null ? "Paused." : html `Paused. It can wait until ${whenTime(until)} (${Math.max(1, Math.round(limitMs / 60_000))} min) before it ends.`}</p>` : "",
826
+ livePause.state === null ? form(html ``, html `<button type="submit" class="task-control-button">Pause</button>`, "pause") : "",
827
+ paused ? form(html ``, html `<button type="submit" class="task-control-button">Resume</button>`, "resume", { mode: "resume" }) : "",
828
+ form(html `<input type="text" name="note" required maxlength="${TEXT_LIMITS.note}" placeholder="${paused ? "note to continue with" : "note for the agent"}" aria-label="${paused ? "note to continue with" : "note for the agent"}">`, html `<button type="submit" class="task-control-button">${paused ? "Continue" : "Pause and steer"}</button>`, "pause-and-steer"),
829
+ ]);
830
+ }
831
+ export function taskControlHtml(control, taskId, csrf, surface, inert = false, stopElsewhere = false, livePause = null) {
819
832
  if (control.kind === "none")
820
833
  return html ``;
821
834
  const guarded = csrf !== "" && !inert;
@@ -833,6 +846,7 @@ export function taskControlHtml(control, taskId, csrf, surface, inert = false, s
833
846
  return joinHtml([
834
847
  html `<section class="card task-control" id="task-control" data-task-control="stop" data-control-run="${control.run}" aria-label="stop this attempt">`,
835
848
  html `<div class="task-control-copy"><details><summary>Stop details · ${role(control)} #${control.run}</summary><p class="meta">Stopping ends only this attempt's own processes. Its branch, uncommitted work, evidence, and decisions stay preserved; other tasks keep running.</p></details></div>`,
849
+ stopElsewhere || !guarded || livePause === null || livePause.run !== control.run ? "" : pauseFormsHtml(taskId, livePause),
836
850
  stopElsewhere ? "" : guarded
837
851
  ? runForm("stop", "inline task-control-form task-stop-form", html `<button type="submit" class="danger task-control-button">Stop</button>`)
838
852
  : html `<button type="button" class="danger task-control-button" disabled>Stop</button>`,
@@ -1894,7 +1908,7 @@ export function taskBodyParts(data) {
1894
1908
  const statusHtml = html `${data.assignment != null && assignmentOptions !== null ? assignmentSummaryHtml(data.assignment, assignmentOptions) : receiptLeads ? completionReceiptCard(data.completion.receipt, task.id, "task", status) : taskStatusCard(status, task.id, data.dispatch ?? null, liveRunId, approveForm !== "" && data.dispatch?.action === "approve-scope")}${checkProgressHtml(data.checkProgress ?? null)}`;
1895
1909
  // The exact-run control (v52), directly under the scheduler's answer:
1896
1910
  // the one place a person stops or resumes THIS attempt.
1897
- const controlHtml = taskControlDetailsHtml(data.control ?? { kind: "none" }, task.id, data.csrf, "task");
1911
+ const controlHtml = taskControlDetailsHtml(data.control ?? { kind: "none" }, task.id, data.csrf, "task", false, false, data.livePause ?? null);
1898
1912
  const previousResult = data.completion?.receipt == null || receiptLeads ? null : {
1899
1913
  title: data.assignment != null && task.state === "done" ? "Result" : "Previous result",
1900
1914
  html: (completionReceiptCard(data.completion.receipt, task.id, "task", data.assignment == null ? receiptStatusOf(data.completion.receipt) : assignmentStatusOf(data.assignment), data.assignment ?? null, data.assignment == null)),
@@ -2175,6 +2189,11 @@ export function taskBodyParts(data) {
2175
2189
  // The Building card carries Stop for the build it describes (the same form), in place of a second card below it.
2176
2190
  const stop = building && data.control?.kind === "stop" && data.control.run === liveRun.id && data.csrf !== ""
2177
2191
  ? { action: `${taskHref(task.id)}/stop`, run: data.control.run } : null;
2192
+ // Pause and Pause and steer sit beside that Stop, for the same run, when it is a Claude build that takes live input.
2193
+ // Each form carries its own request id, so a repeated submit is the same request and the two never collide.
2194
+ const pause = stop !== null && data.livePause?.run === stop.run
2195
+ ? { action: `${taskHref(task.id)}/pause`, run: stop.run, state: data.livePause.state, requests: [`web-${randomUUID()}`, `web-${randomUUID()}`, `web-${randomUUID()}`],
2196
+ until: data.livePause.until ?? null, limitMs: data.livePause.limitMs ?? null } : null;
2178
2197
  // The Building card keeps its way to the build's own record (/r/<id>), whatever act it shows.
2179
2198
  const record = building ? { label: `Build #${liveRun.id} record`, href: `/r/${liveRun.id}` } : null;
2180
2199
  const earlier = earlierStopped.length === 0 ? null : {
@@ -2204,6 +2223,7 @@ export function taskBodyParts(data) {
2204
2223
  // One quiet line under the step: what the agent did last, and when.
2205
2224
  activity: liveRun === undefined ? null : data.activity ?? null,
2206
2225
  stop,
2226
+ pause,
2207
2227
  record,
2208
2228
  earlier,
2209
2229
  approval: fragmentString(approvalHtml),
@@ -129,7 +129,8 @@ get("people", "people.page", "/people", { limited: "unscoped", ...free });
129
129
  get("pages", "system", "/system");
130
130
  get("tasks", "tasks", "/tasks", { limited: "collection" });
131
131
  get("tasks", "queue", "/queue");
132
- get("pages", "peek", "/peek");
132
+ get("pages", "peek", "/peek", free);
133
+ get("pages", "agents", "/agents", { limited: "unscoped", ...free });
133
134
  get("pages", "fleet", "/fleet", free);
134
135
  get("tasks", "tasks.new", "/tasks/new", { limited: "collection", ...free });
135
136
  get("tasks", "task.page", `^\\/t\\/${TASK}$`, { project: "task", limited: "resource", ...free, sample: "/t/one" });
@@ -330,6 +331,7 @@ post("tasks", "task.act.plan-edit", `^\\/t\\/${TASK}\\/plan-edit$`, { project: "
330
331
  post("tasks", "task.act.next", `^\\/t\\/${TASK}\\/next$`, { project: "task", limited: "resource", sample: "/t/one/next" });
331
332
  post("tasks", "task.act.reopen", `^\\/t\\/${TASK}\\/reopen$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/reopen" });
332
333
  post("tasks", "task.act.steer", `^\\/t\\/${TASK}\\/steer$`, { project: "task", limited: "resource", sample: "/t/one/steer" });
334
+ post("tasks", "task.act.reply", `^\\/t\\/${TASK}\\/reply$`, { project: "task", limited: "resource", sample: "/t/one/reply" });
333
335
  post("tasks", "task.act.accept-proof", `^\\/t\\/${TASK}\\/accept-proof$`, { project: "task", limited: "resource", sample: "/t/one/accept-proof" });
334
336
  post("tasks", "task.act.accept-revision", `^\\/t\\/${TASK}\\/accept-revision$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/accept-revision" });
335
337
  post("tasks", "task.act.reject-revision", `^\\/t\\/${TASK}\\/reject-revision$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/reject-revision" });
@@ -339,6 +341,7 @@ post("tasks", "task.act.complete", `^\\/t\\/${TASK}\\/complete$`, { project: "ta
339
341
  post("tasks", "task.act.merge", `^\\/t\\/${TASK}\\/merge$`, { project: "task", limited: "resource", sample: "/t/one/merge", ...approver("Only an approver can merge.") });
340
342
  post("tasks", "task.act.confirm-stopped", `^\\/t\\/${TASK}\\/confirm-stopped$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/confirm-stopped", ...approver("Only an approver can confirm a build stopped.") });
341
343
  post("tasks", "task.act.stop", `^\\/t\\/${TASK}\\/stop$`, { project: "task", limited: "resource", sample: "/t/one/stop" });
344
+ post("tasks", "task.act.pause", `^\\/t\\/${TASK}\\/pause$`, { project: "task", limited: "resource", sample: "/t/one/pause" });
342
345
  post("tasks", "task.act.resume-arm", `^\\/t\\/${TASK}\\/resume-arm$`, { project: "task", limited: "resource", sample: "/t/one/resume-arm" });
343
346
  post("tasks", "task.act.resume", `^\\/t\\/${TASK}\\/resume$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/resume" });
344
347
  for (const one of ["block", "unblock", "repair-dependency", "follow-up"])
@@ -105,6 +105,7 @@ import { removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from
105
105
  import { ssoSettingsHtml } from "../sso-ui.js";
106
106
  import { lastSweep, saveSweep, storageSweepOff } from "../storage-sweep.js";
107
107
  import { storageHtml } from "../storage-ui.js";
108
+ import { landedSummary } from "../landed-cleanup.js";
108
109
  import { bytesWords, parseCleanup } from "../storage.js";
109
110
  import { isDigestTime, RESULT_SCREENSHOTS } from "../store.js";
110
111
  import { checkTeamsCredentials, clearTeamsCredentials, loadTeamsCredentials, saveTeamsCredentials, TeamsError } from "../teams-api.js";
@@ -398,7 +399,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
398
399
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
399
400
  const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
400
401
  const sweep = { last: lastSweep(store), off: storageSweepOff() };
401
- return sendScreen(response, 200, screen("Storage", html `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${(storageHtml({ plan, csrf: who.session.csrf, sweep }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
402
+ return sendScreen(response, 200, screen("Storage", html `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${(storageHtml({ plan, csrf: who.session.csrf, sweep, landed: landedSummary(store) }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
402
403
  }
403
404
  // Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
404
405
  // exactly what turning on would do, or what to fix first.
@@ -418,6 +418,15 @@ export function createTaskViews(runtime) {
418
418
  const found = runtime.store.runsFor(ref.id).find(one => runIsLive(one));
419
419
  return found === undefined ? null : found.id;
420
420
  })(),
421
+ // Live steering: the Claude build that takes Pause right now, and where its latest pause stands.
422
+ livePause: (() => {
423
+ const run = ref === null ? null : runtime.store.liveInputRun(ref.id, now);
424
+ if (run === null)
425
+ return null;
426
+ const pause = runtime.store.livePauseOf(run);
427
+ const until = pause?.pausedAt == null || pause.limitMs === null ? null : new Date(Date.parse(pause.pausedAt) + pause.limitMs).toISOString();
428
+ return { run, state: pause?.state ?? null, until, limitMs: pause?.limitMs ?? null };
429
+ })(),
421
430
  worker: (() => {
422
431
  const runners = runtime.store.listRunners().filter(one => one.retiredAt === null);
423
432
  const eligible = ref?.repo === null || ref?.repo === undefined
@@ -46,6 +46,7 @@ import { runCostWords } from "../summary.js";
46
46
  import { requestTaskStop, resumeTaskStop } from "../task-control.js";
47
47
  import { templateByName } from "../templates.js";
48
48
  import { TEXT_LIMITS } from "../text-limits.js";
49
+ import { agentQuestion, answerAgentQuestion, markAgentSeen } from "../agent-board.js";
49
50
  import { WorkIndexCursorError, workIndexPage } from "../work-index.js";
50
51
  import { buildProgressOf, parseWorkView } from "../workspace-ui.js";
51
52
  import { requestContext } from "./request-context.js";
@@ -458,6 +459,9 @@ export function createTasksHandlers(runtime) {
458
459
  return sendScreen(response, 409, screen("Result changed", html `<h1>Result changed</h1><p>This acceptance link no longer matches the current result. Review the current task before accepting.</p><p class="refusal-back"><a class="button-link" href="/review">Review results</a></p>`, { chrome: chromeFor(project, "runs") }));
459
460
  }
460
461
  const selected = selectedRow === null ? null : reviewCockpitViewOf(selectedRow, who, now);
462
+ // Opening a run's own result page is viewing it: it leaves this person's unread Done on the Agents board.
463
+ if (wantedId !== null && selected?.run != null)
464
+ markAgentSeen(store, who.name, selected.run.id, now);
461
465
  // A build that delivered nothing reads Failed here, and so does a failed task's delivered result: what went wrong
462
466
  // is the card and Retry the ink act. That result may still be accepted, but only in outline, as Accept anyway, with
463
467
  // a reason.
@@ -1621,8 +1625,31 @@ export function createTasksHandlers(runtime) {
1621
1625
  : "no such task";
1622
1626
  return taskScreen(response, who, taskId, said, 400);
1623
1627
  }
1628
+ // From the Agents board, back to the same agent (allow-listed: a run of this task, never a URL from the form).
1629
+ const run = Number(body.get("run") ?? "");
1630
+ if (body.get("return") === "agents" && Number.isSafeInteger(run) && store.getRun(run)?.taskRef === ref.id)
1631
+ return redirect(response, `/agents?run=${run}&said=steered`);
1624
1632
  return redirect(response, taskHref(taskId));
1625
1633
  }
1634
+ case "reply": {
1635
+ const body = readForm(posted, CONSOLE_FORMS.taskReply);
1636
+ // A reply is steering, so it is a browser session's act too.
1637
+ if (who.via !== "cookie")
1638
+ return refuse(response, who, 403, "replying to an agent is a browser session's act");
1639
+ // The same gates as a pause: the route's act scope already refuses a viewer; these say why, and the demo answers nothing.
1640
+ if (who.role !== "approver")
1641
+ return taskScreen(response, who, taskId, "your login can watch — replying to an agent is an approver's act", 403);
1642
+ if (store.isDemo())
1643
+ return taskScreen(response, who, taskId, "the demo authorizes nothing", 403);
1644
+ const id = Number(body.get("question") ?? "");
1645
+ const asked = Number.isSafeInteger(id) && id > 0 ? agentQuestion(store, id) : null;
1646
+ if (asked === null || asked.taskRef !== ref.id)
1647
+ return taskScreen(response, who, taskId, "that question is not on this task", 404);
1648
+ const answered = answerAgentQuestion(store, { question: id, answer: body.get("answer") ?? "", author: who.name, at: now,
1649
+ file: (task, note) => store.fileSteerNote(task, verifiedAuthor(who.name), note, now) });
1650
+ const said = answered.ok ? "replied" : answered.reason === "invalid-answer" || answered.reason === "not-saved" ? "reply-invalid" : answered.reason === "closed" ? "reply-closed" : "reply-stale";
1651
+ return redirect(response, `/agents?run=${asked.run}&said=${said}`);
1652
+ }
1626
1653
  case "hold": {
1627
1654
  const body = readForm(posted, CONSOLE_FORMS.taskHold);
1628
1655
  const reason = (body.get("reason") ?? "").trim() || "held from the console";
@@ -2306,6 +2333,40 @@ export function createTasksHandlers(runtime) {
2306
2333
  const back = body.get("return") ?? "";
2307
2334
  return redirect(response, back === "chat" ? `${taskChatHref(taskId)}#task-control` : `${taskHref(taskId)}#task-control`);
2308
2335
  }
2336
+ case "pause": {
2337
+ const body = readForm(posted, CONSOLE_FORMS.taskPause);
2338
+ // Pause, and Pause and steer (live steering): a browser session's act, an approver's act, naming the ONE run
2339
+ // the page showed under the page's own request id. It stops the running Claude build's turn and keeps the
2340
+ // build; a note that rides along is filed in the same transaction and sent right after. mode=resume continues a
2341
+ // paused build with no note. Never a stop.
2342
+ if (who.via !== "cookie")
2343
+ return refuse(response, who, 403, "pausing a build is a browser session's act");
2344
+ if (who.role !== "approver")
2345
+ return taskScreen(response, who, taskId, "your login can watch — pausing a build is an approver's act", 403);
2346
+ if (store.isDemo())
2347
+ return taskScreen(response, who, taskId, "the demo authorizes nothing", 403);
2348
+ const named = (body.get("run") ?? "").trim();
2349
+ if (!/^[0-9]{1,15}$/.test(named))
2350
+ return taskScreen(response, who, taskId, "which build? a pause names one exact run", 400);
2351
+ const note = (body.get("note") ?? "").trim();
2352
+ // Resume: the paused build goes on where it stopped; no note is filed.
2353
+ const asked = body.get("mode") === "resume"
2354
+ ? store.requestResume({ taskId, runId: Number(named), by: verifiedAuthor(who.name), via: "web", requestId: body.get("request") ?? "" }, now)
2355
+ : store.requestPause({ taskId, runId: Number(named), by: verifiedAuthor(who.name), via: "web", requestId: body.get("request") ?? "", ...(note === "" ? {} : { note }) }, now);
2356
+ if (!asked.ok) {
2357
+ const words = {
2358
+ stale: `build #${named} has stopped taking notes — reload to see the current attempt`,
2359
+ "not-paused": `build #${named} isn't paused any more — reload to see where it stands`,
2360
+ "invalid-request": "that form was incomplete — reload and try again",
2361
+ duplicate: "that form was already used for another build — reload and try again",
2362
+ "task-finished": "this task is finished",
2363
+ "unknown-task": "no such task",
2364
+ };
2365
+ return taskScreen(response, who, taskId, words[asked.reason] ?? asked.detail, asked.reason === "invalid-note" ? 400 : 409);
2366
+ }
2367
+ const back = body.get("return") ?? "";
2368
+ return redirect(response, back === "chat" ? `${taskChatHref(taskId)}#task-control` : `${taskHref(taskId)}#task-control`);
2369
+ }
2309
2370
  case "resume-arm": {
2310
2371
  const body = readForm(posted, CONSOLE_FORMS.taskResume);
2311
2372
  return armTaskResume(response, who, taskId, (body.get("run") ?? "").trim(), body.get("return") === "chat" ? "chat" : "task", now);
@@ -2715,6 +2776,7 @@ export function createTasksHandlers(runtime) {
2715
2776
  "task.act.next": taskAct("next"),
2716
2777
  "task.act.reopen": taskAct("reopen"),
2717
2778
  "task.act.steer": taskAct("steer"),
2779
+ "task.act.reply": taskAct("reply"),
2718
2780
  "task.act.accept-proof": taskAct("accept-proof"),
2719
2781
  "task.act.accept-revision": taskAct("accept-revision"),
2720
2782
  "task.act.reject-revision": taskAct("reject-revision"),
@@ -2724,6 +2786,7 @@ export function createTasksHandlers(runtime) {
2724
2786
  "task.act.merge": taskMerge,
2725
2787
  "task.act.confirm-stopped": taskConfirmStopped,
2726
2788
  "task.act.stop": taskAct("stop"),
2789
+ "task.act.pause": taskAct("pause"),
2727
2790
  "task.act.resume-arm": taskAct("resume-arm"),
2728
2791
  "task.act.resume": taskAct("resume"),
2729
2792
  "task.act.block": taskAct("block"),
@@ -98,13 +98,18 @@ export declare function unusedShared(root: string, checkouts: readonly {
98
98
  export declare function removeShared(path: string): boolean;
99
99
  /**
100
100
  * The daily sweep: remove each copy no checkout uses and none has linked for `unusedMs` (a week), and old debris.
101
- * Right before a copy goes, who uses it and when it was last linked are read again; a copy a checkout picked up in
102
- * between stays.
101
+ * Right before a copy goes, who uses it, when it was last linked and whether live work uses it (`spare`,
102
+ * storage-sweep.ts) are read again; a copy a checkout picked up in between stays. Removal never holds the event loop.
103
103
  */
104
104
  export declare function pruneUnusedShared(root: string, checkouts: () => readonly {
105
105
  path: string;
106
106
  repo: string;
107
- }[], now: Date, unusedMs?: number): {
107
+ }[], now: Date, unusedMs?: number, options?: {
108
+ spare?: (path: string) => boolean;
109
+ /** Deterministic race/failure seams; never supplied by the worker. */
110
+ quarantined?: (original: string, trash: string) => void | Promise<void>;
111
+ remove?: (path: string) => Promise<void>;
112
+ }): Promise<{
108
113
  removed: SharedUse[];
109
114
  failed: string[];
110
- };
115
+ }>;