toolroll 0.9.59 → 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 (152) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +8 -6
  3. package/dist/agent-board.d.ts +208 -0
  4. package/dist/agent-board.js +454 -0
  5. package/dist/agent-fence.d.ts +19 -0
  6. package/dist/agent-fence.js +100 -8
  7. package/dist/agent-pulse.d.ts +43 -0
  8. package/dist/agent-pulse.js +214 -0
  9. package/dist/agent-turns.d.ts +52 -0
  10. package/dist/agent-turns.js +137 -0
  11. package/dist/agents-board.d.ts +101 -0
  12. package/dist/agents-board.js +225 -0
  13. package/dist/assignment-adapters.d.ts +2 -1
  14. package/dist/assignment-adapters.js +1 -1
  15. package/dist/assignment-delivery.d.ts +16 -0
  16. package/dist/assignment-delivery.js +54 -28
  17. package/dist/assignment-status.d.ts +14 -5
  18. package/dist/assignment-status.js +70 -39
  19. package/dist/assignment.d.ts +10 -2
  20. package/dist/assignment.js +21 -14
  21. package/dist/bin.js +11 -2
  22. package/dist/brief-fence.d.ts +9 -0
  23. package/dist/brief-fence.js +22 -0
  24. package/dist/browser/workspace.css +2 -2
  25. package/dist/browser/workspace.js +29 -25
  26. package/dist/browser-crew.js +2 -0
  27. package/dist/browser-workspace.d.ts +79 -1
  28. package/dist/browser-workspace.js +1 -0
  29. package/dist/builder.d.ts +8 -0
  30. package/dist/builder.js +178 -29
  31. package/dist/chat-channel.js +10 -1
  32. package/dist/chat-core.d.ts +6 -0
  33. package/dist/chat-core.js +8 -0
  34. package/dist/chat-delivery.js +22 -0
  35. package/dist/chat-messages.js +8 -1
  36. package/dist/chat-quiet.js +1 -0
  37. package/dist/chat-voice.d.ts +2 -0
  38. package/dist/chat-voice.js +2 -0
  39. package/dist/checkout-cleanup.d.ts +3 -0
  40. package/dist/checkout-cleanup.js +11 -2
  41. package/dist/cli-fast.d.ts +8 -0
  42. package/dist/cli-fast.js +18 -0
  43. package/dist/cli-help.d.ts +5 -0
  44. package/dist/cli-help.js +91 -0
  45. package/dist/cli.d.ts +6 -5
  46. package/dist/cli.js +17 -96
  47. package/dist/contracts/console-api.d.ts +3 -1
  48. package/dist/contracts/console-api.js +5 -1
  49. package/dist/contracts/lead-tools.d.ts +16 -2
  50. package/dist/contracts/lead-tools.js +5 -1
  51. package/dist/design/tokens.css +234 -0
  52. package/dist/desktop-update.d.ts +1 -1
  53. package/dist/discord.js +2 -1
  54. package/dist/evidence-pack.js +1 -1
  55. package/dist/evidence.d.ts +28 -0
  56. package/dist/evidence.js +87 -17
  57. package/dist/exec.d.ts +32 -0
  58. package/dist/exec.js +229 -10
  59. package/dist/flow-actions.js +2 -1
  60. package/dist/guides.js +14 -11
  61. package/dist/invoke.js +110 -18
  62. package/dist/landed-cleanup.d.ts +157 -0
  63. package/dist/landed-cleanup.js +707 -0
  64. package/dist/lead-cli.js +22 -19
  65. package/dist/lead-contract.js +1 -1
  66. package/dist/lead-doors.js +49 -2
  67. package/dist/lead-follow.d.ts +2 -0
  68. package/dist/lead-follow.js +138 -88
  69. package/dist/lead-tools.js +48 -8
  70. package/dist/live-input.d.ts +68 -0
  71. package/dist/live-input.js +254 -0
  72. package/dist/live.d.ts +1 -1
  73. package/dist/live.js +4 -2
  74. package/dist/mailbox.js +3 -2
  75. package/dist/maintenance.d.ts +2 -0
  76. package/dist/maintenance.js +5 -2
  77. package/dist/mcp-core.js +1 -1
  78. package/dist/operate-remote-arguments.js +5 -1
  79. package/dist/operate-remote.js +1 -1
  80. package/dist/operate.d.ts +1 -1
  81. package/dist/operate.js +201 -19
  82. package/dist/orphan-sweep.d.ts +2 -0
  83. package/dist/orphan-sweep.js +5 -3
  84. package/dist/project-delete-ui.d.ts +11 -0
  85. package/dist/project-delete-ui.js +29 -2
  86. package/dist/project-tools.d.ts +7 -11
  87. package/dist/project-tools.js +13 -37
  88. package/dist/provider.d.ts +73 -8
  89. package/dist/provider.js +87 -17
  90. package/dist/result-completion.d.ts +7 -0
  91. package/dist/result-completion.js +9 -0
  92. package/dist/runner.d.ts +4 -2
  93. package/dist/runner.js +3 -3
  94. package/dist/serve.js +22 -3
  95. package/dist/server/chrome.d.ts +6 -3
  96. package/dist/server/chrome.js +8 -4
  97. package/dist/server/console.css +9 -64
  98. package/dist/server/live.d.ts +3 -0
  99. package/dist/server/live.js +35 -2
  100. package/dist/server/pages.js +59 -35
  101. package/dist/server/render-chat.js +14 -2
  102. package/dist/server/render-tasks.d.ts +14 -2
  103. package/dist/server/render-tasks.js +38 -20
  104. package/dist/server/route-table.js +4 -1
  105. package/dist/server/settings.js +2 -1
  106. package/dist/server/task-views.js +10 -0
  107. package/dist/server/tasks.js +63 -0
  108. package/dist/shared-deps.d.ts +9 -4
  109. package/dist/shared-deps.js +89 -13
  110. package/dist/slack-socket.d.ts +12 -0
  111. package/dist/slack-socket.js +29 -0
  112. package/dist/slack.d.ts +0 -7
  113. package/dist/slack.js +2 -24
  114. package/dist/staged-runtimes.d.ts +16 -4
  115. package/dist/staged-runtimes.js +160 -38
  116. package/dist/steer-live.d.ts +19 -0
  117. package/dist/steer-live.js +93 -0
  118. package/dist/steer-mailbox.d.ts +39 -0
  119. package/dist/steer-mailbox.js +207 -0
  120. package/dist/steer-status.d.ts +25 -0
  121. package/dist/steer-status.js +44 -0
  122. package/dist/storage-sweep-child.d.ts +1 -0
  123. package/dist/storage-sweep-child.js +76 -0
  124. package/dist/storage-sweep-process.d.ts +41 -0
  125. package/dist/storage-sweep-process.js +187 -0
  126. package/dist/storage-sweep.d.ts +30 -6
  127. package/dist/storage-sweep.js +214 -28
  128. package/dist/storage-ui.d.ts +2 -0
  129. package/dist/storage-ui.js +16 -4
  130. package/dist/storage.d.ts +6 -0
  131. package/dist/storage.js +35 -0
  132. package/dist/store.d.ts +202 -2
  133. package/dist/store.js +440 -13
  134. package/dist/surface.js +5 -3
  135. package/dist/sweep-gate.d.ts +10 -0
  136. package/dist/sweep-gate.js +18 -0
  137. package/dist/sweep-quarantine.d.ts +28 -0
  138. package/dist/sweep-quarantine.js +153 -0
  139. package/dist/task-status.d.ts +2 -0
  140. package/dist/task-status.js +5 -1
  141. package/dist/team-updates.js +3 -3
  142. package/dist/test-temp.d.ts +6 -3
  143. package/dist/test-temp.js +15 -9
  144. package/dist/toolroll-update.js +15 -2
  145. package/dist/work-index.d.ts +1 -0
  146. package/dist/work-index.js +32 -12
  147. package/dist/work-summary.d.ts +2 -2
  148. package/dist/workspace-revision.js +3 -1
  149. package/dist/worktree.d.ts +3 -1
  150. package/dist/worktree.js +93 -10
  151. package/package.json +21 -21
  152. package/scripts/provider-canary.mjs +235 -147
@@ -30,83 +30,28 @@
30
30
  font-family: "Geist Mono"; font-style: normal; font-weight: 600;
31
31
  font-display: swap; src: url("/fonts/geist-mono-600.woff2") format("woff2");
32
32
  }
33
- /* One palette for every page (2026-09-27): neutral grey and paper with
34
- * one chart magenta, light by day and dark after hours. The device decides unless
35
- * the person pins a theme (html[data-theme], set from their cookie). The
36
- * console's older token names map onto it, so the React workspace, the
37
- * server-rendered pages and the pre-script fallback all paint the same. */
33
+ /* Signal's colour, shadow, radius, type and spacing tokens (--so-*) are
34
+ * generated by youi into src/design/tokens.css, light and dark, and the
35
+ * server prepends that sheet to this one (chrome.ts STYLE). Edit them in the
36
+ * design-language repo, never here. The device decides the scheme unless the
37
+ * person pins one (html[data-theme], set from their cookie). What follows are
38
+ * the console's older names: views onto those tokens, never a second ramp. */
38
39
  :root {
39
- color-scheme: light;
40
-
41
- --so-ground: #efefef; --so-paper: #ffffff; --so-sidebar: #efefef; --so-raised: #f5f5f5;
42
- --so-ink: #171717; --so-muted: #666666; --so-line: #e6e6e6; --so-input-line: #d4d4d4;
43
- --so-accent: #171717; --so-accent-hover: #383838; --so-accent-text: #171717; --so-on-accent: #ffffff; --so-soft: #f2f2f2;
44
- --so-nav-ink: #525252; --so-nav-hover: #e4e4e4; --so-nav-current: #ffffff; --so-nav-current-ink: #171717;
45
- --so-signal: #171717; --so-signal-hover: #383838; --so-on-signal: #ffffff; --so-signal-soft: #e8e8e8;
46
- --so-danger: #c4320a; --so-danger-soft: #feebe7; --so-success: #218358; --so-success-soft: #e6f6eb;
47
- --so-warning: #ab6400; --so-warning-soft: #fff4d5; --so-info: #0d74ce; --so-info-soft: #e6f4fe;
48
- --so-neutral-ink: #525252; --so-neutral-soft: #f0f0f0; --so-live: #0d74ce;
49
- --so-attention: var(--so-signal); --so-on-attention: var(--so-on-signal); --so-attention-soft: var(--so-signal-soft);
50
- --so-sheet-shadow: 0 0 0 1px rgb(0 0 0 / .06), 0 1px 2px rgb(0 0 0 / .04), 0 4px 12px -6px rgb(0 0 0 / .06);
51
- --so-pill-shadow: 0 0 0 1px rgb(0 0 0 / .06), 0 1px 2px rgb(0 0 0 / .06);
52
- --so-overlay: rgb(0 0 0 / .32); --so-shadow-overlay: 0 0 0 1px rgb(0 0 0 / .08), 0 24px 48px -12px rgb(0 0 0 / .22);
53
- --so-selection: #d4d4d4; --so-scroll: #cfcfcf; --so-code-bg: #fafafa; --so-user-bubble: #f2f2f2;
54
-
55
40
  --background: var(--so-ground); --foreground: var(--so-ink); --card: var(--so-paper);
56
41
  --muted: var(--so-raised); --muted-foreground: var(--so-muted); --border: var(--so-line); --input: var(--so-input-line);
57
42
  --primary: var(--so-accent); --primary-foreground: var(--so-on-accent); --secondary: var(--so-raised); --secondary-foreground: var(--so-ink);
58
43
  --accent: var(--so-soft); --destructive: var(--so-danger); --destructive-strong: var(--so-danger); --destructive-soft: var(--so-danger-soft);
59
44
  --success: var(--so-success); --success-soft: var(--so-success-soft); --warning: var(--so-warning); --warning-soft: var(--so-warning-soft);
60
45
  --running: var(--so-info); --running-soft: var(--so-info-soft); --ring: var(--so-signal);
61
- /* Magenta means one thing: a person is needed (approve, answer, decide). */
46
+ /* The accent means one thing: a person is needed (approve, answer, decide). */
62
47
  --brand: var(--so-attention); --brand-foreground: var(--so-on-attention); --brand-soft: var(--so-attention-soft);
63
48
  --glass: var(--so-paper); --glass-strong: var(--so-paper); --glass-border: var(--so-line); --glass-highlight: transparent;
64
49
  --ambient-one: transparent; --ambient-two: transparent; --user-message: var(--so-user-bubble);
65
50
  --surface: var(--so-paper); --ok: var(--so-success); --danger: var(--so-danger); --fg-muted: var(--so-muted);
66
51
  --shadow: none; --shadow-overlay: var(--so-shadow-overlay);
67
52
  --radius: 0.625rem;
68
- --so-ease-out: cubic-bezier(.23, 1, .32, 1);
69
- --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
70
- --font-mono: "Geist Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
71
- /* Phone spacing steps (760px and narrower): side gutter, gap under the
72
- header, gap between page blocks, between rows, and inside a card. */
73
- --so-phone-gutter: 16px; --so-phone-gap: 12px; --so-phone-block: 14px; --so-phone-row: 8px; --so-phone-card: 12px;
74
- }
75
- @media (prefers-color-scheme: dark) {
76
- :root:not([data-theme="light"]) {
77
- color-scheme: dark;
78
-
79
- --so-ground: #0b0b0b; --so-paper: #161616; --so-sidebar: #0b0b0b; --so-raised: #1c1c1c;
80
- --so-ink: #ededed; --so-muted: #a1a1a1; --so-line: #262626; --so-input-line: #363636;
81
- --so-accent: #ededed; --so-accent-hover: #ffffff; --so-accent-text: #ededed; --so-on-accent: #0a0a0a; --so-soft: #1f1f1f;
82
- --so-nav-ink: #a1a1a1; --so-nav-hover: #171717; --so-nav-current: #1f1f1f; --so-nav-current-ink: #ededed;
83
- --so-signal: #ededed; --so-signal-hover: #ffffff; --so-on-signal: #0a0a0a; --so-signal-soft: #2e2e2e;
84
- --so-danger: #ff977d; --so-danger-soft: rgb(255 151 125 / .12); --so-success: #3dd68c; --so-success-soft: rgb(61 214 140 / .12);
85
- --so-warning: #ffca16; --so-warning-soft: rgb(255 202 22 / .12); --so-info: #70b8ff; --so-info-soft: rgb(112 184 255 / .12);
86
- --so-neutral-ink: #b4b4b4; --so-neutral-soft: #1f1f1f; --so-live: #70b8ff;
87
- --so-attention: var(--so-signal); --so-on-attention: var(--so-on-signal); --so-attention-soft: var(--so-signal-soft);
88
- --so-sheet-shadow: 0 0 0 1px #262626, 0 1px 2px rgb(0 0 0 / .4);
89
- --so-pill-shadow: 0 0 0 1px #2a2a2a, 0 1px 2px rgb(0 0 0 / .5);
90
- --so-overlay: rgb(0 0 0 / .6); --so-shadow-overlay: 0 0 0 1px #2e2e2e, 0 24px 48px -12px rgb(0 0 0 / .7);
91
- --so-selection: #3a3a3a; --so-scroll: #333333; --so-code-bg: #111111; --so-user-bubble: #202020;
92
- }
93
- }
94
- :root[data-theme="dark"] {
95
- color-scheme: dark;
96
-
97
- --so-ground: #0b0b0b; --so-paper: #161616; --so-sidebar: #0b0b0b; --so-raised: #1c1c1c;
98
- --so-ink: #ededed; --so-muted: #a1a1a1; --so-line: #262626; --so-input-line: #363636;
99
- --so-accent: #ededed; --so-accent-hover: #ffffff; --so-accent-text: #ededed; --so-on-accent: #0a0a0a; --so-soft: #1f1f1f;
100
- --so-nav-ink: #a1a1a1; --so-nav-hover: #171717; --so-nav-current: #1f1f1f; --so-nav-current-ink: #ededed;
101
- --so-signal: #ededed; --so-signal-hover: #ffffff; --so-on-signal: #0a0a0a; --so-signal-soft: #2e2e2e;
102
- --so-danger: #ff977d; --so-danger-soft: rgb(255 151 125 / .12); --so-success: #3dd68c; --so-success-soft: rgb(61 214 140 / .12);
103
- --so-warning: #ffca16; --so-warning-soft: rgb(255 202 22 / .12); --so-info: #70b8ff; --so-info-soft: rgb(112 184 255 / .12);
104
- --so-neutral-ink: #b4b4b4; --so-neutral-soft: #1f1f1f; --so-live: #70b8ff;
105
- --so-attention: var(--so-signal); --so-on-attention: var(--so-on-signal); --so-attention-soft: var(--so-signal-soft);
106
- --so-sheet-shadow: 0 0 0 1px #262626, 0 1px 2px rgb(0 0 0 / .4);
107
- --so-pill-shadow: 0 0 0 1px #2a2a2a, 0 1px 2px rgb(0 0 0 / .5);
108
- --so-overlay: rgb(0 0 0 / .6); --so-shadow-overlay: 0 0 0 1px #2e2e2e, 0 24px 48px -12px rgb(0 0 0 / .7);
109
- --so-selection: #3a3a3a; --so-scroll: #333333; --so-code-bg: #111111; --so-user-bubble: #202020;
53
+ --font-sans: var(--so-font-sans);
54
+ --font-mono: var(--so-font-mono);
110
55
  }
111
56
  * { box-sizing: border-box; }
112
57
  /* Touch: no grey flash on a tap, no double-tap zoom wait on a control,
@@ -20,6 +20,9 @@ export type LiveRoom = {
20
20
  } | {
21
21
  kind: "team";
22
22
  conversation: string | null;
23
+ } | {
24
+ kind: "agent";
25
+ run: number;
23
26
  };
24
27
  /** A room's name, read strictly: anything else is not a room. */
25
28
  export declare function parseLiveRoom(name: string): LiveRoom | null;
@@ -1,3 +1,6 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { boardRunOf, holdAgentView, releaseAgentView } from "../agent-board.js";
3
+ import { agentRunFingerprint } from "../agents-board.js";
1
4
  import { createLiveRooms, LiveConnection } from "../live-bus.js";
2
5
  import { reproveRemote } from "../operate-remote.js";
3
6
  import { rowVisible } from "../project.js";
@@ -8,7 +11,7 @@ import { adapterPolicy } from "./route-policy.js";
8
11
  import { requestContext } from "./request-context.js";
9
12
  /** A page asks for a few rooms at once: its workspace, what it shows, its conversation. */
10
13
  export const LIVE_ROOMS_PER_PAGE = 5;
11
- const ROOM_NAME = /^(workspace|task|flow|chat|team)(?::([^?]{1,400}))?(?:\?([^#]{0,600}))?$/;
14
+ const ROOM_NAME = /^(workspace|task|flow|chat|team|agent)(?::([^?]{1,400}))?(?:\?([^#]{0,600}))?$/;
12
15
  /** At most one workspace nudge a second: a burst of writes is one re-read, the first heard at once. */
13
16
  const WORKSPACE_SETTLE_MS = 1_000;
14
17
  /** A running reply's preview: at most one snapshot every 80 ms; the last one always goes out. */
@@ -45,6 +48,8 @@ export function parseLiveRoom(name) {
45
48
  if (key !== null || !only("conversation"))
46
49
  return null;
47
50
  return { kind, conversation: params.get("conversation") || null };
51
+ case "agent":
52
+ return key !== null && /^[1-9][0-9]{0,9}$/.test(key) && query === undefined ? { kind, run: Number(key) } : null;
48
53
  }
49
54
  return null;
50
55
  }
@@ -61,6 +66,8 @@ export function createLiveHandlers(runtime, live) {
61
66
  return cursor === null ? null : String(cursor);
62
67
  }, null, { bus: live.bus });
63
68
  const chatViewers = new Set();
69
+ // One agent's open view (Agents board): what it shows, keyed by the exact run.
70
+ const agentRooms = createLiveRooms(run => agentRunFingerprint(store, run), null, { bus: live.bus });
64
71
  /** The same session, still signed in, from the cookie and the account (never this request's context). */
65
72
  const readBrowser = (request, who) => {
66
73
  const again = identify(request, false);
@@ -177,7 +184,7 @@ export function createLiveHandlers(runtime, live) {
177
184
  if ([...url.searchParams.keys()].some(key => key !== "room") || names.length === 0 || names.length > LIVE_ROOMS_PER_PAGE || new Set(names).size !== names.length)
178
185
  return json(response, 400, { error: "room" });
179
186
  const rooms = names.map(name => ({ name, room: parseLiveRoom(name) }));
180
- if (rooms.some(one => one.room === null) || rooms.filter(one => one.room?.kind === "workspace").length > 1 || rooms.filter(one => one.room?.kind === "chat").length > 1 || rooms.filter(one => one.room?.kind === "team").length > 1)
187
+ if (rooms.some(one => one.room === null) || rooms.filter(one => one.room?.kind === "agent").length > 1 || rooms.filter(one => one.room?.kind === "workspace").length > 1 || rooms.filter(one => one.room?.kind === "chat").length > 1 || rooms.filter(one => one.room?.kind === "team").length > 1)
181
188
  return json(response, 400, { error: "room" });
182
189
  // Admit every room before the stream opens, with the answers each page's own stream gave.
183
190
  const joins = [];
@@ -198,6 +205,31 @@ export function createLiveHandlers(runtime, live) {
198
205
  // A sign-out or a narrowed account stops it.
199
206
  valid: () => signedIn() && rowVisible(liveCeiling(), repo) && store.accountCanAccess(who.name, repo) }));
200
207
  }
208
+ else if (room.kind === "agent") {
209
+ const browser = who;
210
+ const run = store.getRun(room.run);
211
+ const repo = run === null ? null : store.refForId(run.taskRef)?.repo ?? null;
212
+ if (run === null || repo === null || !visible(repo) || !store.accountCanAccess(who.name, repo))
213
+ return json(response, 404, { error: "run" });
214
+ // A repair is its build's own activity: its room is the build's.
215
+ const signedIn = sameBrowser(request, browser), runId = boardRunOf(store, run.id), view = randomBytes(12).toString("hex");
216
+ // The watch is a short lease in the database, renewed on every keep-alive and released when the page goes:
217
+ // the process that sends chat pings reads it there (agent-board.ts watchingAgent).
218
+ const watch = () => { try {
219
+ holdAgentView(store, view, who.name, runId, clock());
220
+ }
221
+ catch { /* a missed renewal only lets a ping through */ } };
222
+ joins.push(connection => {
223
+ watch();
224
+ connection.onKeepAlive(watch);
225
+ connection.onClose(() => { try {
226
+ releaseAgentView(store, view, clock());
227
+ }
228
+ catch { /* it lapses on its own */ } });
229
+ agentRooms.join(runId, { name: who.name, room: name, connection,
230
+ valid: () => signedIn() && rowVisible(liveCeiling(), repo) && store.accountCanAccess(who.name, repo) });
231
+ });
232
+ }
201
233
  else if (room.kind === "flow") {
202
234
  const browser = who;
203
235
  const flow = store.getFlow(room.flow);
@@ -302,6 +334,7 @@ export function createLiveHandlers(runtime, live) {
302
334
  detachChat();
303
335
  workspaceRooms.close();
304
336
  teamRooms.close();
337
+ agentRooms.close();
305
338
  for (const viewer of [...chatViewers])
306
339
  viewer.connection.close();
307
340
  },
@@ -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,
@@ -494,7 +494,7 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
494
494
  reserve: { label: "Worker assignment", action: payload["worker"] === null ? "release" : "reserve", icon: html `<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>` },
495
495
  hold: { label: "Pause work", action: "hold", icon: html `<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>` },
496
496
  unhold: { label: "Resume work", action: "release hold", icon: html `<path d="m7 4 13 8-13 8z"/>` },
497
- steer: { label: "Guidance for next attempt", action: "add guidance", icon: html `<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>` },
497
+ steer: { label: "Guidance for the agent", action: "add guidance", icon: html `<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>` },
498
498
  scope: { label: "Scope revision", action: "save scope", icon: html `<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4z"/>` },
499
499
  answer: { label: "Decision answer", action: "confirm answer", icon: html `<path d="M9.1 9a3 3 0 1 1 5.8 1c0 2-3 2-3 4"/><path d="M12 18h.01"/><circle cx="12" cy="12" r="9"/>` },
500
500
  cancel: { label: "Cancel task", action: "open task", icon: html `<path d="m15 9-6 6"/><path d="m9 9 6 6"/><circle cx="12" cy="12" r="9"/>` },
@@ -570,9 +570,21 @@ 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
- what = html `<h3>Guide <a href="${taskHref(task)}">${taskTitle}</a>'s next attempt</h3><p class="proposal-summary">${text("note")}</p>${facts(["when", "next attempt"], ["project", mono(repoId)])}<p class="meta proposal-disclosure">This guides the next attempt without changing the task’s scope. It does not interrupt work already running.</p>`;
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>`;
576
588
  }
577
589
  else if (view.kind === "agents") {
578
590
  // The agents card (v48): exactly what changes, in the same words the
@@ -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;
@@ -570,6 +580,8 @@ export declare function taskBodyParts(data: {
570
580
  } | null;
571
581
  /** Operator steering notes (arc 1), delivery state included. */
572
582
  steering?: SteerNote[];
583
+ /** A build's agent is running now and reads new notes when it next checks for them (v120). */
584
+ steeringLive?: boolean;
573
585
  /** The publication grant the publisher would act under — from
574
586
  * publicationGrantFor(repo) only; null when none, or no project. */
575
587
  grant?: PublicationGrant | null;
@@ -34,8 +34,10 @@ 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";
40
+ import { steerDestinationWords, steerStandingParts } from "../steer-status.js";
39
41
  import { reviewHref, taskChatHref, taskHref } from "./http.js";
40
42
  import { coordinatorProposalsSection } from "./render-chat.js";
41
43
  import { completionReceiptCard, criterionMatrixHtml, evidenceWords, homePhaseWords, incidentWords, needsCheck, pullRequestCardHtml, reasonWords, receiptStatusOf, repairChainHtml, reportItemsHtml, reviewRetryPanel, runNoun, runOutcomeBadge, semanticCoverageHtml, terminalDiffCard, verificationExplanation, verificationRecovered } from "./render-results.js";
@@ -807,14 +809,26 @@ export function checkProgressHtml(progress, id = "check-progress") {
807
809
  : Object.values(progress.suites).every(one => one.state === "passed") ? "passed" : "unknown";
808
810
  return html `<p id="${id}" class="check-progress mono" data-check-progress data-final="${state}" role="status" aria-live="polite">${progress.line}</p>`;
809
811
  }
810
- export function taskControlDetailsHtml(control, taskId, csrf, surface, inert = false, stopElsewhere = false) {
811
- 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);
812
814
  return control.kind === "paused" || control.kind === "review-stopped" || control.kind === "stopping"
813
815
  ? html `<details id="task-control-details"><summary>${control.kind === "paused" ? "Preserved work and resume" : "Stop details"}</summary>${card}</details>` : card;
814
816
  }
815
- /** `stopElsewhere`: the Building card already carries this exact run's Stop, so the control card keeps only its details.
816
- * `csrf` is only a guard now: an empty one (a read-only surface) renders no forms; postForm writes the field. */
817
- 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) {
818
832
  if (control.kind === "none")
819
833
  return html ``;
820
834
  const guarded = csrf !== "" && !inert;
@@ -832,6 +846,7 @@ export function taskControlHtml(control, taskId, csrf, surface, inert = false, s
832
846
  return joinHtml([
833
847
  html `<section class="card task-control" id="task-control" data-task-control="stop" data-control-run="${control.run}" aria-label="stop this attempt">`,
834
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),
835
850
  stopElsewhere ? "" : guarded
836
851
  ? runForm("stop", "inline task-control-form task-stop-form", html `<button type="submit" class="danger task-control-button">Stop</button>`)
837
852
  : html `<button type="button" class="danger task-control-button" disabled>Stop</button>`,
@@ -1370,24 +1385,21 @@ export function taskBodyParts(data) {
1370
1385
  }
1371
1386
  return box("problem", "Dispatch unknown", "Refresh this task before relying on its scheduler state.", "unknown");
1372
1387
  })();
1373
- // Operator steering (arc 1): notes for the agent, each wearing exactly
1374
- // where it stands — waiting, attached, proven delivered, or superseded.
1388
+ // Operator steering (arc 1, v120): notes for the agent, each wearing the
1389
+ // same words the CLI and the lead use — read (and when), waiting for the
1390
+ // running agent, bound for the next attempt, or ended unread.
1375
1391
  const steering = data.steering ?? [];
1376
- const steerState = (one) => one.authorshipState !== "verified"
1377
- ? "recorded before steering required a credential — never delivered"
1378
- : one.supersededAt !== null
1379
- ? "the task ended before this landed"
1380
- : one.deliveredAt !== null
1381
- ? `reached build #${one.attachedRun}`
1382
- : one.attachedRun !== null
1383
- ? `attached to build #${one.attachedRun} — delivery not yet proven`
1384
- : "waiting for the next attempt";
1392
+ const steeringLive = data.steeringLive === true;
1393
+ const steerState = (one) => {
1394
+ const { words, readAt } = steerStandingParts(one, steeringLive);
1395
+ return readAt === null ? html `${words}` : html `${words} ${whenTime(readAt)}`;
1396
+ };
1385
1397
  const steerRows = steering.length === 0 ? "" : joinHtml(steering.map(one => html `<p class="row"><span class="meta">${one.author} · ${whenTime(one.createdAt)} · ${steerState(one)}</span> ${one.note}</p>`), "\n");
1386
1398
  const steerForm = data.csrf === "" || task.state === "done" || task.state === "cancelled"
1387
1399
  ? ""
1388
1400
  : joinHtml([
1389
- postForm(`${taskHref(task.id)}/steer`, html `<input type="text" name="note" placeholder="guidance for the next attempt" aria-label="steering note" style="width:100%;max-width:28rem"><button type="submit">Steer</button>`, { attrs: { class: "row" } }),
1390
- html `<p class="meta">Lands when the next attempt starts — a running agent is not interrupted, and a note cannot widen the approved scope</p>`,
1401
+ postForm(`${taskHref(task.id)}/steer`, html `<input type="text" name="note" placeholder="guidance for the agent" aria-label="steering note" style="width:100%;max-width:28rem"><button type="submit">Steer</button>`, { attrs: { class: "row" } }),
1402
+ html `<p class="meta">${steerDestinationWords(steeringLive)} A note can't widen the approved scope.</p>`,
1391
1403
  ]);
1392
1404
  const steeringCard = steerRows === "" && steerForm === "" ? "" : html `<h2>Steering</h2>${steerRows}${steerForm}`;
1393
1405
  const holds = data.holds.length === 0
@@ -1896,7 +1908,7 @@ export function taskBodyParts(data) {
1896
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)}`;
1897
1909
  // The exact-run control (v52), directly under the scheduler's answer:
1898
1910
  // the one place a person stops or resumes THIS attempt.
1899
- 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);
1900
1912
  const previousResult = data.completion?.receipt == null || receiptLeads ? null : {
1901
1913
  title: data.assignment != null && task.state === "done" ? "Result" : "Previous result",
1902
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)),
@@ -2177,6 +2189,11 @@ export function taskBodyParts(data) {
2177
2189
  // The Building card carries Stop for the build it describes (the same form), in place of a second card below it.
2178
2190
  const stop = building && data.control?.kind === "stop" && data.control.run === liveRun.id && data.csrf !== ""
2179
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;
2180
2197
  // The Building card keeps its way to the build's own record (/r/<id>), whatever act it shows.
2181
2198
  const record = building ? { label: `Build #${liveRun.id} record`, href: `/r/${liveRun.id}` } : null;
2182
2199
  const earlier = earlierStopped.length === 0 ? null : {
@@ -2206,6 +2223,7 @@ export function taskBodyParts(data) {
2206
2223
  // One quiet line under the step: what the agent did last, and when.
2207
2224
  activity: liveRun === undefined ? null : data.activity ?? null,
2208
2225
  stop,
2226
+ pause,
2209
2227
  record,
2210
2228
  earlier,
2211
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
@@ -473,6 +482,7 @@ export function createTaskViews(runtime) {
473
482
  return { rows, decisions: decisionsFor(runtime.store, rows), now };
474
483
  })(),
475
484
  steering: ref === null ? [] : runtime.store.listSteerNotes(ref.id),
485
+ steeringLive: ref !== null && runtime.store.steeringLiveBuild(ref.id, now) !== null,
476
486
  // "publishes as" reads ONLY publicationGrantFor(repo) — the grant
477
487
  // the publisher would act under — never listGrants(), which is
478
488
  // dispatch authority (slice 1c).