toolroll 0.9.60 → 0.9.62

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 (128) hide show
  1. package/CHANGELOG.md +32 -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/cli.js +28 -11
  34. package/dist/contracts/console-api.d.ts +3 -1
  35. package/dist/contracts/console-api.js +5 -1
  36. package/dist/contracts/lead-tools.d.ts +16 -2
  37. package/dist/contracts/lead-tools.js +5 -1
  38. package/dist/desktop-update.d.ts +1 -1
  39. package/dist/evidence-pack.js +1 -1
  40. package/dist/evidence.d.ts +6 -0
  41. package/dist/evidence.js +14 -3
  42. package/dist/exec.d.ts +32 -0
  43. package/dist/exec.js +229 -10
  44. package/dist/invoke.js +110 -18
  45. package/dist/landed-cleanup.d.ts +170 -0
  46. package/dist/landed-cleanup.js +814 -0
  47. package/dist/lead-cli.js +22 -19
  48. package/dist/lead-contract.d.ts +1 -1
  49. package/dist/lead-contract.js +4 -4
  50. package/dist/lead-doors.js +45 -1
  51. package/dist/lead-tools.js +40 -7
  52. package/dist/live-input.d.ts +68 -0
  53. package/dist/live-input.js +254 -0
  54. package/dist/live.d.ts +1 -1
  55. package/dist/live.js +4 -2
  56. package/dist/maintenance.d.ts +2 -0
  57. package/dist/maintenance.js +5 -2
  58. package/dist/operate-remote-arguments.js +5 -1
  59. package/dist/operate-remote.js +1 -1
  60. package/dist/operate.d.ts +1 -1
  61. package/dist/operate.js +249 -26
  62. package/dist/orphan-sweep.d.ts +2 -0
  63. package/dist/orphan-sweep.js +5 -3
  64. package/dist/project-admission.d.ts +10 -6
  65. package/dist/project-admission.js +33 -11
  66. package/dist/project-delete-ui.d.ts +12 -0
  67. package/dist/project-delete-ui.js +32 -2
  68. package/dist/project-tools.d.ts +7 -11
  69. package/dist/project-tools.js +13 -37
  70. package/dist/provider.d.ts +65 -8
  71. package/dist/provider.js +80 -21
  72. package/dist/remote-main.d.ts +57 -0
  73. package/dist/remote-main.js +179 -0
  74. package/dist/result-completion.d.ts +7 -0
  75. package/dist/result-completion.js +9 -0
  76. package/dist/runner.d.ts +4 -2
  77. package/dist/runner.js +3 -3
  78. package/dist/serve.js +19 -1
  79. package/dist/server/live.d.ts +3 -0
  80. package/dist/server/live.js +35 -2
  81. package/dist/server/pages.js +59 -35
  82. package/dist/server/render-chat.js +12 -0
  83. package/dist/server/render-tasks.d.ts +15 -2
  84. package/dist/server/render-tasks.js +30 -7
  85. package/dist/server/route-table.js +4 -1
  86. package/dist/server/settings.js +5 -3
  87. package/dist/server/task-views.js +11 -0
  88. package/dist/server/tasks.js +63 -0
  89. package/dist/shared-deps.d.ts +9 -4
  90. package/dist/shared-deps.js +89 -13
  91. package/dist/staged-runtimes.d.ts +16 -4
  92. package/dist/staged-runtimes.js +160 -38
  93. package/dist/steer-live.d.ts +19 -0
  94. package/dist/steer-live.js +93 -0
  95. package/dist/steer-status.d.ts +8 -2
  96. package/dist/steer-status.js +19 -5
  97. package/dist/storage-sweep-child.d.ts +1 -0
  98. package/dist/storage-sweep-child.js +76 -0
  99. package/dist/storage-sweep-process.d.ts +48 -0
  100. package/dist/storage-sweep-process.js +204 -0
  101. package/dist/storage-sweep-schedule.d.ts +30 -0
  102. package/dist/storage-sweep-schedule.js +164 -0
  103. package/dist/storage-sweep.d.ts +31 -6
  104. package/dist/storage-sweep.js +165 -39
  105. package/dist/storage-ui.d.ts +4 -0
  106. package/dist/storage-ui.js +22 -7
  107. package/dist/storage.d.ts +6 -0
  108. package/dist/storage.js +35 -0
  109. package/dist/store.d.ts +200 -3
  110. package/dist/store.js +443 -17
  111. package/dist/surface.js +8 -4
  112. package/dist/sweep-quarantine.d.ts +28 -0
  113. package/dist/sweep-quarantine.js +153 -0
  114. package/dist/task-base.d.ts +35 -0
  115. package/dist/task-base.js +51 -0
  116. package/dist/task-status.d.ts +2 -0
  117. package/dist/task-status.js +5 -1
  118. package/dist/test-temp.d.ts +6 -3
  119. package/dist/test-temp.js +15 -9
  120. package/dist/toolroll-update.js +4 -2
  121. package/dist/work-index.d.ts +1 -0
  122. package/dist/work-index.js +32 -12
  123. package/dist/work-summary.d.ts +2 -2
  124. package/dist/workspace-revision.js +3 -1
  125. package/dist/worktree.d.ts +57 -3
  126. package/dist/worktree.js +201 -23
  127. package/package.json +1 -1
  128. 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>`;
@@ -3,6 +3,7 @@ import { type Html } from "../html.js";
3
3
  import { type RunActivity } from "../activity-line.js";
4
4
  import { type AgentChoice } from "../agentconfig.js";
5
5
  import { type AssignmentSnapshot } from "../assignment.js";
6
+ import { type TaskBaseView } from "../task-base.js";
6
7
  import { type BrowserTaskView } from "../browser-workspace.js";
7
8
  import { type FormView } from "../contracts/console-api.js";
8
9
  import { type DispatchDiagnosis } from "../dispatch.js";
@@ -264,10 +265,18 @@ export declare function taskStatusCard(status: DisplayStatus & {
264
265
  diagnostics?: WorkStatus["diagnostics"];
265
266
  }, taskId: string, dispatch: DispatchDiagnosis | null, runId: number | null, approvalAction?: boolean): Html;
266
267
  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;
268
+ export declare function taskControlDetailsHtml(control: TaskControlView, taskId: string, csrf: string, surface: "task" | "chat", inert?: boolean, stopElsewhere?: boolean, livePause?: LivePauseFact | null): Html;
268
269
  /** `stopElsewhere`: the Building card already carries this exact run's Stop, so the control card keeps only its details.
269
270
  * `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;
271
+ /** Live steering: the Claude build that takes Pause right now, its latest pause (null: none, or it continued), and
272
+ * until when a paused build can wait before it ends (null: no idle limit). */
273
+ export type LivePauseFact = {
274
+ run: number;
275
+ state: "pausing" | "paused" | null;
276
+ until?: string | null;
277
+ limitMs?: number | null;
278
+ };
279
+ export declare function taskControlHtml(control: TaskControlView, taskId: string, csrf: string, surface: "task" | "chat", inert?: boolean, stopElsewhere?: boolean, livePause?: LivePauseFact | null): Html;
271
280
  /** The resume confirmation (v52): the exact run restated, what resuming
272
281
  * does and does not do, the gate that would still keep work from
273
282
  * starting, and the password typed again. */
@@ -489,6 +498,8 @@ export declare function taskBodyParts(data: {
489
498
  liveRunId?: number | null;
490
499
  /** What the agent did last on that live run, and when (task-activity.ts). */
491
500
  activity?: RunActivity | null;
501
+ /** Live steering: the Claude build that takes Pause right now, and its latest pause (null: none, or it continued). */
502
+ livePause?: LivePauseFact | null;
492
503
  /** Workers that are both alive and authorized for this task's project. */
493
504
  worker?: {
494
505
  answering: number;
@@ -578,6 +589,8 @@ export declare function taskBodyParts(data: {
578
589
  /** The pull request opened through Complete (its state, link, CI and merge), or the offer to open one for a
579
590
  * result already marked complete; null when neither applies. */
580
591
  pullRequest?: TaskPullRequest | null;
592
+ /** Fresh base: the commit this task's work starts from, and how far current main has moved past it. */
593
+ base?: TaskBaseView | null;
581
594
  /** The task's whole family (the original and its revisions) and every attempt across it, for the thread. */
582
595
  family?: {
583
596
  root: {
@@ -7,6 +7,7 @@ import { gateWords } from "../approval-policy.js";
7
7
  import { assignmentCardOf, assignmentStatusOf, assignmentSummaryHtml } from "../assignment-ui.js";
8
8
  import {} from "../assignment.js";
9
9
  import { holdOwnerWords } from "../board.js";
10
+ import { baseStandingWords } from "../task-base.js";
10
11
  import {} from "../browser-workspace.js";
11
12
  import { permissionPlainWords } from "../chat-decide.js";
12
13
  import { CHAT_TASK_ACTIONS } from "../chat-task-actions.js";
@@ -34,7 +35,8 @@ import { runCostWords } from "../summary.js";
34
35
  import {} from "../task-control.js";
35
36
  import { assignmentStageOf, demoChecksOf, stageOfCode, stageOfDispatch, STATUS_MORE, taskStatusOf } from "../task-status.js";
36
37
  import { buildProgressOf, dispatchActionLabel, earlierAttemptsWords, receiptPublicationWords, resultStatusOf, REVIEW_TOKENS, workStatusOf } from "../workspace-ui.js";
37
- import { createHash } from "node:crypto";
38
+ import { createHash, randomUUID } from "node:crypto";
39
+ import { TEXT_LIMITS } from "../text-limits.js";
38
40
  import { isOverdue, oneLineOf, projectChip, regionScript, safePrUrl, screen, SENSITIVE_INPUT, sentenceCase, shortDigest, transcriptScript, whenTime } from "./chrome.js";
39
41
  import { steerDestinationWords, steerStandingParts } from "../steer-status.js";
40
42
  import { reviewHref, taskChatHref, taskHref } from "./http.js";
@@ -808,14 +810,26 @@ export function checkProgressHtml(progress, id = "check-progress") {
808
810
  : Object.values(progress.suites).every(one => one.state === "passed") ? "passed" : "unknown";
809
811
  return html `<p id="${id}" class="check-progress mono" data-check-progress data-final="${state}" role="status" aria-live="polite">${progress.line}</p>`;
810
812
  }
811
- export function taskControlDetailsHtml(control, taskId, csrf, surface, inert = false, stopElsewhere = false) {
812
- const card = taskControlHtml(control, taskId, csrf, surface, inert, stopElsewhere);
813
+ export function taskControlDetailsHtml(control, taskId, csrf, surface, inert = false, stopElsewhere = false, livePause = null) {
814
+ const card = taskControlHtml(control, taskId, csrf, surface, inert, stopElsewhere, livePause);
813
815
  return control.kind === "paused" || control.kind === "review-stopped" || control.kind === "stopping"
814
816
  ? html `<details id="task-control-details"><summary>${control.kind === "paused" ? "Preserved work and resume" : "Stop details"}</summary>${card}</details>` : card;
815
817
  }
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) {
818
+ /** Pause and Pause and steer beside the task page's Stop (live steering): the same exact run, each form under its own
819
+ * request id so a repeated submit is the same request. Pause hides once asked; a paused build says until when it can
820
+ * wait and offers Resume, or the note to continue with. */
821
+ function pauseFormsHtml(taskId, livePause) {
822
+ 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 } });
823
+ const paused = livePause.state === "paused";
824
+ const until = livePause.until ?? null, limitMs = livePause.limitMs ?? null;
825
+ return joinHtml([
826
+ 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>` : "",
827
+ livePause.state === null ? form(html ``, html `<button type="submit" class="task-control-button">Pause</button>`, "pause") : "",
828
+ paused ? form(html ``, html `<button type="submit" class="task-control-button">Resume</button>`, "resume", { mode: "resume" }) : "",
829
+ 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"),
830
+ ]);
831
+ }
832
+ export function taskControlHtml(control, taskId, csrf, surface, inert = false, stopElsewhere = false, livePause = null) {
819
833
  if (control.kind === "none")
820
834
  return html ``;
821
835
  const guarded = csrf !== "" && !inert;
@@ -833,6 +847,7 @@ export function taskControlHtml(control, taskId, csrf, surface, inert = false, s
833
847
  return joinHtml([
834
848
  html `<section class="card task-control" id="task-control" data-task-control="stop" data-control-run="${control.run}" aria-label="stop this attempt">`,
835
849
  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>`,
850
+ stopElsewhere || !guarded || livePause === null || livePause.run !== control.run ? "" : pauseFormsHtml(taskId, livePause),
836
851
  stopElsewhere ? "" : guarded
837
852
  ? runForm("stop", "inline task-control-form task-stop-form", html `<button type="submit" class="danger task-control-button">Stop</button>`)
838
853
  : html `<button type="button" class="danger task-control-button" disabled>Stop</button>`,
@@ -1894,7 +1909,7 @@ export function taskBodyParts(data) {
1894
1909
  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
1910
  // The exact-run control (v52), directly under the scheduler's answer:
1896
1911
  // the one place a person stops or resumes THIS attempt.
1897
- const controlHtml = taskControlDetailsHtml(data.control ?? { kind: "none" }, task.id, data.csrf, "task");
1912
+ const controlHtml = taskControlDetailsHtml(data.control ?? { kind: "none" }, task.id, data.csrf, "task", false, false, data.livePause ?? null);
1898
1913
  const previousResult = data.completion?.receipt == null || receiptLeads ? null : {
1899
1914
  title: data.assignment != null && task.state === "done" ? "Result" : "Previous result",
1900
1915
  html: (completionReceiptCard(data.completion.receipt, task.id, "task", data.assignment == null ? receiptStatusOf(data.completion.receipt) : assignmentStatusOf(data.assignment), data.assignment ?? null, data.assignment == null)),
@@ -2094,6 +2109,8 @@ export function taskBodyParts(data) {
2094
2109
  work.push({ label: "Status", parts: [data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions).status.headline : status.label] });
2095
2110
  if (data.repo !== null)
2096
2111
  work.push({ label: "Project", parts: [projectName(data.repo)] });
2112
+ if (data.base != null)
2113
+ work.push({ label: "Base", parts: [baseStandingWords(data.base)] });
2097
2114
  work.push({ label: "Agent", parts: [lastRunForAgent !== undefined ? `${lastRunForAgent.provider}${lastRunForAgent.model === null ? "" : ` · ${lastRunForAgent.model}`} on ${lastRunForAgent.runner}`
2098
2115
  : data.route?.legacy != null ? `${data.route.legacy.provider} · ${data.route.legacy.model}` : "The project's default"] });
2099
2116
  if (scope !== null)
@@ -2175,6 +2192,11 @@ export function taskBodyParts(data) {
2175
2192
  // The Building card carries Stop for the build it describes (the same form), in place of a second card below it.
2176
2193
  const stop = building && data.control?.kind === "stop" && data.control.run === liveRun.id && data.csrf !== ""
2177
2194
  ? { action: `${taskHref(task.id)}/stop`, run: data.control.run } : null;
2195
+ // Pause and Pause and steer sit beside that Stop, for the same run, when it is a Claude build that takes live input.
2196
+ // Each form carries its own request id, so a repeated submit is the same request and the two never collide.
2197
+ const pause = stop !== null && data.livePause?.run === stop.run
2198
+ ? { action: `${taskHref(task.id)}/pause`, run: stop.run, state: data.livePause.state, requests: [`web-${randomUUID()}`, `web-${randomUUID()}`, `web-${randomUUID()}`],
2199
+ until: data.livePause.until ?? null, limitMs: data.livePause.limitMs ?? null } : null;
2178
2200
  // The Building card keeps its way to the build's own record (/r/<id>), whatever act it shows.
2179
2201
  const record = building ? { label: `Build #${liveRun.id} record`, href: `/r/${liveRun.id}` } : null;
2180
2202
  const earlier = earlierStopped.length === 0 ? null : {
@@ -2204,6 +2226,7 @@ export function taskBodyParts(data) {
2204
2226
  // One quiet line under the step: what the agent did last, and when.
2205
2227
  activity: liveRun === undefined ? null : data.activity ?? null,
2206
2228
  stop,
2229
+ pause,
2207
2230
  record,
2208
2231
  earlier,
2209
2232
  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"])
@@ -104,7 +104,9 @@ import { budgetStates, monthNamed, spendItems, usd as spendUsd, subagentNames as
104
104
  import { removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "../sso-settings.js";
105
105
  import { ssoSettingsHtml } from "../sso-ui.js";
106
106
  import { lastSweep, saveSweep, storageSweepOff } from "../storage-sweep.js";
107
+ import { sweepSchedule } from "../storage-sweep-schedule.js";
107
108
  import { storageHtml } from "../storage-ui.js";
109
+ import { landedSummary } from "../landed-cleanup.js";
108
110
  import { bytesWords, parseCleanup } from "../storage.js";
109
111
  import { isDigestTime, RESULT_SCREENSHOTS } from "../store.js";
110
112
  import { checkTeamsCredentials, clearTeamsCredentials, loadTeamsCredentials, saveTeamsCredentials, TeamsError } from "../teams-api.js";
@@ -301,7 +303,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
301
303
  return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
302
304
  const selector = projects.length > 1 ? html `<form class="approval-project" method="get" action="/settings/project"><label>Project<select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? " selected" : ""}>${projectName(p)}</option>`)}</select></label><button>Show</button></form>` : "";
303
305
  const notice = { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") };
304
- const content = chosen === "" ? html `${notice.said ? html `<p role="status">${notice.said}</p>` : ""}<p>No projects yet.</p>` : (projectSettingsHtml(projectViewOf(chosen, who), notice));
306
+ const content = chosen === "" ? html `${notice.said ? html `<p role="status">${notice.said}</p>` : ""}<p>No projects yet.</p>` : (projectSettingsHtml({ ...projectViewOf(chosen, who), remoteUrl: store.projectRemote(chosen) }, notice));
305
307
  return sendScreen(response, 200, screen("Project", html `<p><a href="/settings">Settings</a></p><h1>Project</h1>${selector}${content}`, { chrome: chromeFor(chosen || null, "settings") }));
306
308
  }
307
309
  // v102: Settings → Approval rules, a project's separation of duties. Anyone who sees the project reads them; an instance operator sets them.
@@ -397,8 +399,8 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
397
399
  if (databaseFile === null)
398
400
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
399
401
  const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
400
- 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
+ const sweep = { last: lastSweep(store), off: storageSweepOff(), schedule: sweepSchedule(store, now) };
403
+ 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
404
  }
403
405
  // Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
404
406
  // exactly what turning on would do, or what to fix first.
@@ -1,3 +1,4 @@
1
+ import { taskBaseOf } from "../task-base.js";
1
2
  import { assignmentPresentationOf, historicalAssessmentReason } from '../assignment-presentation.js';
2
3
  import { html, joinHtml } from "../html.js";
3
4
  import { assignmentStatusOf, assignmentWithEvidence } from '../assignment-ui.js';
@@ -418,6 +419,15 @@ export function createTaskViews(runtime) {
418
419
  const found = runtime.store.runsFor(ref.id).find(one => runIsLive(one));
419
420
  return found === undefined ? null : found.id;
420
421
  })(),
422
+ // Live steering: the Claude build that takes Pause right now, and where its latest pause stands.
423
+ livePause: (() => {
424
+ const run = ref === null ? null : runtime.store.liveInputRun(ref.id, now);
425
+ if (run === null)
426
+ return null;
427
+ const pause = runtime.store.livePauseOf(run);
428
+ const until = pause?.pausedAt == null || pause.limitMs === null ? null : new Date(Date.parse(pause.pausedAt) + pause.limitMs).toISOString();
429
+ return { run, state: pause?.state ?? null, until, limitMs: pause?.limitMs ?? null };
430
+ })(),
421
431
  worker: (() => {
422
432
  const runners = runtime.store.listRunners().filter(one => one.retiredAt === null);
423
433
  const eligible = ref?.repo === null || ref?.repo === undefined
@@ -473,6 +483,7 @@ export function createTaskViews(runtime) {
473
483
  return { rows, decisions: decisionsFor(runtime.store, rows), now };
474
484
  })(),
475
485
  steering: ref === null ? [] : runtime.store.listSteerNotes(ref.id),
486
+ base: taskBaseOf(runtime.store, taskId),
476
487
  steeringLive: ref !== null && runtime.store.steeringLiveBuild(ref.id, now) !== null,
477
488
  // "publishes as" reads ONLY publicationGrantFor(repo) — the grant
478
489
  // the publisher would act under — never listGrants(), which is
@@ -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
+ }>;