toolroll 0.9.63 → 0.9.67

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 (302) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +33 -5
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +117 -2
  16. package/dist/agent-fence.js +439 -12
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +39 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/batch-checks.js +3 -2
  32. package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
  33. package/dist/browser/terminal.css +37 -0
  34. package/dist/browser/terminal.js +228 -0
  35. package/dist/browser/workspace.css +2 -2
  36. package/dist/browser/workspace.js +42 -38
  37. package/dist/browser-shell.js +6 -3
  38. package/dist/browser-workspace.d.ts +235 -1
  39. package/dist/browser-workspace.js +60 -0
  40. package/dist/builder.d.ts +10 -1
  41. package/dist/builder.js +87 -24
  42. package/dist/chat-actions.d.ts +13 -1
  43. package/dist/chat-actions.js +197 -15
  44. package/dist/chat-ask.d.ts +4 -0
  45. package/dist/chat-ask.js +14 -0
  46. package/dist/chat-channel.js +20 -0
  47. package/dist/chat-continuity.js +5 -4
  48. package/dist/chat-decide.d.ts +0 -14
  49. package/dist/chat-decide.js +4 -1
  50. package/dist/chat-delivery.d.ts +9 -0
  51. package/dist/chat-delivery.js +45 -6
  52. package/dist/chat-messages.d.ts +8 -0
  53. package/dist/chat-messages.js +36 -4
  54. package/dist/check-levels-ui.js +2 -2
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +19 -0
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +4 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +21 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/console-api.d.ts +13 -4
  71. package/dist/contracts/console-api.js +13 -4
  72. package/dist/contracts/flow-card.d.ts +4 -0
  73. package/dist/contracts/flow-card.js +3 -0
  74. package/dist/contracts/flow.d.ts +46 -0
  75. package/dist/contracts/flow.js +11 -2
  76. package/dist/contracts/lead-tools.d.ts +92 -0
  77. package/dist/contracts/lead-tools.js +13 -0
  78. package/dist/contracts/provider.d.ts +11 -1
  79. package/dist/contracts/provider.js +1 -1
  80. package/dist/contracts/route.d.ts +76 -1
  81. package/dist/contracts/route.js +1 -1
  82. package/dist/contracts/store-json.d.ts +2 -0
  83. package/dist/control-setup.d.ts +1 -1
  84. package/dist/control-ui.js +2 -2
  85. package/dist/converse.d.ts +16 -0
  86. package/dist/converse.js +32 -4
  87. package/dist/demo-chat.js +4 -4
  88. package/dist/demo.js +2 -2
  89. package/dist/desktop-update-gate.js +6 -0
  90. package/dist/desktop-update.d.ts +1 -1
  91. package/dist/discord-chat.js +4 -1
  92. package/dist/doctor.js +1 -1
  93. package/dist/egress-bridge.d.ts +17 -0
  94. package/dist/egress-bridge.js +111 -0
  95. package/dist/egress.d.ts +119 -0
  96. package/dist/egress.js +423 -0
  97. package/dist/exec.d.ts +78 -0
  98. package/dist/exec.js +367 -9
  99. package/dist/export-ui.js +2 -2
  100. package/dist/flow-engine.js +24 -5
  101. package/dist/flow-gallery-ui.js +2 -2
  102. package/dist/flow-gallery.js +27 -8
  103. package/dist/flow-share.js +16 -5
  104. package/dist/flow-triggers.d.ts +77 -8
  105. package/dist/flow-triggers.js +517 -70
  106. package/dist/flows-cli.js +8 -2
  107. package/dist/flows-ui.d.ts +0 -2
  108. package/dist/flows-ui.js +52 -4
  109. package/dist/flows.d.ts +4 -0
  110. package/dist/flows.js +9 -1
  111. package/dist/html.d.ts +9 -0
  112. package/dist/html.js +11 -0
  113. package/dist/invoke.d.ts +35 -0
  114. package/dist/invoke.js +347 -28
  115. package/dist/keys.d.ts +18 -14
  116. package/dist/keys.js +5 -0
  117. package/dist/lead-capabilities.js +3 -2
  118. package/dist/lead-contract.js +2 -0
  119. package/dist/lead-doors.d.ts +2 -0
  120. package/dist/lead-doors.js +7 -0
  121. package/dist/lead-follow.d.ts +2 -0
  122. package/dist/lead-follow.js +58 -2
  123. package/dist/lead-progress.js +1 -0
  124. package/dist/lead-queue-runtime.d.ts +46 -0
  125. package/dist/lead-queue-runtime.js +191 -0
  126. package/dist/lead-queue.d.ts +104 -0
  127. package/dist/lead-queue.js +187 -0
  128. package/dist/lead-sweep.d.ts +67 -0
  129. package/dist/lead-sweep.js +99 -0
  130. package/dist/lead-tools.d.ts +22 -0
  131. package/dist/lead-tools.js +152 -1
  132. package/dist/lead.d.ts +13 -5
  133. package/dist/lead.js +89 -29
  134. package/dist/limits-ui.d.ts +8 -1
  135. package/dist/limits-ui.js +26 -0
  136. package/dist/live-bus.d.ts +8 -0
  137. package/dist/live-bus.js +40 -4
  138. package/dist/live-detail.d.ts +27 -0
  139. package/dist/live-detail.js +57 -0
  140. package/dist/live-notes.d.ts +20 -0
  141. package/dist/live-notes.js +47 -0
  142. package/dist/live-records.d.ts +140 -0
  143. package/dist/live-records.js +935 -0
  144. package/dist/live-terminal.d.ts +269 -0
  145. package/dist/live-terminal.js +686 -0
  146. package/dist/live.d.ts +38 -27
  147. package/dist/live.js +142 -124
  148. package/dist/mcp-oauth.d.ts +6 -3
  149. package/dist/mcp-oauth.js +8 -4
  150. package/dist/memory-pass.js +2 -2
  151. package/dist/mobile-viewport.d.ts +5 -1
  152. package/dist/mobile-viewport.js +18 -2
  153. package/dist/model-catalog.js +4 -0
  154. package/dist/monitoring-ui.js +2 -2
  155. package/dist/needs-you.d.ts +1 -1
  156. package/dist/needs-you.js +11 -4
  157. package/dist/operate.d.ts +4 -0
  158. package/dist/operate.js +335 -19
  159. package/dist/passwordless.d.ts +23 -0
  160. package/dist/passwordless.js +26 -0
  161. package/dist/peek-cli.d.ts +12 -4
  162. package/dist/peek-cli.js +19 -11
  163. package/dist/phase-routing.js +10 -3
  164. package/dist/planner-source.d.ts +3 -0
  165. package/dist/planner-source.js +14 -0
  166. package/dist/planner.d.ts +2 -1
  167. package/dist/planner.js +7 -1
  168. package/dist/policy-ui.js +2 -2
  169. package/dist/policy.d.ts +1 -20
  170. package/dist/policy.js +29 -2
  171. package/dist/project-delete-ui.d.ts +18 -0
  172. package/dist/project-delete-ui.js +60 -9
  173. package/dist/project-delete.js +2 -2
  174. package/dist/project-memory.d.ts +3 -1
  175. package/dist/project-memory.js +57 -25
  176. package/dist/project-tools.d.ts +5 -3
  177. package/dist/project-tools.js +12 -9
  178. package/dist/provider-auth.d.ts +4 -3
  179. package/dist/provider-auth.js +5 -4
  180. package/dist/provider-connection.d.ts +2 -1
  181. package/dist/provider-connection.js +5 -0
  182. package/dist/provider.d.ts +25 -2
  183. package/dist/provider.js +63 -9
  184. package/dist/pull-request-ui.js +2 -2
  185. package/dist/push.js +1 -1
  186. package/dist/quick-start.d.ts +24 -0
  187. package/dist/quick-start.js +127 -0
  188. package/dist/release-adapters.d.ts +6 -0
  189. package/dist/release-adapters.js +70 -3
  190. package/dist/release.d.ts +10 -1
  191. package/dist/release.js +34 -15
  192. package/dist/request-budget-ui.js +2 -2
  193. package/dist/result-follow-ups.js +3 -2
  194. package/dist/result-review.d.ts +1 -1
  195. package/dist/result-review.js +9 -3
  196. package/dist/retention-ui.js +2 -2
  197. package/dist/retention.js +10 -1
  198. package/dist/run-efficiency.d.ts +73 -0
  199. package/dist/run-efficiency.js +228 -0
  200. package/dist/runner.d.ts +5 -1
  201. package/dist/runner.js +22 -1
  202. package/dist/scope.d.ts +183 -5
  203. package/dist/scope.js +256 -31
  204. package/dist/scout-net.d.ts +4 -1
  205. package/dist/scout-net.js +37 -12
  206. package/dist/scout.d.ts +20 -6
  207. package/dist/scout.js +27 -3
  208. package/dist/serve.js +248 -40
  209. package/dist/server/chat.d.ts +1 -1
  210. package/dist/server/chat.js +122 -31
  211. package/dist/server/chrome.d.ts +16 -2
  212. package/dist/server/chrome.js +63 -11
  213. package/dist/server/console.css +107 -42
  214. package/dist/server/flows.js +38 -8
  215. package/dist/server/guards.d.ts +1 -0
  216. package/dist/server/guards.js +23 -9
  217. package/dist/server/http.d.ts +7 -0
  218. package/dist/server/http.js +3 -2
  219. package/dist/server/live.d.ts +34 -1
  220. package/dist/server/live.js +39 -17
  221. package/dist/server/pages-assets.js +5 -0
  222. package/dist/server/pages.js +90 -13
  223. package/dist/server/people-tokens.d.ts +3 -0
  224. package/dist/server/people-tokens.js +55 -17
  225. package/dist/server/remote-hooks.js +12 -6
  226. package/dist/server/remote.d.ts +2 -1
  227. package/dist/server/remote.js +14 -5
  228. package/dist/server/render-chat.d.ts +12 -3
  229. package/dist/server/render-chat.js +47 -16
  230. package/dist/server/render-pages.d.ts +32 -4
  231. package/dist/server/render-pages.js +83 -13
  232. package/dist/server/render-people.d.ts +3 -2
  233. package/dist/server/render-people.js +3 -2
  234. package/dist/server/render-results.d.ts +18 -2
  235. package/dist/server/render-results.js +37 -28
  236. package/dist/server/render-settings.js +6 -5
  237. package/dist/server/render-tasks.d.ts +35 -10
  238. package/dist/server/render-tasks.js +111 -27
  239. package/dist/server/request-context.d.ts +2 -0
  240. package/dist/server/request-context.js +2 -1
  241. package/dist/server/route-table.d.ts +3 -0
  242. package/dist/server/route-table.js +47 -5
  243. package/dist/server/runtime.d.ts +33 -2
  244. package/dist/server/session.d.ts +25 -1
  245. package/dist/server/session.js +18 -4
  246. package/dist/server/settings.js +142 -10
  247. package/dist/server/sweeps.d.ts +4 -0
  248. package/dist/server/sweeps.js +58 -0
  249. package/dist/server/task-views.d.ts +2 -1
  250. package/dist/server/task-views.js +96 -6
  251. package/dist/server/tasks.js +327 -78
  252. package/dist/server/terminals.d.ts +31 -0
  253. package/dist/server/terminals.js +365 -0
  254. package/dist/setup-guide.js +3 -2
  255. package/dist/slack-chat.js +4 -1
  256. package/dist/spend-ui.js +3 -3
  257. package/dist/spend.js +2 -1
  258. package/dist/steer-capabilities.d.ts +32 -0
  259. package/dist/steer-capabilities.js +60 -0
  260. package/dist/steer-controls.d.ts +14 -0
  261. package/dist/steer-controls.js +36 -0
  262. package/dist/steer-status.js +2 -2
  263. package/dist/storage-ui.js +4 -4
  264. package/dist/store-baseline.d.ts +1 -1
  265. package/dist/store-baseline.js +1 -1
  266. package/dist/store.d.ts +440 -15
  267. package/dist/store.js +1072 -52
  268. package/dist/subagent-desk.js +1 -1
  269. package/dist/summary.js +3 -0
  270. package/dist/task-source.d.ts +58 -0
  271. package/dist/task-source.js +93 -0
  272. package/dist/team-leads.js +1 -1
  273. package/dist/teams-chat.js +4 -1
  274. package/dist/teardown-git.d.ts +22 -0
  275. package/dist/teardown-git.js +136 -2
  276. package/dist/telegram-lead.d.ts +3 -0
  277. package/dist/telegram-lead.js +44 -7
  278. package/dist/telegram.d.ts +11 -2
  279. package/dist/telegram.js +51 -17
  280. package/dist/terminal-catalog.d.ts +11 -0
  281. package/dist/terminal-catalog.js +129 -0
  282. package/dist/text-limits.d.ts +2 -0
  283. package/dist/text-limits.js +2 -0
  284. package/dist/tool-broker.d.ts +14 -0
  285. package/dist/tool-broker.js +67 -2
  286. package/dist/toolroll-update-ui.js +2 -2
  287. package/dist/toolroll-update.js +13 -3
  288. package/dist/tools-ui.js +2 -2
  289. package/dist/unfenced-test-support.d.ts +2 -0
  290. package/dist/unfenced-test-support.js +11 -0
  291. package/dist/webhook-limits.d.ts +23 -0
  292. package/dist/webhook-limits.js +30 -0
  293. package/dist/webhook-readers.d.ts +56 -0
  294. package/dist/webhook-readers.js +138 -0
  295. package/dist/webhook-signing.d.ts +27 -0
  296. package/dist/webhook-signing.js +119 -0
  297. package/dist/work-index.d.ts +29 -0
  298. package/dist/work-index.js +16 -2
  299. package/dist/workspace-revision.js +3 -1
  300. package/dist/writes-mode.d.ts +9 -0
  301. package/dist/writes-mode.js +8 -0
  302. package/package.json +15 -2
@@ -0,0 +1,31 @@
1
+ import { type IncomingMessage, type Server } from "node:http";
2
+ import type { Duplex } from "node:stream";
3
+ import { TerminalHost } from "../live-terminal.js";
4
+ import type { LiveBus } from "../live-bus.js";
5
+ import type { Store } from "../store.js";
6
+ import type { ServeOptions } from "./http.js";
7
+ import type { ServerRuntime } from "./runtime.js";
8
+ export type TerminalDeps = {
9
+ host: TerminalHost | null;
10
+ /** Why there is no host (no pty library on this install), in words. */
11
+ problem: string | null;
12
+ bus: LiveBus;
13
+ allowedHost: (host: string | undefined) => boolean;
14
+ /** serve.ts's: the source a loopback proxy forwarded for (`fwd:<hop>`), else the peer. */
15
+ forwardedSourceOf: (request: IncomingMessage) => string;
16
+ publicOrigin: URL | null;
17
+ /** The console's server: its upgrade event is answered here. */
18
+ server: Server;
19
+ };
20
+ /** The console's terminal host: on a machine that runs agents, when this install has a pty library; else why not. */
21
+ export declare function terminalHostFor(store: Store, options: Pick<ServeOptions, "terminalHost" | "localRunner">): {
22
+ host: TerminalHost | null;
23
+ problem: string | null;
24
+ };
25
+ export declare function createTerminalHandlers(runtime: ServerRuntime, deps: TerminalDeps): {
26
+ registrations: import("./handler-registry.js").Registration[];
27
+ upgrade: (request: IncomingMessage, socket: Duplex, head: Buffer) => boolean;
28
+ /** Shutdown: no more attaches, every socket dropped, every program ended (bounded by the host's grace). The problem,
29
+ * when a terminal's processes couldn't be proved stopped. */
30
+ close(): Promise<Error | null>;
31
+ };
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Live terminals over the console (docs/plans/live-terminal.md): the Live page, its state, start/close/remove, and the
3
+ * WebSocket a page attaches with.
4
+ *
5
+ * A terminal is shell-equivalent on this computer, so every road is the coding workspace's: a browser sign-in of an
6
+ * installation-wide operator (codingActorAllowed), a project the coding workspace admits, and a fresh password to
7
+ * start one. The socket is not a route-table row (an upgrade never reaches the request handler); it re-checks
8
+ * everything the console's request path checks, and more, BEFORE the upgrade is accepted:
9
+ *
10
+ * host allowlisted → HTTPS or a private path (this computer, the tailnet) → Origin exactly this page's own origin →
11
+ * no token or Authorization → cookie session, re-proved against the database → operator of the same generation →
12
+ * the terminal is theirs and its project still admitted → a viewer slot free.
13
+ *
14
+ * While open, the same proof runs before every input frame, before output once it is a second old, every 5 s, and at
15
+ * once on every database change (the live bus); a failure closes the socket with 4401.
16
+ */
17
+ import { randomBytes } from "node:crypto";
18
+ import { STATUS_CODES } from "node:http";
19
+ import { dirname, join } from "node:path";
20
+ import { WebSocketServer } from "ws";
21
+ import { readForm, CONSOLE_FORMS } from "../contracts/console-api.js";
22
+ import { html, htmlString } from "../html.js";
23
+ import { openTerminalLedger } from "../terminal-catalog.js";
24
+ import { loadPtySpawn, MAX_FRAME_BYTES, TerminalHost, MAX_VIEWERS, parseTerminalFrame, TERMINAL_PROGRAMS, TerminalError } from "../live-terminal.js";
25
+ import { logEvent } from "../log.js";
26
+ import { passwordlessOn } from "../passwordless.js";
27
+ import { projectName, rowVisible } from "../project.js";
28
+ import { transportOf } from "../public-access.js";
29
+ import { handlersOf } from "./handler-registry.js";
30
+ import { respond } from "./http.js";
31
+ import { rereadBrowser } from "./live.js";
32
+ const TERMINAL_ID = /^\/term\/([a-f0-9]{32})(?:\/(socket|close|remove))?$/;
33
+ /** A viewer this far behind is dropped; its page reconnects and replays the scrollback. */
34
+ const BEHIND_BYTES = 4 * 1024 * 1024;
35
+ /** Typed input per socket: a burst of this many bytes, refilled at this many a second. */
36
+ const INPUT_BURST = 256 * 1024;
37
+ const INPUT_PER_SECOND = 256 * 1024;
38
+ const REPROVE_MS = 1_000;
39
+ const SWEEP_MS = 5_000;
40
+ /** The console's terminal host: on a machine that runs agents, when this install has a pty library; else why not. */
41
+ export function terminalHostFor(store, options) {
42
+ if (options.terminalHost !== undefined)
43
+ return { host: options.terminalHost, problem: options.terminalHost === null ? "Terminals are off on this console." : null };
44
+ const database = store.isDemo() ? null : store.databaseFile();
45
+ if (options.localRunner === undefined || database === null || database === "")
46
+ return { host: null, problem: "Terminals run on the computer that runs your agents. Open Toolroll there." };
47
+ const spawn = loadPtySpawn();
48
+ if (spawn === null)
49
+ return { host: null, problem: "This install has no terminal support (the optional @lydell/node-pty package didn't load). Reinstall Toolroll to add it." };
50
+ const ledger = openTerminalLedger(store.raw());
51
+ return { host: new TerminalHost({ spawn, worktreeRoot: join(dirname(database), "live-worktrees"), ...(ledger === null ? {} : { ledger }) }), problem: null };
52
+ }
53
+ export function createTerminalHandlers(runtime, deps) {
54
+ const { store, codingActorAllowed, codingProjectAllowed, liveCeiling } = runtime;
55
+ const transportOfRequest = (request) => transportOf({ peer: request.socket.remoteAddress, joinSource: deps.forwardedSourceOf(request),
56
+ forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
57
+ const sockets = new Set();
58
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES, perMessageDeflate: false });
59
+ /** An operator signed in with a browser, of the generation they signed in with; else null. */
60
+ const operatorOf = (who) => who?.via === "cookie" && who.role === "approver" && !store.isDemo() && codingActorAllowed({ name: who.name, generation: who.session.generation }) ? who : null;
61
+ /** The project a terminal works in is one the coding workspace admits for this person, now. */
62
+ const projectAllowed = (name, repo) => rowVisible(liveCeiling(), repo) && store.accountCanAccess(name, repo) && codingProjectAllowed(repo);
63
+ const owned = (who, terminal) => terminal !== null && terminal.owner === who.name && terminal.generation === who.session.generation;
64
+ const record = (event, who, terminal) => logEvent("info", `terminal.${event}`, { actor: who, terminal: terminal.id, repo: terminal.repo, program: terminal.program });
65
+ const json = (ctx, status, value) => respond(ctx.response, status, "application/json", JSON.stringify(value));
66
+ const refusal = (ctx) => {
67
+ if (operatorOf(ctx.who) === null)
68
+ return "Terminals are for installation-wide operators signed in with a browser.";
69
+ if (deps.host === null)
70
+ return deps.problem ?? "Terminals aren't available on this install.";
71
+ return null;
72
+ };
73
+ /** The Live page: its own small document and bundle. */
74
+ async function page(ctx) {
75
+ const { who, url, response, request } = ctx;
76
+ if (operatorOf(who) === null)
77
+ return respond(response, 403, "text/plain; charset=utf-8", "Terminals are for installation-wide operators signed in with a browser.");
78
+ const id = TERMINAL_ID.exec(url.pathname)?.[1] ?? "";
79
+ const nonce = randomBytes(16).toString("base64");
80
+ const host = request.headers.host ?? "";
81
+ const document = html `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"><meta name="theme-color" content="#0C0E12"><meta name="apple-mobile-web-app-capable" content="yes"><title>Live — Toolroll</title><link rel="icon" href="/icon.svg"><link rel="manifest" href="/manifest.webmanifest"><link rel="stylesheet" href="/assets/terminal.css"></head><body><div id="so-term"></div><div id="so-term-boot" hidden data-csrf="${who.session.csrf}" data-session="${id}" data-passwordless="${passwordlessOn(store) ? "1" : ""}"></div><script type="module" src="/assets/terminal.js" nonce="${nonce}"></script></body></html>`;
82
+ response.writeHead(200, {
83
+ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer",
84
+ // The socket is this page's own host: named outright, since older WebKit doesn't read 'self' as ws:/wss:.
85
+ "Content-Security-Policy": `default-src 'none'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self'; manifest-src 'self'; script-src 'nonce-${nonce}'; connect-src 'self' ws://${host} wss://${host}; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
86
+ });
87
+ response.end(htmlString(document));
88
+ }
89
+ async function state(ctx) {
90
+ const who = operatorOf(ctx.who);
91
+ if (who === null)
92
+ return json(ctx, 403, { error: "operator" });
93
+ const sessions = deps.host === null ? [] : deps.host.list(who.name).filter(one => owned(who, one) && projectAllowed(who.name, one.repo));
94
+ return json(ctx, 200, {
95
+ available: deps.host !== null, problem: deps.host === null ? deps.problem ?? "Terminals aren't available on this install." : null,
96
+ programs: TERMINAL_PROGRAMS,
97
+ projects: runtime.consoleProjects().filter(repo => projectAllowed(who.name, repo)).map(path => ({ path, name: projectName(path) })),
98
+ sessions: sessions.map(one => ({ id: one.id, repo: one.repo, project: projectName(one.repo), program: one.program, branch: one.branch, startedAt: one.startedAt, endedAt: one.endedAt, exit: one.exit, cleanup: one.cleanup })),
99
+ });
100
+ }
101
+ async function start(ctx) {
102
+ const refused = refusal(ctx);
103
+ if (refused !== null)
104
+ return json(ctx, 403, { ok: false, error: refused });
105
+ const who = ctx.who;
106
+ const body = readForm(ctx.posted, CONSOLE_FORMS.term);
107
+ if ([...new Set(body.keys())].some(key => body.sent.getAll(key).length !== 1))
108
+ return json(ctx, 400, { ok: false, error: "Submit one value for each field." });
109
+ const repo = body.get("repo") ?? "", program = body.get("program") ?? "";
110
+ if (!TERMINAL_PROGRAMS.includes(program))
111
+ return json(ctx, 400, { ok: false, error: "Choose Claude Code, Codex or a shell." });
112
+ if (!runtime.consoleProjects().includes(repo) || !projectAllowed(who.name, repo))
113
+ return json(ctx, 403, { ok: false, error: "Choose a project available to your account." });
114
+ // The organisation's policy decides whether this provider may run in a person's own session.
115
+ if (program !== "shell") {
116
+ const stopped = store.sessionPolicyRefusal(program, null, "live terminals");
117
+ if (stopped !== null)
118
+ return json(ctx, 403, { ok: false, error: stopped });
119
+ }
120
+ if (!runtime.authenticateApprover(who, body.get("password") ?? "").ok)
121
+ return json(ctx, 403, { ok: false, error: "Enter your Toolroll password to start a terminal." });
122
+ // The same session, still an operator of the same generation, and the project still theirs: asked right before the
123
+ // program starts (after the slow worktree work) and again once it has.
124
+ const stillAllowed = () => {
125
+ const again = rereadBrowser(runtime, ctx.request, who);
126
+ if (again === null || operatorOf(again) === null)
127
+ return "Your access changed. Sign in again.";
128
+ return projectAllowed(who.name, repo) && runtime.consoleProjects().includes(repo) ? null : "That project is no longer available to your account.";
129
+ };
130
+ try {
131
+ const terminal = await deps.host.start({ name: who.name, generation: who.session.generation }, repo, program, undefined, stillAllowed);
132
+ const after = stillAllowed();
133
+ if (after !== null) {
134
+ // Nothing of it stays: the program is swept, then its worktree removed (a sweep that can't prove itself keeps it).
135
+ await deps.host.close(terminal.id);
136
+ await deps.host.remove(terminal.id).catch(() => { });
137
+ return json(ctx, 403, { ok: false, error: after });
138
+ }
139
+ record("start", who.name, terminal);
140
+ return json(ctx, 200, { ok: true, id: terminal.id });
141
+ }
142
+ catch (error) {
143
+ return json(ctx, 409, { ok: false, error: error instanceof TerminalError ? error.message : "The terminal didn't start." });
144
+ }
145
+ }
146
+ const act = (verb) => async (ctx) => {
147
+ const refused = refusal(ctx);
148
+ if (refused !== null)
149
+ return json(ctx, 403, { ok: false, error: refused });
150
+ const who = ctx.who;
151
+ const terminal = deps.host.get(TERMINAL_ID.exec(ctx.url.pathname)?.[1] ?? "");
152
+ if (!owned(who, terminal) || !projectAllowed(who.name, terminal.repo))
153
+ return json(ctx, 404, { ok: false, error: "No such terminal." });
154
+ try {
155
+ if (verb === "close")
156
+ await deps.host.close(terminal.id);
157
+ else
158
+ await deps.host.remove(terminal.id);
159
+ record(verb, who.name, terminal);
160
+ return json(ctx, 200, { ok: true });
161
+ }
162
+ catch (error) {
163
+ return json(ctx, 409, { ok: false, error: error instanceof TerminalError ? error.message : `The terminal couldn't be ${verb === "close" ? "closed" : "removed"}.` });
164
+ }
165
+ };
166
+ /** A plain GET of the socket address: it only answers an upgrade. */
167
+ async function socketWithoutUpgrade(ctx) {
168
+ ctx.response.setHeader("Upgrade", "websocket");
169
+ return respond(ctx.response, 426, "text/plain; charset=utf-8", "This address answers a WebSocket upgrade only.");
170
+ }
171
+ // ---- the socket ------------------------------------------------------------------------------------------------
172
+ const reprovers = new Set();
173
+ const unsubscribe = deps.bus.subscribe(() => { for (const reprove of [...reprovers])
174
+ reprove(); });
175
+ const sweep = setInterval(() => { for (const reprove of [...reprovers])
176
+ reprove(); }, SWEEP_MS);
177
+ sweep.unref?.();
178
+ /** Answer a refused upgrade in plain HTTP and drop the connection. */
179
+ const refuseUpgrade = (socket, status, words) => {
180
+ const body = `${words}\n`;
181
+ try {
182
+ socket.end(`HTTP/1.1 ${status} ${STATUS_CODES[status] ?? ""}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\nCache-Control: no-store\r\n\r\n${body}`);
183
+ }
184
+ catch { /* already gone */ }
185
+ socket.destroy();
186
+ };
187
+ /** True when this request was an upgrade for a terminal (answered or refused here); false leaves it to others. */
188
+ function upgrade(request, socket, head) {
189
+ let url;
190
+ try {
191
+ url = new URL(request.url ?? "/", "http://placeholder");
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ const match = TERMINAL_ID.exec(url.pathname);
197
+ if (match === null || match[2] !== "socket")
198
+ return false;
199
+ socket.on("error", () => socket.destroy());
200
+ const id = match[1];
201
+ if (request.method !== "GET") {
202
+ refuseUpgrade(socket, 405, "Only GET upgrades.");
203
+ return true;
204
+ }
205
+ if (url.search !== "") {
206
+ refuseUpgrade(socket, 400, "This address takes no query; credentials never travel in URLs.");
207
+ return true;
208
+ }
209
+ const host = request.headers.host;
210
+ if (!deps.allowedHost(host)) {
211
+ refuseUpgrade(socket, 421, "This console doesn't answer at that address.");
212
+ return true;
213
+ }
214
+ const transport = transportOfRequest(request);
215
+ if (!transport.https && !transport.privatePath) {
216
+ refuseUpgrade(socket, 403, "Open the console over HTTPS to use a terminal from here.");
217
+ return true;
218
+ }
219
+ // Exactly the origin of a page served at this host, over this transport (the public https origin for its host).
220
+ const expected = deps.publicOrigin !== null && host === deps.publicOrigin.host ? deps.publicOrigin.origin : `${transport.https ? "https" : "http"}://${host}`;
221
+ if (request.headers.origin !== expected) {
222
+ refuseUpgrade(socket, 403, "A terminal opens only from this console's own page.");
223
+ return true;
224
+ }
225
+ if (request.headers.authorization !== undefined) {
226
+ refuseUpgrade(socket, 403, "Terminals open with a browser sign-in only.");
227
+ return true;
228
+ }
229
+ // Read without touching: a session ended elsewhere is never written back by being seen (round 2, finding 1).
230
+ const who = operatorOf(runtime.identify(request, false));
231
+ if (who === null || rereadBrowser(runtime, request, who) === null) {
232
+ refuseUpgrade(socket, 401, "Sign in as an installation-wide operator.");
233
+ return true;
234
+ }
235
+ const terminal = deps.host?.get(id) ?? null;
236
+ if (!owned(who, terminal) || !projectAllowed(who.name, terminal.repo)) {
237
+ refuseUpgrade(socket, 404, "No such terminal.");
238
+ return true;
239
+ }
240
+ if (deps.host.viewerCount(id) >= MAX_VIEWERS) {
241
+ refuseUpgrade(socket, 429, `This terminal is open in ${MAX_VIEWERS} places already.`);
242
+ return true;
243
+ }
244
+ wss.handleUpgrade(request, socket, head, ws => attach(ws, request, who, terminal));
245
+ return true;
246
+ }
247
+ function attach(ws, request, who, terminal) {
248
+ const host = deps.host;
249
+ sockets.add(ws);
250
+ let provedAt = 0, closed = false, detach = null;
251
+ let budget = INPUT_BURST, filledAt = Date.now();
252
+ const shut = (code, reason) => {
253
+ if (closed)
254
+ return;
255
+ closed = true;
256
+ detach?.();
257
+ reprovers.delete(reprove);
258
+ try {
259
+ ws.close(code, reason);
260
+ }
261
+ catch {
262
+ ws.terminate();
263
+ }
264
+ // A peer that never answers the close is dropped outright.
265
+ setTimeout(() => ws.terminate(), 2_000).unref?.();
266
+ };
267
+ const prove = () => {
268
+ const again = rereadBrowser(runtime, request, who);
269
+ const ok = again !== null && operatorOf(again) !== null && owned(who, host.get(terminal.id)) && projectAllowed(who.name, terminal.repo);
270
+ if (ok)
271
+ provedAt = Date.now();
272
+ return ok;
273
+ };
274
+ const reprove = () => { if (!closed && !prove())
275
+ shut(4401, "signed out"); };
276
+ const stillProved = () => Date.now() - provedAt < REPROVE_MS || prove();
277
+ const viewer = {
278
+ output(data) {
279
+ if (closed)
280
+ return;
281
+ if (!stillProved())
282
+ return shut(4401, "signed out");
283
+ if (ws.bufferedAmount > BEHIND_BYTES)
284
+ return shut(4008, "fell behind");
285
+ ws.send(data, { binary: true });
286
+ },
287
+ control(message) {
288
+ if (closed)
289
+ return;
290
+ if (!stillProved())
291
+ return shut(4401, "signed out");
292
+ ws.send(JSON.stringify(message));
293
+ },
294
+ };
295
+ ws.on("message", (data, isBinary) => {
296
+ if (closed)
297
+ return;
298
+ if (isBinary)
299
+ return shut(1003, "text frames only");
300
+ if (!prove())
301
+ return shut(4401, "signed out");
302
+ const text = Array.isArray(data) ? Buffer.concat(data).toString("utf8") : Buffer.from(data).toString("utf8");
303
+ const frame = parseTerminalFrame(text);
304
+ if (frame === null)
305
+ return shut(1003, "not a terminal frame");
306
+ if (frame.t === "resize")
307
+ return host.resize(terminal.id, frame.cols, frame.rows);
308
+ const now = Date.now();
309
+ budget = Math.min(INPUT_BURST, budget + ((now - filledAt) / 1000) * INPUT_PER_SECOND);
310
+ filledAt = now;
311
+ const size = Buffer.byteLength(frame.d);
312
+ if (size > budget)
313
+ return shut(1008, "typing faster than a terminal reads");
314
+ budget -= size;
315
+ // The program isn't reading: nothing more is queued for it until it does (round 2, finding 5).
316
+ if (!host.input(terminal.id, frame.d))
317
+ ws.send(JSON.stringify({ t: "input-full" }));
318
+ });
319
+ ws.on("close", () => { closed = true; detach?.(); reprovers.delete(reprove); sockets.delete(ws); });
320
+ ws.on("error", () => shut(1011, "socket error"));
321
+ if (!prove())
322
+ return shut(4401, "signed out");
323
+ try {
324
+ detach = host.attach(terminal.id, viewer);
325
+ }
326
+ catch (error) {
327
+ return shut(error instanceof TerminalError && /open in/.test(error.message) ? 4429 : 4404, "unavailable");
328
+ }
329
+ reprovers.add(reprove);
330
+ record("attach", who.name, terminal);
331
+ }
332
+ deps.server.on("upgrade", (request, socket, head) => {
333
+ if (!upgrade(request, socket, head))
334
+ socket.destroy();
335
+ });
336
+ return {
337
+ registrations: handlersOf("tasks", {
338
+ "term.page": page,
339
+ "term.session": async (ctx) => page(ctx),
340
+ "term.state": state,
341
+ "term.start": start,
342
+ "term.close": act("close"),
343
+ "term.remove": act("remove"),
344
+ "term.socket": socketWithoutUpgrade,
345
+ }),
346
+ upgrade,
347
+ /** Shutdown: no more attaches, every socket dropped, every program ended (bounded by the host's grace). The problem,
348
+ * when a terminal's processes couldn't be proved stopped. */
349
+ async close() {
350
+ unsubscribe();
351
+ clearInterval(sweep);
352
+ for (const ws of sockets)
353
+ ws.terminate();
354
+ sockets.clear();
355
+ wss.close();
356
+ const stopped = await deps.host?.stop();
357
+ if (stopped !== undefined && !stopped.clean) {
358
+ logEvent("error", "terminal.shutdown-uncertain", {});
359
+ // Reported, never thrown: the server still closes, and its close callback carries this (serve.ts).
360
+ return new Error("A live terminal's processes could not be proved stopped. Check for them before replacing Toolroll.");
361
+ }
362
+ return null;
363
+ },
364
+ };
365
+ }
@@ -1,16 +1,17 @@
1
1
  /** Read-only suggestions for the shared desktop and browser setup flow. */
2
+ import { withAcp } from "./acp-agents.js";
2
3
  import { createHash } from "node:crypto";
3
4
  import { existsSync, lstatSync, readFileSync } from "node:fs";
4
5
  import { homedir } from "node:os";
5
6
  import { join } from "node:path";
6
7
  import { validModelId } from "./provider.js";
7
8
  import { planInstall, applyInstall, skillContent, SKILL_DIR, SKILL_FILE } from "./skills.js";
8
- export const ASSISTANTS = {
9
+ export const ASSISTANTS = withAcp({
9
10
  claude: { name: "Claude", description: "Use Claude Code for your tasks." },
10
11
  codex: { name: "Codex", description: "Use your OpenAI coding assistant." },
11
12
  gemini: { name: "Gemini", description: "Use Google's coding assistant." },
12
13
  openrouter: { name: "OpenRouter", description: "Use a model from your OpenRouter account." },
13
- };
14
+ }, agent => ({ name: agent.name, description: `Use ${agent.name} for builds, once it's checked on this computer.` }));
14
15
  export function modelChoices(provider, configured, home = homedir()) {
15
16
  // Aliases documented at https://code.claude.com/docs/en/model-config.
16
17
  let choices = provider === "claude"
@@ -4,7 +4,7 @@ import { chatQuestionButtons } from "./subagent-question.js";
4
4
  import { chatAskButtons } from "./chat-ask.js";
5
5
  import { channelInbox } from "./chat-inbox.js";
6
6
  import { roomCommand } from "./chat-rooms.js";
7
- import { processChatEvent, applyChatAction, planChatNotifications, channelAccess, planRoomMessages, } from "./chat-delivery.js";
7
+ import { processChatEvent, applyChatAction, planChatNotifications, channelAccess, planRoomMessages, heldForWatch, } from "./chat-delivery.js";
8
8
  /** Slack is a transport for the same saved assistant and confirmation doors. */
9
9
  import { proposalPreview, proposalLink, proposalOutcomeText, armedCardText, armedYesLabel, } from "./chat-channel.js";
10
10
  import { LEAD_MESSAGE_MAX_CHARS } from "./lead.js";
@@ -404,6 +404,9 @@ export async function deliverSlackPart(options) {
404
404
  unfurl_links: false,
405
405
  unfurl_media: false,
406
406
  };
407
+ // The watch, at the last moment before the network (cockpit §3).
408
+ if (heldForWatch(state, store, row.id, event, binding.approver, content, now))
409
+ return true;
407
410
  const answer = target
408
411
  ? await options.api("chat.update", { ...args, ts: target })
409
412
  : await options.api("chat.postMessage", {
package/dist/spend-ui.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * only) stop new work at 100 %. An instance operator's page; budgets change
5
5
  * with a step-up.
6
6
  */
7
- import { html, postForm } from "./html.js";
7
+ import { html, postForm, reauth } from "./html.js";
8
8
  import { budgetLabel, usd } from "./spend.js";
9
9
  const projectName = (repo) => repo.split("/").filter(Boolean).pop() ?? repo;
10
10
  export const SPEND_CSS = `.spend{max-width:960px;min-width:0}.spend-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.spend-head h1{margin:0}` +
@@ -48,14 +48,14 @@ export function spendHtml(view, notice) {
48
48
  const width = Math.min(100, Math.max(0, budget.percent));
49
49
  const over = budget.spentMicrousd >= budget.limitMicrousd;
50
50
  const label = budgetLabel(budget, budget.scope === "subagent" ? nameOfSubagent(budget.key) : undefined).replace(/'s$/, "");
51
- return html `<div class="budget${over ? " over" : ""}" data-budget="${budget.id}"><span class="name">${label}</span><span class="figures">${usd(budget.spentMicrousd)} of ${usd(budget.limitMicrousd)} · ${budget.percent}%</span><span class="bar" aria-hidden="true"><span style="width:${width}%"></span></span><span class="meta">${over && budget.hardStop ? "Used up: new API work waits until next month or a higher budget." : budget.hardStop ? "Stops API work at 100%." : "Alerts only."}${budget.unpriced > 0 ? ` ${budget.unpriced} unpriced` : ""}</span><details><summary>Change</summary>${postForm("/spend/budget", html `<label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" value="${Math.round(budget.limitMicrousd / 1_000_000)}" required></label><label class="choice"><input type="checkbox" name="stop" value="1"${budget.hardStop ? html ` checked` : ""}> Stop new work at 100%</label><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label><button type="submit" name="action" value="save">Save</button> <button type="submit" name="action" value="remove">Remove</button>`, { attrs: { class: "budget-form" }, hidden: { target: `${budget.scope}:${budget.key}` } })}</details></div>`;
51
+ return html `<div class="budget${over ? " over" : ""}" data-budget="${budget.id}"><span class="name">${label}</span><span class="figures">${usd(budget.spentMicrousd)} of ${usd(budget.limitMicrousd)} · ${budget.percent}%</span><span class="bar" aria-hidden="true"><span style="width:${width}%"></span></span><span class="meta">${over && budget.hardStop ? "Used up: new API work waits until next month or a higher budget." : budget.hardStop ? "Stops API work at 100%." : "Alerts only."}${budget.unpriced > 0 ? ` ${budget.unpriced} unpriced` : ""}</span><details><summary>Change</summary>${postForm("/spend/budget", html `<label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" value="${Math.round(budget.limitMicrousd / 1_000_000)}" required></label><label class="choice"><input type="checkbox" name="stop" value="1"${budget.hardStop ? html ` checked` : ""}> Stop new work at 100%</label>${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`)}<button type="submit" name="action" value="save">Save</button> <button type="submit" name="action" value="remove">Remove</button>`, { attrs: { class: "budget-form" }, hidden: { target: `${budget.scope}:${budget.key}` } })}</details></div>`;
52
52
  });
53
53
  const groups = ["Everything", "Projects", "People", "Subagents"];
54
54
  const options = groups.map(group => {
55
55
  const members = view.targets.filter(one => one.group === group);
56
56
  return members.length === 0 ? "" : html `<optgroup label="${group}">${members.map(one => html `<option value="${one.value}">${one.label}</option>`)}</optgroup>`;
57
57
  });
58
- const add = html `<details${view.budgets.length === 0 ? html ` open` : ""}><summary>Add a budget</summary>${postForm("/spend/budget", html `<label>For<select name="target" required>${options}</select></label><label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" required></label><label class="choice"><input type="checkbox" name="stop" value="1" checked> Stop new work at 100%</label><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label><button type="submit" name="action" value="save">Add budget</button>`, { attrs: { class: "budget-form" } })}</details>`;
58
+ const add = html `<details${view.budgets.length === 0 ? html ` open` : ""}><summary>Add a budget</summary>${postForm("/spend/budget", html `<label>For<select name="target" required>${options}</select></label><label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" required></label><label class="choice"><input type="checkbox" name="stop" value="1" checked> Stop new work at 100%</label>${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`)}<button type="submit" name="action" value="save">Add budget</button>`, { attrs: { class: "budget-form" } })}</details>`;
59
59
  return html `<article class="spend"><div class="spend-head"><h1>Spend</h1><p class="spend-month"><a href="/spend?month=${view.previous}">← ${view.previous}</a><strong>${view.month}</strong>${view.next === null ? "" : html `<a href="/spend?month=${view.next}">${view.next} →</a>`}<a href="/spend?month=${view.month}&amp;format=csv" download>CSV</a></p></div>${note}<p class="spend-total" data-spend-total="${total}">${usd(total)}</p><p class="meta">${parts.join(" · ")}${unpriced > 0 ? ` · ${unpriced} unpriced (no reported cost or catalogue price)` : ""} · UTC month</p><section><h2>Budgets</h2>${budgets.length === 0 ? html `<p class="meta">No budgets yet.</p>` : budgets}${add}</section><div class="spend-grid">${table("By project", breakdown(view.items, item => item.project), projectName)}${table("By person", breakdown(view.items, item => item.person), key => key)}${table("By subagent", breakdown(view.items, item => item.subagent === null ? null : String(item.subagent)), nameOfSubagent)}${table("By model", breakdown(view.items, item => `${item.provider}${item.model === null ? "" : ` · ${item.model}`}`), key => key)}</div><p class="meta">Subscription work is $0; its limits are on Tasks. API work is what the provider reported, or its tokens at the prices in Settings → Models.</p></article>`;
60
60
  }
61
61
  /** One row per piece of spend, for a spreadsheet. */
package/dist/spend.js CHANGED
@@ -7,7 +7,8 @@ export function billingOf(provider, db) {
7
7
  if (provider.endsWith("-api"))
8
8
  return "api-key";
9
9
  const setting = provider.endsWith("-subscription") ? "subscription"
10
- : Object.hasOwn(SUBSCRIPTION_CAPABLE, provider) ? readAuthMode(provider) : "api-key";
10
+ // An ACP agent's work is metered until its billing declaration says otherwise (that is per run, not here).
11
+ : Object.hasOwn(SUBSCRIPTION_CAPABLE, provider) ? (readAuthMode(provider) === "subscription" ? "subscription" : "api-key") : "api-key";
11
12
  if (setting === "api-key" || db === undefined)
12
13
  return setting;
13
14
  return seenBilling(db, base) ?? setting;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What a running attempt can be told and done to, in one place (docs/plans/agents-cockpit.md §4, R10): every live
3
+ * view of an attempt offers exactly these, and says plainly what is missing. Each answer is derived from the same facts
4
+ * the store's own gates read (steeringLiveBuild / liveInputRun for notes and pauses, requestRunStop's rules for Stop),
5
+ * so the view never offers what the store would refuse.
6
+ *
7
+ * Builder with live input (Claude, on this computer's runner) notes reach it at once · pause/resume · stop
8
+ * Any other builder (Codex, OpenRouter, Gemini, ACP; another computer's runner relays) notes by mailbox while it is
9
+ * open, read at its next check; before it starts, in its brief · stop
10
+ * A builder past its mailbox (finishing, or repairing its handoff) no notes for this attempt
11
+ * A protocol repair (a `repair` child of a builder), a planner's correction no notes · Stop stops the owning attempt
12
+ * Planner, scout, reviewer no notes (none reads a mailbox) · stop
13
+ *
14
+ * A repair *task* (a task filed to fix a result) runs as a builder, so it is the builder row.
15
+ */
16
+ import type { Store } from "./store.js";
17
+ export type SteerSend = "live" | "mailbox";
18
+ export type SteerCapability = {
19
+ /** How a note reaches this attempt; null: it takes none now. */
20
+ send: SteerSend | null;
21
+ /** Pause, resume, and pause-and-steer (live input only). */
22
+ pause: boolean;
23
+ /** The run a Stop names (the owning attempt for a child), and what the button says; null: the store would refuse. */
24
+ stop: {
25
+ run: number;
26
+ label: string;
27
+ } | null;
28
+ /** One plain sentence for the composer: how a note travels, or why there is none. */
29
+ words: string;
30
+ };
31
+ /** The capability of this exact run, now. Null when it is not running. */
32
+ export declare function steerCapabilityOf(store: Store, runId: number, now: Date): SteerCapability | null;
@@ -0,0 +1,60 @@
1
+ const ROLE_WORDS = { planner: "planner", scout: "scout", reviewer: "reviewer" };
2
+ /** The attempt a run belongs to: up its parents while they share its claim (a protocol repair, a planner's correction). */
3
+ function ownerOf(store, runId) {
4
+ let at = store.getRun(runId);
5
+ for (let depth = 0; at !== null && at.parentRun !== null && depth < 16; depth++) {
6
+ const parent = store.getRun(at.parentRun);
7
+ if (parent === null || parent.leaseId !== at.leaseId)
8
+ break;
9
+ at = parent;
10
+ }
11
+ return at?.id ?? runId;
12
+ }
13
+ /** Whether a Stop of this run would be accepted now — requestRunStop's own rules, read without writing. */
14
+ function stoppable(store, runId, now) {
15
+ const run = store.getRun(runId);
16
+ if (run === null)
17
+ return false;
18
+ if (store.stopOf(runId) !== null)
19
+ return true; // asking again is the same stop
20
+ if (run.outcome !== null)
21
+ return false;
22
+ if (run.role !== "reviewer" && store.currentLiveLease(run.taskRef, now) !== run.leaseId)
23
+ return false;
24
+ return store.publicationForRun(runId) === null;
25
+ }
26
+ /** The capability of this exact run, now. Null when it is not running. */
27
+ export function steerCapabilityOf(store, runId, now) {
28
+ const run = store.getRun(runId);
29
+ if (run === null || run.outcome !== null || run.finishedAt !== null)
30
+ return null;
31
+ const owner = ownerOf(store, run.id);
32
+ const stopOf = (label) => stoppable(store, owner, now) ? { run: owner, label } : null;
33
+ // A child of an attempt (a protocol repair, a planner's correction) is part of it: no notes, and Stop names the owner.
34
+ if (owner !== run.id) {
35
+ return { send: null, pause: false, stop: stopOf("Stop this attempt"),
36
+ words: run.role === "repair" ? "It's repairing its handoff and reads no notes now. Stop this attempt to change its course."
37
+ : "It's part of a larger attempt and reads no notes. Stop this attempt to change its course." };
38
+ }
39
+ const role = ROLE_WORDS[run.role];
40
+ if (role !== undefined)
41
+ return { send: null, pause: false, stop: stopOf("Stop"), words: `This ${role} can't take notes; stop it to change its course.` };
42
+ if (run.role !== "builder")
43
+ return null;
44
+ const repairing = store.handle.prepare("SELECT 1 AS hit FROM run WHERE parent_run = ? AND role = 'repair' AND outcome IS NULL").get(run.id) !== undefined;
45
+ if (repairing)
46
+ return { send: null, pause: false, stop: stopOf("Stop this attempt"), words: "It's repairing its handoff and reads no notes now. Stop this attempt to change its course." };
47
+ if (store.liveInputRun(run.taskRef, now) === run.id)
48
+ return { send: "live", pause: true, stop: stopOf("Stop"), words: "Notes reach the agent right away." };
49
+ if (store.steeringLiveBuild(run.taskRef, now) === run.id)
50
+ return { send: "mailbox", pause: false, stop: stopOf("Stop"), words: "It reads notes at its next check." };
51
+ // Not yet at work: a note filed now is in its brief when it starts. Past its mailbox: none reaches this attempt.
52
+ const state = store.handle.prepare("SELECT phase, steer_opened_at FROM run WHERE id = ?").get(run.id);
53
+ const starting = state !== undefined && state["phase"] == null && state["steer_opened_at"] == null && store.currentLiveLease(run.taskRef, now) === run.leaseId;
54
+ if (starting)
55
+ return { send: "mailbox", pause: false, stop: stopOf("Stop"), words: "It reads notes when it starts working." };
56
+ // Never given a mailbox (steering off for it), or past it.
57
+ if (state?.["steer_opened_at"] == null)
58
+ return { send: null, pause: false, stop: stopOf("Stop"), words: "It doesn't read notes; anything you send waits for the next attempt." };
59
+ return { send: null, pause: false, stop: stopOf("Stop"), words: "It has stopped reading notes; anything you send now waits for the next attempt." };
60
+ }
@@ -0,0 +1,14 @@
1
+ import type { BrowserAgentControls } from "./browser-workspace.js";
2
+ import type { Store } from "./store.js";
3
+ /** The attempt running for a task now: one under the task's live claim (any role; a child shows as its owner's own),
4
+ * or a review, which runs under its own claim (requestRunStop exempts it from the task's). */
5
+ export declare function liveAttemptOf(store: Store, taskRef: number, now: Date): number | null;
6
+ /** One running attempt's composer: the note's target (null: it takes none now) and its controls. Null when it is not
7
+ * running, or this person may not act on it (a viewer, the demo). */
8
+ export declare function steerControlsFor(store: Store, taskId: string, runId: number, now: Date, canAct: boolean): {
9
+ steer: {
10
+ action: string;
11
+ run: number;
12
+ } | null;
13
+ controls: BrowserAgentControls;
14
+ } | null;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The composer's facts for one running attempt, for whichever surface shows it (the Agents board, a task page, a flow
3
+ * card's task): what the attempt takes (steer-capabilities.ts), and the exact run each form names. One helper, so every
4
+ * surface offers the same acts for the same attempt (docs/plans/agents-cockpit.md §4).
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ import { steerCapabilityOf } from "./steer-capabilities.js";
8
+ const taskHrefOf = (taskId) => `/t/${encodeURIComponent(taskId)}`;
9
+ /** The attempt running for a task now: one under the task's live claim (any role; a child shows as its owner's own),
10
+ * or a review, which runs under its own claim (requestRunStop exempts it from the task's). */
11
+ export function liveAttemptOf(store, taskRef, now) {
12
+ const lease = store.currentLiveLease(taskRef, now);
13
+ const row = store.handle.prepare(`SELECT id FROM run WHERE task_ref = ? AND outcome IS NULL AND finished_at IS NULL
14
+ AND (lease_id = ? OR role = 'reviewer') ORDER BY (lease_id = ?) DESC, id DESC LIMIT 1`).get(taskRef, lease ?? "", lease ?? "");
15
+ return row === undefined ? null : Number(row["id"]);
16
+ }
17
+ /** One running attempt's composer: the note's target (null: it takes none now) and its controls. Null when it is not
18
+ * running, or this person may not act on it (a viewer, the demo). */
19
+ export function steerControlsFor(store, taskId, runId, now, canAct) {
20
+ if (!canAct || store.isDemo())
21
+ return null;
22
+ const capability = steerCapabilityOf(store, runId, now);
23
+ if (capability === null)
24
+ return null;
25
+ const href = taskHrefOf(taskId);
26
+ const pause = capability.pause ? (() => {
27
+ const state = store.livePauseOf(runId);
28
+ const until = state?.pausedAt == null || state.limitMs === null ? null : new Date(Date.parse(state.pausedAt) + state.limitMs).toISOString();
29
+ // Each form its own request id, so a repeated submit is the same request and two forms never collide.
30
+ return { action: `${href}/pause`, run: runId, state: state?.state ?? null, requests: [`web-${randomUUID()}`, `web-${randomUUID()}`, `web-${randomUUID()}`], until };
31
+ })() : null;
32
+ return {
33
+ steer: capability.send === null ? null : { action: `${href}/steer`, run: runId },
34
+ controls: { send: capability.send, words: capability.words, pause, stop: capability.stop === null ? null : { action: `${href}/stop`, ...capability.stop } },
35
+ };
36
+ }