toolroll 0.9.63 → 0.9.65

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 (292) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -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 +50 -2
  16. package/dist/agent-fence.js +157 -10
  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 +36 -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/browser/THIRD_PARTY_NOTICES.txt +48 -0
  32. package/dist/browser/terminal.css +37 -0
  33. package/dist/browser/terminal.js +117 -0
  34. package/dist/browser/workspace.css +2 -2
  35. package/dist/browser/workspace.js +42 -38
  36. package/dist/browser-shell.js +6 -3
  37. package/dist/browser-workspace.d.ts +235 -1
  38. package/dist/browser-workspace.js +60 -0
  39. package/dist/builder.d.ts +6 -1
  40. package/dist/builder.js +34 -13
  41. package/dist/chat-actions.d.ts +13 -1
  42. package/dist/chat-actions.js +197 -15
  43. package/dist/chat-ask.d.ts +4 -0
  44. package/dist/chat-ask.js +14 -0
  45. package/dist/chat-channel.js +20 -0
  46. package/dist/chat-continuity.js +5 -4
  47. package/dist/chat-decide.d.ts +0 -14
  48. package/dist/chat-decide.js +4 -1
  49. package/dist/chat-delivery.d.ts +9 -0
  50. package/dist/chat-delivery.js +45 -6
  51. package/dist/chat-messages.d.ts +8 -0
  52. package/dist/chat-messages.js +36 -4
  53. package/dist/check-levels-ui.js +2 -2
  54. package/dist/checkout-proof.d.ts +28 -0
  55. package/dist/checkout-proof.js +62 -0
  56. package/dist/claim.d.ts +12 -0
  57. package/dist/claim.js +19 -0
  58. package/dist/cli-help.d.ts +1 -1
  59. package/dist/cli-help.js +4 -1
  60. package/dist/cli.d.ts +3 -0
  61. package/dist/cli.js +21 -2
  62. package/dist/coding-ui.js +6 -4
  63. package/dist/console-instance.d.ts +124 -0
  64. package/dist/console-instance.js +337 -0
  65. package/dist/contracts/chat-actions.d.ts +16 -0
  66. package/dist/contracts/chat-actions.js +4 -0
  67. package/dist/contracts/chat-content.d.ts +19 -0
  68. package/dist/contracts/chat-content.js +6 -0
  69. package/dist/contracts/console-api.d.ts +12 -4
  70. package/dist/contracts/console-api.js +12 -4
  71. package/dist/contracts/flow-card.d.ts +4 -0
  72. package/dist/contracts/flow-card.js +3 -0
  73. package/dist/contracts/flow.d.ts +46 -0
  74. package/dist/contracts/flow.js +11 -2
  75. package/dist/contracts/lead-tools.d.ts +92 -0
  76. package/dist/contracts/lead-tools.js +13 -0
  77. package/dist/contracts/provider.d.ts +11 -1
  78. package/dist/contracts/provider.js +1 -1
  79. package/dist/contracts/route.d.ts +76 -1
  80. package/dist/contracts/route.js +1 -1
  81. package/dist/contracts/store-json.d.ts +2 -0
  82. package/dist/control-setup.d.ts +1 -1
  83. package/dist/control-ui.js +2 -2
  84. package/dist/converse.d.ts +16 -0
  85. package/dist/converse.js +32 -4
  86. package/dist/demo-chat.js +4 -4
  87. package/dist/demo.js +2 -2
  88. package/dist/desktop-update-gate.js +6 -0
  89. package/dist/desktop-update.d.ts +1 -1
  90. package/dist/discord-chat.js +4 -1
  91. package/dist/doctor.js +1 -1
  92. package/dist/egress-bridge.d.ts +17 -0
  93. package/dist/egress-bridge.js +111 -0
  94. package/dist/egress.d.ts +119 -0
  95. package/dist/egress.js +423 -0
  96. package/dist/exec.d.ts +78 -0
  97. package/dist/exec.js +367 -9
  98. package/dist/export-ui.js +2 -2
  99. package/dist/flow-engine.js +24 -5
  100. package/dist/flow-gallery-ui.js +2 -2
  101. package/dist/flow-gallery.js +27 -8
  102. package/dist/flow-share.js +16 -5
  103. package/dist/flow-triggers.d.ts +77 -8
  104. package/dist/flow-triggers.js +517 -70
  105. package/dist/flows-cli.js +8 -2
  106. package/dist/flows-ui.d.ts +0 -2
  107. package/dist/flows-ui.js +52 -4
  108. package/dist/flows.d.ts +4 -0
  109. package/dist/flows.js +9 -1
  110. package/dist/html.d.ts +9 -0
  111. package/dist/html.js +11 -0
  112. package/dist/invoke.d.ts +17 -0
  113. package/dist/invoke.js +217 -21
  114. package/dist/keys.d.ts +18 -14
  115. package/dist/keys.js +5 -0
  116. package/dist/lead-capabilities.js +3 -2
  117. package/dist/lead-contract.js +2 -0
  118. package/dist/lead-doors.d.ts +2 -0
  119. package/dist/lead-doors.js +7 -0
  120. package/dist/lead-follow.d.ts +2 -0
  121. package/dist/lead-follow.js +58 -2
  122. package/dist/lead-progress.js +1 -0
  123. package/dist/lead-queue-runtime.d.ts +46 -0
  124. package/dist/lead-queue-runtime.js +191 -0
  125. package/dist/lead-queue.d.ts +104 -0
  126. package/dist/lead-queue.js +187 -0
  127. package/dist/lead-sweep.d.ts +67 -0
  128. package/dist/lead-sweep.js +99 -0
  129. package/dist/lead-tools.d.ts +22 -0
  130. package/dist/lead-tools.js +152 -1
  131. package/dist/lead.d.ts +13 -5
  132. package/dist/lead.js +89 -29
  133. package/dist/limits-ui.d.ts +8 -1
  134. package/dist/limits-ui.js +26 -0
  135. package/dist/live-bus.d.ts +8 -0
  136. package/dist/live-bus.js +40 -4
  137. package/dist/live-detail.d.ts +27 -0
  138. package/dist/live-detail.js +57 -0
  139. package/dist/live-notes.d.ts +20 -0
  140. package/dist/live-notes.js +47 -0
  141. package/dist/live-records.d.ts +140 -0
  142. package/dist/live-records.js +935 -0
  143. package/dist/live-terminal.d.ts +258 -0
  144. package/dist/live-terminal.js +649 -0
  145. package/dist/live.d.ts +38 -27
  146. package/dist/live.js +142 -124
  147. package/dist/mcp-oauth.d.ts +6 -3
  148. package/dist/mcp-oauth.js +8 -4
  149. package/dist/memory-pass.js +2 -2
  150. package/dist/mobile-viewport.d.ts +5 -1
  151. package/dist/mobile-viewport.js +18 -2
  152. package/dist/model-catalog.js +4 -0
  153. package/dist/monitoring-ui.js +2 -2
  154. package/dist/needs-you.d.ts +1 -1
  155. package/dist/needs-you.js +11 -4
  156. package/dist/operate.d.ts +2 -0
  157. package/dist/operate.js +275 -17
  158. package/dist/passwordless.d.ts +23 -0
  159. package/dist/passwordless.js +26 -0
  160. package/dist/peek-cli.d.ts +12 -4
  161. package/dist/peek-cli.js +19 -11
  162. package/dist/phase-routing.js +10 -3
  163. package/dist/planner-source.d.ts +3 -0
  164. package/dist/planner-source.js +14 -0
  165. package/dist/planner.d.ts +2 -1
  166. package/dist/planner.js +7 -1
  167. package/dist/policy-ui.js +2 -2
  168. package/dist/policy.d.ts +1 -20
  169. package/dist/policy.js +29 -2
  170. package/dist/project-delete-ui.d.ts +12 -0
  171. package/dist/project-delete-ui.js +41 -9
  172. package/dist/project-delete.js +2 -2
  173. package/dist/project-memory.d.ts +3 -1
  174. package/dist/project-memory.js +57 -25
  175. package/dist/project-tools.d.ts +5 -3
  176. package/dist/project-tools.js +12 -9
  177. package/dist/provider-auth.d.ts +4 -3
  178. package/dist/provider-auth.js +5 -4
  179. package/dist/provider-connection.d.ts +2 -1
  180. package/dist/provider-connection.js +5 -0
  181. package/dist/provider.d.ts +25 -2
  182. package/dist/provider.js +63 -9
  183. package/dist/pull-request-ui.js +2 -2
  184. package/dist/push.js +1 -1
  185. package/dist/quick-start.d.ts +24 -0
  186. package/dist/quick-start.js +127 -0
  187. package/dist/release.d.ts +3 -0
  188. package/dist/release.js +14 -8
  189. package/dist/request-budget-ui.js +2 -2
  190. package/dist/result-review.d.ts +1 -1
  191. package/dist/result-review.js +9 -3
  192. package/dist/retention-ui.js +2 -2
  193. package/dist/retention.js +10 -1
  194. package/dist/run-efficiency.d.ts +73 -0
  195. package/dist/run-efficiency.js +228 -0
  196. package/dist/runner.d.ts +5 -1
  197. package/dist/runner.js +22 -1
  198. package/dist/scope.d.ts +170 -5
  199. package/dist/scope.js +251 -31
  200. package/dist/scout-net.d.ts +4 -1
  201. package/dist/scout-net.js +37 -12
  202. package/dist/scout.d.ts +20 -6
  203. package/dist/scout.js +27 -3
  204. package/dist/serve.js +247 -40
  205. package/dist/server/chat.d.ts +1 -1
  206. package/dist/server/chat.js +122 -31
  207. package/dist/server/chrome.d.ts +6 -1
  208. package/dist/server/chrome.js +18 -7
  209. package/dist/server/console.css +52 -38
  210. package/dist/server/flows.js +38 -8
  211. package/dist/server/guards.d.ts +1 -0
  212. package/dist/server/guards.js +23 -9
  213. package/dist/server/http.d.ts +7 -0
  214. package/dist/server/http.js +3 -2
  215. package/dist/server/live.d.ts +34 -1
  216. package/dist/server/live.js +39 -17
  217. package/dist/server/pages-assets.js +5 -0
  218. package/dist/server/pages.js +90 -13
  219. package/dist/server/people-tokens.d.ts +3 -0
  220. package/dist/server/people-tokens.js +55 -17
  221. package/dist/server/remote-hooks.js +12 -6
  222. package/dist/server/remote.d.ts +2 -1
  223. package/dist/server/remote.js +14 -5
  224. package/dist/server/render-chat.d.ts +12 -3
  225. package/dist/server/render-chat.js +47 -16
  226. package/dist/server/render-pages.d.ts +4 -1
  227. package/dist/server/render-pages.js +5 -4
  228. package/dist/server/render-people.d.ts +3 -2
  229. package/dist/server/render-people.js +3 -2
  230. package/dist/server/render-results.d.ts +18 -2
  231. package/dist/server/render-results.js +37 -28
  232. package/dist/server/render-settings.js +6 -5
  233. package/dist/server/render-tasks.d.ts +35 -10
  234. package/dist/server/render-tasks.js +110 -27
  235. package/dist/server/request-context.d.ts +2 -0
  236. package/dist/server/request-context.js +2 -1
  237. package/dist/server/route-table.d.ts +3 -0
  238. package/dist/server/route-table.js +46 -5
  239. package/dist/server/runtime.d.ts +33 -2
  240. package/dist/server/session.d.ts +25 -1
  241. package/dist/server/session.js +18 -4
  242. package/dist/server/settings.js +121 -10
  243. package/dist/server/sweeps.d.ts +4 -0
  244. package/dist/server/sweeps.js +58 -0
  245. package/dist/server/task-views.d.ts +2 -1
  246. package/dist/server/task-views.js +96 -6
  247. package/dist/server/tasks.js +320 -74
  248. package/dist/server/terminals.d.ts +31 -0
  249. package/dist/server/terminals.js +365 -0
  250. package/dist/setup-guide.js +3 -2
  251. package/dist/slack-chat.js +4 -1
  252. package/dist/spend-ui.js +3 -3
  253. package/dist/spend.js +2 -1
  254. package/dist/steer-capabilities.d.ts +32 -0
  255. package/dist/steer-capabilities.js +60 -0
  256. package/dist/steer-controls.d.ts +14 -0
  257. package/dist/steer-controls.js +36 -0
  258. package/dist/steer-status.js +2 -2
  259. package/dist/storage-ui.js +4 -4
  260. package/dist/store-baseline.d.ts +1 -1
  261. package/dist/store-baseline.js +1 -1
  262. package/dist/store.d.ts +430 -15
  263. package/dist/store.js +1041 -52
  264. package/dist/subagent-desk.js +1 -1
  265. package/dist/summary.js +3 -0
  266. package/dist/task-source.d.ts +58 -0
  267. package/dist/task-source.js +93 -0
  268. package/dist/team-leads.js +1 -1
  269. package/dist/teams-chat.js +4 -1
  270. package/dist/telegram-lead.d.ts +3 -0
  271. package/dist/telegram-lead.js +44 -7
  272. package/dist/telegram.d.ts +11 -2
  273. package/dist/telegram.js +51 -17
  274. package/dist/terminal-catalog.d.ts +11 -0
  275. package/dist/terminal-catalog.js +129 -0
  276. package/dist/text-limits.d.ts +2 -0
  277. package/dist/text-limits.js +2 -0
  278. package/dist/tool-broker.d.ts +2 -0
  279. package/dist/tool-broker.js +1 -0
  280. package/dist/toolroll-update-ui.js +2 -2
  281. package/dist/toolroll-update.js +13 -3
  282. package/dist/tools-ui.js +2 -2
  283. package/dist/webhook-limits.d.ts +23 -0
  284. package/dist/webhook-limits.js +30 -0
  285. package/dist/webhook-readers.d.ts +56 -0
  286. package/dist/webhook-readers.js +138 -0
  287. package/dist/webhook-signing.d.ts +27 -0
  288. package/dist/webhook-signing.js +119 -0
  289. package/dist/work-index.d.ts +29 -0
  290. package/dist/work-index.js +16 -2
  291. package/dist/workspace-revision.js +3 -1
  292. package/package.json +11 -2
@@ -1,3 +1,12 @@
1
+ import { steerCapabilityOf } from "../steer-capabilities.js";
2
+ import { canReveal, localCheckoutOf, revealInFinder } from "../checkout-proof.js";
3
+ /** A form's way back to a flow card ("flow:<flow>:<card>"), read strictly: numbers only, never a path. */
4
+ function returnedFlow(value) {
5
+ const match = /^flow:([1-9][0-9]{0,9}):([1-9][0-9]{0,9})$/.exec(value ?? "");
6
+ return match === null ? null : { flow: Number(match[1]), card: Number(match[2]) };
7
+ }
8
+ import { originOf } from "../task-source.js";
9
+ import { ACP_AGENTS, isAcpProvider } from "../acp-agents.js";
1
10
  import { handlersOf } from './handler-registry.js';
2
11
  import { closedQuestionWords } from "../decision.js";
3
12
  import { DEPRECATED_PAGE } from "../deprecations.js";
@@ -21,7 +30,9 @@ import { evidencePack, evidencePackHtml } from "../evidence-pack.js";
21
30
  import { readVerifiedArtifact, readVerifiedReport, writeEvidenceFile } from "../evidence.js";
22
31
  import { computeGaps } from "../gaps.js";
23
32
  import { limitsView } from "../limits-ui.js";
24
- import { readLiveWindow } from "../live.js";
33
+ import { liveWindowLines, liveWindowRecords, readLiveWindow } from "../live.js";
34
+ import { validKeyTail } from "../live-records.js";
35
+ import { runNotes } from "../live-notes.js";
25
36
  import { latestFinishedAttempt, retryNoteOf } from '../needs-you.js';
26
37
  import { REMOTE_MESSAGES } from "../operate-remote.js";
27
38
  import { aggregateNewNames, observeWorktree, parseBaseTreeSnapshot, PEEK_LIMITS } from "../peek.js";
@@ -31,7 +42,7 @@ import { milestonesOf, parseExecutionPlanDocument } from "../plan.js";
31
42
  import { levelOfProfile } from "../policy.js";
32
43
  import { authorizedProject, canonicalProject, projectName, rowVisible } from "../project.js";
33
44
  import { fileTaskProposal } from "../proposal.js";
34
- import { isProviderId, validateSpec } from "../provider.js";
45
+ import { isProviderId, PROVIDER_IDS, validateSpec } from "../provider.js";
35
46
  import { completeAndOpenPullRequest, mergeAsPerson } from '../pull-request-flow.js';
36
47
  import { isQualityMode } from "../quality.js";
37
48
  import { createResultRevision, requestResultChanges } from "../result-actions.js";
@@ -39,7 +50,7 @@ import { structuredHandoffView, terminalDiffView } from "../result-evidence-read
39
50
  import { fileAddTestsTask, requestFollowUpChecks } from '../result-follow-ups.js';
40
51
  import { commentSourceKey, isRevisionFeedback, parseResultTab, resultReturnTarget } from "../result-review.js";
41
52
  import { isAlive as runnerAlive } from "../runner.js";
42
- import { acceptanceLinesToInput, acceptanceToLines, approvalOf, approve as approveScope, hasFreshIdentitySignIn, modeFilingCoverage, PLACEHOLDER_RUBRIC, proposeGuarded } from "../scope.js";
53
+ import { passwordlessSessionFor, acceptanceLinesToInput, acceptanceToLines, approvalOf, approve as approveScope, freshnessFor, spendingOnlyOnSuccess, ssoNowAvailable, withFreshness, modeFilingCoverage, PLACEHOLDER_RUBRIC, proposeGuarded } from "../scope.js";
43
54
  import { subagentNames as subagentNamesOf } from "../spend.js";
44
55
  import { verifiedAuthor } from "../store.js";
45
56
  import { runCostWords } from "../summary.js";
@@ -53,7 +64,8 @@ import { requestContext } from "./request-context.js";
53
64
  import { oneLineOf, QUEUE_VIEW, refuse, regionScript, screen, transcriptScript, whenTime } from "./chrome.js";
54
65
  import { chatReturnWithSaid, matchTaskPath, redirect, respond, reviewHref, safeChatReturn, safeReturn, SAFETY, taskChatHref, taskHref } from "./http.js";
55
66
  import { boardBody, donePage, inboxFingerprints, inboxPage, newTaskPage, nextPage, parseInboxTab, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, tasksPage, workPage } from "./render-pages.js";
56
- import { ACCEPT_ANYWAY_NEEDS_REASON, editorFileHref, proofBundleView, rankReviewQueue, RESULT_REFUSALS, REVIEW_QUEUE_CAP, reviewCockpitPage, reviewPriorityOf, runFactsFragment, runOutcomeBadge, runPage, RUNS_PAGE, runsPage, withRefusal } from "./render-results.js";
67
+ import { runEfficiencyOf } from "../run-efficiency.js";
68
+ import { ACCEPT_ANYWAY_NEEDS_REASON, editorFileHref, proofBundleView, rankReviewQueue, RESULT_REFUSALS, REVIEW_QUEUE_CAP, reviewCockpitDetailParts, reviewCockpitPage, reviewPriorityOf, runFactsFragment, runOutcomeBadge, runPage, RUNS_PAGE, runsPage, withRefusal } from "./render-results.js";
57
69
  import { approvalFormDigest, approveRefusalWords, checkProgressHtml, consentDoorOf, decisionPage, nonceHashOf, PermissionsWouldChange, requirementsFromEditor, resumeDigestOf, taskOf } from "./render-tasks.js";
58
70
  import {} from "./session.js";
59
71
  export function createTasksHandlers(runtime) {
@@ -299,6 +311,7 @@ export function createTasksHandlers(runtime) {
299
311
  approvalDigest,
300
312
  deliverable: ref?.deliverable ?? "branch",
301
313
  route,
314
+ origin: originOf(store, item.approval.taskId, repo => store.accountCanAccess(who.name, repo)),
302
315
  csrf, nonce, remaining: remaining.length, skipped: [...skipped], now,
303
316
  }));
304
317
  }
@@ -413,6 +426,70 @@ export function createTasksHandlers(runtime) {
413
426
  functional: { script: regionScript("board-region", "1"), fetches: true },
414
427
  }));
415
428
  }
429
+ /** A completed (or attempted) row as the review list ranks it: its proof reasons, CI standing, and the assignment
430
+ * when that assignment's receipt is this very row. */
431
+ function reviewRowOf(row, now) {
432
+ const assignment = assignmentOf(store, row.taskId, now, workAccess(), evidenceRoot);
433
+ return { ...row, proofReasons: row.runId === null ? [] : store.proofVerdictFor(row.runId)?.reasons ?? [], ciFailing: ciFailingFor(row.runId, row.prNumber),
434
+ assignment: assignment?.activeTaskId === row.taskId && (assignment.receipt?.runId ?? null) === row.runId ? assignment : null };
435
+ }
436
+ /** The result a link names (the task's own result, or the exact run `namedRun` of it): its completed row when the
437
+ * task is done (`inQueue` when the review list already holds it), else that attempt's own result (a failed or stopped
438
+ * build has a result page too), with what went wrong and Retry / Accept anyway as the review page offers them.
439
+ * Shared by /review and the task page's Result tab (docs/plans/one-page-task-and-agent.md). */
440
+ function selectResultFor(wantedId, namedRun, project, who, now, inQueue = null) {
441
+ const csrf = who.via === "cookie" ? who.session.csrf : "";
442
+ const beyond = inQueue !== null ? null : completedRowFor(wantedId, project);
443
+ const beyondRow = beyond === null ? null : reviewRowOf(beyond, now);
444
+ const completedChoice = inQueue ?? (beyondRow === null ? null : { ...beyondRow, priority: reviewPriorityOf(beyondRow) });
445
+ // A build that failed (or stopped) has its own result page too: the run named, or a failed task's latest attempt.
446
+ const attempt = (completedChoice !== null && (namedRun === null || completedChoice.runId === namedRun)) ? null : attemptRowFor(wantedId, namedRun, project);
447
+ const attemptRow = attempt === null ? null : reviewRowOf(attempt, now);
448
+ const chosen = attemptRow !== null ? { ...attemptRow, priority: reviewPriorityOf(attemptRow) } : completedChoice;
449
+ const selected = chosen === null ? null : reviewCockpitViewOf(chosen, who, now);
450
+ // A build that delivered nothing reads Failed here, and so does a failed task's delivered result: what went wrong
451
+ // is the card and Retry the ink act. That result may still be accepted, but only in outline, as Accept anyway, with
452
+ // a reason.
453
+ const delivered = selected?.run?.outcome === "built" || selected?.run?.outcome === "no-change";
454
+ const failedTask = selected === null ? null : store.getTask(selected.taskId);
455
+ if (selected !== null && attemptRow !== null && selected.run !== null && (!delivered || failedTask?.state === "failed")) {
456
+ const attemptRun = store.getRun(selected.run.id);
457
+ const runs = attemptRun === null ? [] : store.runsFor(attemptRun.taskRef);
458
+ const latest = latestFinishedAttempt(runs);
459
+ // Retry from here only when the task failed, nothing holds it, and this is its latest attempt or a result it delivered.
460
+ const retry = failedTask?.state === "failed" && (latest?.id === selected.run.id || delivered) && csrf !== "" && who.role === "approver" && store.currentLiveLease(attemptRun.taskRef, now) === null
461
+ ? { action: `${taskHref(selected.taskId)}/requeue` } : null;
462
+ // Accept anyway: the same acceptance this task's latest result takes, never for an older one or one already accepted.
463
+ const family = delivered ? familyOf(selected.taskId) : null;
464
+ const acceptAnyway = delivered && csrf !== "" && who.role === "approver" && family?.current.id === selected.taskId && family.problem === null &&
465
+ runs.find(runIsTaskResult)?.id === selected.run.id && store.proofAcceptance(selected.run.id) === null
466
+ ? { action: `${taskHref(selected.taskId)}/accept-proof`, run: selected.run.id } : null;
467
+ if (attemptRun !== null) {
468
+ // A delivered result didn't fail itself: the task's failure is its latest attempt's.
469
+ const failure = delivered ? failureOf(runs, null) : explainAttempt(attemptRun, null);
470
+ selected.failure = { ...failure, retry: retry === null ? null : { ...retry, note: retryNoteOf(failure.suggestion) }, ...(acceptAnyway === null ? {} : { acceptAnyway }) };
471
+ }
472
+ }
473
+ return { chosen, attemptRow, beyond, selected };
474
+ }
475
+ /** Whether `run` is a result selectResultFor shows for version `taskId`: a finished attempt that didn't deliver (failed,
476
+ * stopped, refused…), or the delivered one that is the version's own result — its completed row's, or, for a failed
477
+ * version, its latest finished attempt. An older delivered build is not (its version's result is the newer one). */
478
+ function resultRunAdmissible(taskId, run, project) {
479
+ if ((run.role !== "builder" && run.role !== "scout") || (run.finishedAt === null && run.outcome === null) || runIsLive(run))
480
+ return false;
481
+ if (run.outcome !== "built" && run.outcome !== "no-change")
482
+ return true;
483
+ const task = store.getTask(taskId);
484
+ if (task?.state === "failed")
485
+ return latestFinishedAttempt(store.runsFor(run.taskRef).filter(one => (one.role === "builder" || one.role === "scout") && (one.finishedAt !== null || one.outcome !== null)))?.id === run.id;
486
+ return completedRowFor(taskId, project)?.runId === run.id;
487
+ }
488
+ /** A run's own task and its family's root: the task pages a result form may return to. */
489
+ function runTasks(taskRef) {
490
+ const task = store.externalIdFor(taskRef);
491
+ return task === null ? [] : [task, familyOf(task)?.root.id ?? task];
492
+ }
416
493
  async function showReview(ctx) {
417
494
  const { url, who, response, now, project, chosenProject } = ctx;
418
495
  // The review cockpit (Priority 5): every visible COMPLETED task,
@@ -423,11 +500,7 @@ export function createTasksHandlers(runtime) {
423
500
  // stored verdict, and the sealed patch downloads exactly as stored.
424
501
  // Admission binds BEFORE the SQL limit (the done page's own rule),
425
502
  // and every row is re-proved against the ceiling before ranking.
426
- const resultRow = (row) => {
427
- const assignment = assignmentOf(store, row.taskId, now, workAccess(), evidenceRoot);
428
- return { ...row, proofReasons: row.runId === null ? [] : store.proofVerdictFor(row.runId)?.reasons ?? [], ciFailing: ciFailingFor(row.runId, row.prNumber),
429
- assignment: assignment?.activeTaskId === row.taskId && (assignment.receipt?.runId ?? null) === row.runId ? assignment : null };
430
- };
503
+ const resultRow = (row) => reviewRowOf(row, now);
431
504
  const rows = familiesInView(project, { states: ["done"], limit: REVIEW_QUEUE_CAP }).flatMap(family => {
432
505
  const row = completedRowFor(family.current.id, project);
433
506
  return row === null ? [] : [{ ...resultRow(row), title: family.root.title }];
@@ -441,50 +514,23 @@ export function createTasksHandlers(runtime) {
441
514
  // directly — re-proved as done, in this project, and inside the
442
515
  // ceiling — so an old receipt never reads as unfinished or foreign.
443
516
  // The ranked queue itself stays bounded and says so.
444
- const beyond = wantedId === null || inQueue !== null ? null : completedRowFor(wantedId, project);
445
- const beyondRow = beyond === null ? null : resultRow(beyond);
446
- const completedChoice = inQueue ?? (beyondRow === null ? null : { ...beyondRow, priority: reviewPriorityOf(beyondRow) });
447
517
  const expectedRun = url.searchParams.get("run");
448
518
  const namedRun = expectedRun !== null && /^[1-9]\d*$/.test(expectedRun) ? Number(expectedRun) : null;
449
- // A build that failed (or stopped) has its own result page too: the run named, or a failed task's latest attempt.
450
- const attempt = wantedId === null || (completedChoice !== null && (namedRun === null || completedChoice.runId === namedRun)) ? null : attemptRowFor(wantedId, namedRun, project);
451
- const attemptRow = attempt === null ? null : resultRow(attempt);
452
- const chosen = attemptRow !== null ? { ...attemptRow, priority: reviewPriorityOf(attemptRow) } : completedChoice;
453
- const selectedRow = wantedId === null ? ranked[0] ?? null : chosen;
454
519
  const csrf = who.via === "cookie" ? who.session.csrf : "";
520
+ const picked = wantedId === null ? null : selectResultFor(wantedId, namedRun, project, who, now, inQueue);
521
+ const chosen = picked?.chosen ?? null, attemptRow = picked?.attemptRow ?? null, beyond = picked?.beyond ?? null;
522
+ const selectedRow = wantedId === null ? ranked[0] ?? null : chosen;
455
523
  // An exact link to a result this person can't see (or that never existed) reads the same either way.
456
524
  if (expectedRun !== null && wantedId !== null && chosen === null)
457
525
  return refuse(response, who, 404, "No such result in your projects.", "/work");
458
526
  if (expectedRun !== null && (!/^[1-9]\d*$/.test(expectedRun) || selectedRow?.runId !== Number(expectedRun))) {
459
527
  return sendScreen(response, 409, screen("Result changed", html `<h1>Result changed</h1><p>This acceptance link no longer matches the current result. Review the current task before accepting.</p><p class="refusal-back"><a class="button-link" href="/review">Review results</a></p>`, { chrome: chromeFor(project, "runs") }));
460
528
  }
461
- const selected = selectedRow === null ? null : reviewCockpitViewOf(selectedRow, who, now);
462
- // Opening a run's own result page is viewing it: it leaves this person's unread Done on the Agents board.
463
- if (wantedId !== null && selected?.run != null)
529
+ const selected = picked !== null ? picked.selected : selectedRow === null ? null : reviewCockpitViewOf(selectedRow, who, now);
530
+ // Opening a run's own result page is viewing it: it leaves this person's unread Done on the Agents board — but only
531
+ // once the run has finished (cockpit §3): a page shown while it was still running saw no finished version.
532
+ if (wantedId !== null && selected?.run != null && store.getRun(selected.run.id)?.finishedAt != null)
464
533
  markAgentSeen(store, who.name, selected.run.id, now);
465
- // A build that delivered nothing reads Failed here, and so does a failed task's delivered result: what went wrong
466
- // is the card and Retry the ink act. That result may still be accepted, but only in outline, as Accept anyway, with
467
- // a reason.
468
- const delivered = selected?.run?.outcome === "built" || selected?.run?.outcome === "no-change";
469
- const failedTask = selected === null ? null : store.getTask(selected.taskId);
470
- if (selected !== null && attemptRow !== null && selected.run !== null && (!delivered || failedTask?.state === "failed")) {
471
- const attemptRun = store.getRun(selected.run.id);
472
- const runs = attemptRun === null ? [] : store.runsFor(attemptRun.taskRef);
473
- const latest = latestFinishedAttempt(runs);
474
- // Retry from here only when the task failed, nothing holds it, and this is its latest attempt or a result it delivered.
475
- const retry = failedTask?.state === "failed" && (latest?.id === selected.run.id || delivered) && csrf !== "" && who.role === "approver" && store.currentLiveLease(attemptRun.taskRef, now) === null
476
- ? { action: `${taskHref(selected.taskId)}/requeue` } : null;
477
- // Accept anyway: the same acceptance this task's latest result takes, never for an older one or one already accepted.
478
- const family = delivered ? familyOf(selected.taskId) : null;
479
- const acceptAnyway = delivered && csrf !== "" && who.role === "approver" && family?.current.id === selected.taskId && family.problem === null &&
480
- runs.find(runIsTaskResult)?.id === selected.run.id && store.proofAcceptance(selected.run.id) === null
481
- ? { action: `${taskHref(selected.taskId)}/accept-proof`, run: selected.run.id } : null;
482
- if (attemptRun !== null) {
483
- // A delivered result didn't fail itself: the task's failure is its latest attempt's.
484
- const failure = delivered ? failureOf(runs, null) : explainAttempt(attemptRun, null);
485
- selected.failure = { ...failure, retry: retry === null ? null : { ...retry, note: retryNoteOf(failure.suggestion) }, ...(acceptAnyway === null ? {} : { acceptAnyway }) };
486
- }
487
- }
488
534
  const reviewPage = reviewCockpitPage(chromeFor(wantedId === null ? project : chosenProject, "runs"), {
489
535
  queue: ranked,
490
536
  queueCap: REVIEW_QUEUE_CAP,
@@ -570,9 +616,42 @@ export function createTasksHandlers(runtime) {
570
616
  if (version !== null && !family.versions.some(one => one.id === version))
571
617
  return refuse(response, who, 404, "That version is not available for this task.", taskHref(family.root.id));
572
618
  // A version's own address lands on its family's page, still asking for the plan editor when it did (Chat's Edit plan).
573
- if (task.taskId !== family.root.id)
574
- return redirect(response, `${taskHref(family.root.id)}?version=${encodeURIComponent(task.taskId)}${url.searchParams.get("edit") === "plan" ? "&edit=plan" : ""}`);
575
- return taskScreen(response, who, version ?? family.current.id, null, 200, undefined, undefined, url.searchParams.get("edit") === "plan");
619
+ if (task.taskId !== family.root.id) {
620
+ // Everything else the address asked for (its tab, result run and view, a form's feedback) rides along.
621
+ const kept = new URLSearchParams(url.searchParams);
622
+ kept.delete("version");
623
+ return redirect(response, `${taskHref(family.root.id)}?version=${encodeURIComponent(task.taskId)}${kept.size === 0 ? "" : `&${kept.toString()}`}`);
624
+ }
625
+ // One page per task (docs/plans/one-page-task-and-agent.md): the tab named, and the Result tab's result — the shown
626
+ // version's own, or the exact run `run=` names, which must be a result of that very version.
627
+ const tabWord = url.searchParams.get("tab");
628
+ const tab = tabWord === "result" || tabWord === "plan" ? tabWord : "activity";
629
+ const runWord = url.searchParams.get("run");
630
+ const named = runWord === null ? null : /^[1-9][0-9]{0,14}$/.test(runWord) ? Number(runWord) : -1;
631
+ if (named === -1)
632
+ return refuse(response, who, 404, "No such result for this task.", taskHref(family.root.id));
633
+ // A run named without a version is shown with the version it belongs to: a saved link to a result outlives a newer
634
+ // revision becoming current. A run of no version of this family is no result of it.
635
+ const owner = named === null || version !== null ? null : (() => { const ref = store.getRun(named)?.taskRef; const id = ref === undefined ? null : store.externalIdFor(ref); return id !== null && family.versions.some(one => one.id === id) ? id : null; })();
636
+ const shown = version ?? owner ?? family.current.id;
637
+ const picked = selectResultFor(shown, named, ctx.project, who, ctx.now);
638
+ if (named !== null && picked.chosen?.runId !== named)
639
+ return refuse(response, who, 404, "No such result for this task.", taskHref(family.root.id));
640
+ const base = `${taskHref(family.root.id)}?${shown !== family.current.id ? `version=${encodeURIComponent(shown)}&` : ""}tab=result`;
641
+ const csrf = who.via === "cookie" ? who.session.csrf : "";
642
+ const result = picked.selected === null ? null : (() => {
643
+ const parts = reviewCockpitDetailParts(picked.selected, csrf, url.searchParams.get("noted") !== null, who.via === "cookie" && who.role === "approver", parseResultTab(url.searchParams.get("section")), who.name, { kind: "task", base });
644
+ // Current: the result the task itself would show (its latest), however the address named it — the header then
645
+ // owns its status and ceremonies, so no form is ever on the page twice.
646
+ const latest = named === null ? picked.chosen?.runId ?? null : selectResultFor(shown, null, ctx.project, who, ctx.now).chosen?.runId ?? null;
647
+ return { html: parts.html, selected: parts.selected, current: shown === family.current.id && latest !== null && latest === picked.chosen?.runId };
648
+ })();
649
+ // The attempts the picker offers: exactly the runs this page would show for their version (the same rule as above).
650
+ const admissible = new Set(family.versions.flatMap(one => {
651
+ const ref = store.lookupRef(one.id);
652
+ return ref === null ? [] : store.runsFor(ref.id).filter(run => resultRunAdmissible(one.id, run, ctx.project)).map(run => run.id);
653
+ }));
654
+ return taskScreen(response, who, shown, null, 200, undefined, undefined, url.searchParams.get("edit") === "plan", { tab, result, admissible });
576
655
  }
577
656
  // v103: a task's evidence pack, as a printable page or JSON (the whole family, sealed ledger entries included).
578
657
  async function showTaskEvidence(ctx) {
@@ -644,23 +723,28 @@ export function createTasksHandlers(runtime) {
644
723
  return respond(response, peeked.status, "text/html; charset=utf-8", peeked.body);
645
724
  }
646
725
  if (url.searchParams.get("fragment") === "transcript") {
647
- // The live transcript window (arc 1): raw sanitized TEXT as JSON —
648
- // the sink is textContent, never innerHTML. Guards, enumerated:
649
- // cookie session; run visible (proved above); this machine's
650
- // runner asserted; byte offsets validated; the file read through
651
- // its own descriptor with the exact numeric-id name. Display
652
- // state, not evidence — and never cached.
726
+ // The live conversation's window (arc 1; live-conversation.md §3–4):
727
+ // JSON over sanitized text or records — the sinks are textContent and
728
+ // React text, never innerHTML. Guards, enumerated: cookie session; run
729
+ // visible (proved above); this machine's runner asserted AND owning
730
+ // the run; byte offsets validated; the file read through its own
731
+ // descriptor with the exact numeric-id name, and its generation
732
+ // matched. Display state, not evidence — and never cached.
653
733
  response.setHeader("cache-control", "no-store");
654
734
  if (who.via !== "cookie")
655
735
  return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
656
- if (options.localRunner === undefined) {
657
- return respond(response, 200, "application/json", JSON.stringify({ error: "off" }));
736
+ const records = url.searchParams.get("shape") === "records";
737
+ // Notes come from the store, not the file: they show even where the transcript cannot be read.
738
+ const notesOf = () => (records ? runNotes(store, found, who.name, now) : {});
739
+ if (options.localRunner === undefined || found.runner !== options.localRunner) {
740
+ return respond(response, 200, "application/json", JSON.stringify({ error: "off", ...notesOf() }));
658
741
  }
659
742
  const fromRaw = url.searchParams.get("from") ?? "0";
660
743
  const from = /^[0-9]{1,15}$/.test(fromRaw) ? Number(fromRaw) : Number.NaN;
661
744
  if (!Number.isSafeInteger(from) || from < 0) {
662
745
  return respond(response, 400, "application/json", JSON.stringify({ error: "offset" }));
663
746
  }
747
+ const sawGen = url.searchParams.get("gen");
664
748
  const live = runIsLive(found);
665
749
  // The final drain: a finished run's tail — including a torn last
666
750
  // line — may be read until the sweep removes the file.
@@ -671,15 +755,35 @@ export function createTasksHandlers(runtime) {
671
755
  }
672
756
  // Missing or unreadable: nothing to show. Final only when the
673
757
  // run can never write again.
674
- return respond(response, 200, "application/json", JSON.stringify({ text: "", nextOffset: from, final: !live }));
758
+ // Nothing was read, so the key state the cursor carried is handed straight back.
759
+ const inKey = url.searchParams.get("inkey") === "1";
760
+ const keyTail = validKeyTail(url.searchParams.get("ktail") ?? "") ? url.searchParams.get("ktail") ?? "" : "";
761
+ const empty = records ? { records: [], nextOffset: from, gen: sawGen, inKey, keyTail, final: !live } : { text: "", nextOffset: from, inKey, keyTail, final: !live };
762
+ return respond(response, 200, "application/json", JSON.stringify({ ...empty, ...notesOf() }));
763
+ }
764
+ // An offset belongs to the file it was read from: another file starts over, visibly.
765
+ if (sawGen !== null && sawGen !== "" && sawGen !== window.gen) {
766
+ return respond(response, 409, "application/json", JSON.stringify({ error: "replaced" }));
675
767
  }
676
768
  // Re-proof after the read (peek discipline): the run row still says
677
- // what admission said, or nothing is shown.
769
+ // what admission said — visible, and still this machine's — or nothing is shown.
678
770
  const again = store.getRun(found.id);
679
- if (again === null || !runVisible(again)) {
680
- return respond(response, 200, "application/json", JSON.stringify({ text: "", nextOffset: from, final: true }));
771
+ if (again === null || !runVisible(again) || again.runner !== options.localRunner) {
772
+ const inKey = url.searchParams.get("inkey") === "1";
773
+ const keyTail = validKeyTail(url.searchParams.get("ktail") ?? "") ? url.searchParams.get("ktail") ?? "" : "";
774
+ return respond(response, 200, "application/json", JSON.stringify(records ? { records: [], nextOffset: from, gen: window.gen, inKey, keyTail, final: true } : { text: "", nextOffset: from, inKey, keyTail, final: true }));
775
+ }
776
+ const final = !live && window.eof;
777
+ // A key block split across reads stays hidden: the cursor carries whether the last read ended inside one.
778
+ const tailRaw = url.searchParams.get("ktail") ?? "";
779
+ const keys = { inKey: url.searchParams.get("inkey") === "1", tail: validKeyTail(tailRaw) ? tailRaw : "" };
780
+ if (records) {
781
+ const shown = liveWindowRecords(window, keys);
782
+ return respond(response, 200, "application/json", JSON.stringify({ records: shown, nextOffset: window.nextOffset, gen: window.gen, inKey: keys.inKey, keyTail: keys.tail ?? "", final, ...notesOf() }));
681
783
  }
682
- return respond(response, 200, "application/json", JSON.stringify({ text: window.text, nextOffset: window.nextOffset, final: !live && window.eof }));
784
+ // The plain words, whatever the file's format; the offset moves by the bytes read even when nothing shows.
785
+ const lines = liveWindowLines(window, keys);
786
+ return respond(response, 200, "application/json", JSON.stringify({ text: lines.length === 0 ? "" : `${lines.join("\n")}\n`, nextOffset: window.nextOffset, gen: window.gen, inKey: keys.inKey, keyTail: keys.tail ?? "", final }));
683
787
  }
684
788
  // One result page (2026-10-02): a builder's finished result opens on
685
789
  // its task's result page, titled with the task. The raw run record
@@ -1160,7 +1264,7 @@ export function createTasksHandlers(runtime) {
1160
1264
  ? `Something from build #${run.id} may still be running, so it can't be confirmed stopped yet.` : `Build #${run.id} can't be confirmed stopped.`, 409);
1161
1265
  }
1162
1266
  bustBadge();
1163
- const to = resultReturnTarget(body.get("return"), run.id);
1267
+ const to = resultReturnTarget(body.get("return"), run.id, runTasks(run.taskRef));
1164
1268
  return redirect(response, body.get("return") ? to : back);
1165
1269
  }
1166
1270
  async function taskMerge(ctx) {
@@ -1195,6 +1299,28 @@ export function createTasksHandlers(runtime) {
1195
1299
  return noPage(who, response);
1196
1300
  return taskMutation(response, who, act.taskId, verb, posted, now);
1197
1301
  };
1302
+ // Cockpit E: Show in Finder — the checkout proved again right now (live, this machine's runner, inside the pool),
1303
+ // then Finder asked to reveal it with a fixed argv. Nothing runs in it; a Mac only.
1304
+ async function runReveal(ctx) {
1305
+ const { url, who, response, now, posted } = ctx;
1306
+ const asked = /^\/r\/([0-9]{1,15})\/reveal$/.exec(url.pathname);
1307
+ if (asked === null)
1308
+ return noPage(who, response);
1309
+ readForm(posted, CONSOLE_FORMS.runReveal);
1310
+ const id = Number(asked[1]);
1311
+ const found = store.getRun(id);
1312
+ if (found === null || !visible(taskRepoOf(found.taskRef)))
1313
+ return refuse(response, who, 404, "no such run");
1314
+ if (store.isDemo())
1315
+ return refuse(response, who, 403, "the demo authorizes nothing");
1316
+ const proof = localCheckoutOf(store, id, { runner: options.localRunner, poolRoot: options.poolRoot }, now);
1317
+ if (!proof.ok)
1318
+ return redirect(response, `/agents?run=${id}&said=reveal-refused`);
1319
+ if (!canReveal(options.revealPath !== undefined))
1320
+ return redirect(response, `/agents?run=${id}&said=reveal-mac-only`);
1321
+ const shown = await (options.revealPath ?? revealInFinder)(proof.path);
1322
+ return redirect(response, `/agents?run=${id}&said=${shown ? "revealed" : "reveal-failed"}`);
1323
+ }
1198
1324
  async function runNote(ctx) {
1199
1325
  const { url, who, response, now, posted } = ctx;
1200
1326
  const noted = /^\/r\/([0-9]{1,15})\/note$/.exec(url.pathname);
@@ -1235,9 +1361,12 @@ export function createTasksHandlers(runtime) {
1235
1361
  // the chat's result view, or the run page — validated to those exact
1236
1362
  // shapes. A refusal sends them back THERE, where the browser has kept
1237
1363
  // their draft, so a failed submission is recoverable in place.
1238
- const returnTo = resultReturnTarget(body.get("return"), id);
1364
+ const returnTo = resultReturnTarget(body.get("return"), id, runTasks(found.taskRef));
1239
1365
  const selectedTab = parseResultTab(body.get("tab"));
1240
- const back = selectedTab === "summary" ? returnTo : `${returnTo}${returnTo.includes("?") ? "&" : "?"}tab=${selectedTab}`;
1366
+ // A task page's Result tab keeps the result's own view in `section=` (its `tab` is the task's).
1367
+ const viewParam = returnTo.startsWith("/t/") ? "section" : "tab";
1368
+ const back = selectedTab === "summary" ? returnTo
1369
+ : `${returnTo.replace(/&section=(?:summary|changes|checks)$/, "")}${returnTo.includes("?") ? "&" : "?"}${viewParam}=${selectedTab}`;
1241
1370
  if (body.get("intent") === "revise") {
1242
1371
  const result = requestResultChanges(store, evidenceRoot, {
1243
1372
  run: id, batch: body.get("batch"), source: body.get("source"), actor: who.name,
@@ -1337,7 +1466,7 @@ export function createTasksHandlers(runtime) {
1337
1466
  if (found === null || !visible(taskRepoOf(found.taskRef))) {
1338
1467
  return refuse(response, who, 404, "no such run");
1339
1468
  }
1340
- const back = resultReturnTarget(body.get("return"), id);
1469
+ const back = resultReturnTarget(body.get("return"), id, runTasks(found.taskRef));
1341
1470
  const result = createResultRevision(store, evidenceRoot, {
1342
1471
  run: id, batch: body.get("batch"), source: body.get("source"), actor: who.name,
1343
1472
  repos: admissionList(), includeUnplaced: visible(null), allowMode: who.via === "cookie",
@@ -1359,8 +1488,7 @@ export function createTasksHandlers(runtime) {
1359
1488
  if (found === null || !visible(taskRepoOf(found.taskRef)))
1360
1489
  return refuse(response, who, 404, "no such run");
1361
1490
  // The task page that offered Run checks in place is a return target too: this run's own task, or its family's root.
1362
- const runTask = store.externalIdFor(found.taskRef);
1363
- const back = resultReturnTarget(body.get("return"), id, runTask === null ? [] : [runTask, familyOf(runTask)?.root.id ?? runTask]);
1491
+ const back = resultReturnTarget(body.get("return"), id, runTasks(found.taskRef));
1364
1492
  if (who.via !== "cookie")
1365
1493
  return refuse(response, who, 403, "Sign in with a browser session to do this.", back);
1366
1494
  if (op === "checks") {
@@ -1615,6 +1743,72 @@ export function createTasksHandlers(runtime) {
1615
1743
  if (who.via !== "cookie") {
1616
1744
  return refuse(response, who, 403, "steering is a browser session's act");
1617
1745
  }
1746
+ // The same gates as a pause or a stop (the route's act scope already refuses a viewer; this says why).
1747
+ if (who.role !== "approver")
1748
+ return taskScreen(response, who, taskId, "your login can watch — sending an agent a note is an approver's act", 403);
1749
+ if (store.isDemo())
1750
+ return taskScreen(response, who, taskId, "the demo authorizes nothing", 403);
1751
+ // From the Agents view, the note is for the exact run on screen (live-conversation.md §6): filed only while that
1752
+ // run is still the task's live build, once per draft key. Nothing is filed on a refusal; the view keeps the draft.
1753
+ // An unsent note from an attempt that ended, sent on to the task's next attempt from that agent's page: filed
1754
+ // task-level, back to the same agent with its draft key so only a confirmed send clears the draft.
1755
+ if (body.has("after")) {
1756
+ const afterRaw = body.get("after") ?? "";
1757
+ const keyRaw = body.get("key") ?? "";
1758
+ const ended = /^[1-9][0-9]{0,14}$/.test(afterRaw) ? store.getRun(Number(afterRaw)) : null;
1759
+ const afterBack = body.get("return");
1760
+ const afterFlow = returnedFlow(afterBack);
1761
+ if (ended === null || ended.taskRef !== ref.id || !/^[A-Za-z0-9-]{8,64}$/.test(keyRaw) || body.has("run") || (afterBack !== null && afterBack !== "task" && afterFlow === null)) {
1762
+ return taskScreen(response, who, taskId, "that note did not say which agent it came from, so nothing was sent", 400);
1763
+ }
1764
+ // The same receipt as a send to that run: a draft already filed while it ran comes back as sent, not twice.
1765
+ const filed = store.fileSteerNoteForRun(taskId, verifiedAuthor(who.name), body.get("note") ?? "", now, { run: ended.id, key: keyRaw, onward: true });
1766
+ // From the task page or a flow card: back there with the draft's key (only that draft clears), or the refusal in words.
1767
+ if (afterBack !== null) {
1768
+ if (filed.ok)
1769
+ return redirect(response, afterFlow !== null ? `/flows/${afterFlow.flow}?card=${afterFlow.card}&sent=${keyRaw}` : `${taskHref(taskId)}?sent=${keyRaw}#steering`);
1770
+ return taskScreen(response, who, taskId, filed.reason === "task-finished" ? "this task is finished, so there's no next attempt to send it to" : filed.reason === "key-reused" ? "that form was already used — reload and send it again" : "that note will not store", 409);
1771
+ }
1772
+ if (filed.ok)
1773
+ return redirect(response, `/agents?run=${ended.id}&said=steered-next&sent=${keyRaw}`);
1774
+ return redirect(response, `/agents?run=${ended.id}&said=${filed.reason === "task-finished" ? "steer-next-finished" : filed.reason === "key-reused" ? "steer-stale" : "steer-invalid"}`);
1775
+ }
1776
+ // A form that asks for a run but names none properly is refused — never quietly filed unbound.
1777
+ if (body.get("return") === "agents" || body.get("return") === "task" || returnedFlow(body.get("return")) !== null || body.has("run") || body.has("key")) {
1778
+ const runRaw = body.get("run") ?? "";
1779
+ const keyRaw = body.get("key") ?? "";
1780
+ const flowBack = returnedFlow(body.get("return"));
1781
+ const from = flowBack !== null ? "task" : body.get("return");
1782
+ if ((from !== "agents" && from !== "task") || !/^[1-9][0-9]{0,14}$/.test(runRaw) || !/^[A-Za-z0-9-]{8,64}$/.test(keyRaw)) {
1783
+ return taskScreen(response, who, taskId, "that note did not say which agent it is for, so nothing was sent", 400);
1784
+ }
1785
+ const shown = store.getRun(Number(runRaw));
1786
+ if (shown === null || shown.taskRef !== ref.id)
1787
+ return taskScreen(response, who, taskId, "that run is not part of this task", 404);
1788
+ // An attempt that takes no notes (a planner, a scout, a reviewer, one repairing its handoff) is never filed one:
1789
+ // it would never read it (cockpit §4).
1790
+ const capability = steerCapabilityOf(store, shown.id, now);
1791
+ if (capability !== null && capability.send === null) {
1792
+ return from === "agents" ? redirect(response, `/agents?run=${shown.id}&said=steer-no-notes`) : taskScreen(response, who, taskId, capability.words, 409);
1793
+ }
1794
+ const bound = store.fileSteerNoteForRun(taskId, verifiedAuthor(who.name), body.get("note") ?? "", now, { run: shown.id, key: keyRaw });
1795
+ // From the task page: back to its steering card, or the refusal in words (the page keeps nothing to restore).
1796
+ if (from === "task") {
1797
+ // Back where it was written (the task page, or a flow card's task), with its draft's key: only that draft clears.
1798
+ if (bound.ok)
1799
+ return redirect(response, flowBack !== null ? `/flows/${flowBack.flow}?card=${flowBack.card}&sent=${keyRaw}` : `${taskHref(taskId)}?sent=${keyRaw}#steering`);
1800
+ return taskScreen(response, who, taskId, bound.reason === "attempt-ended" || bound.reason === "task-finished" ? "that attempt ended before your note, so nothing was sent — reload and send it to the current one"
1801
+ : bound.reason === "invalid-note" ? "that note will not store" : "that form was already used — reload and send it again", 409);
1802
+ }
1803
+ // The draft's own key comes back: only that draft is cleared from the box.
1804
+ if (bound.ok)
1805
+ return redirect(response, `/agents?run=${shown.id}&said=steered&sent=${keyRaw}`);
1806
+ if (bound.reason === "attempt-ended" || bound.reason === "task-finished")
1807
+ return redirect(response, `/agents?run=${shown.id}&said=steer-ended`);
1808
+ if (bound.reason === "invalid-note")
1809
+ return redirect(response, `/agents?run=${shown.id}&said=steer-invalid`);
1810
+ return redirect(response, `/agents?run=${shown.id}&said=steer-stale`);
1811
+ }
1618
1812
  // The session IS the verified principal here — cookie + CSRF proved it.
1619
1813
  const filed = store.fileSteerNote(taskId, verifiedAuthor(who.name), body.get("note") ?? "", now);
1620
1814
  if (!filed.ok) {
@@ -1625,10 +1819,6 @@ export function createTasksHandlers(runtime) {
1625
1819
  : "no such task";
1626
1820
  return taskScreen(response, who, taskId, said, 400);
1627
1821
  }
1628
- // From the Agents board, back to the same agent (allow-listed: a run of this task, never a URL from the form).
1629
- const run = Number(body.get("run") ?? "");
1630
- if (body.get("return") === "agents" && Number.isSafeInteger(run) && store.getRun(run)?.taskRef === ref.id)
1631
- return redirect(response, `/agents?run=${run}&said=steered`);
1632
1822
  return redirect(response, taskHref(taskId));
1633
1823
  }
1634
1824
  case "reply": {
@@ -1999,7 +2189,7 @@ export function createTasksHandlers(runtime) {
1999
2189
  return taskScreen(response, who, taskId, "nothing to change about the agents", 400);
2000
2190
  if (phaseGiven !== null) {
2001
2191
  if (providerGiven === null || !isProviderId(providerGiven))
2002
- return taskScreen(response, who, taskId, "provider is claude, codex, openrouter, or gemini", 400);
2192
+ return taskScreen(response, who, taskId, `provider is one of ${PROVIDER_IDS.join(", ")}`, 400);
2003
2193
  if (modelGiven === "")
2004
2194
  return taskScreen(response, who, taskId, "name the exact model id — approvals bind exact agents", 400);
2005
2195
  const valid = validateSpec({ provider: providerGiven, model: modelGiven });
@@ -2007,6 +2197,8 @@ export function createTasksHandlers(runtime) {
2007
2197
  return taskScreen(response, who, taskId, `agents not changed: ${valid.problem}`, 400);
2008
2198
  if (phaseGiven === "review" && providerGiven === "gemini")
2009
2199
  return taskScreen(response, who, taskId, "gemini cannot review yet — choose claude or codex", 400);
2200
+ if ((phaseGiven === "plan" || phaseGiven === "review") && isAcpProvider(providerGiven))
2201
+ return taskScreen(response, who, taskId, `${ACP_AGENTS[providerGiven].name} builds and repairs only — choose another agent to ${phaseGiven}`, 400);
2010
2202
  }
2011
2203
  // The configured-choice proof lives INSIDE the edit transaction
2012
2204
  // (`configured`): the pair is held to the role's choices under the
@@ -2186,7 +2378,9 @@ export function createTasksHandlers(runtime) {
2186
2378
  if (!consumeApprovalNonce(nonce, who.name, taskId, digest)) {
2187
2379
  return approvalProblem("that approval form is stale — read it again", 409);
2188
2380
  }
2189
- if (token === "" && !hasFreshIdentitySignIn(who.name)) {
2381
+ // An empty password stands for an explicit identity-provider re-check not yet spent, or (cockpit B2) a password
2382
+ // remembered on this session; approve() decides whether these terms may stand on a remembered one.
2383
+ if (token === "" && !passwordlessSessionFor(store, who.name) && !ssoNowAvailable(who.name) && withFreshness("approve-standard", () => freshnessFor(who.name)) === null) {
2190
2384
  return approvalProblem("approval requires your password, typed again", 400);
2191
2385
  }
2192
2386
  const scopeRow = store.getScope(taskId);
@@ -2208,7 +2402,8 @@ export function createTasksHandlers(runtime) {
2208
2402
  return approvalProblem(`approval is blocked: ${view.problem}`, 409);
2209
2403
  }
2210
2404
  }
2211
- const approved = approveScope(store, taskId, who.name, now, scopeRow.digest, token);
2405
+ // A re-check is spent only if this yes is kept (sealed, or a protected-work vote recorded).
2406
+ const approved = spendingOnlyOnSuccess(() => withFreshness("approve-standard", () => approveScope(store, taskId, who.name, now, scopeRow.digest, token)), result => !result.ok && !(result.reason === "second-approver" && !result.already));
2212
2407
  if (!approved.ok) {
2213
2408
  // v102: a first yes on protected work is recorded, not refused — the page says who else is needed.
2214
2409
  if (approved.reason === "second-approver")
@@ -2218,6 +2413,20 @@ export function createTasksHandlers(runtime) {
2218
2413
  const status = approved.reason === "changed" || approved.reason === "unrouted" ? 409 : 403;
2219
2414
  return approvalProblem(approveRefusalWords(approved.reason), status);
2220
2415
  }
2416
+ // Approve & build again (the plan was rewritten after a build): the same yes requeues, only for the build the
2417
+ // sheet named, and only while it is still the task's latest result, made to an earlier plan than this one.
2418
+ const rebuildRaw = body.get("rebuild") ?? "";
2419
+ if (rebuildRaw !== "") {
2420
+ const named = /^[0-9]{1,15}$/.test(rebuildRaw) ? store.getRun(Number(rebuildRaw)) : null;
2421
+ // Built to an earlier plan than the one just approved (the scope this approval sealed, scopeRow.digest);
2422
+ // requeueTask re-proves, in its own transaction, that it is still the latest result and that scope approved.
2423
+ const offered = named != null && named.taskRef === ref.id && named.role === "builder" && !!named.scopeDigest && named.scopeDigest !== scopeRow.digest;
2424
+ const requeued = offered ? store.requeueTask(taskId, who.name, now, { run: named.id, scopeDigest: scopeRow.digest }) : null;
2425
+ if (requeued === null || (!requeued.ok && requeued.reason === "moved"))
2426
+ return approvalProblem("Approved. The task changed while this page was open, so it wasn't built again — check its status.", 200);
2427
+ if (!requeued.ok)
2428
+ return approvalProblem(`Approved, but building again didn't start (${requeued.reason}). Use Build again.`, 200);
2429
+ }
2221
2430
  return redirect(response, approvalBack);
2222
2431
  }
2223
2432
  case "accept-proof": {
@@ -2297,7 +2506,8 @@ export function createTasksHandlers(runtime) {
2297
2506
  const completed = checkAssignmentAsOperator(store, taskId, digest, principal, now, evidenceRoot);
2298
2507
  if (!completed.ok)
2299
2508
  return taskScreen(response, who, taskId, completed.message, 409);
2300
- return redirect(response, reviewHref(taskId, Number(namedRun)));
2509
+ // Back where it was completed (the task page's Result tab, or the result page), as Accept and finish does.
2510
+ return redirect(response, body.get("return") === null ? reviewHref(taskId, Number(namedRun)) : safeReturn(body.get("return")));
2301
2511
  }
2302
2512
  case "retry-review": {
2303
2513
  return taskScreen(response, who, taskId, "Separate agent reviews have retired. Open the saved result to mark it complete or request a revision.", 410);
@@ -2331,6 +2541,12 @@ export function createTasksHandlers(runtime) {
2331
2541
  return taskScreen(response, who, taskId, words[asked.reason] ?? asked.detail, 409);
2332
2542
  }
2333
2543
  const back = body.get("return") ?? "";
2544
+ // From the Agents board: back to the same agent, saying what happens now.
2545
+ if (back === "agents")
2546
+ return redirect(response, `/agents?run=${Number(named)}&said=stopping`);
2547
+ const stopFlow = returnedFlow(back);
2548
+ if (stopFlow !== null)
2549
+ return redirect(response, `/flows/${stopFlow.flow}?card=${stopFlow.card}`);
2334
2550
  return redirect(response, back === "chat" ? `${taskChatHref(taskId)}#task-control` : `${taskHref(taskId)}#task-control`);
2335
2551
  }
2336
2552
  case "pause": {
@@ -2365,6 +2581,26 @@ export function createTasksHandlers(runtime) {
2365
2581
  return taskScreen(response, who, taskId, words[asked.reason] ?? asked.detail, asked.reason === "invalid-note" ? 400 : 409);
2366
2582
  }
2367
2583
  const back = body.get("return") ?? "";
2584
+ // A note that rode along came from a composer's draft: its key comes back, so only that draft clears.
2585
+ const keyRaw = body.get("key") ?? "";
2586
+ // Only a note this request actually stored clears its draft: a repeated request answers with the note it stored
2587
+ // the first time, and a draft edited since is not that note.
2588
+ const storedId = "note" in asked && typeof asked.note === "number" ? asked.note : null;
2589
+ const stored = storedId === null ? null : store.handle.prepare("SELECT note FROM task_steer WHERE id = ?").get(storedId)?.["note"];
2590
+ const sent = note !== "" && /^[A-Za-z0-9-]{8,64}$/.test(keyRaw) && typeof stored === "string" && stored.trim() === note ? keyRaw : null;
2591
+ // A note that rode along but is not the one this request stored (a reused form): said plainly, the draft kept.
2592
+ if (note !== "" && (typeof stored !== "string" || stored.trim() !== note)) {
2593
+ if (back === "agents")
2594
+ return redirect(response, `/agents?run=${Number(named)}&said=pause-note-stale`);
2595
+ return taskScreen(response, who, taskId, "that form was already used, so your note was not sent — it is still in the box; send it again", 409);
2596
+ }
2597
+ if (back === "agents")
2598
+ return redirect(response, `/agents?run=${Number(named)}&said=${body.get("mode") === "resume" ? "resuming" : note === "" ? "pausing" : "pausing-with-note"}${sent === null ? "" : `&sent=${sent}`}`);
2599
+ const pauseFlow = returnedFlow(back);
2600
+ if (pauseFlow !== null)
2601
+ return redirect(response, `/flows/${pauseFlow.flow}?card=${pauseFlow.card}${sent === null ? "" : `&sent=${sent}`}`);
2602
+ if (back === "task")
2603
+ return redirect(response, `${taskHref(taskId)}${sent === null ? "" : `?sent=${sent}`}#task-control`);
2368
2604
  return redirect(response, back === "chat" ? `${taskChatHref(taskId)}#task-control` : `${taskHref(taskId)}#task-control`);
2369
2605
  }
2370
2606
  case "resume-arm": {
@@ -2523,7 +2759,7 @@ export function createTasksHandlers(runtime) {
2523
2759
  handoff: run?.handoff ?? null,
2524
2760
  costUsd: run?.costUsd ?? null,
2525
2761
  provider: run?.provider ?? null,
2526
- authMode: run?.authMode === "subscription" || run?.authMode === "api-key" ? run.authMode : null,
2762
+ authMode: run?.authMode === "subscription" || run?.authMode === "api-key" || run?.authMode === "agent-login" ? run.authMode : null,
2527
2763
  ranMinutes: run === null || run.finishedAt === null
2528
2764
  ? null
2529
2765
  : Math.max(1, Math.round((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 60_000)),
@@ -2598,6 +2834,15 @@ export function createTasksHandlers(runtime) {
2598
2834
  summary: detail.handoff?.conclusion ?? run.handoff,
2599
2835
  },
2600
2836
  detail,
2837
+ // How it ran: derived from the run's own records once it has finished, display only.
2838
+ efficiency: (() => { try {
2839
+ return runEfficiencyOf(store, evidenceRoot, run.id, now);
2840
+ }
2841
+ catch {
2842
+ return null;
2843
+ } })(),
2844
+ // What its network was and what its proxy saw (egress.ts), display only.
2845
+ network: store.runNetwork(run.id),
2601
2846
  reviewRetry: store.reviewRetryStateOf(run.id),
2602
2847
  review: reviewFactsFor(run.id),
2603
2848
  notes: store.notesForRun(run.id),
@@ -2793,6 +3038,7 @@ export function createTasksHandlers(runtime) {
2793
3038
  "task.act.unblock": taskAct("unblock"),
2794
3039
  "task.act.repair-dependency": taskAct("repair-dependency"),
2795
3040
  "task.act.follow-up": taskAct("follow-up"),
3041
+ "run.act.reveal": runReveal,
2796
3042
  "run.act.note": runNote,
2797
3043
  "run.act.comment": runComment,
2798
3044
  "run.act.revise": runRevise,