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,5 +1,8 @@
1
+ import { steerControlsFor } from "../steer-controls.js";
2
+ import { canReveal, cdCommandOf, localCheckoutOf } from "../checkout-proof.js";
3
+ import { instanceHttpAnswer } from "../console-instance.js";
1
4
  import { handlersOf } from './handler-registry.js';
2
- import { html, joinHtml, postForm } from "../html.js";
5
+ import { html, joinHtml, postForm, reauth } from "../html.js";
3
6
  /** pages handlers, moved without changing their route bodies. */
4
7
  import { createHmac, randomBytes } from "node:crypto";
5
8
  import { existsSync, opendirSync, readdirSync, realpathSync } from "node:fs";
@@ -21,7 +24,7 @@ import { run as execRun } from "../exec.js";
21
24
  import { computeGaps } from "../gaps.js";
22
25
  import { LEDGER_CSV_HEADER, ledgerCsvRows } from "../ledger-csv.js";
23
26
  import { ledgerBody } from "../ledger-view.js";
24
- import { agentRowOf, agentsBoardOf, agentFactsFor, sealedDiffOf } from "../agents-board.js";
27
+ import { agentLanesOf, agentRowOf, agentsBoardOf, agentFactsFor, laneWindowOf, sealedDiffOf } from "../agents-board.js";
25
28
  import { markAgentSeen } from "../agent-board.js";
26
29
  import { prometheusMetrics } from "../metrics.js";
27
30
  import { readMonitoring } from "../monitoring-settings.js";
@@ -37,12 +40,14 @@ import { serveInstallAsset } from "./pages-assets.js";
37
40
  import { healthz } from "./remote-hooks.js";
38
41
  import { projectChip, refuse, regionScript, relativeAge, screen, settingsRows, transcriptScript, WORKSPACE_STYLE, workToolRows } from "./chrome.js";
39
42
  import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
43
+ import { assignmentOf } from "../assignment.js";
44
+ import { assignmentTaskStatusOf } from "../assignment-presentation.js";
40
45
  import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, originUrlOf, portfolioOverview, projectsPage, systemPage, workbenchRail } from "./render-pages.js";
41
46
  import { capsPage } from "./render-settings.js";
42
47
  import { decisionAnswerScript, taskBody } from "./render-tasks.js";
43
48
  import {} from "./session.js";
44
49
  export function createPagesHandlers(runtime) {
45
- const { clock, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, projectOf, lookupSession } = runtime;
50
+ const { clock, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession } = runtime;
46
51
  // The filesystem browser (operator request): pick a project folder by
47
52
  // looking, not typing. CONFINED to exactly what opening allows —
48
53
  // explicit --repo mode has an enumerable list and gets no browser at
@@ -382,11 +387,57 @@ export function createPagesHandlers(runtime) {
382
387
  /** What the board says after a Reply or a steer, in fixed words (never text from the address). */
383
388
  const AGENT_SAID = {
384
389
  replied: { tone: "info", text: "Reply sent. The agent reads it at its next check." },
385
- steered: { tone: "info", text: "Note sent. The agent reads it at its next check." },
390
+ steered: { tone: "info", text: "Note sent. It shows in the conversation with whether the agent has read it." },
391
+ "steer-ended": { tone: "error", text: "That attempt ended before your note, so nothing was sent. Your note is kept below." },
392
+ "steered-next": { tone: "info", text: "Note sent. The task's next attempt reads it before starting." },
393
+ "steer-next-finished": { tone: "error", text: "This task is finished, so there's no next attempt to send it to. Your note is kept below." },
394
+ "steer-invalid": { tone: "error", text: "That note will not store. Your note is still in the box." },
395
+ "steer-stale": { tone: "error", text: "That form was already used. Your note is still in the box — send it again." },
386
396
  "reply-closed": { tone: "error", text: "That question was already answered." },
387
397
  "reply-stale": { tone: "error", text: "This attempt ended before your reply, so nothing was sent." },
388
398
  "reply-invalid": { tone: "error", text: "Write a reply of up to 1,500 characters." },
399
+ "steer-no-notes": { tone: "error", text: "This agent takes no notes right now, so nothing was sent. Stop it to change its course." },
400
+ stopping: { tone: "info", text: "Stopping. The work so far is kept." },
401
+ pausing: { tone: "info", text: "Pausing. It stops at the end of its current step." },
402
+ "pausing-with-note": { tone: "info", text: "Pausing. Your note goes to it as soon as it stops." },
403
+ "pause-note-stale": { tone: "error", text: "That form was already used, so your note was not sent. It is still in the box — send it again." },
404
+ resuming: { tone: "info", text: "Resuming. It carries on where it stopped." },
405
+ revealed: { tone: "info", text: "Shown in Finder." },
406
+ "reveal-refused": { tone: "error", text: "That checkout couldn't be shown: it isn't this attempt's running checkout on this computer any more." },
407
+ "reveal-mac-only": { tone: "error", text: "Show in Finder works on a Mac. Copy the command instead." },
408
+ "reveal-failed": { tone: "error", text: "Finder didn't show it. The path is above; copy the command instead." },
389
409
  };
410
+ /**
411
+ * The opened agent's head (docs/plans/one-page-task-and-agent.md): its task's status in the task page's own words and
412
+ * that status's one action as a link to the task page tab holding its form, and where this run opens there — a
413
+ * result (the shared predicate: a finished builder or scout run) on the Result tab at this run, a plan on the Plan
414
+ * tab, anything else on its record.
415
+ */
416
+ function agentTaskHeadOf(taskId, runId, live, access, now) {
417
+ const family = store.taskFamilyOf(taskId, access.repos, access.includeUnplaced);
418
+ const root = family?.root.id ?? taskId;
419
+ const versionOf = (id) => family !== null && id !== family.current.id ? `version=${encodeURIComponent(id)}&` : "";
420
+ const run = store.getRun(runId);
421
+ const open = live || run === null ? null
422
+ : run.finishedAt !== null && (run.role === "builder" || run.role === "scout") ? { label: run.role === "scout" ? "Report" : "Result", href: `${taskHref(root)}?${versionOf(taskId)}tab=result&run=${runId}` }
423
+ : run.role === "planner" ? { label: "Plan", href: `${taskHref(root)}?${versionOf(taskId)}tab=plan` }
424
+ : { label: "Record", href: `/r/${runId}?record=1` };
425
+ let task = null;
426
+ try {
427
+ const assignment = family === null ? null : assignmentOf(store, family.current.id, now, access, evidenceRoot);
428
+ if (assignment !== null) {
429
+ const words = assignmentTaskStatusOf(assignment);
430
+ const wants = words.primaryAction?.href ?? "";
431
+ // A result to review is on the Result tab; everything else the task page heads with (the approval, a retry…).
432
+ const href = /^\/review\?result=|[?&]result=/.test(wants) ? `${taskHref(root)}?tab=result` : taskHref(root);
433
+ task = { headline: words.headline, sentence: words.sentence, tone: words.tone, action: { label: words.primaryAction?.label ?? "Open task", href } };
434
+ }
435
+ }
436
+ catch {
437
+ task = null;
438
+ }
439
+ return { task, open };
440
+ }
390
441
  // The Agents board (one place to check on running agents): rows by state, and the opened agent's live view.
391
442
  async function agentsPage(ctx) {
392
443
  const { url, who, response, project } = ctx;
@@ -402,29 +453,43 @@ export function createPagesHandlers(runtime) {
402
453
  if (facts.outcome !== null && markAgentSeen(store, who.name, facts.runId, now))
403
454
  facts.seen = true;
404
455
  const live = facts.outcome === null;
456
+ const composer = live ? steerControlsFor(store, facts.taskId, facts.runId, now, who.role === "approver") : null;
405
457
  const sealed = live ? null : sealedDiffOf(store, evidenceRoot, facts.runId);
406
458
  // A reply's own word first; otherwise the note that the agent asked past the questions cap.
407
459
  const said = AGENT_SAID[url.searchParams.get("said") ?? ""] ?? (facts.questionNote === null ? null : { tone: "info", text: facts.questionNote });
408
460
  selected = {
409
461
  ...agentRowOf(store, facts, now, evidenceRoot), live,
410
462
  question: live && facts.question !== null ? { ...facts.question, action: `${taskHref(facts.taskId)}/reply` } : null,
411
- transcript: options.localRunner === undefined ? null : `/r/${facts.runId}?fragment=transcript`,
412
- // The live look at its checkout needs this machine's runner and pool, as the run page's does.
413
- peek: live && options.localRunner !== undefined && options.poolRoot !== undefined ? `/r/${facts.runId}?fragment=peek` : null,
463
+ // The conversation's window: its notes answer anywhere; its transcript only from the machine that runs it (the window says so).
464
+ transcript: `/r/${facts.runId}?fragment=transcript&shape=records`,
465
+ // The live look at its checkout needs this machine's runner — the one running it — and pool, as the run page's does.
466
+ peek: live && options.localRunner !== undefined && run.runner === options.localRunner && options.poolRoot !== undefined ? `/r/${facts.runId}?fragment=peek` : null,
414
467
  files: (sealed?.files ?? []).slice(0, 30).map(one => ({ path: one.path, added: one.additions, removed: one.deletions })),
415
468
  filesMore: Math.max(0, (sealed?.fileCount ?? 0) - 30),
416
- steer: live ? { action: `${taskHref(facts.taskId)}/steer`, run: facts.runId } : null,
469
+ // A note goes only to an attempt that reads notes (steer-controls.ts); the composer says what it takes.
470
+ steer: composer?.steer ?? null,
471
+ controls: composer?.controls ?? null,
472
+ checkout: (() => {
473
+ if (!live || who.role !== "approver" || store.isDemo())
474
+ return null;
475
+ const proof = localCheckoutOf(store, facts.runId, { runner: options.localRunner, poolRoot: options.poolRoot }, now);
476
+ // Show in Finder only where it can work (a Mac); elsewhere the path and the command stand alone.
477
+ return proof.ok ? { path: proof.path, cd: cdCommandOf(proof.path), reveal: canReveal(options.revealPath !== undefined) ? `/r/${facts.runId}/reveal` : null } : null;
478
+ })(),
417
479
  canAct: who.role === "approver" && !store.isDemo(),
418
480
  taskHref: taskHref(facts.taskId),
419
481
  // The run's own address: a finished result opens on its result page, anything else on its record.
420
482
  resultHref: live ? null : `/r/${facts.runId}`,
483
+ ...agentTaskHeadOf(facts.taskId, facts.runId, live, access, now),
421
484
  room: `agent:${facts.runId}`, said,
422
485
  };
423
486
  }
424
487
  }
425
488
  const groups = agentsBoardOf(store, now, access, who.name, evidenceRoot);
426
- const view = { kind: "agents", groups, selected,
427
- empty: groups.length === 0 ? "No agents in the last day. They show here the moment one starts." : null };
489
+ const lanes = selected === null ? agentLanesOf(store, now, access, who.name, laneWindowOf(url.searchParams.get("window"))) : null;
490
+ const view = { kind: "agents", groups, selected, lanes,
491
+ // Empty only when nothing ran at all: a planner or review on the timeline is activity too.
492
+ empty: groups.length === 0 && (lanes === null || lanes === undefined) ? "No agents in the last day. They show here the moment one starts." : null };
428
493
  const fallback = joinHtml([
429
494
  html `<h1>Agents</h1>`,
430
495
  groups.length === 0 ? html `<p class="meta">${view.empty ?? ""}</p>` : joinHtml(groups.map(group => joinHtml([
@@ -456,7 +521,7 @@ export function createPagesHandlers(runtime) {
456
521
  html `<p class="meta" id="fleet-region-stamp"></p>`,
457
522
  html `<h2>Register a worker</h2>`,
458
523
  postForm("/fleet/runner/register", html `<label>Name<input type="text" name="name" placeholder="builder-2" maxlength="60" required></label><label>Capacity<input type="text" name="capacity" inputmode="numeric" value="1" aria-label="capacity"></label><label>Your password, typed again<input type="password" name="token" autocomplete="current-password" required></label><button type="submit">Register — its token is shown once</button>`, { attrs: { class: "card" } }),
459
- html `<details class="arm-danger"><summary>Retire a worker</summary>${postForm("/fleet/runner/retire", html `<label>Which worker<input type="text" name="name" placeholder="builder-1" required></label><label>Your password, typed again<input type="password" name="token" autocomplete="current-password" required></label><button type="submit" class="danger">Retire it</button>`, { attrs: { class: "card" } })}</details>`,
524
+ html `<details class="arm-danger"><summary>Retire a worker</summary>${postForm("/fleet/runner/retire", html `<label>Which worker<input type="text" name="name" placeholder="builder-1" required></label>${reauth(html `<label>Your password, typed again<input type="password" name="token" autocomplete="current-password" required></label>`)}<button type="submit" class="danger">Retire it</button>`, { attrs: { class: "card" } })}</details>`,
460
525
  ], "\n"),
461
526
  // The password fields make this screen sensitive: sendScreen strips
462
527
  // the chrome additions and keeps the reorder poller — the named
@@ -649,7 +714,8 @@ export function createPagesHandlers(runtime) {
649
714
  if (who.via !== "cookie")
650
715
  return refuse(response, who, 403, "runner registration is a browser surface");
651
716
  const token = body.get("token") ?? "";
652
- if (!authenticateApprover(who, token).ok) {
717
+ // A worker credential is minted here: a credential act, a password typed now even with Passwordless on.
718
+ if (!authenticateCredential(who, token).ok) {
653
719
  return refuse(response, who, 403, "registering a worker takes your password, typed again", "/fleet");
654
720
  }
655
721
  const name = (body.get("name") ?? "").trim();
@@ -769,7 +835,7 @@ export function createPagesHandlers(runtime) {
769
835
  // (finding 22/33) — the record is consumed from the RETURNED live
770
836
  // session, synchronously, before the first await.
771
837
  const password = body.get("token") ?? "";
772
- if (password === "" || !authenticateApprover(who, password).ok) {
838
+ if (!authenticateApprover(who, password).ok) {
773
839
  return projectsScreen(response, who, "cloning takes your password, typed again", 403);
774
840
  }
775
841
  const cookieId = /(?:^|;\s*)standing-orders_session=([0-9a-f]{64})/.exec(request.headers.cookie ?? "")?.[1];
@@ -879,6 +945,16 @@ export function createPagesHandlers(runtime) {
879
945
  peeks[path] = peekOf(path);
880
946
  return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined, who.via === "cookie" ? (path) => projectCheckLevel(store, path).level : undefined));
881
947
  }
948
+ async function instanceEdge(ctx) {
949
+ const { request, response, url } = ctx;
950
+ const secret = options.instanceSecret?.() ?? null;
951
+ if (secret === null)
952
+ return respond(response, 404, "application/json; charset=utf-8", JSON.stringify({ error: "not-up" }));
953
+ // Bound to what THIS console saw as the receiving end of this very connection — never the Host header.
954
+ const answer = instanceHttpAnswer(secret, url.searchParams.getAll("c").length === 1 ? url.searchParams.get("c") : null, { address: request.socket.localAddress, port: request.socket.localPort });
955
+ response.setHeader("Cache-Control", "no-store");
956
+ return respond(response, answer.status, "application/json; charset=utf-8", answer.body);
957
+ }
882
958
  async function healthzEdge(ctx) {
883
959
  const { response, method } = ctx;
884
960
  return healthz(store, response, method === 'HEAD');
@@ -941,6 +1017,7 @@ export function createPagesHandlers(runtime) {
941
1017
  "projects.onboard-confirm": onboardConfirm,
942
1018
  }, {
943
1019
  "edge.healthz": healthzEdge,
1020
+ "edge.instance": instanceEdge,
944
1021
  "edge.browser-asset": browserAsset,
945
1022
  "edge.workspace-style": workspaceStyle,
946
1023
  "edge.install-asset": installAsset,
@@ -1,6 +1,9 @@
1
1
  import type { ServerRuntime } from './runtime.js';
2
2
  import { type IncomingMessage, type ServerResponse } from "node:http";
3
3
  import { type FormView } from "../contracts/console-api.js";
4
+ /** When the provider checked the person, in ms, if its auth_time is a check made for this visit: present, no earlier
5
+ * than the second the visit began, and not in the future beyond the skew. Null otherwise. */
6
+ export declare function sameVisitCheck(authTime: number | null, startedAt: number, now: number): number | null;
4
7
  export declare function createPeopleHandlers(runtime: ServerRuntime): {
5
8
  registrations: import("./handler-registry.js").Registration[];
6
9
  takeJoinAttempt: (source: string) => boolean;
@@ -3,7 +3,7 @@ import { adapterPolicy } from "./route-policy.js";
3
3
  /** settings handlers, moved without changing their route bodies. */
4
4
  import { mintApiToken, TOKEN_DAYS } from "../api-tokens.js";
5
5
  import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
6
- import { html, htmlString, joinHtml, postForm } from "../html.js";
6
+ import { html, htmlString, joinHtml, postForm, reauth } from "../html.js";
7
7
  import { credentialsHtml, tokenShownHtml } from "../credentials-ui.js";
8
8
  import { projectName } from "../project.js";
9
9
  import { requestLimitsHtml, tokenLimitWords } from "../request-budget-ui.js";
@@ -21,12 +21,26 @@ import { logEvent } from "../log.js";
21
21
  import { accessFromGroups, accountNameFor, exchangeOidcCode, newOidcVisit, oidcAuthorizeUrl, verifyIdToken } from "../oidc.js";
22
22
  import { personAuditHtml, personHref } from "../people-audit-ui.js";
23
23
  import { cursorOf as auditCursorOf, remoteAudit } from "../remote-audit.js";
24
- import { hashPassword } from "../scope.js";
24
+ import { credentialProof, hashPassword, proveAccountNow } from "../scope.js";
25
25
  import { form, HANDOFF_STYLE, loginHref, loginReturn, page, SIGN_IN_COOKIE, signInSpent, startedHere } from "./http.js";
26
26
  import { joinDeadPage, joinFormPage, loginPage, signupPage } from "./render-people.js";
27
27
  import { SESSION_COOKIE, SIGN_IN_LINK_PATH } from "./session.js";
28
+ /** Clock skew allowed between the provider and this server when reading auth_time — forward only (a provider clock
29
+ * running ahead). auth_time is in whole seconds, so a check made in the visit's own second is floored below startedAt:
30
+ * the one allowance backward is that rounding, never a check made before the visit began. */
31
+ const AUTH_TIME_SKEW_MS = 60_000;
32
+ /** When the provider checked the person, in ms, if its auth_time is a check made for this visit: present, no earlier
33
+ * than the second the visit began, and not in the future beyond the skew. Null otherwise. */
34
+ export function sameVisitCheck(authTime, startedAt, now) {
35
+ if (authTime === null || !Number.isFinite(authTime))
36
+ return null;
37
+ const at = authTime * 1000;
38
+ if (at < Math.floor(startedAt / 1000) * 1000 || at > now + AUTH_TIME_SKEW_MS)
39
+ return null;
40
+ return Math.min(Math.max(at, startedAt), now);
41
+ }
28
42
  export function createPeopleHandlers(runtime) {
29
- const { settingsUpdates, joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, defaultProject, cookieSecure, consoleOrigin, providerFor, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
43
+ const { settingsUpdates, joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, defaultProject, cookieSecure, consoleOrigin, providerFor, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
30
44
  // v101: Settings → Sessions & tokens: where you're signed in, and your API tokens. An instance operator can see everyone's.
31
45
  async function sessionsPage(ctx) {
32
46
  const { url, who, response, project } = ctx;
@@ -95,7 +109,7 @@ export function createPeopleHandlers(runtime) {
95
109
  : joinHtml(acts.map(act => html `<p class="row meta">${act.kind} ${act.subject} \u00b7 ${act.at.slice(0, 16).replace("T", " ")}</p>`), "\n"),
96
110
  approverView && one.revokedAt === null && one.name !== who.name
97
111
  ? postForm("/people/revoke", html `\
98
- <label>Your password<input type="password" name="token" autocomplete="current-password"></label>\
112
+ ${reauth(html `<label>Your password<input type="password" name="token" autocomplete="current-password"></label>`)}\
99
113
  <button type="submit">Remove ${one.name}'s sign-in</button>\
100
114
  <span class="meta">ends their access and everything it signed \u2014 history stays</span>`, { attrs: { class: "row" }, hidden: { name: one.name } })
101
115
  : "",
@@ -105,7 +119,7 @@ export function createPeopleHandlers(runtime) {
105
119
  const invites = approverView ? store.openInvites(now) : [];
106
120
  const inviteRows = invites.map(one => html `<p class="row">${one.role} invite · ${one.projects === null ? "all projects" : one.projects.map(repo => projectName(repo)).join(", ")} \u00b7 from ${personChip(one.mintedBy)} \u00b7 expires ${one.expiresAt.slice(0, 16).replace("T", " ")}${one.attempts > 0 ? ` \u00b7 ${one.attempts} failed attempt${one.attempts === 1 ? "" : "s"}` : ""}\
107
121
  ${postForm("/people/invite-revoke", html `\
108
- <label>Password <input type="password" name="token" autocomplete="current-password" style="width:8rem"></label>\
122
+ ${reauth(html `<label>Password <input type="password" name="token" autocomplete="current-password" style="width:8rem"></label>`)}\
109
123
  <button type="submit">Cancel it</button>`, { attrs: { style: "display:inline" }, hidden: { id: one.id } })}</p>`);
110
124
  const inviteCard = !approverView
111
125
  ? ""
@@ -193,10 +207,13 @@ export function createPeopleHandlers(runtime) {
193
207
  return back("problem", "Choose when it expires.");
194
208
  if (access === "act" && who.role !== "approver")
195
209
  return back("problem", "A viewer's token can only read.");
196
- // A token is a credential: made with the person's password (or their identity provider's check from moments ago), counted once.
210
+ // A token is a credential: made with the person's password typed now (or an explicit identity-provider re-check,
211
+ // spent by it), counted once. Never a remembered password: this act is outside every freshness allowlist.
197
212
  const typed = body.get("password") ?? "";
198
- const confirmed = who.role === "approver" ? authenticateApprover(who, typed).ok
199
- : typed === "" ? requestContext.getStore()?.sso?.fresh === true : authenticateAccount(store, who.name, typed).ok;
213
+ // Passwordless never covers it (docs/plans/passwordless.md: a credential act).
214
+ const viewerProof = who.role === "approver" || who.via !== "cookie" ? null : proveAccountNow(store, who.name, typed, { credential: true });
215
+ const confirmed = who.role === "approver" ? authenticateCredential(who, typed).ok
216
+ : viewerProof !== null && viewerProof.ok && credentialProof(viewerProof.method);
200
217
  if (!confirmed)
201
218
  return back("problem", "Enter your Toolroll password to make a token.");
202
219
  const minted = mintApiToken();
@@ -210,7 +227,8 @@ export function createPeopleHandlers(runtime) {
210
227
  async function peopleProjects(ctx) {
211
228
  const { who, response, now, posted } = ctx;
212
229
  const body = readForm(posted, CONSOLE_FORMS.peopleProjects);
213
- if (!authenticateApprover(who, body.get("token") ?? "", null).ok)
230
+ // Widening access (up to instance operator) is a credential act: a password typed now, Passwordless or not.
231
+ if (!authenticateCredential(who, body.get("token") ?? "", null).ok)
214
232
  return refuse(response, who, 403, "Saving access requires your password.", "/people");
215
233
  const access = readAccessForm(body);
216
234
  if (!access.ok)
@@ -229,7 +247,7 @@ export function createPeopleHandlers(runtime) {
229
247
  const token = body.get("token") ?? "";
230
248
  // RAISING authority is a password ceremony (the doctrine): adding a
231
249
  // person to the instance is exactly that.
232
- if (!authenticateApprover(who, token).ok) {
250
+ if (!authenticateCredential(who, token).ok) {
233
251
  return refuse(response, who, 403, "making an invite takes your password, typed again", "/people");
234
252
  }
235
253
  const role = body.get("role") === "approver" ? "approver" : "viewer";
@@ -346,7 +364,9 @@ export function createPeopleHandlers(runtime) {
346
364
  // Bounded: anyone may start a sign-in, so the oldest waiting ones give way.
347
365
  while (ssoVisits.size >= 1000)
348
366
  ssoVisits.delete(ssoVisits.keys().next().value);
349
- ssoVisits.set(visit.state, { visit, provider: found.provider, redirect: redirectUri, intent, returnTo, expires: Date.now() + 15 * 60_000 });
367
+ const asker = intent === "sign-in" || who?.via !== "cookie" ? null : sessions.hashOf(who.session);
368
+ ssoVisits.set(visit.state, { visit, provider: found.provider, redirect: redirectUri, intent, returnTo, expires: Date.now() + 15 * 60_000, startedAt: Date.now(),
369
+ session: asker === null || who?.via !== "cookie" ? null : { hash: asker, generation: who.session.generation } });
350
370
  response.writeHead(302, { location: oidcAuthorizeUrl(found.provider, settings, visit, redirectUri, intent === "reauth"), "cache-control": "no-store", "referrer-policy": "no-referrer",
351
371
  "set-cookie": `${SIGN_IN_COOKIE}=${visit.state}; Path=${SSO_CALLBACK}; Max-Age=900; HttpOnly; SameSite=Lax${origin.startsWith("https:") ? "; Secure" : ""}` });
352
372
  response.end();
@@ -371,18 +391,28 @@ export function createPeopleHandlers(runtime) {
371
391
  if (handoff.intent === "link" || handoff.intent === "reauth") {
372
392
  if (who?.via !== "cookie")
373
393
  return redirect(response, loginHref(handoff.returnTo));
394
+ // Only the session that asked: same kept session, same credential generation.
395
+ if (handoff.session === null || sessions.hashOf(who.session) !== handoff.session.hash || who.session.generation !== handoff.session.generation) {
396
+ return page(response, 403, loginPage(`That ${settings.label} check was started by another sign-in. Start it again from this page.`, handoff.returnTo, ssoOffer()));
397
+ }
374
398
  if (handoff.intent === "link") {
375
399
  const linked = store.linkSsoIdentity(who.name, identity, clock());
376
400
  if (!linked.ok)
377
401
  return redirect(response, `/settings/sign-in?problem=${encodeURIComponent(`That ${settings.label} account signs in as someone else here.`)}`);
378
- who.session.sso = { at: Date.now() };
402
+ who.session.sso = { at: handoff.checkedAt ?? 0 };
379
403
  sessions.persist(who.session);
380
404
  return redirect(response, `/settings/sign-in?said=${encodeURIComponent(`This account signs in with ${settings.label} now.`)}`);
381
405
  }
382
406
  // A step-up: the provider checked the same person again.
383
407
  if (store.ssoAccount(identity.issuer, identity.subject) !== who.name)
384
408
  return page(response, 403, loginPage(`That ${settings.label} account isn't the one signed in here.`, handoff.returnTo, ssoOffer()));
385
- who.session.sso = { at: Date.now() };
409
+ who.session.sso = { at: handoff.checkedAt ?? 0 };
410
+ // An explicit re-check on request: the one identity-provider event that stands in for a password typed now (for ONE
411
+ // step-up, spent by it) and may start a freshness window (B2). Kept in memory only: a restart forgets it.
412
+ if (handoff.checkedAt !== null) {
413
+ who.session.ssoReauthAt = handoff.checkedAt;
414
+ who.session.ssoNowAt = handoff.checkedAt;
415
+ }
386
416
  sessions.persist(who.session);
387
417
  recordSignIn(who.name, "confirmed", settings.label);
388
418
  return redirect(response, handoff.returnTo);
@@ -397,7 +427,7 @@ export function createPeopleHandlers(runtime) {
397
427
  const account = store.accountOf(signedIn.account);
398
428
  const id = randomBytes(32).toString("hex");
399
429
  sessions.set(id, { ...arrival(request), name: signedIn.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
400
- project: defaultProject, projectRevision: 1, sso: { at: Date.now() } });
430
+ project: defaultProject, projectRevision: 1, sso: { at: handoff.checkedAt ?? 0 } });
401
431
  recordSignIn(signedIn.account, "signed in", settings.label);
402
432
  response.setHeader("Set-Cookie", [signInSpent("/login/sso/finish"), `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`]);
403
433
  return redirect(response, handoff.returnTo);
@@ -447,6 +477,8 @@ export function createPeopleHandlers(runtime) {
447
477
  lastSeen: Date.now(),
448
478
  project: defaultProject,
449
479
  projectRevision: 1,
480
+ // A password typed on this computer starts this session's freshness window (cockpit B2); from anywhere else, never.
481
+ ...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
450
482
  });
451
483
  response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
452
484
  return redirect(response, "/");
@@ -496,6 +528,9 @@ export function createPeopleHandlers(runtime) {
496
528
  lastSeen: Date.now(),
497
529
  project: defaultProject,
498
530
  projectRevision: 1,
531
+ // A password typed to sign in on this computer starts this session's freshness window (cockpit B2); from anywhere
532
+ // else (a phone, a proxy) it signs in and proves nothing more.
533
+ ...(fromThisComputer(request) ? { passwordAt: Date.now() } : {}),
499
534
  });
500
535
  response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
501
536
  return redirect(response, returnTo);
@@ -641,8 +676,11 @@ export function createPeopleHandlers(runtime) {
641
676
  logEvent("warn", "sso.refused", { reason: verified.said });
642
677
  return back(verified.said);
643
678
  }
644
- // A step-up must be a check made just now, not a remembered one.
645
- if (held.intent === "reauth" && verified.claims.authTime !== null && Date.now() / 1000 - verified.claims.authTime > 300)
679
+ // When the provider checked the person (its auth_time), proved against this visit (cockpit B2, N18): no earlier than
680
+ // this visit began, and not in the future beyond a minute of clock skew. A step-up must carry one that holds — a
681
+ // check made for this request, never a remembered one; any other sign-in without one signs in, but is never fresh.
682
+ const checkedAt = sameVisitCheck(verified.claims.authTime, held.startedAt, Date.now());
683
+ if (held.intent === "reauth" && checkedAt === null)
646
684
  return back(`${settings.label} didn't check you again. Try once more.`);
647
685
  const key = randomBytes(24).toString("base64url");
648
686
  for (const [one, value] of ssoHandoffs)
@@ -650,7 +688,7 @@ export function createPeopleHandlers(runtime) {
650
688
  ssoHandoffs.delete(one);
651
689
  while (ssoHandoffs.size >= 1000)
652
690
  ssoHandoffs.delete(ssoHandoffs.keys().next().value);
653
- ssoHandoffs.set(key, { claims: verified.claims, issuer: held.provider.issuer, intent: held.intent, returnTo: held.returnTo, expires: Date.now() + 60_000 });
691
+ ssoHandoffs.set(key, { claims: verified.claims, issuer: held.provider.issuer, intent: held.intent, returnTo: held.returnTo, expires: Date.now() + 60_000, checkedAt, session: held.session });
654
692
  const to = `/login/sso/finish?h=${key}`;
655
693
  response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "x-frame-options": "DENY",
656
694
  // The hand-off key is this browser's alone, like the visit's state: its cookie rides to /login/sso/finish.
@@ -1,5 +1,5 @@
1
1
  import { adapterPolicy } from "./route-policy.js";
2
- import { flowFormPage, FORM_PATH, HOOK_PATH, receiveFlowForm, receiveFlowHook } from "../flow-triggers.js";
2
+ import { flowFormPage, FORM_PATH, HOOK_PATH, hookRefusedUnread, receiveFlowForm, receiveFlowHook } from "../flow-triggers.js";
3
3
  import { keepPushedUpdate, loadBotToken, pushedByTelegram, telegramHookSecret } from "../telegram.js";
4
4
  /** Flow webhooks (v82): the one road a public relay may expose. The secret
5
5
  * address proves nothing about the sender on its own for GitHub and Linear:
@@ -48,15 +48,21 @@ export async function telegramHook(ctx, request, response) {
48
48
  }
49
49
  export async function flowHook(ctx, request, response, url) {
50
50
  const { store, options, clock } = ctx;
51
- const reply = (status, said) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify({ said })); request.resume(); };
51
+ const reply = (status, said, retryAfter) => {
52
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...(retryAfter === undefined ? {} : { "retry-after": String(retryAfter) }) });
53
+ response.end(JSON.stringify({ said }));
54
+ request.resume();
55
+ };
52
56
  if (url.pathname.startsWith(FORM_PATH))
53
57
  return flowForm(ctx, request, response, url);
54
58
  if (options.configDir === undefined || !url.pathname.startsWith(HOOK_PATH))
55
59
  return reply(404, "No such address.");
56
60
  if (request.method !== "POST")
57
61
  return reply(405, "Send a POST.");
62
+ // Refused before it is read (v126): it still counts toward its address's minute, as anything unproven does.
63
+ const refused = (status, said) => { const over = hookRefusedUnread(store, url.pathname.slice(HOOK_PATH.length), clock()); return over === null ? reply(status, said) : reply(over.status, over.said, over.retryAfter); };
58
64
  if (Number(request.headers["content-length"] ?? 0) > 1_000_000)
59
- return reply(413, "Too large.");
65
+ return refused(413, "Too large.");
60
66
  const chunks = [];
61
67
  try {
62
68
  await new Promise((resolve, reject) => {
@@ -70,11 +76,11 @@ export async function flowHook(ctx, request, response, url) {
70
76
  });
71
77
  }
72
78
  catch (error) {
73
- return reply(error instanceof Error && error.message === "too-large" ? 413 : 400, "Couldn't read that.");
79
+ return refused(error instanceof Error && error.message === "too-large" ? 413 : 400, "Couldn't read that.");
74
80
  }
75
81
  try {
76
- const answer = receiveFlowHook(store, url.pathname.slice(HOOK_PATH.length), { headers: request.headers, body: Buffer.concat(chunks) }, options.configDir, clock());
77
- return reply(answer.status, answer.said);
82
+ const answer = receiveFlowHook(store, url.pathname.slice(HOOK_PATH.length), { headers: request.headersDistinct, body: Buffer.concat(chunks) }, options.configDir, clock());
83
+ return reply(answer.status, answer.said, answer.retryAfter);
78
84
  }
79
85
  catch {
80
86
  return reply(500, "Not saved.");
@@ -1,3 +1,4 @@
1
+ import { type FreshFacts } from "../scope.js";
1
2
  /** tasks handlers, moved without changing their route bodies. */
2
3
  import { type IncomingMessage } from "node:http";
3
4
  import { CodingWorkspace } from '../coding-workspace.js';
@@ -45,11 +46,11 @@ export interface RemoteRuntime {
45
46
  managedRepos: () => string[];
46
47
  joinSourceOf: (request: IncomingMessage) => string;
47
48
  ssoSettings: () => import("../oidc.js").OidcSettings | null;
48
- SSO_FRESH_MS: number;
49
49
  oauthTokenBudget: SourceAdmission;
50
50
  hookContext: RemoteHookContext;
51
51
  teamsSourceBudget: SourceAdmission;
52
52
  teamsTenantBudget: SourceAdmission;
53
+ freshFactsOf: (request: IncomingMessage, who: Who | null) => FreshFacts | null;
53
54
  }
54
55
  export declare function createRemoteHandlers(runtime: RemoteRuntime): {
55
56
  registrations: import("./handler-registry.js").Registration[];
@@ -1,3 +1,4 @@
1
+ import { credentialProof, freshFacts, proveAccountNow, ssoNowAvailable } from "../scope.js";
1
2
  import { handlersOf } from './handler-registry.js';
2
3
  /** tasks handlers, moved without changing their route bodies. */
3
4
  import {} from "node:http";
@@ -8,7 +9,6 @@ import { createOAuthHttp, resourceMetadataUrl } from "../mcp-oauth.js";
8
9
  import { reproveRemote } from "../operate-remote.js";
9
10
  import { rowVisible } from "../project.js";
10
11
  import { RequestBudget, SourceAdmission } from "../request-budget.js";
11
- import { authenticateAccount } from "../scope.js";
12
12
  import { createSessionRunner } from '../session-remote.js';
13
13
  import {} from "../store.js";
14
14
  import { handleTeamHttp, TEAM_PASSWORD_REVERIFY_MS } from '../team-http.js';
@@ -17,7 +17,7 @@ import { flowHook, telegramHook } from "./remote-hooks.js";
17
17
  import { respond } from "./http.js";
18
18
  import {} from "./session.js";
19
19
  export function createRemoteHandlers(runtime) {
20
- const { identify, store, admitPasswordSource, admitBearer, allowedHost, team, teamBrowserReply, requestBudget, options, coding, codingProjects, liveCeiling, clock, evidenceRoot, consoleOrigin, managedRepos, joinSourceOf, ssoSettings, SSO_FRESH_MS, oauthTokenBudget, hookContext, teamsSourceBudget, teamsTenantBudget } = runtime;
20
+ const { identify, store, admitPasswordSource, admitBearer, allowedHost, team, teamBrowserReply, requestBudget, options, coding, codingProjects, liveCeiling, clock, evidenceRoot, consoleOrigin, managedRepos, joinSourceOf, ssoSettings, oauthTokenBudget, hookContext, teamsSourceBudget, teamsTenantBudget } = runtime;
21
21
  /**
22
22
  * The full principal a live `so_` API token stands for: its person and their generation, the token's own read/act
23
23
  * scope, its project limit narrowed by the person's current access, and its id. Every route that accepts an API token
@@ -119,8 +119,9 @@ export function createRemoteHandlers(runtime) {
119
119
  const who = identify(request, false);
120
120
  if (who === null || who.via !== "cookie")
121
121
  return null;
122
- return { name: who.name, role: who.role, csrf: who.session.csrf,
123
- sso: who.session.sso === undefined ? null : { label: ssoSettings()?.label ?? "your identity provider", fresh: Date.now() - who.session.sso.at < SSO_FRESH_MS } };
122
+ return { name: who.name, role: who.role, csrf: who.session.csrf, generation: who.session.generation,
123
+ sso: who.session.sso === undefined ? null : { label: ssoSettings()?.label ?? "your identity provider",
124
+ fresh: freshFacts.run(runtime.freshFactsOf(request, who), () => ssoNowAvailable(who.name)) } };
124
125
  },
125
126
  sameSite: request => {
126
127
  const origin = request.headers.origin, referer = request.headers.referer;
@@ -129,7 +130,15 @@ export function createRemoteHandlers(runtime) {
129
130
  },
130
131
  // The step-up for a credential: the person's password, or (empty) a fresh identity-provider check. What they may
131
132
  // grant (their role, their projects) is checked beside it.
132
- confirm: (session, typed) => typed === "" ? session.sso?.fresh === true : authenticateAccount(store, session.name, typed).ok,
133
+ // Proved on this request's live session at the moment of consent (signed in here still, same generation), through
134
+ // the one account step-up: a password typed now, or an explicit re-check (spent). Never a remembered password.
135
+ confirm: (request, session, typed) => {
136
+ const who = identify(request, false);
137
+ if (who === null || who.via !== "cookie" || who.name !== session.name || who.session.generation !== session.generation)
138
+ return false;
139
+ // Connecting an outside app is a credential act: never a session (Passwordless) or a remembered password.
140
+ return freshFacts.run(runtime.freshFactsOf(request, who), () => { const proved = proveAccountNow(store, who.name, typed, { credential: true }); return proved.ok && credentialProof(proved.method); });
141
+ },
133
142
  projectsFor: account => store.knownRepos().filter(repo => store.accountCanAccess(account, repo)).sort(),
134
143
  admitToken: request => oauthTokenBudget.admit(joinSourceOf(request)),
135
144
  });
@@ -1,3 +1,5 @@
1
+ /** The conversation: chat and lead pages, proposal cards and the task chat. */
2
+ import type { TaskOrigin } from "../task-source.js";
1
3
  import { type AssignmentSnapshot } from "../assignment.js";
2
4
  import { type BrowserActionCard } from "../browser-workspace.js";
3
5
  import { type ChatDraft } from "../converse.js";
@@ -10,6 +12,7 @@ import { type TaskControlView } from "../task-control.js";
10
12
  import { type TeamChatProviderResolver } from "../team-chat-authorization.js";
11
13
  import { type TeamSnapshot } from "../team-contract.js";
12
14
  import { type DisplayStatus } from "../workspace-ui.js";
15
+ import type { LeadQueueRow } from "../lead-queue.js";
13
16
  import { type Html } from "../html.js";
14
17
  import { type Chrome, type Screen } from "./chrome.js";
15
18
  import { type ProjectPeek } from "./render-pages.js";
@@ -77,6 +80,8 @@ export type TaskChatFocus = {
77
80
  label: string;
78
81
  filedAgo: string | null;
79
82
  } | null;
83
+ /** v126: where the work came from (an outside event's line of context). */
84
+ origin?: TaskOrigin | null;
80
85
  /** The repair chain row when this task is a machine-drafted repair. */
81
86
  repairChain: RepairChainRow | null;
82
87
  } | null;
@@ -214,7 +219,7 @@ export declare function chatPage(chrome: Chrome, data: {
214
219
  /** Chat's first run is on the page: it already says what to do while there is no lead. */
215
220
  firstRunShown?: boolean;
216
221
  }): Screen;
217
- export declare function chatAckPage(chrome: Chrome, turn: ChatTurn, nonce: string): Screen;
222
+ export declare function chatAckPage(chrome: Chrome, turn: ChatTurn, nonce: string, reply?: LeadTurn | null): Screen;
218
223
  /** The card that starts a conversation: the one password ceremony (mate arc §1). */
219
224
  export declare function leadMintCard(enabled: {
220
225
  billing: "metered" | "subscription";
@@ -301,19 +306,23 @@ export type LeadThreadRows = {
301
306
  focusTask: TaskChatFocus | null;
302
307
  /** The lead's open question to this reader, drawn as buttons under its reply. */
303
308
  ask?: LeadAsk | null;
309
+ /** The turn whose question the task has moved past: shown without buttons, marked out of date. */
310
+ staleAsk?: number | null;
304
311
  /** Every question the lead asked in these messages, by turn: answered ones show their words without buttons. */
305
312
  asks?: Map<number, LeadAsk>;
313
+ /** The person's lead queue rows for these messages, by message id (docs/plans/lead-message-queue.md). */
314
+ queue?: Map<number, LeadQueueRow>;
306
315
  };
307
316
  /** The lead's question as buttons: each option sends itself as the next message (the same POST as a typed one);
308
317
  * "Something else" moves to the composer. Nothing shows while a reply is running. */
309
318
  export declare function leadAskHtml(ask: LeadAsk, target: {
310
319
  task: string | null;
311
320
  project: string | null;
312
- }, composer: string, open?: boolean): Html;
321
+ }, composer: string, open?: boolean, stale?: boolean): Html;
313
322
  /** The thread's messages as the React conversation renders them, each
314
323
  * with the proposal cards its turn produced; `back` is where a card's
315
324
  * confirm or dismiss returns. */
316
- export declare function leadBrowserMessages(rows: Pick<LeadThreadRows, "messages" | "proposals" | "decisions" | "pending" | "ask" | "asks">, back: string | null, target?: {
325
+ export declare function leadBrowserMessages(rows: Pick<LeadThreadRows, "messages" | "proposals" | "decisions" | "pending" | "ask" | "staleAsk" | "asks" | "queue">, back: string | null, target?: {
317
326
  task: string | null;
318
327
  project: string | null;
319
328
  }): import("../browser-workspace.js").BrowserMessage[];