toolroll 0.9.53 → 0.9.55

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 (254) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +5 -4
  3. package/dist/agent-onboard.js +4 -1
  4. package/dist/approval-rules-ui.d.ts +3 -2
  5. package/dist/approval-rules-ui.js +15 -20
  6. package/dist/assignment-ui.d.ts +3 -2
  7. package/dist/assignment-ui.js +4 -11
  8. package/dist/backup-ui.d.ts +2 -1
  9. package/dist/backup-ui.js +16 -27
  10. package/dist/brand-mark.d.ts +3 -2
  11. package/dist/brand-mark.js +4 -4
  12. package/dist/browser/workspace.css +1 -1
  13. package/dist/browser/workspace.js +25 -25
  14. package/dist/browser-shell.d.ts +2 -1
  15. package/dist/browser-shell.js +9 -9
  16. package/dist/browser-workspace.d.ts +2 -3
  17. package/dist/browser-workspace.js +0 -3
  18. package/dist/chat-ask.js +8 -8
  19. package/dist/chat-core.d.ts +171 -0
  20. package/dist/chat-core.js +288 -0
  21. package/dist/chat-decide.d.ts +10 -8
  22. package/dist/chat-decide.js +26 -21
  23. package/dist/chat-delivery-state.d.ts +51 -75
  24. package/dist/chat-delivery-state.js +140 -170
  25. package/dist/chat-delivery.d.ts +21 -2
  26. package/dist/chat-delivery.js +313 -194
  27. package/dist/chat-flow.d.ts +29 -1
  28. package/dist/chat-flow.js +24 -50
  29. package/dist/chat-messages.d.ts +319 -0
  30. package/dist/chat-messages.js +841 -0
  31. package/dist/chat-migration.d.ts +30 -0
  32. package/dist/chat-migration.js +802 -0
  33. package/dist/chat-polish.d.ts +5 -4
  34. package/dist/chat-polish.js +9 -10
  35. package/dist/chat-quiet.d.ts +3 -0
  36. package/dist/chat-quiet.js +5 -0
  37. package/dist/check-levels-ui.d.ts +2 -1
  38. package/dist/check-levels-ui.js +26 -53
  39. package/dist/cli.d.ts +3 -1
  40. package/dist/cli.js +9 -3
  41. package/dist/coding-shipping-ui.d.ts +2 -1
  42. package/dist/coding-shipping-ui.js +6 -6
  43. package/dist/coding-ui.d.ts +3 -1
  44. package/dist/coding-ui.js +30 -29
  45. package/dist/contracts/chat-content.d.ts +71 -2
  46. package/dist/contracts/chat-content.js +22 -3
  47. package/dist/contracts/chat-tables.d.ts +11 -4
  48. package/dist/contracts/chat-tables.js +165 -87
  49. package/dist/contracts/cli.d.ts +1 -3
  50. package/dist/contracts/console-api.d.ts +1 -1
  51. package/dist/contracts/console-api.js +6 -5
  52. package/dist/contracts/discord-callback.js +2 -1
  53. package/dist/contracts/health.d.ts +1 -3
  54. package/dist/contracts/slack-callback.js +3 -2
  55. package/dist/contracts/store-json.d.ts +1 -5
  56. package/dist/contracts/store-json.js +1 -4
  57. package/dist/contracts/teams-callback.js +2 -1
  58. package/dist/control-ui.d.ts +5 -4
  59. package/dist/control-ui.js +9 -34
  60. package/dist/credentials-ui.d.ts +5 -4
  61. package/dist/credentials-ui.js +23 -24
  62. package/dist/demo-chat.d.ts +3 -3
  63. package/dist/demo-chat.js +29 -76
  64. package/dist/discord-chat.js +19 -38
  65. package/dist/discord-settings.d.ts +3 -2
  66. package/dist/discord-settings.js +21 -55
  67. package/dist/discord.js +4 -14
  68. package/dist/envelope.js +10 -0
  69. package/dist/evidence-pack.d.ts +2 -1
  70. package/dist/evidence-pack.js +45 -49
  71. package/dist/export-ui.d.ts +7 -2
  72. package/dist/export-ui.js +4 -10
  73. package/dist/export.js +4 -4
  74. package/dist/flow-gallery-ui.d.ts +4 -3
  75. package/dist/flow-gallery-ui.js +28 -43
  76. package/dist/flow-starters-ui.d.ts +2 -1
  77. package/dist/flow-starters-ui.js +12 -15
  78. package/dist/flow-triggers.d.ts +1 -0
  79. package/dist/flow-triggers.js +16 -14
  80. package/dist/flows-ui.d.ts +4 -3
  81. package/dist/flows-ui.js +19 -30
  82. package/dist/flows.js +2 -1
  83. package/dist/guides.js +108 -3
  84. package/dist/html.d.ts +76 -0
  85. package/dist/html.js +173 -0
  86. package/dist/integrations-ui.d.ts +2 -1
  87. package/dist/integrations-ui.js +19 -23
  88. package/dist/kits-ui.d.ts +5 -4
  89. package/dist/kits-ui.js +18 -20
  90. package/dist/knowledge-ui.d.ts +6 -5
  91. package/dist/knowledge-ui.js +26 -25
  92. package/dist/lead-context.d.ts +11 -1
  93. package/dist/lead-context.js +6 -8
  94. package/dist/ledger-view.d.ts +2 -1
  95. package/dist/ledger-view.js +27 -29
  96. package/dist/limits-ui.d.ts +2 -1
  97. package/dist/limits-ui.js +7 -7
  98. package/dist/live-bus.d.ts +50 -8
  99. package/dist/live-bus.js +133 -50
  100. package/dist/mate-tools.js +3 -2
  101. package/dist/mcp-oauth.js +35 -36
  102. package/dist/models-ui.d.ts +2 -1
  103. package/dist/models-ui.js +25 -35
  104. package/dist/modes.d.ts +13 -1
  105. package/dist/modes.js +17 -1
  106. package/dist/monitoring-ui.d.ts +4 -3
  107. package/dist/monitoring-ui.js +13 -26
  108. package/dist/notification-rows.d.ts +30 -0
  109. package/dist/notification-rows.js +102 -0
  110. package/dist/openrouter-models.d.ts +2 -1
  111. package/dist/openrouter-models.js +5 -9
  112. package/dist/operate.js +16 -9
  113. package/dist/people-audit-ui.d.ts +2 -1
  114. package/dist/people-audit-ui.js +13 -13
  115. package/dist/plane-review.js +5 -11
  116. package/dist/policy-ui.d.ts +3 -2
  117. package/dist/policy-ui.js +17 -23
  118. package/dist/project-delete-ui.d.ts +4 -3
  119. package/dist/project-delete-ui.js +15 -27
  120. package/dist/project-delete.js +21 -26
  121. package/dist/pull-request-ui.d.ts +2 -1
  122. package/dist/pull-request-ui.js +17 -32
  123. package/dist/recipe-ui.d.ts +8 -7
  124. package/dist/recipe-ui.js +39 -32
  125. package/dist/release-adapters.d.ts +60 -0
  126. package/dist/release-adapters.js +435 -0
  127. package/dist/release-cli.d.ts +24 -0
  128. package/dist/release-cli.js +181 -0
  129. package/dist/release-gate.d.ts +80 -0
  130. package/dist/release-gate.js +150 -0
  131. package/dist/release-links.d.ts +29 -0
  132. package/dist/release-links.js +102 -0
  133. package/dist/release.d.ts +331 -0
  134. package/dist/release.js +550 -0
  135. package/dist/reply-shape.d.ts +2 -1
  136. package/dist/reply-shape.js +5 -5
  137. package/dist/repository-context-ui.d.ts +2 -1
  138. package/dist/repository-context-ui.js +2 -7
  139. package/dist/request-budget-ui.d.ts +2 -1
  140. package/dist/request-budget-ui.js +16 -16
  141. package/dist/result-review.d.ts +2 -3
  142. package/dist/result-review.js +3 -9
  143. package/dist/result-shots.js +2 -2
  144. package/dist/retention-ui.d.ts +7 -1
  145. package/dist/retention-ui.js +7 -11
  146. package/dist/retention.js +1 -1
  147. package/dist/serve.d.ts +11 -2
  148. package/dist/serve.js +69 -58
  149. package/dist/server/chat.d.ts +3 -6
  150. package/dist/server/chat.js +969 -1006
  151. package/dist/server/chrome.d.ts +291 -0
  152. package/dist/server/chrome.js +747 -0
  153. package/dist/server/console.css +2819 -0
  154. package/dist/server/flows.d.ts +2 -6
  155. package/dist/server/flows.js +840 -766
  156. package/dist/server/guards.d.ts +1 -1
  157. package/dist/server/guards.js +5 -2
  158. package/dist/server/handler-context.d.ts +1 -1
  159. package/dist/server/handler-context.js +1 -1
  160. package/dist/server/handler-registry.d.ts +8 -6
  161. package/dist/server/handler-registry.js +14 -2
  162. package/dist/server/http.d.ts +274 -0
  163. package/dist/server/http.js +239 -0
  164. package/dist/server/live.d.ts +34 -0
  165. package/dist/server/live.js +309 -0
  166. package/dist/server/pages.d.ts +2 -6
  167. package/dist/server/pages.js +808 -789
  168. package/dist/server/people-tokens.d.ts +1 -6
  169. package/dist/server/people-tokens.js +580 -572
  170. package/dist/server/remote.d.ts +4 -7
  171. package/dist/server/remote.js +37 -70
  172. package/dist/server/render-chat.d.ts +402 -0
  173. package/dist/server/render-chat.js +927 -0
  174. package/dist/server/render-pages.d.ts +535 -0
  175. package/dist/server/render-pages.js +1277 -0
  176. package/dist/server/render-people.d.ts +32 -0
  177. package/dist/server/render-people.js +121 -0
  178. package/dist/server/render-results.d.ts +744 -0
  179. package/dist/server/render-results.js +2237 -0
  180. package/dist/server/render-settings.d.ts +147 -0
  181. package/dist/server/render-settings.js +577 -0
  182. package/dist/server/render-tasks.d.ts +632 -0
  183. package/dist/server/render-tasks.js +2345 -0
  184. package/dist/server/request-context.d.ts +46 -0
  185. package/dist/server/request-context.js +17 -0
  186. package/dist/server/route-table.d.ts +25 -3
  187. package/dist/server/route-table.js +163 -75
  188. package/dist/server/runtime.d.ts +15 -7
  189. package/dist/server/runtime.js +7 -1
  190. package/dist/server/session.d.ts +138 -0
  191. package/dist/server/session.js +94 -0
  192. package/dist/server/settings.d.ts +1 -5
  193. package/dist/server/settings.js +2182 -2117
  194. package/dist/server/task-views.d.ts +11 -4
  195. package/dist/server/task-views.js +26 -22
  196. package/dist/server/tasks.d.ts +9 -9
  197. package/dist/server/tasks.js +1486 -1418
  198. package/dist/server-telemetry.d.ts +4 -3
  199. package/dist/server-telemetry.js +4 -8
  200. package/dist/skills-ui.d.ts +4 -3
  201. package/dist/skills-ui.js +14 -17
  202. package/dist/skills.d.ts +1 -1
  203. package/dist/skills.js +2 -1
  204. package/dist/slack-api.d.ts +5 -1
  205. package/dist/slack-api.js +2 -2
  206. package/dist/slack-chat.d.ts +9 -4
  207. package/dist/slack-chat.js +29 -52
  208. package/dist/slack-settings.d.ts +3 -2
  209. package/dist/slack-settings.js +23 -57
  210. package/dist/slack.js +6 -16
  211. package/dist/spend-ui.d.ts +2 -1
  212. package/dist/spend-ui.js +9 -32
  213. package/dist/sso-ui.d.ts +3 -2
  214. package/dist/sso-ui.js +26 -27
  215. package/dist/storage-ui.d.ts +2 -1
  216. package/dist/storage-ui.js +14 -33
  217. package/dist/store.d.ts +97 -364
  218. package/dist/store.js +207 -1531
  219. package/dist/surface.js +14 -3
  220. package/dist/task-composer.d.ts +2 -1
  221. package/dist/task-composer.js +7 -7
  222. package/dist/task-status.d.ts +4 -3
  223. package/dist/task-status.js +15 -16
  224. package/dist/team-http.d.ts +5 -9
  225. package/dist/team-http.js +5 -63
  226. package/dist/team-ui.d.ts +2 -1
  227. package/dist/team-ui.js +2 -5
  228. package/dist/teammate-question.js +12 -12
  229. package/dist/teammates-ui.d.ts +5 -4
  230. package/dist/teammates-ui.js +61 -90
  231. package/dist/teams-chat.js +12 -9
  232. package/dist/teams-settings.d.ts +3 -2
  233. package/dist/teams-settings.js +22 -33
  234. package/dist/teams.js +4 -5
  235. package/dist/telegram-flow.js +5 -5
  236. package/dist/telegram-settings.d.ts +3 -2
  237. package/dist/telegram-settings.js +14 -25
  238. package/dist/telegram.js +4 -4
  239. package/dist/toolroll-update-ui.d.ts +3 -2
  240. package/dist/toolroll-update-ui.js +35 -39
  241. package/dist/toolroll-update.d.ts +10 -0
  242. package/dist/toolroll-update.js +50 -3
  243. package/dist/tools-ui.d.ts +5 -2
  244. package/dist/tools-ui.js +31 -31
  245. package/dist/when-html.d.ts +11 -3
  246. package/dist/when-html.js +5 -6
  247. package/dist/workspace-revision.js +3 -1
  248. package/dist/workspace-ui.d.ts +4 -2
  249. package/dist/workspace-ui.js +10 -10
  250. package/package.json +1 -1
  251. package/dist/server/shared.d.ts +0 -3151
  252. package/dist/server/shared.js +0 -12001
  253. package/dist/slack-state.d.ts +0 -13
  254. package/dist/slack-state.js +0 -10
@@ -1,3 +1,4 @@
1
+ import { handlersOf } from './handler-registry.js';
1
2
  /** chat handlers, moved without changing their route bodies. */
2
3
  import { randomBytes } from "node:crypto";
3
4
  import { chmodSync, rmSync as rmFileSync, writeFileSync as writeFsFileSync } from "node:fs";
@@ -7,7 +8,6 @@ import { assignmentOf, checkAssignmentAsOperator } from '../assignment.js';
7
8
  import { browserWorkActionHref } from '../browser-workspace.js';
8
9
  import { CHAT_ACTIONS, mintSharedActionReview, sharedActionPayload, sharedActionReviewPath } from '../chat-actions.js';
9
10
  import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
10
- import { hiddenFields } from "../control-ui.js";
11
11
  import { buildDataDocument, composeRequest, isSubscriptionChatProvider, parseAssistantEnvelope, performChatRequest, plausibleChatKey, priceOf, settleForPrice, TURN_WALL_CLOCK_MS, worstCaseForPrice } from "../converse.js";
12
12
  import { DEMO_CHAT_SCRIPT, demoChatHtml, demoThreadHtml } from "../demo-chat.js";
13
13
  import { withDispatchDiagnoses } from "../dispatch.js";
@@ -33,1023 +33,979 @@ import { runActivityOf } from "../task-activity.js";
33
33
  import { teamWorkspaceHtml } from '../team-ui.js';
34
34
  import { loadBotToken, redactToken } from "../telegram.js";
35
35
  import { WORK_INDEX_MAX_LIMIT, workIndexPage } from "../work-index.js";
36
- import { chatAckPage, chatPage, chatResultHref, chatReturnWithLatest, chatReturnWithSaid, completionForm, coordinatorProposalsSection, decisionsFor, escape, homePhaseWords, mateAfterComposerHtml, mateChatVersion, mateMintCard, matePage, mateThreadHtml, oneLineUa, owedAcceptanceOf, PHONE_CARD_FACT, projectChatHref, redirect, refuse, requestContext, respond, resultPanelHtml, safeChatReturn, safeReturn, screen, TASK_COMPOSER_MODES, taskChatHref, taskChatLiveRegion, taskHref, teamProposalCardParts } from "./shared.js";
36
+ import { html, htmlString, postForm } from "../html.js";
37
+ import { requestContext } from "./request-context.js";
38
+ import { refuse, screen } from "./chrome.js";
39
+ import { chatResultHref, chatReturnWithLatest, chatReturnWithSaid, projectChatHref, redirect, respond, safeChatReturn, safeReturn, taskChatHref, taskHref } from "./http.js";
40
+ import { chatAckPage, chatPage, coordinatorProposalsSection, decisionsFor, mateAfterComposerHtml, mateChatVersion, mateMintCard, matePage, mateThreadHtml, PHONE_CARD_FACT, TASK_COMPOSER_MODES, taskChatLiveRegion, teamProposalCardParts } from "./render-chat.js";
41
+ import {} from "./render-pages.js";
42
+ import { completionForm, homePhaseWords, owedAcceptanceOf, resultPanelHtml } from "./render-results.js";
43
+ import { browserCaller, oneLineUa } from "./session.js";
37
44
  export function createChatHandlers(runtime) {
38
- const { firstRunStepsNow, managedRepos, leadWords, store, options, phoneSetup, visible, liveTurns, mateSaid, evidenceRoot, clock, sessions, CHAT_CANDIDATE_TTL_MS, chatFetcher, CHAT_CANDIDATES_PER_APPROVER, chatCeilingDigest, workAccess, familyOf, firstTasks, matePrincipal, taskChatFocus, chatScopeOf, chatStreams, mateConversationRows, demoLeadHere, sendScreen, chromeFor, teamBrowserReply, team, teamChatProvider, runVisible, runIsLive, resultDetailOf, pullRequestTargetOf, chatEnablement, startMateConversation, projectFamilyPeek, needsYouBadge, liveRefreshSeconds, chatKeyFor, chatCatalog, providerHome, mintApprovalNonce, checkLocalAgents, agentSignInCommand, bustBadge, authenticateApprover, revisionDestination, armTaskResume, consumeApprovalNonce } = runtime;
39
- async function get(ctx) {
40
- const { url, who, request, response, now, project, chosenProject, posted, route } = ctx;
41
- if (url.pathname === "/chat/stream") {
42
- // The live reply (chat streaming): server-sent snapshots of the turn
43
- // answering in this person's thread for the same task or project the
44
- // send names. A refresh hint and a preview only — the finished
45
- // message is read the usual way. No turn running: one closing event.
46
- if (who.via !== "cookie" || who.role !== "approver")
47
- return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
48
- const principal = matePrincipal(who);
49
- if (principal === null)
50
- return respond(response, 403, "application/json", JSON.stringify({ error: "standing" }));
51
- const requestedTask = url.searchParams.get("task");
52
- const focusTask = requestedTask === null || requestedTask === "" ? null : taskChatFocus(requestedTask, now, who, { mintNonce: false });
53
- if (requestedTask !== null && requestedTask !== "" && focusTask === null)
54
- return respond(response, 404, "application/json", JSON.stringify({ error: "task" }));
55
- const chatProject = focusTask !== null ? null : chatProjectOf(url.searchParams.getAll("project").filter(one => one !== ""));
56
- if (chatProject === undefined)
57
- return respond(response, 404, "application/json", JSON.stringify({ error: "project" }));
58
- const thread = store.liveMateThreadFor(who.name, chatScopeOf(focusTask, chatProject));
59
- const live = thread === null ? undefined : liveTurns.get(thread.id);
60
- response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", "x-content-type-options": "nosniff", "x-accel-buffering": "no" });
61
- const snapshot = () => `event: turn\ndata: ${JSON.stringify({ steps: live?.steps ?? [], done: live === undefined || live.done, ok: live?.ok ?? false })}\n\n`;
62
- if (live === undefined || live.done) {
63
- response.end(snapshot());
64
- return;
65
- }
66
- chatStreams.add(response);
67
- let queued = null;
68
- const heartbeat = setInterval(() => { response.write(": keep-alive\n\n"); }, 15_000);
69
- heartbeat.unref();
70
- const close = () => {
71
- clearInterval(heartbeat);
72
- if (queued !== null)
73
- clearTimeout(queued);
74
- live.listeners.delete(listener);
75
- chatStreams.delete(response);
76
- if (!response.writableEnded)
77
- response.end();
78
- };
79
- // At most one snapshot every 80 ms; the last one always goes out.
80
- const flush = () => {
81
- queued = null;
82
- if (response.writableEnded)
83
- return;
84
- response.write(snapshot());
85
- if (live.done)
86
- close();
87
- };
88
- const listener = () => {
89
- if (live.done) {
90
- if (queued !== null)
91
- clearTimeout(queued);
92
- flush();
93
- return;
94
- }
95
- if (queued === null)
96
- queued = setTimeout(flush, 80);
97
- };
98
- live.listeners.add(listener);
99
- response.write(snapshot());
100
- request.once("close", close);
101
- response.once("close", close);
102
- return;
103
- }
104
- if (url.pathname === "/chat/mate/status") {
105
- // The read-only refresh (package 2): the same JSON status as before —
106
- // session binding, the send receipt, the live turn — plus a version
107
- // of the DISPLAYED facts and, only when the caller's version differs,
108
- // the server-rendered thread / task fragments the page swaps into
109
- // its safe regions. No-store, cookie-and-approver only, the same
110
- // admission and ceiling checks as the page; nothing here writes.
111
- if (who.via !== "cookie" || who.role !== "approver")
112
- return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
113
- const session = store.activeMateSession(who.name);
114
- const principal = matePrincipal(who);
115
- if (session === null || principal === null || session.ceilingDigest !== principal.ceilingDigest || session.approverGeneration !== principal.generation) {
116
- return respond(response, 200, "application/json", JSON.stringify({ session: null }));
117
- }
118
- store.sweepStaleMateTurns(now);
119
- const request = url.searchParams.get("request") ?? "";
120
- const receipt = /^[a-f0-9]{32}$/.test(request) ? store.mateRequestReceipt(session.id, request) : null;
121
- const requestedTask = url.searchParams.get("task");
122
- const focusTask = requestedTask === null || requestedTask === "" ? null : taskChatFocus(requestedTask, now, who, { mintNonce: false });
123
- if (requestedTask !== null && requestedTask !== "" && focusTask === null) {
124
- // The lens's task is gone or no longer admitted: the page keeps its
125
- // draft and says so; it never falls back to the unified thread.
126
- return respond(response, 200, "application/json", JSON.stringify({ session: session.id, task: requestedTask, unavailable: true, received: receipt !== null }));
127
- }
128
- const chatProject = focusTask !== null ? null : chatProjectOf(url.searchParams.getAll("project").filter(one => one !== ""));
129
- if (chatProject === undefined)
130
- return respond(response, 200, "application/json", JSON.stringify({ session: session.id, unavailable: true, received: receipt !== null }));
131
- const rows = mateConversationRows(who, principal, focusTask, now, chatProject);
132
- const version = mateChatVersion({ ...rows, focusTask });
133
- const known = url.searchParams.get("version") ?? "";
134
- const csrf = who.session.csrf;
135
- const fragments = known === version
136
- ? null
137
- : {
138
- thread: mateThreadHtml({ ...rows, focusTask, csrf, now, problem: takeMateNote(csrf, session.id), chatProject }),
139
- after: mateAfterComposerHtml({ messages: rows.messages, pending: rows.pending, focusTask, csrf }),
140
- live: focusTask === null ? null : taskChatLiveRegion(focusTask, csrf, true, rows.pending !== null),
141
- };
142
- return respond(response, 200, "application/json", JSON.stringify({
143
- session: session.id,
144
- task: focusTask?.id ?? "",
145
- version,
146
- pending: rows.pending !== null,
147
- received: receipt !== null,
148
- approval: focusTask?.approval?.digest ?? "",
149
- ...(fragments === null ? {} : { fragments }),
150
- }));
45
+ const { firstRunStepsNow, managedRepos, leadWords, store, options, phoneSetup, visible, liveTurns, mateSaid, evidenceRoot, clock, sessions, CHAT_CANDIDATE_TTL_MS, chatFetcher, CHAT_CANDIDATES_PER_APPROVER, chatCeilingDigest, workAccess, familyOf, firstTasks, matePrincipal, taskChatFocus, chatScopeOf, mateConversationRows, demoLeadHere, sendScreen, chromeFor, teamBrowserReply, team, teamChatProvider, runVisible, runIsLive, resultDetailOf, pullRequestTargetOf, chatEnablement, startMateConversation, projectFamilyPeek, needsYouBadge, liveRefreshSeconds, chatKeyFor, chatCatalog, providerHome, mintApprovalNonce, checkLocalAgents, agentSignInCommand, bustBadge, authenticateApprover, revisionDestination, armTaskResume, consumeApprovalNonce } = runtime;
46
+ // The read-only refresh (package 2): the same JSON status as before —
47
+ // session binding, the send receipt, the live turn — plus a version
48
+ // of the DISPLAYED facts and, only when the caller's version differs,
49
+ // the server-rendered thread / task fragments the page swaps into
50
+ // its safe regions. No-store, cookie-and-approver only, the same
51
+ // admission and ceiling checks as the page; nothing here writes.
52
+ async function mateStatus(ctx) {
53
+ const { url, response, now } = ctx;
54
+ const who = browserCaller(ctx.who);
55
+ const session = store.activeMateSession(who.name);
56
+ const principal = matePrincipal(who);
57
+ if (session === null || principal === null || session.ceilingDigest !== principal.ceilingDigest || session.approverGeneration !== principal.generation) {
58
+ return respond(response, 200, "application/json", JSON.stringify({ session: null }));
151
59
  }
152
- // `toolroll demo`: Chat is the scripted lead, never a model.
153
- if (url.pathname === "/chat/demo/live") {
154
- const lead = demoLeadHere();
155
- if (lead === null || who.via !== "cookie")
156
- return respond(response, 404, "application/json", JSON.stringify({ ok: false }));
157
- const exchanges = lead.exchanges();
158
- return respond(response, 200, "application/json", JSON.stringify({
159
- version: lead.version(), working: exchanges.some(one => one.state === "working"),
160
- html: demoThreadHtml(exchanges, who.session.csrf, demoResultView),
161
- }));
60
+ store.sweepStaleMateTurns(now);
61
+ const request = url.searchParams.get("request") ?? "";
62
+ const receipt = /^[a-f0-9]{32}$/.test(request) ? store.mateRequestReceipt(session.id, request) : null;
63
+ const requestedTask = url.searchParams.get("task");
64
+ const focusTask = requestedTask === null || requestedTask === "" ? null : taskChatFocus(requestedTask, now, who, { mintNonce: false });
65
+ if (requestedTask !== null && requestedTask !== "" && focusTask === null) {
66
+ // The lens's task is gone or no longer admitted: the page keeps its
67
+ // draft and says so; it never falls back to the unified thread.
68
+ return respond(response, 200, "application/json", JSON.stringify({ session: session.id, task: requestedTask, unavailable: true, received: receipt !== null }));
162
69
  }
163
- if (url.pathname === "/chat" && demoLeadHere() !== null && who.via === "cookie" &&
70
+ const chatProject = focusTask !== null ? null : chatProjectOf(url.searchParams.getAll("project").filter(one => one !== ""));
71
+ if (chatProject === undefined)
72
+ return respond(response, 200, "application/json", JSON.stringify({ session: session.id, unavailable: true, received: receipt !== null }));
73
+ const rows = mateConversationRows(who, principal, focusTask, now, chatProject);
74
+ const version = mateChatVersion({ ...rows, focusTask });
75
+ const known = url.searchParams.get("version") ?? "";
76
+ const csrf = who.session.csrf;
77
+ const fragments = known === version
78
+ ? null
79
+ : {
80
+ thread: htmlString(mateThreadHtml({ ...rows, focusTask, csrf, now, problem: takeMateNote(csrf, session.id), chatProject })),
81
+ after: htmlString(mateAfterComposerHtml({ messages: rows.messages, pending: rows.pending, focusTask })),
82
+ live: focusTask === null ? null : htmlString(taskChatLiveRegion(focusTask, csrf, true, rows.pending !== null)),
83
+ };
84
+ return respond(response, 200, "application/json", JSON.stringify({
85
+ session: session.id,
86
+ task: focusTask?.id ?? "",
87
+ version,
88
+ pending: rows.pending !== null,
89
+ received: receipt !== null,
90
+ approval: focusTask?.approval?.digest ?? "",
91
+ ...(fragments === null ? {} : { fragments }),
92
+ }));
93
+ }
94
+ // `toolroll demo`: Chat is the scripted lead, never a model.
95
+ async function demoLive(ctx) {
96
+ const { who, response } = ctx;
97
+ const lead = demoLeadHere();
98
+ if (lead === null || who.via !== "cookie")
99
+ return respond(response, 404, "application/json", JSON.stringify({ ok: false }));
100
+ const exchanges = lead.exchanges();
101
+ return respond(response, 200, "application/json", JSON.stringify({
102
+ version: lead.version(), working: exchanges.some(one => one.state === "working"),
103
+ html: htmlString(demoThreadHtml(exchanges, demoResultView)),
104
+ }));
105
+ }
106
+ async function chatView(ctx) {
107
+ const { url, who, response, now } = ctx;
108
+ if (demoLeadHere() !== null && who.via === "cookie" &&
164
109
  !["task", "result", "project", "conversation", "team", "proposal"].some(name => url.searchParams.has(name))) {
165
110
  const lead = demoLeadHere();
166
111
  const said = url.searchParams.get("said")?.slice(0, 300) ?? null;
167
- return sendScreen(response, 200, screen("Chat", demoChatHtml({ exchanges: lead.exchanges(), csrf: who.session.csrf, version: lead.version(), problem: said, resultOf: demoResultView }), {
112
+ return sendScreen(response, 200, screen("Chat", demoChatHtml({ exchanges: lead.exchanges(), version: lead.version(), problem: said, resultOf: demoResultView }), {
168
113
  chrome: chromeFor(null, "chat", undefined, "all"),
169
114
  functional: { script: DEMO_CHAT_SCRIPT, fetches: true },
170
115
  }));
171
116
  }
172
- if (url.pathname === "/chat") {
173
- // Cookie sessions only (Codex v3 review, change 7): drafts live in
174
- // THIS session's memory; a bearer caller has nowhere to keep them.
175
- if (who.via !== "cookie")
176
- return refuse(response, who, 403, "chat is a browser surface — it keeps your drafts in the session");
177
- if (url.searchParams.get('private') !== '1' && !url.searchParams.has('task') && !url.searchParams.has('result') && !url.searchParams.has('project')) {
178
- const reply = teamBrowserReply(await team.execute({ name: who.name, generation: who.session.generation }, {
179
- operation: url.searchParams.has('conversation') ? 'show' : 'list',
180
- args: { ...(url.searchParams.has('conversation') ? { conversationId: url.searchParams.get('conversation') } : {}),
181
- ...(url.searchParams.has('lead') ? { leadId: url.searchParams.get('lead') } : {}) },
182
- }), { name: who.name, generation: who.session.generation }, who.session.csrf);
183
- if (!reply.ok || !reply.snapshot)
184
- return refuse(response, who, 403, reply.message, '/chat?private=1');
185
- if (url.searchParams.has('proposal')) {
186
- const id = Number(url.searchParams.get('proposal')), selected = reply.snapshot.selected;
187
- const proposal = Number.isSafeInteger(id) && id > 0 ? store.getMateProposal(id) : null;
188
- if (!selected || !proposal || proposal.thread !== selected.threadId)
189
- return refuse(response, who, 404, 'This proposal is unavailable.', '/chat');
190
- const back = '/chat?conversation=' + encodeURIComponent(selected.id);
191
- const decision = proposal.kind === 'answer' && typeof proposal.payload['decision'] === 'number' ? store.getDecision(proposal.payload['decision']) : null;
192
- const card = teamProposalCardParts(store, { name: who.name, generation: who.session.generation }, proposal, reply.snapshot, who.session.csrf, decision, clock(), teamChatProvider).html;
193
- return sendScreen(response, 200, screen('Review action', `<p><a href="${escape(back)}">Back to conversation</a></p>` + card, { chrome: chromeFor(null, 'chat', undefined, 'all') }));
194
- }
195
- if (reply.snapshot.leads.length > 0 || url.searchParams.has('conversation') || url.searchParams.get('team') === '1') {
196
- const said = url.searchParams.get('said')?.slice(0, 1_000);
197
- return sendScreen(response, 200, screen('Chat', (said ? `<p role="alert">${escape(said)}</p>` : '') + teamWorkspaceHtml(reply.snapshot), {
198
- chrome: chromeFor(null, 'chat', undefined, 'all'), workspace: { team: reply.snapshot, ...(said ? { notices: [said] } : {}) },
199
- }));
200
- }
201
- }
202
- store.sweepStaleChatTurns(now);
203
- store.sweepStaleMateTurns(now);
204
- store.sweepCoordinatorProposals(now);
205
- sweepChatDrafts(Date.now());
206
- const requestedTask = url.searchParams.get("task");
207
- const focusTask = taskChatFocus(requestedTask, now, who, { mintNonce: !requestContext.getStore()?.workspaceRead });
208
- // A project's own thread (v77): /chat?project=<path>. A task lens wins.
209
- const chatProject = focusTask !== null ? null : chatProjectOf(url.searchParams.getAll("project"));
210
- if (chatProject === undefined)
211
- return refuse(response, who, 404, "That project is not available in this workspace.", "/chat");
212
- if (focusTask !== null && requestedTask !== focusTask.id) {
213
- url.searchParams.set("task", focusTask.id);
214
- return redirect(response, `/chat?${url.searchParams.toString()}`);
215
- }
216
- // A seal receipt names the exact child it returned. Reopening an old
217
- // receipt must not present a newer revision's approval as its answer.
218
- const requestedRevision = url.searchParams.get("revision");
219
- if (requestedRevision !== null) {
220
- if (focusTask === null || !focusTask.family.versions.some(one => one.id === requestedRevision) || url.searchParams.has("result")) {
221
- return refuse(response, who, 404, "That revision is not available for this task.", "/work");
222
- }
223
- if (requestedRevision !== focusTask.executionId)
224
- return redirect(response, `${taskHref(focusTask.id)}?version=${encodeURIComponent(requestedRevision)}`);
225
- }
226
- // The result detail (package 3): `?result=<run>` names one finished
227
- // build OF THIS TASK; anything else — another task's run, a live or
228
- // failed attempt, a number that is no run — says so and shows the
229
- // conversation alone. The task lens is never inferred from the run.
230
- const requestedResult = url.searchParams.get("result");
231
- const resultRun = (() => {
232
- if (focusTask === null || requestedResult === null || !/^[0-9]{1,15}$/.test(requestedResult))
233
- return null;
234
- const found = store.getRun(Number(requestedResult));
235
- if (found === null || !runVisible(found) || !focusTask.family.versions.some(one => one.refId === found.taskRef))
236
- return null;
237
- if (runIsLive(found) || (found.outcome !== "built" && found.outcome !== "no-change"))
238
- return null;
239
- return found;
240
- })();
241
- const roomId = url.searchParams.get('conversation');
242
- const resultLink = (href) => roomId ? href + '&conversation=' + encodeURIComponent(roomId) : href;
243
- // Accept and finish is here: the one act that accepts the person's own checks and finishes the task.
244
- const finishes = resultRun !== null && who.role === 'approver' && focusTask?.assignment?.state === 'ready-to-check' && focusTask.assignment.receipt?.runId === resultRun.id;
245
- const resultPanel = resultRun === null
246
- ? null
247
- : resultPanelHtml(resultDetailOf(resultRun, who, now), {
248
- place: "chat",
249
- tab: parseResultTab(url.searchParams.get("tab")),
250
- csrf: who.session.csrf,
251
- user: who.name,
252
- noted: url.searchParams.get("noted") !== null,
253
- requestToken: randomBytes(16).toString("hex"),
254
- hrefFor: one => resultLink(chatResultHref(focusTask?.id ?? "", resultRun.id, one)),
255
- returnTo: resultLink(chatResultHref(focusTask?.id ?? "", resultRun.id)),
256
- back: { href: roomId ? "/chat?conversation=" + encodeURIComponent(roomId) : taskChatHref(focusTask?.id ?? ""), label: "Back to chat" },
257
- finishes,
258
- }) + (finishes && focusTask?.assignment?.receipt != null
259
- ? completionForm(focusTask.assignment.receipt.taskId, resultRun.id, focusTask.assignment.receipt.digest, who.session.csrf, pullRequestTargetOf(resultRun.id), owedAcceptanceOf(focusTask.assignment.receipt)) : '');
260
- const focusProblem = requestedTask !== null && focusTask === null
261
- ? "That task is not available in this workspace."
262
- : requestedResult !== null && focusTask !== null && resultRun === null
263
- ? "That result is not available for this task. The conversation is shown without it."
264
- : null;
265
- if (roomId) {
266
- const shared = await team.execute({ name: who.name, generation: who.session.generation }, { operation: 'show', args: { conversationId: roomId } });
267
- if (!shared.ok || !shared.snapshot?.selected)
268
- return refuse(response, who, 404, shared.message, '/chat');
269
- if (focusTask && !shared.snapshot.selected.projects.includes(store.lookupRef(focusTask.executionId)?.repo ?? ''))
270
- return refuse(response, who, 404, 'This task is outside the conversation’s projects.', '/chat?conversation=' + encodeURIComponent(roomId));
271
- return sendScreen(response, 200, screen('Chat', teamWorkspaceHtml(shared.snapshot) + (resultPanel ?? ''), {
272
- chrome: chromeFor(null, 'chat', undefined, 'all'), functional: { script: RESULT_REVIEW_SCRIPT, fetches: true },
273
- workspace: { team: shared.snapshot,
274
- focus: focusTask ? { id: focusTask.id, title: focusTask.title, html: taskChatLiveRegion(focusTask, who.session.csrf, true, false, taskHref(focusTask.executionId) + "#approve") } : null,
275
- result: resultPanel && resultRun ? { runId: resultRun.id, html: resultPanel } : null,
276
- notices: focusProblem ? [focusProblem] : [],
277
- },
117
+ // Cookie sessions only (Codex v3 review, change 7): drafts live in
118
+ // THIS session's memory; a bearer caller has nowhere to keep them.
119
+ if (who.via !== "cookie")
120
+ return refuse(response, who, 403, "chat is a browser surface — it keeps your drafts in the session");
121
+ if (url.searchParams.get('private') !== '1' && !url.searchParams.has('task') && !url.searchParams.has('result') && !url.searchParams.has('project')) {
122
+ const reply = teamBrowserReply(await team.execute({ name: who.name, generation: who.session.generation }, {
123
+ operation: url.searchParams.has('conversation') ? 'show' : 'list',
124
+ args: { ...(url.searchParams.has('conversation') ? { conversationId: url.searchParams.get('conversation') } : {}),
125
+ ...(url.searchParams.has('lead') ? { leadId: url.searchParams.get('lead') } : {}) },
126
+ }), { name: who.name, generation: who.session.generation }, who.session.csrf);
127
+ if (!reply.ok || !reply.snapshot)
128
+ return refuse(response, who, 403, reply.message, '/chat?private=1');
129
+ if (url.searchParams.has('proposal')) {
130
+ const id = Number(url.searchParams.get('proposal')), selected = reply.snapshot.selected;
131
+ const proposal = Number.isSafeInteger(id) && id > 0 ? store.getMateProposal(id) : null;
132
+ if (!selected || !proposal || proposal.thread !== selected.threadId)
133
+ return refuse(response, who, 404, 'This proposal is unavailable.', '/chat');
134
+ const back = '/chat?conversation=' + encodeURIComponent(selected.id);
135
+ const decision = proposal.kind === 'answer' && typeof proposal.payload['decision'] === 'number' ? store.getDecision(proposal.payload['decision']) : null;
136
+ const card = teamProposalCardParts(store, { name: who.name, generation: who.session.generation }, proposal, reply.snapshot, who.session.csrf, decision, clock(), teamChatProvider).html;
137
+ return sendScreen(response, 200, screen('Review action', html `<p><a href="${back}">Back to conversation</a></p>${card}`, { chrome: chromeFor(null, 'chat', undefined, 'all') }));
138
+ }
139
+ if (reply.snapshot.leads.length > 0 || url.searchParams.has('conversation') || url.searchParams.get('team') === '1') {
140
+ const said = url.searchParams.get('said')?.slice(0, 1_000);
141
+ return sendScreen(response, 200, screen('Chat', html `${said ? html `<p role="alert">${said}</p>` : ''}${teamWorkspaceHtml(reply.snapshot)}`, {
142
+ chrome: chromeFor(null, 'chat', undefined, 'all'), workspace: { team: reply.snapshot, ...(said ? { notices: [said] } : {}) },
278
143
  }));
279
144
  }
280
- // Pending cards, and the recently answered ones so the door's words are read (last 30).
281
- const repos = managedRepos();
282
- const allCoordinatorRows = who.role === "approver" ? store.listCoordinatorProposals({ repos, states: ["pending", "confirmed", "refused"], limit: 30 }) : [];
283
- const coordinatorRows = focusTask === null
284
- ? allCoordinatorRows
285
- : allCoordinatorRows.filter(one => focusTask.family.versions.some(version => version.id === one.payload["task"]));
286
- const enabled = chatEnablement();
287
- const pending = store.liveChatTurnFor(who.name);
288
- const latched = enabled.ok ? store.latchedChatTurns(enabled.credentialKey) : [];
289
- // The mate (mate arc §5): while a mate session is live, /chat IS the
290
- // thread — the same rows the CLI reads. Without one, fleet chat as
291
- // before, plus the card that mints a session.
292
- let mateSession = enabled.ok && who.role === "approver" ? store.activeMateSession(who.name) : null;
293
- const principal = enabled.ok && who.role === "approver" ? matePrincipal(who) : null;
294
- // Chat opens ready to talk (2026-09-23): an approver in good standing
295
- // needs no second password to start their own conversation. The one
296
- // exception to "a GET writes nothing": the strict same-site session
297
- // cookie keeps other sites out, and starting spends nothing. Actions
298
- // the lead proposes still go through their own confirmation cards.
299
- // Membership chat only: it spends no dollars. Direct-API chat keeps a
300
- // one-tap Start with its visible spending limit (no password).
301
- const settingsView = url.searchParams.get("settings") === "1";
302
- if (!settingsView && enabled.ok && enabled.billing === "subscription" && principal !== null && (mateSession === null || mateSession.ceilingDigest !== principal.ceilingDigest) && !requestContext.getStore()?.workspaceRead) {
303
- startMateConversation(who, principal, enabled, 0, false, now);
304
- mateSession = store.activeMateSession(who.name);
305
- }
306
- // A session under another ceiling is not continuable from here; a GET
307
- // writes nothing (slice-2 review, finding 7) — the mint card below
308
- // starts a new conversation, and minting ends the old session.
309
- const ceilingStale = enabled.ok && mateSession !== null && principal !== null && mateSession.ceilingDigest !== principal.ceilingDigest;
310
- const catchUp = leadBriefHtml(assignmentCatchUp(store, now, { principal: "operator", repos }, { limit: 6 }, evidenceRoot));
311
- const chatProjects = repos.map((repo, index) => {
312
- let peek = null;
313
- try {
314
- peek = projectFamilyPeek(repo, now);
315
- }
316
- catch {
317
- // The project rail is orientation, like chrome's project peek: a
318
- // failed count must not make the conversation itself disappear.
319
- }
320
- return { id: `r${index + 1}`, label: projectName(repo), path: repo, peek };
321
- });
322
- let fleetSnapshot = null;
323
- if (repos.length > 0) {
324
- try {
325
- fleetSnapshot = store.chatSnapshot(repos, now);
326
- const summaries = workIndexPage(store, now, { principal: 'operator', repos, includeUnplaced: false, viewer: who.name }, { limit: 100 }).items;
327
- fleetSnapshot.assignmentStates = Object.fromEntries(summaries.flatMap(value => [value.rootId, value.activeTaskId].map(id => [id, { state: value.assignmentState, label: value.status.label, detail: value.status.detail }])));
328
- fleetSnapshot.attentionCount = needsYouBadge(null);
329
- }
330
- catch {
331
- // The project rail already degrades each pulse independently.
332
- // A failed briefing query must not make the conversation vanish.
333
- }
334
- }
335
- // The first run belongs to the lead conversation, for someone who can act on it.
336
- const firstRun = focusTask === null && who.role === "approver" ? chatFirstRun(now, chatProject) : undefined;
337
- const phone = focusTask === null && who.via === "cookie" ? phoneCard(who, now) : undefined;
338
- // Console v2: the landing's live view (who is working, four counts, Catch up in tabs).
339
- const home = focusTask === null && chatProject === null && !roomId ? chatHomeOf(who, repos, now) : null;
340
- const withFirstRun = (shown) => firstRun === undefined && phone === undefined && home === null ? shown
341
- : { ...shown, ...(home === null ? {} : { refreshSeconds: liveRefreshSeconds() }),
342
- workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
343
- if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
344
- {
345
- const said = takeMateNote(who.session.csrf, mateSession.id);
346
- return sendScreen(response, 200, withFirstRun(matePage(chromeFor(null, "chat", undefined, "all"), {
347
- session: mateSession,
348
- ...mateConversationRows(who, principal, focusTask, now, chatProject),
349
- chatProject,
350
- latched,
351
- config: enabled.config,
352
- turnsToday: store.chatTurnsToday(who.name, now),
353
- weeklySpent: store.chatWeeklySpendMicrousd(enabled.credentialKey, now),
354
- projects: chatProjects,
355
- fleetSnapshot,
356
- catchUp,
357
- follow: leadFollowStatus(store, who.name),
358
- focusTask,
359
- csrf: who.session.csrf,
360
- problem: url.searchParams.get("said") ?? focusProblem ?? said,
361
- now,
362
- resultPanel,
363
- resultRunId: resultRun?.id ?? null,
364
- })));
365
- }
366
- }
367
- return sendScreen(response, 200, withFirstRun(chatPage(chromeFor(null, "chat", undefined, "all"), {
368
- settingsOpen: settingsView,
369
- firstRunShown: firstRun !== undefined,
370
- enabled,
371
- pending,
372
- latched,
373
- chat: who.session.chat ?? null,
374
- recent: store.recentChatTurns(who.name, 10),
375
- turnsToday: store.chatTurnsToday(who.name, now),
376
- weeklySpent: enabled.ok ? store.chatWeeklySpendMicrousd(enabled.credentialKey, now) : 0,
377
- projects: chatProjects,
378
- fleetSnapshot,
379
- catchUp,
380
- focusTask,
381
- canManage: who.role === "approver",
382
- config: store.getChatConfig(),
383
- keyFacts: ["anthropic-api", "openrouter-api"].map(one => {
384
- const found = chatKeyFor(one);
385
- return {
386
- provider: one,
387
- state: found === null ? "none" : found.source,
388
- tail: found === null || found.source === "environment" ? null : redactToken(found.key),
389
- };
390
- }),
391
- openrouterModels: (await chatCatalog())?.map(one => one.id) ?? null,
392
- liveModels: [...modelOptions(store, "claude", now, providerHome), ...modelOptions(store, "codex", now, providerHome)],
145
+ }
146
+ store.sweepStaleChatTurns(now);
147
+ store.sweepStaleMateTurns(now);
148
+ store.sweepCoordinatorProposals(now);
149
+ sweepChatDrafts(Date.now());
150
+ const requestedTask = url.searchParams.get("task");
151
+ const focusTask = taskChatFocus(requestedTask, now, who, { mintNonce: !requestContext.getStore()?.workspaceRead });
152
+ // A project's own thread (v77): /chat?project=<path>. A task lens wins.
153
+ const chatProject = focusTask !== null ? null : chatProjectOf(url.searchParams.getAll("project"));
154
+ if (chatProject === undefined)
155
+ return refuse(response, who, 404, "That project is not available in this workspace.", "/chat");
156
+ if (focusTask !== null && requestedTask !== focusTask.id) {
157
+ url.searchParams.set("task", focusTask.id);
158
+ return redirect(response, `/chat?${url.searchParams.toString()}`);
159
+ }
160
+ // A seal receipt names the exact child it returned. Reopening an old
161
+ // receipt must not present a newer revision's approval as its answer.
162
+ const requestedRevision = url.searchParams.get("revision");
163
+ if (requestedRevision !== null) {
164
+ if (focusTask === null || !focusTask.family.versions.some(one => one.id === requestedRevision) || url.searchParams.has("result")) {
165
+ return refuse(response, who, 404, "That revision is not available for this task.", "/work");
166
+ }
167
+ if (requestedRevision !== focusTask.executionId)
168
+ return redirect(response, `${taskHref(focusTask.id)}?version=${encodeURIComponent(requestedRevision)}`);
169
+ }
170
+ // The result detail (package 3): `?result=<run>` names one finished
171
+ // build OF THIS TASK; anything else — another task's run, a live or
172
+ // failed attempt, a number that is no run — says so and shows the
173
+ // conversation alone. The task lens is never inferred from the run.
174
+ const requestedResult = url.searchParams.get("result");
175
+ const resultRun = (() => {
176
+ if (focusTask === null || requestedResult === null || !/^[0-9]{1,15}$/.test(requestedResult))
177
+ return null;
178
+ const found = store.getRun(Number(requestedResult));
179
+ if (found === null || !runVisible(found) || !focusTask.family.versions.some(one => one.refId === found.taskRef))
180
+ return null;
181
+ if (runIsLive(found) || (found.outcome !== "built" && found.outcome !== "no-change"))
182
+ return null;
183
+ return found;
184
+ })();
185
+ const roomId = url.searchParams.get('conversation');
186
+ const resultLink = (href) => roomId ? href + '&conversation=' + encodeURIComponent(roomId) : href;
187
+ // Accept and finish is here: the one act that accepts the person's own checks and finishes the task.
188
+ const finishes = resultRun !== null && who.role === 'approver' && focusTask?.assignment?.state === 'ready-to-check' && focusTask.assignment.receipt?.runId === resultRun.id;
189
+ const resultPanel = resultRun === null
190
+ ? null
191
+ : html `${resultPanelHtml(resultDetailOf(resultRun, who, now), {
192
+ place: "chat",
193
+ tab: parseResultTab(url.searchParams.get("tab")),
393
194
  csrf: who.session.csrf,
394
- problem: url.searchParams.get("said") ??
395
- focusProblem ??
396
- (ceilingStale ? "Project access changed. Your tasks and results are saved. Start a conversation with the current projects to continue." : null) ??
397
- takeMateNote(who.session.csrf, null),
398
- resultPanel,
399
- ...(enabled.ok && who.role === "approver" ? { mateMint: mateMintCard(who.session.csrf, enabled, focusTask === null ? "/chat" : taskChatHref(focusTask.id)) } : {}),
400
- ...(who.role === "approver" ? { coordinatorProposals: coordinatorProposalsSection(coordinatorRows, decisionsFor(store, coordinatorRows), who.session.csrf, now, true, focusTask === null ? null : taskChatHref(focusTask.id)) } : {}),
401
- })));
402
- }
403
- const chatAck = /^\/chat\/ack\/([0-9]{1,15})$/.exec(url.pathname);
404
- if (chatAck !== null) {
405
- if (who.via !== "cookie")
406
- return refuse(response, who, 403, "acknowledgement is a browser ceremony");
407
- const turn = store.getChatTurn(Number(chatAck[1]));
408
- if (turn === null || !turn.unknownSpend || turn.acknowledgedAt !== null) {
409
- return refuse(response, who, 404, "no acknowledgement is waiting there", "/chat");
195
+ user: who.name,
196
+ noted: url.searchParams.get("noted") !== null,
197
+ requestToken: randomBytes(16).toString("hex"),
198
+ hrefFor: one => resultLink(chatResultHref(focusTask?.id ?? "", resultRun.id, one)),
199
+ returnTo: resultLink(chatResultHref(focusTask?.id ?? "", resultRun.id)),
200
+ back: { href: roomId ? "/chat?conversation=" + encodeURIComponent(roomId) : taskChatHref(focusTask?.id ?? ""), label: "Back to chat" },
201
+ finishes,
202
+ })}${finishes && focusTask?.assignment?.receipt != null
203
+ ? (completionForm(focusTask.assignment.receipt.taskId, resultRun.id, focusTask.assignment.receipt.digest, pullRequestTargetOf(resultRun.id), owedAcceptanceOf(focusTask.assignment.receipt))) : ''}`;
204
+ const focusProblem = requestedTask !== null && focusTask === null
205
+ ? "That task is not available in this workspace."
206
+ : requestedResult !== null && focusTask !== null && resultRun === null
207
+ ? "That result is not available for this task. The conversation is shown without it."
208
+ : null;
209
+ if (roomId) {
210
+ const shared = await team.execute({ name: who.name, generation: who.session.generation }, { operation: 'show', args: { conversationId: roomId } });
211
+ if (!shared.ok || !shared.snapshot?.selected)
212
+ return refuse(response, who, 404, shared.message, '/chat');
213
+ if (focusTask && !shared.snapshot.selected.projects.includes(store.lookupRef(focusTask.executionId)?.repo ?? ''))
214
+ return refuse(response, who, 404, 'This task is outside the conversation’s projects.', '/chat?conversation=' + encodeURIComponent(roomId));
215
+ return sendScreen(response, 200, screen('Chat', html `${teamWorkspaceHtml(shared.snapshot)}${resultPanel}`, {
216
+ chrome: chromeFor(null, 'chat', undefined, 'all'), functional: { script: RESULT_REVIEW_SCRIPT, fetches: true },
217
+ workspace: { team: shared.snapshot,
218
+ focus: focusTask ? { id: focusTask.id, title: focusTask.title, html: htmlString(taskChatLiveRegion(focusTask, who.session.csrf, true, false, taskHref(focusTask.executionId) + "#approve")) } : null,
219
+ result: resultPanel && resultRun ? { runId: resultRun.id, html: htmlString(resultPanel) } : null,
220
+ notices: focusProblem ? [focusProblem] : [],
221
+ },
222
+ }));
223
+ }
224
+ // Pending cards, and the recently answered ones so the door's words are read (last 30).
225
+ const repos = managedRepos();
226
+ const allCoordinatorRows = who.role === "approver" ? store.listCoordinatorProposals({ repos, states: ["pending", "confirmed", "refused"], limit: 30 }) : [];
227
+ const coordinatorRows = focusTask === null
228
+ ? allCoordinatorRows
229
+ : allCoordinatorRows.filter(one => focusTask.family.versions.some(version => version.id === one.payload["task"]));
230
+ const enabled = chatEnablement();
231
+ const pending = store.liveChatTurnFor(who.name);
232
+ const latched = enabled.ok ? store.latchedChatTurns(enabled.credentialKey) : [];
233
+ // The mate (mate arc §5): while a mate session is live, /chat IS the
234
+ // thread — the same rows the CLI reads. Without one, fleet chat as
235
+ // before, plus the card that mints a session.
236
+ let mateSession = enabled.ok && who.role === "approver" ? store.activeMateSession(who.name) : null;
237
+ const principal = enabled.ok && who.role === "approver" ? matePrincipal(who) : null;
238
+ // Chat opens ready to talk (2026-09-23): an approver in good standing
239
+ // needs no second password to start their own conversation. The one
240
+ // exception to "a GET writes nothing": the strict same-site session
241
+ // cookie keeps other sites out, and starting spends nothing. Actions
242
+ // the lead proposes still go through their own confirmation cards.
243
+ // Membership chat only: it spends no dollars. Direct-API chat keeps a
244
+ // one-tap Start with its visible spending limit (no password).
245
+ const settingsView = url.searchParams.get("settings") === "1";
246
+ if (!settingsView && enabled.ok && enabled.billing === "subscription" && principal !== null && (mateSession === null || mateSession.ceilingDigest !== principal.ceilingDigest) && !requestContext.getStore()?.workspaceRead) {
247
+ startMateConversation(who, principal, enabled, 0, false, now);
248
+ mateSession = store.activeMateSession(who.name);
249
+ }
250
+ // A session under another ceiling is not continuable from here; a GET
251
+ // writes nothing (slice-2 review, finding 7) — the mint card below
252
+ // starts a new conversation, and minting ends the old session.
253
+ const ceilingStale = enabled.ok && mateSession !== null && principal !== null && mateSession.ceilingDigest !== principal.ceilingDigest;
254
+ const catchUp = leadBriefHtml(assignmentCatchUp(store, now, { principal: "operator", repos }, { limit: 6 }, evidenceRoot));
255
+ const chatProjects = repos.map((repo, index) => {
256
+ let peek = null;
257
+ try {
258
+ peek = projectFamilyPeek(repo, now);
410
259
  }
411
- // The one nonce in chat — this screen restates exact financial terms
412
- // and re-enables spend, which is precisely what nonces are for.
413
- const nonce = mintApprovalNonce(who.name, `chat-ack-${turn.id}`, String(turn.reservedMicrousd));
414
- return sendScreen(response, 200, chatAckPage(chromeFor(project, "chat"), turn, nonce, who.session.csrf));
415
- }
416
- const sharedReview = /^\/chat\/action\/([0-9]{1,15})$/.exec(url.pathname);
417
- if (sharedReview !== null) {
418
- if (who.via !== 'cookie' || who.role !== 'approver')
419
- return refuse(response, who, 403, 'Sign in to review this action.', '/projects');
420
- const reviewProposal = store.getMateProposal(Number(sharedReview[1]));
421
- const sharedConversation = reviewProposal ? store.handle.prepare('SELECT id FROM team_conversation WHERE thread=?').get(reviewProposal.thread) : null;
422
- let principal = null;
423
- if (sharedConversation) {
424
- try {
425
- const access = team.domain.access({ name: who.name, generation: who.session.generation }, String(sharedConversation['id']), 'contributor');
426
- const checked = verifyApproverStanding(store, who.name, who.session.generation, access.conversation.projects);
427
- principal = checked.ok ? checked.who : null;
428
- }
429
- catch {
430
- return refuse(response, who, 404, 'This proposal is unavailable.', '/chat');
431
- }
260
+ catch {
261
+ // The project rail is orientation, like chrome's project peek: a
262
+ // failed count must not make the conversation itself disappear.
432
263
  }
433
- else
434
- principal = matePrincipal(who);
435
- if (principal === null)
436
- return refuse(response, who, 403, 'Your access changed. Sign in again.', '/chat');
264
+ return { id: `r${index + 1}`, label: projectName(repo), path: repo, peek };
265
+ });
266
+ let fleetSnapshot = null;
267
+ if (repos.length > 0) {
437
268
  try {
438
- const id = Number(sharedReview[1]), saved = store.getMateProposal(id), savedAction = saved?.kind === 'action' ? sharedActionPayload(saved.payload) : null;
439
- if (saved && savedAction && saved.state !== 'pending' && (sharedConversation !== null || store.getMateThread(saved.thread)?.approver === principal.name) && principal.repos.includes(savedAction.repo) && store.accountCanAccess(principal.name, savedAction.repo)) {
440
- const task = typeof saved.outcome?.['taskId'] === 'string' ? saved.outcome['taskId'] : typeof savedAction.request['task'] === 'string' ? savedAction.request['task'] : null;
441
- const destination = task ? taskHref(task) : `/settings/${savedAction.operation.startsWith('skill_') ? 'skills' : 'knowledge'}?repo=${encodeURIComponent(savedAction.repo)}`;
442
- const said = typeof saved.outcome?.['said'] === 'string' ? saved.outcome['said'] : saved.state === 'dismissed' ? 'Action dismissed.' : saved.state === 'expired' ? 'This proposal expired. Ask for a fresh proposal.' : 'This action has no completed outcome yet.';
443
- return sendScreen(response, 200, screen('Action outcome', `<h1>${escape(savedAction.title)}</h1><p>${escape(said)}</p><a class="button-link" href="${escape(destination)}">${task ? 'Open task' : 'Open project settings'}</a>`, { chrome: chromeFor(savedAction.repo, 'chat') }));
444
- }
445
- const review = mintSharedActionReview(store, principal, id, evidenceRoot, clock()), action = review.payload;
446
- const terms = action.terms.map(term => `<p style="white-space:pre-wrap;overflow-wrap:anywhere">${escape(term)}</p>`).join('');
447
- const evidenceLink = action.operation === 'result_accept' ? `<a href="/r/${Number(action.request['run'])}">Inspect this result</a>` : '';
448
- const content = `<section class="shared-action"><h1>${escape(action.title)}</h1>${evidenceLink}${terms}<form method="post" action="/chat/proposal/${id}/confirm">${hiddenFields({ csrf: who.session.csrf, nonce: review.nonce, return: '/chat' })}<label class="arm"><input type="checkbox" name="confirm" value="yes" required>I confirm this exact action</label>${CHAT_ACTIONS[action.operation].password ? '<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>' : ''}<button>${escape(CHAT_ACTIONS[action.operation].label)}</button></form></section>`;
449
- return sendScreen(response, 200, screen('Review action', content, { chrome: chromeFor(action.repo, 'chat') }));
269
+ fleetSnapshot = store.chatSnapshot(repos, now);
270
+ const summaries = workIndexPage(store, now, { principal: 'operator', repos, includeUnplaced: false, viewer: who.name }, { limit: 100 }).items;
271
+ fleetSnapshot.assignmentStates = Object.fromEntries(summaries.flatMap(value => [value.rootId, value.activeTaskId].map(id => [id, { state: value.assignmentState, label: value.status.label, detail: value.status.detail }])));
272
+ fleetSnapshot.attentionCount = needsYouBadge(null);
450
273
  }
451
- catch (error) {
452
- return refuse(response, who, 409, error instanceof Error ? error.message : 'This action could not be reviewed.', '/chat');
274
+ catch {
275
+ // The project rail already degrades each pulse independently.
276
+ // A failed briefing query must not make the conversation vanish.
453
277
  }
454
278
  }
455
- if (url.pathname === "/push/key") {
456
- // Session-gated: the key is not secret, but strangers get nothing.
457
- if (who.via !== "cookie")
458
- return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
459
- const keys = loadOrCreateVapidKeys(dirname(options.telegramTokenFile ?? "."));
460
- response.setHeader("cache-control", "no-store");
461
- return respond(response, 200, "application/json", JSON.stringify({ key: keys.publicKey }));
462
- }
463
- // While no agent is signed in, Chat asks this computer again on its own (onboarding): a fresh, non-spending sign-in
464
- // check at most every few seconds, and the lead turns on as soon as one is found.
465
- if (url.pathname === "/lead/status") {
466
- if (who.via !== "cookie")
467
- return respond(response, 403, "application/json", JSON.stringify({ error: "sign in" }));
468
- if (Date.now() - runtime.leadRecheckedAt > 4_000) {
469
- runtime.leadRecheckedAt = Date.now();
470
- await Promise.race([checkLocalAgents(true), new Promise(done => setTimeout(done, 8_000).unref?.())]);
279
+ // The first run belongs to the lead conversation, for someone who can act on it.
280
+ const firstRun = focusTask === null && who.role === "approver" ? chatFirstRun(now, chatProject) : undefined;
281
+ const phone = focusTask === null && who.via === "cookie" ? phoneCard(who, now) : undefined;
282
+ // Console v2: the landing's live view (who is working, four counts, Catch up in tabs).
283
+ const home = focusTask === null && chatProject === null && !roomId ? chatHomeOf(who, repos, now) : null;
284
+ const withFirstRun = (shown) => firstRun === undefined && phone === undefined && home === null ? shown
285
+ : { ...shown, ...(home === null ? {} : { refreshSeconds: liveRefreshSeconds() }),
286
+ workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
287
+ if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
288
+ {
289
+ const said = takeMateNote(who.session.csrf, mateSession.id);
290
+ return sendScreen(response, 200, withFirstRun(matePage(chromeFor(null, "chat", undefined, "all"), {
291
+ session: mateSession,
292
+ ...mateConversationRows(who, principal, focusTask, now, chatProject),
293
+ chatProject,
294
+ latched,
295
+ config: enabled.config,
296
+ turnsToday: store.chatTurnsToday(who.name, now),
297
+ weeklySpent: store.chatWeeklySpendMicrousd(enabled.credentialKey, now),
298
+ projects: chatProjects,
299
+ fleetSnapshot,
300
+ catchUp,
301
+ follow: leadFollowStatus(store, who.name),
302
+ focusTask,
303
+ csrf: who.session.csrf,
304
+ problem: url.searchParams.get("said") ?? focusProblem ?? said,
305
+ now,
306
+ resultPanel,
307
+ resultRunId: resultRun?.id ?? null,
308
+ })));
471
309
  }
472
- response.setHeader("cache-control", "no-store");
473
- return respond(response, 200, "application/json", JSON.stringify({ lead: store.getChatConfig() === null ? "off" : "on", agent: runtime.localSignIn?.signedIn ?? null, command: agentSignInCommand() }));
474
- }
475
- if (url.pathname === "/chat/task-status") {
476
- if (who.via !== "cookie")
477
- return respond(response, 403, "text/plain; charset=utf-8", "sign in to see this task");
478
- const focus = taskChatFocus(url.searchParams.get("task"), now, who, { mintNonce: false });
479
- if (focus === null)
480
- return respond(response, 404, "text/plain; charset=utf-8", "this task is not available in this workspace");
481
- response.setHeader("cache-control", "no-store");
482
- return respond(response, 200, "text/html; charset=utf-8", taskChatLiveRegion(focus, who.session.csrf, true));
483
- }
484
- return refuse(response, who, 404, "There's no page at this address.", "/chat");
310
+ }
311
+ return sendScreen(response, 200, withFirstRun(chatPage(chromeFor(null, "chat", undefined, "all"), {
312
+ settingsOpen: settingsView,
313
+ firstRunShown: firstRun !== undefined,
314
+ enabled,
315
+ pending,
316
+ latched,
317
+ chat: who.session.chat ?? null,
318
+ recent: store.recentChatTurns(who.name, 10),
319
+ turnsToday: store.chatTurnsToday(who.name, now),
320
+ weeklySpent: enabled.ok ? store.chatWeeklySpendMicrousd(enabled.credentialKey, now) : 0,
321
+ projects: chatProjects,
322
+ fleetSnapshot,
323
+ catchUp,
324
+ focusTask,
325
+ canManage: who.role === "approver",
326
+ config: store.getChatConfig(),
327
+ keyFacts: ["anthropic-api", "openrouter-api"].map(one => {
328
+ const found = chatKeyFor(one);
329
+ return {
330
+ provider: one,
331
+ state: found === null ? "none" : found.source,
332
+ tail: found === null || found.source === "environment" ? null : redactToken(found.key),
333
+ };
334
+ }),
335
+ openrouterModels: (await chatCatalog())?.map(one => one.id) ?? null,
336
+ liveModels: [...modelOptions(store, "claude", now, providerHome), ...modelOptions(store, "codex", now, providerHome)],
337
+ csrf: who.session.csrf,
338
+ problem: url.searchParams.get("said") ??
339
+ focusProblem ??
340
+ (ceilingStale ? "Project access changed. Your tasks and results are saved. Start a conversation with the current projects to continue." : null) ??
341
+ takeMateNote(who.session.csrf, null),
342
+ resultPanel,
343
+ ...(enabled.ok && who.role === "approver" ? { mateMint: mateMintCard(enabled, focusTask === null ? "/chat" : taskChatHref(focusTask.id)) } : {}),
344
+ ...(who.role === "approver" ? { coordinatorProposals: coordinatorProposalsSection(coordinatorRows, decisionsFor(store, coordinatorRows), now, true, focusTask === null ? null : taskChatHref(focusTask.id)) } : {}),
345
+ })));
485
346
  }
486
- async function post(ctx) {
487
- const { url, who, request, response, now, project, chosenProject, posted, route } = ctx;
488
- // The scripted demo lead's acts: ask, approve, change a plan, complete or send back a result.
489
- const demoAct = /^\/chat\/demo\/(?:ask|([0-9]{1,9})\/(approve|change|revise|complete))$/.exec(url.pathname);
490
- if (demoAct !== null) {
491
- const body = readForm(posted, CONSOLE_FORMS.demo);
492
- const lead = demoLeadHere();
493
- if (lead === null || who.via !== "cookie")
494
- return refuse(response, who, 404, "There's no page at this address.", "/chat");
495
- if (who.role !== "approver")
496
- return redirect(response, `/chat?said=${encodeURIComponent("Your login can watch. Approving and completing is an approver's act.")}`);
497
- const now = clock();
498
- if (demoAct[1] === undefined) {
499
- const message = (body.get("message") ?? "").trim();
500
- if (message === "")
501
- return redirect(response, `/chat?said=${encodeURIComponent("Type a request first.")}`);
502
- const asked = lead.ask(message, now);
503
- return redirect(response, `/chat#demo-${asked.id}`);
504
- }
505
- const id = Number(demoAct[1]);
506
- const note = body.get("note") ?? "";
507
- const done = demoAct[2] === "approve" ? lead.approve(id, now)
508
- : demoAct[2] === "change" ? lead.change(id, note, now)
509
- : demoAct[2] === "revise" ? lead.requestChanges(id, note, now)
510
- : lead.complete(id, now, (taskId, runId) => {
511
- const principal = matePrincipal(who);
512
- if (principal === null)
513
- return { ok: false, message: "Your access changed. Sign in again." };
514
- const current = assignmentOf(store, taskId, now, { principal: "operator", repos: principal.repos }, evidenceRoot);
515
- if (current?.receipt?.runId !== runId)
516
- return { ok: false, message: "This result changed. Reload before marking it complete." };
517
- const completed = checkAssignmentAsOperator(store, taskId, current.receipt.digest, principal, now, evidenceRoot);
518
- return completed.ok ? { ok: true } : { ok: false, message: completed.message };
519
- });
520
- bustBadge();
521
- if (!done.ok)
522
- return redirect(response, `/chat?said=${encodeURIComponent(done.message)}`);
523
- const latest = lead.exchanges().at(-1);
524
- return redirect(response, demoAct[2] === "change" || demoAct[2] === "revise" ? `/chat#demo-${latest?.id ?? id}` : `/chat#demo-${id}-work`);
525
- }
526
- if (url.pathname === "/push/subscribe") {
527
- const body = readForm(posted, CONSOLE_FORMS.pushSubscribe);
528
- // Enrolling a durable notification sink is a CEREMONY (arc 3 finding
529
- // 6): browser session + CSRF + the password typed again; bearer
530
- // identities are machines and are refused outright.
531
- if (who.via !== "cookie")
532
- return refuse(response, who, 403, "push enrollment is a browser session's act");
533
- if (store.isDemo())
534
- return refuse(response, who, 403, "the sandbox never pushes");
535
- const password = body.get("token") ?? "";
536
- if (password === "" || !authenticateApprover(who, password).ok) {
537
- return redirect(response, `/settings?said=${encodeURIComponent("enrolling this device takes your password, typed again")}`);
538
- }
539
- const endpoint = body.get("endpoint") ?? "";
540
- const p256dh = body.get("p256dh") ?? "";
541
- const auth = body.get("auth") ?? "";
542
- const checked = validatePushEndpoint(endpoint);
543
- if (!checked.ok)
544
- return redirect(response, `/settings?said=${encodeURIComponent(checked.problem)}`);
545
- const p256dhBytes = Buffer.from(p256dh, "base64url");
546
- const authBytes = Buffer.from(auth, "base64url");
547
- if (p256dhBytes.length !== 65 || p256dhBytes[0] !== 4 || authBytes.length !== 16) {
548
- return redirect(response, `/settings?said=${encodeURIComponent("that subscription's keys are not the shape a browser mints")}`);
549
- }
550
- const keys = loadOrCreateVapidKeys(dirname(options.telegramTokenFile ?? "."));
551
- const generation = store.approverGeneration(who.name) ?? 1;
552
- const enrolled = store.enrollPushSubscription({
553
- endpoint,
554
- p256dh,
555
- auth,
556
- approver: who.name,
557
- approverGeneration: generation,
558
- uaWords: oneLineUa(request.headers["user-agent"]),
559
- vapidFingerprint: keys.fingerprint,
560
- }, clock());
561
- if (!enrolled.ok) {
562
- return redirect(response, `/settings?said=${encodeURIComponent(enrolled.reason === "approver-cap" ? "five devices per person — remove one first" : "twenty devices per installation — remove one first")}`);
563
- }
564
- return redirect(response, `/settings?said=${encodeURIComponent("this device now gets a buzz when the plane needs a person")}`);
565
- }
566
- if (url.pathname === "/push/remove") {
567
- const body = readForm(posted, CONSOLE_FORMS.pushRemove);
568
- if (who.via !== "cookie")
569
- return refuse(response, who, 403, "push removal is a browser session's act");
570
- const id = Number(body.get("id") ?? "");
571
- const mine = store.listPushSubscriptions(who.name).find(one => one.id === id && one.retiredAt === null);
572
- if (mine === undefined)
573
- return redirect(response, `/settings?said=${encodeURIComponent("that device is not yours to remove, or it is already gone")}`);
574
- store.retirePushSubscription(mine.id, `removed by ${who.name}`, clock());
575
- return redirect(response, `/settings?said=${encodeURIComponent("removed — that device stops receiving pushes")}`);
576
- }
577
- if (url.pathname === "/onboarding/phone/dismiss") {
578
- const body = readForm(posted, CONSOLE_FORMS.phoneDismiss);
579
- if (who.via !== "cookie" || who.role !== "approver")
580
- return refuse(response, who, 403, "An approver puts this away.", "/chat");
581
- store.recordInstallationFact(PHONE_CARD_FACT, who.name, now);
582
- return body.get("quiet") === "1" ? respond(response, 204, "text/plain; charset=utf-8", "") : redirect(response, "/chat");
583
- }
584
- if (url.pathname === "/chat/config") {
585
- const body = readForm(posted, CONSOLE_FORMS.chatConfig);
586
- // The console's own door into `config set chat` (operator request:
587
- // chat lives mainly in the web UI). Same ceremony weight as the CLI
588
- // verb: the password typed again authenticates the approver, the
589
- // write is audited under their name, and the KEY still never
590
- // touches a form or this database — environment only.
591
- if (who.via !== "cookie")
592
- return refuse(response, who, 403, "chat setup is a browser surface");
593
- const back = body.get("return") === "/settings/lead" ? "/settings/lead" : safeChatReturn(body.get("return"));
594
- const password = body.get("token") ?? "";
595
- if (password === "" || !authenticateApprover(who, password).ok) {
596
- return redirect(response, chatReturnWithSaid(back, "configuring chat spend takes your password, typed again"));
597
- }
598
- if (body.get("off") === "1") {
599
- store.clearChatConfig();
600
- return redirect(response, chatReturnWithSaid(back, "chat is off — its settings were removed"));
601
- }
602
- const forget = body.get("forget-key") ?? "";
603
- if (forget === "anthropic-api" || forget === "openrouter-api") {
604
- forgetChatKey(forget);
605
- return redirect(response, chatReturnWithSaid(back, "the stored key file is gone (an environment variable, if set, still applies)"));
606
- }
607
- const provider = body.get("provider") ?? "";
608
- const requestedModel = (body.get("model") ?? "").trim();
609
- const weeklyText = (body.get("weekly-usd") ?? "").trim();
610
- const weekly = Number(weeklyText);
611
- const daily = (body.get("daily-turns") ?? "").trim() === "" ? 50 : Number(body.get("daily-turns"));
612
- if (provider !== "anthropic-api" && provider !== "openrouter-api" && provider !== "claude-subscription" && provider !== "codex-subscription") {
613
- return redirect(response, chatReturnWithSaid(back, "pick a chat provider"));
614
- }
615
- const subscription = isSubscriptionChatProvider(provider);
616
- const model = requestedModel === "" && subscription ? "default" : requestedModel;
617
- if (!validModelId(model)) {
618
- return redirect(response, chatReturnWithSaid(back, "the model id must be 1–128 letters, digits, dots, slashes, colons, underscores, or dashes"));
619
- }
620
- // The key, when pasted, is stored FIRST (0600 file, Telegram-token
621
- // precedent) so the catalog fetch below can already use it. It never
622
- // touches the database and is never echoed back.
623
- const pastedKey = (body.get("key") ?? "").trim();
624
- if (pastedKey !== "") {
625
- if (subscription)
626
- return redirect(response, chatReturnWithSaid(back, "subscription chat uses the CLI's cached login — do not paste an API key"));
627
- const stored = storeChatKey(provider, pastedKey);
628
- if (!stored.ok)
629
- return redirect(response, chatReturnWithSaid(back, stored.message));
630
- }
631
- // The pin: anthropic models come from the compiled table; openrouter
632
- // models come from OpenRouter's OWN catalog, priced by the authority
633
- // that will bill them. No price found anywhere = refused, not guessed.
634
- // Live list prices (Settings → Models) come first; the compiled table
635
- // is the fallback when the catalog has never been fetched.
636
- let pin = subscription ? { inMicrousd: 0, outMicrousd: 0 } : livePin(store, provider, model) ?? priceOf(model);
637
- if (provider === "openrouter-api") {
638
- const catalog = await chatCatalog();
639
- const hit = catalog?.find(one => one.id === model);
640
- if (hit !== undefined)
641
- pin = hit.price;
642
- }
643
- if (!subscription && pin === null) {
644
- return redirect(response, chatReturnWithSaid(back, provider === "openrouter-api" ? "that model is not in OpenRouter's catalog (or the catalog is unreachable) — chat cannot reserve spend it cannot bound" : "that model has no pinned price — chat cannot reserve spend it cannot bound"));
645
- }
646
- if (!subscription && (!Number.isFinite(weekly) || weekly <= 0)) {
647
- return redirect(response, chatReturnWithSaid(back, "the weekly ceiling is a positive dollar amount — chat without one is unbounded, not configured"));
347
+ async function chatAck(ctx) {
348
+ const { url, who, response, project } = ctx;
349
+ if (who.via !== "cookie")
350
+ return refuse(response, who, 403, "acknowledgement is a browser ceremony");
351
+ const turn = store.getChatTurn(Number(url.pathname.split("/")[3]));
352
+ if (turn === null || !turn.unknownSpend || turn.acknowledgedAt !== null) {
353
+ return refuse(response, who, 404, "no acknowledgement is waiting there", "/chat");
354
+ }
355
+ // The one nonce in chat — this screen restates exact financial terms
356
+ // and re-enables spend, which is precisely what nonces are for.
357
+ const nonce = mintApprovalNonce(who.name, `chat-ack-${turn.id}`, String(turn.reservedMicrousd));
358
+ return sendScreen(response, 200, chatAckPage(chromeFor(project, "chat"), turn, nonce));
359
+ }
360
+ async function sharedActionReview(ctx) {
361
+ const { url, response } = ctx;
362
+ const who = browserCaller(ctx.who);
363
+ const proposalId = Number(url.pathname.split("/")[3]);
364
+ const reviewProposal = store.getMateProposal(proposalId);
365
+ const sharedConversation = reviewProposal ? store.handle.prepare('SELECT id FROM team_conversation WHERE thread=?').get(reviewProposal.thread) : null;
366
+ let principal = null;
367
+ if (sharedConversation) {
368
+ try {
369
+ const access = team.domain.access({ name: who.name, generation: who.session.generation }, String(sharedConversation['id']), 'contributor');
370
+ const checked = verifyApproverStanding(store, who.name, who.session.generation, access.conversation.projects);
371
+ principal = checked.ok ? checked.who : null;
648
372
  }
649
- if (!Number.isInteger(daily) || daily <= 0 || daily > 1_000) {
650
- return redirect(response, chatReturnWithSaid(back, "daily turns is a whole number between 1 and 1000"));
373
+ catch {
374
+ return refuse(response, who, 404, 'This proposal is unavailable.', '/chat');
651
375
  }
652
- store.setChatConfig({
653
- provider,
654
- model,
655
- dailyTurns: daily,
656
- weeklyCeilingMicrousd: subscription ? 0 : Math.round(weekly * 1_000_000),
657
- priceInMicrousd: pin.inMicrousd,
658
- priceOutMicrousd: pin.outMicrousd,
659
- }, who.name, now);
660
- return redirect(response, back);
661
376
  }
662
- // ---- the mate (mate arc §5) ------------------------------------------
663
- if (url.pathname === "/chat/mate/mint") {
664
- const body = readForm(posted, CONSOLE_FORMS.mateMint);
665
- if (who.via !== "cookie")
666
- return refuse(response, who, 403, "the mate is a browser surface");
667
- const back = safeChatReturn(body.get("return"));
668
- const enabled = chatEnablement();
669
- if (!enabled.ok)
670
- return redirect(response, chatReturnWithSaid(back, enabled.why));
671
- // The one password ceremony of a conversation (§1): it restates the
672
- // terms — this spend ceiling, over these projects — and mints the
673
- // session every later turn debits without asking again.
674
- const ceilingText = (body.get("ceiling-usd") ?? "").trim();
675
- const ceilingUsd = enabled.billing === "subscription" ? 0 : Number(ceilingText);
676
- if (enabled.billing === "metered" && (!Number.isFinite(ceilingUsd) || ceilingUsd <= 0 || ceilingUsd > 1_000)) {
677
- return redirect(response, chatReturnWithSaid(back, "the session ceiling is a dollar amount between 0 and 1000"));
678
- }
679
- // The signed-in session is the authority (an approver in good
680
- // standing); a typed password, when sent, is still checked.
681
- const token = body.get("token") ?? "";
682
- const verified = token === "" ? verifyApproverStanding(store, who.name, who.session.generation, managedRepos()) : verifyApproverByPassword(store, who.name, token, managedRepos());
683
- if (!verified.ok)
684
- return redirect(response, chatReturnWithSaid(back, token === "" ? "Your access changed. Sign in again to start chat." : "That password did not match."));
685
- startMateConversation(who, verified.who, enabled, ceilingUsd, body.get("follow") === "yes", now);
686
- return redirect(response, back);
377
+ else
378
+ principal = matePrincipal(who);
379
+ if (principal === null)
380
+ return refuse(response, who, 403, 'Your access changed. Sign in again.', '/chat');
381
+ try {
382
+ const id = proposalId, saved = store.getMateProposal(id), savedAction = saved?.kind === 'action' ? sharedActionPayload(saved.payload) : null;
383
+ if (saved && savedAction && saved.state !== 'pending' && (sharedConversation !== null || store.getMateThread(saved.thread)?.approver === principal.name) && principal.repos.includes(savedAction.repo) && store.accountCanAccess(principal.name, savedAction.repo)) {
384
+ const task = typeof saved.outcome?.['taskId'] === 'string' ? saved.outcome['taskId'] : typeof savedAction.request['task'] === 'string' ? savedAction.request['task'] : null;
385
+ const destination = task ? taskHref(task) : `/settings/${savedAction.operation.startsWith('skill_') ? 'skills' : 'knowledge'}?repo=${encodeURIComponent(savedAction.repo)}`;
386
+ const said = typeof saved.outcome?.['said'] === 'string' ? saved.outcome['said'] : saved.state === 'dismissed' ? 'Action dismissed.' : saved.state === 'expired' ? 'This proposal expired. Ask for a fresh proposal.' : 'This action has no completed outcome yet.';
387
+ return sendScreen(response, 200, screen('Action outcome', html `<h1>${savedAction.title}</h1><p>${said}</p><a class="button-link" href="${destination}">${task ? 'Open task' : 'Open project settings'}</a>`, { chrome: chromeFor(savedAction.repo, 'chat') }));
388
+ }
389
+ const review = mintSharedActionReview(store, principal, id, evidenceRoot, clock()), action = review.payload;
390
+ const terms = action.terms.map(term => html `<p style="white-space:pre-wrap;overflow-wrap:anywhere">${term}</p>`);
391
+ const evidenceLink = action.operation === 'result_accept' ? html `<a href="/r/${Number(action.request['run'])}">Inspect this result</a>` : '';
392
+ const content = html `<section class="shared-action"><h1>${action.title}</h1>${evidenceLink}${terms}${postForm(`/chat/proposal/${id}/confirm`, html `<label class="arm"><input type="checkbox" name="confirm" value="yes" required>I confirm this exact action</label>${CHAT_ACTIONS[action.operation].password ? html `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>` : ''}<button>${CHAT_ACTIONS[action.operation].label}</button>`, { returnTo: '/chat', hidden: { nonce: review.nonce } })}</section>`;
393
+ return sendScreen(response, 200, screen('Review action', content, { chrome: chromeFor(action.repo, 'chat') }));
687
394
  }
688
- if (url.pathname === "/chat/mate/follow") {
689
- const body = readForm(posted, CONSOLE_FORMS.mateFollow);
690
- if (who.via !== "cookie")
691
- return refuse(response, who, 403, "Open the conversation to change automatic updates.");
692
- const principal = matePrincipal(who), session = store.activeMateSession(who.name), thread = store.liveMateThreadFor(who.name);
693
- if (!principal || !session || !thread || !configureLeadFollow(store, principal, session, thread, body.get("enabled") === "yes", now))
694
- return refuse(response, who, 409, "Conversation access changed. Start chat again.", "/chat");
695
- return redirect(response, safeChatReturn(body.get("return")));
696
- }
697
- if (url.pathname === "/chat/mate/end") {
698
- const body = readForm(posted, CONSOLE_FORMS.mateEnd);
699
- if (who.via !== "cookie")
700
- return refuse(response, who, 403, "the mate is a browser surface");
701
- const back = safeChatReturn(body.get("return"));
702
- // Ending spend and forgetting the thread takes no password: any
703
- // approver may revoke (§1), and the thread is theirs to drop (ruling 11).
704
- store.failLiveMateTurnsFor(who.name, "ended", now);
705
- store.endMateSessionsFor(who.name, who.name, now);
706
- store.closeMateThreadsFor(who.name, now);
707
- mateSaid.delete(who.session.csrf);
708
- return redirect(response, back);
395
+ catch (error) {
396
+ return refuse(response, who, 409, error instanceof Error ? error.message : 'This action could not be reviewed.', '/chat');
709
397
  }
710
- if (url.pathname === "/chat/mate/stop") {
711
- const body = readForm(posted, CONSOLE_FORMS.mateStop);
712
- if (who.via !== "cookie")
713
- return refuse(response, who, 403, "the mate is a browser surface");
714
- const back = safeChatReturn(body.get("return"));
715
- const wanted = Number(body.get("turn") ?? "");
716
- const live = store.liveMateTurnFor(who.name);
717
- if (!Number.isInteger(wanted) || live === null || live.id !== wanted) {
718
- noteMate(who.session.csrf, null, "that turn has already finished");
719
- return redirect(response, chatReturnWithLatest(back));
720
- }
721
- // A stopped direct-API turn is conservatively charged its reserved
722
- // worst case: dispatch may already have happened. Membership turns
723
- // reserve zero. The conversation itself stays live.
724
- store.failLiveMateTurnsFor(who.name, "stopped", now);
725
- noteMate(who.session.csrf, live.id, "stopped — the conversation is still open");
398
+ }
399
+ // Session-gated: the key is not secret, but strangers get nothing.
400
+ async function pushKey(ctx) {
401
+ const { who, response } = ctx;
402
+ if (who.via !== "cookie")
403
+ return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
404
+ const keys = loadOrCreateVapidKeys(dirname(options.telegramTokenFile ?? "."));
405
+ response.setHeader("cache-control", "no-store");
406
+ return respond(response, 200, "application/json", JSON.stringify({ key: keys.publicKey }));
407
+ }
408
+ // While no agent is signed in, Chat asks this computer again on its own (onboarding): a fresh, non-spending sign-in
409
+ // check at most every few seconds, and the lead turns on as soon as one is found.
410
+ async function leadStatus(ctx) {
411
+ const { who, response } = ctx;
412
+ if (who.via !== "cookie")
413
+ return respond(response, 403, "application/json", JSON.stringify({ error: "sign in" }));
414
+ if (Date.now() - runtime.leadRecheckedAt > 4_000) {
415
+ runtime.leadRecheckedAt = Date.now();
416
+ await Promise.race([checkLocalAgents(true), new Promise(done => setTimeout(done, 8_000).unref?.())]);
417
+ }
418
+ response.setHeader("cache-control", "no-store");
419
+ return respond(response, 200, "application/json", JSON.stringify({ lead: store.getChatConfig() === null ? "off" : "on", agent: runtime.localSignIn?.signedIn ?? null, command: agentSignInCommand() }));
420
+ }
421
+ async function taskStatus(ctx) {
422
+ const { url, who, response, now } = ctx;
423
+ if (who.via !== "cookie")
424
+ return respond(response, 403, "text/plain; charset=utf-8", "sign in to see this task");
425
+ const focus = taskChatFocus(url.searchParams.get("task"), now, who, { mintNonce: false });
426
+ if (focus === null)
427
+ return respond(response, 404, "text/plain; charset=utf-8", "this task is not available in this workspace");
428
+ response.setHeader("cache-control", "no-store");
429
+ return respond(response, 200, "text/html; charset=utf-8", taskChatLiveRegion(focus, who.session.csrf, true));
430
+ }
431
+ // The scripted demo lead's acts: ask, approve, change a plan, complete or send back a result.
432
+ async function demoAsk(ctx) {
433
+ const { who, response, posted } = ctx;
434
+ const body = readForm(posted, CONSOLE_FORMS.demo);
435
+ const lead = demoLeadHere();
436
+ if (lead === null || who.via !== "cookie")
437
+ return refuse(response, who, 404, "There's no page at this address.", "/chat");
438
+ if (who.role !== "approver")
439
+ return redirect(response, `/chat?said=${encodeURIComponent("Your login can watch. Approving and completing is an approver's act.")}`);
440
+ const now = clock();
441
+ const message = (body.get("message") ?? "").trim();
442
+ if (message === "")
443
+ return redirect(response, `/chat?said=${encodeURIComponent("Type a request first.")}`);
444
+ const asked = lead.ask(message, now);
445
+ return redirect(response, `/chat#demo-${asked.id}`);
446
+ }
447
+ const demoAct = (verb) => async (ctx) => {
448
+ const { url, who, response, posted } = ctx;
449
+ const body = readForm(posted, CONSOLE_FORMS.demo);
450
+ const lead = demoLeadHere();
451
+ if (lead === null || who.via !== "cookie")
452
+ return refuse(response, who, 404, "There's no page at this address.", "/chat");
453
+ if (who.role !== "approver")
454
+ return redirect(response, `/chat?said=${encodeURIComponent("Your login can watch. Approving and completing is an approver's act.")}`);
455
+ const now = clock();
456
+ const id = Number(url.pathname.split("/")[3]);
457
+ const note = body.get("note") ?? "";
458
+ const done = verb === "approve" ? lead.approve(id, now)
459
+ : verb === "change" ? lead.change(id, note, now)
460
+ : verb === "revise" ? lead.requestChanges(id, note, now)
461
+ : lead.complete(id, now, (taskId, runId) => {
462
+ const principal = matePrincipal(who);
463
+ if (principal === null)
464
+ return { ok: false, message: "Your access changed. Sign in again." };
465
+ const current = assignmentOf(store, taskId, now, { principal: "operator", repos: principal.repos }, evidenceRoot);
466
+ if (current?.receipt?.runId !== runId)
467
+ return { ok: false, message: "This result changed. Reload before marking it complete." };
468
+ const completed = checkAssignmentAsOperator(store, taskId, current.receipt.digest, principal, now, evidenceRoot);
469
+ return completed.ok ? { ok: true } : { ok: false, message: completed.message };
470
+ });
471
+ bustBadge();
472
+ if (!done.ok)
473
+ return redirect(response, `/chat?said=${encodeURIComponent(done.message)}`);
474
+ const latest = lead.exchanges().at(-1);
475
+ return redirect(response, verb === "change" || verb === "revise" ? `/chat#demo-${latest?.id ?? id}` : `/chat#demo-${id}-work`);
476
+ };
477
+ async function pushSubscribe(ctx) {
478
+ const { who, request, response, posted } = ctx;
479
+ const body = readForm(posted, CONSOLE_FORMS.pushSubscribe);
480
+ // Enrolling a durable notification sink is a CEREMONY (arc 3 finding
481
+ // 6): browser session + CSRF + the password typed again; bearer
482
+ // identities are machines and are refused outright.
483
+ if (who.via !== "cookie")
484
+ return refuse(response, who, 403, "push enrollment is a browser session's act");
485
+ if (store.isDemo())
486
+ return refuse(response, who, 403, "the sandbox never pushes");
487
+ const password = body.get("token") ?? "";
488
+ if (password === "" || !authenticateApprover(who, password).ok) {
489
+ return redirect(response, `/settings?said=${encodeURIComponent("enrolling this device takes your password, typed again")}`);
490
+ }
491
+ const endpoint = body.get("endpoint") ?? "";
492
+ const p256dh = body.get("p256dh") ?? "";
493
+ const auth = body.get("auth") ?? "";
494
+ const checked = validatePushEndpoint(endpoint);
495
+ if (!checked.ok)
496
+ return redirect(response, `/settings?said=${encodeURIComponent(checked.problem)}`);
497
+ const p256dhBytes = Buffer.from(p256dh, "base64url");
498
+ const authBytes = Buffer.from(auth, "base64url");
499
+ if (p256dhBytes.length !== 65 || p256dhBytes[0] !== 4 || authBytes.length !== 16) {
500
+ return redirect(response, `/settings?said=${encodeURIComponent("that subscription's keys are not the shape a browser mints")}`);
501
+ }
502
+ const keys = loadOrCreateVapidKeys(dirname(options.telegramTokenFile ?? "."));
503
+ const generation = store.approverGeneration(who.name) ?? 1;
504
+ const enrolled = store.enrollPushSubscription({
505
+ endpoint,
506
+ p256dh,
507
+ auth,
508
+ approver: who.name,
509
+ approverGeneration: generation,
510
+ uaWords: oneLineUa(request.headers["user-agent"]),
511
+ vapidFingerprint: keys.fingerprint,
512
+ }, clock());
513
+ if (!enrolled.ok) {
514
+ return redirect(response, `/settings?said=${encodeURIComponent(enrolled.reason === "approver-cap" ? "five devices per person — remove one first" : "twenty devices per installation — remove one first")}`);
515
+ }
516
+ return redirect(response, `/settings?said=${encodeURIComponent("this device now gets a buzz when the plane needs a person")}`);
517
+ }
518
+ async function pushRemove(ctx) {
519
+ const { who, response, posted } = ctx;
520
+ const body = readForm(posted, CONSOLE_FORMS.pushRemove);
521
+ if (who.via !== "cookie")
522
+ return refuse(response, who, 403, "push removal is a browser session's act");
523
+ const id = Number(body.get("id") ?? "");
524
+ const mine = store.listPushSubscriptions(who.name).find(one => one.id === id && one.retiredAt === null);
525
+ if (mine === undefined)
526
+ return redirect(response, `/settings?said=${encodeURIComponent("that device is not yours to remove, or it is already gone")}`);
527
+ store.retirePushSubscription(mine.id, `removed by ${who.name}`, clock());
528
+ return redirect(response, `/settings?said=${encodeURIComponent("removed — that device stops receiving pushes")}`);
529
+ }
530
+ async function phoneDismiss(ctx) {
531
+ const { who, response, now, posted } = ctx;
532
+ const body = readForm(posted, CONSOLE_FORMS.phoneDismiss);
533
+ store.recordInstallationFact(PHONE_CARD_FACT, who.name, now);
534
+ return body.get("quiet") === "1" ? respond(response, 204, "text/plain; charset=utf-8", "") : redirect(response, "/chat");
535
+ }
536
+ async function chatConfig(ctx) {
537
+ const { who, response, now, posted } = ctx;
538
+ const body = readForm(posted, CONSOLE_FORMS.chatConfig);
539
+ // The console's own door into `config set chat` (operator request:
540
+ // chat lives mainly in the web UI). Same ceremony weight as the CLI
541
+ // verb: the password typed again authenticates the approver, the
542
+ // write is audited under their name, and the KEY still never
543
+ // touches a form or this database — environment only.
544
+ if (who.via !== "cookie")
545
+ return refuse(response, who, 403, "chat setup is a browser surface");
546
+ const back = body.get("return") === "/settings/lead" ? "/settings/lead" : safeChatReturn(body.get("return"));
547
+ const password = body.get("token") ?? "";
548
+ if (password === "" || !authenticateApprover(who, password).ok) {
549
+ return redirect(response, chatReturnWithSaid(back, "configuring chat spend takes your password, typed again"));
550
+ }
551
+ if (body.get("off") === "1") {
552
+ store.clearChatConfig();
553
+ return redirect(response, chatReturnWithSaid(back, "chat is off — its settings were removed"));
554
+ }
555
+ const forget = body.get("forget-key") ?? "";
556
+ if (forget === "anthropic-api" || forget === "openrouter-api") {
557
+ forgetChatKey(forget);
558
+ return redirect(response, chatReturnWithSaid(back, "the stored key file is gone (an environment variable, if set, still applies)"));
559
+ }
560
+ const provider = body.get("provider") ?? "";
561
+ const requestedModel = (body.get("model") ?? "").trim();
562
+ const weeklyText = (body.get("weekly-usd") ?? "").trim();
563
+ const weekly = Number(weeklyText);
564
+ const daily = (body.get("daily-turns") ?? "").trim() === "" ? 50 : Number(body.get("daily-turns"));
565
+ if (provider !== "anthropic-api" && provider !== "openrouter-api" && provider !== "claude-subscription" && provider !== "codex-subscription") {
566
+ return redirect(response, chatReturnWithSaid(back, "pick a chat provider"));
567
+ }
568
+ const subscription = isSubscriptionChatProvider(provider);
569
+ const model = requestedModel === "" && subscription ? "default" : requestedModel;
570
+ if (!validModelId(model)) {
571
+ return redirect(response, chatReturnWithSaid(back, "the model id must be 1–128 letters, digits, dots, slashes, colons, underscores, or dashes"));
572
+ }
573
+ // The key, when pasted, is stored FIRST (0600 file, Telegram-token
574
+ // precedent) so the catalog fetch below can already use it. It never
575
+ // touches the database and is never echoed back.
576
+ const pastedKey = (body.get("key") ?? "").trim();
577
+ if (pastedKey !== "") {
578
+ if (subscription)
579
+ return redirect(response, chatReturnWithSaid(back, "subscription chat uses the CLI's cached login — do not paste an API key"));
580
+ const stored = storeChatKey(provider, pastedKey);
581
+ if (!stored.ok)
582
+ return redirect(response, chatReturnWithSaid(back, stored.message));
583
+ }
584
+ // The pin: anthropic models come from the compiled table; openrouter
585
+ // models come from OpenRouter's OWN catalog, priced by the authority
586
+ // that will bill them. No price found anywhere = refused, not guessed.
587
+ // Live list prices (Settings → Models) come first; the compiled table
588
+ // is the fallback when the catalog has never been fetched.
589
+ let pin = subscription ? { inMicrousd: 0, outMicrousd: 0 } : livePin(store, provider, model) ?? priceOf(model);
590
+ if (provider === "openrouter-api") {
591
+ const catalog = await chatCatalog();
592
+ const hit = catalog?.find(one => one.id === model);
593
+ if (hit !== undefined)
594
+ pin = hit.price;
595
+ }
596
+ if (!subscription && pin === null) {
597
+ return redirect(response, chatReturnWithSaid(back, provider === "openrouter-api" ? "that model is not in OpenRouter's catalog (or the catalog is unreachable) — chat cannot reserve spend it cannot bound" : "that model has no pinned price — chat cannot reserve spend it cannot bound"));
598
+ }
599
+ if (!subscription && (!Number.isFinite(weekly) || weekly <= 0)) {
600
+ return redirect(response, chatReturnWithSaid(back, "the weekly ceiling is a positive dollar amount — chat without one is unbounded, not configured"));
601
+ }
602
+ if (!Number.isInteger(daily) || daily <= 0 || daily > 1_000) {
603
+ return redirect(response, chatReturnWithSaid(back, "daily turns is a whole number between 1 and 1000"));
604
+ }
605
+ store.setChatConfig({
606
+ provider,
607
+ model,
608
+ dailyTurns: daily,
609
+ weeklyCeilingMicrousd: subscription ? 0 : Math.round(weekly * 1_000_000),
610
+ priceInMicrousd: pin.inMicrousd,
611
+ priceOutMicrousd: pin.outMicrousd,
612
+ }, who.name, now);
613
+ return redirect(response, back);
614
+ }
615
+ // ---- the mate (mate arc §5) ------------------------------------------
616
+ async function mateMint(ctx) {
617
+ const { who, response, now, posted } = ctx;
618
+ const body = readForm(posted, CONSOLE_FORMS.mateMint);
619
+ if (who.via !== "cookie")
620
+ return refuse(response, who, 403, "the mate is a browser surface");
621
+ const back = safeChatReturn(body.get("return"));
622
+ const enabled = chatEnablement();
623
+ if (!enabled.ok)
624
+ return redirect(response, chatReturnWithSaid(back, enabled.why));
625
+ // The one password ceremony of a conversation (§1): it restates the
626
+ // terms — this spend ceiling, over these projects — and mints the
627
+ // session every later turn debits without asking again.
628
+ const ceilingText = (body.get("ceiling-usd") ?? "").trim();
629
+ const ceilingUsd = enabled.billing === "subscription" ? 0 : Number(ceilingText);
630
+ if (enabled.billing === "metered" && (!Number.isFinite(ceilingUsd) || ceilingUsd <= 0 || ceilingUsd > 1_000)) {
631
+ return redirect(response, chatReturnWithSaid(back, "the session ceiling is a dollar amount between 0 and 1000"));
632
+ }
633
+ // The signed-in session is the authority (an approver in good
634
+ // standing); a typed password, when sent, is still checked.
635
+ const token = body.get("token") ?? "";
636
+ const verified = token === "" ? verifyApproverStanding(store, who.name, who.session.generation, managedRepos()) : verifyApproverByPassword(store, who.name, token, managedRepos());
637
+ if (!verified.ok)
638
+ return redirect(response, chatReturnWithSaid(back, token === "" ? "Your access changed. Sign in again to start chat." : "That password did not match."));
639
+ startMateConversation(who, verified.who, enabled, ceilingUsd, body.get("follow") === "yes", now);
640
+ return redirect(response, back);
641
+ }
642
+ async function mateFollow(ctx) {
643
+ const { who, response, now, posted } = ctx;
644
+ const body = readForm(posted, CONSOLE_FORMS.mateFollow);
645
+ if (who.via !== "cookie")
646
+ return refuse(response, who, 403, "Open the conversation to change automatic updates.");
647
+ const principal = matePrincipal(who), session = store.activeMateSession(who.name), thread = store.liveMateThreadFor(who.name);
648
+ if (!principal || !session || !thread || !configureLeadFollow(store, principal, session, thread, body.get("enabled") === "yes", now))
649
+ return refuse(response, who, 409, "Conversation access changed. Start chat again.", "/chat");
650
+ return redirect(response, safeChatReturn(body.get("return")));
651
+ }
652
+ async function mateEnd(ctx) {
653
+ const { who, response, now, posted } = ctx;
654
+ const body = readForm(posted, CONSOLE_FORMS.mateEnd);
655
+ if (who.via !== "cookie")
656
+ return refuse(response, who, 403, "the mate is a browser surface");
657
+ const back = safeChatReturn(body.get("return"));
658
+ // Ending spend and forgetting the thread takes no password: any
659
+ // approver may revoke (§1), and the thread is theirs to drop (ruling 11).
660
+ store.failLiveMateTurnsFor(who.name, "ended", now);
661
+ store.endMateSessionsFor(who.name, who.name, now);
662
+ store.closeMateThreadsFor(who.name, now);
663
+ mateSaid.delete(who.session.csrf);
664
+ return redirect(response, back);
665
+ }
666
+ async function mateStop(ctx) {
667
+ const { who, response, now, posted } = ctx;
668
+ const body = readForm(posted, CONSOLE_FORMS.mateStop);
669
+ if (who.via !== "cookie")
670
+ return refuse(response, who, 403, "the mate is a browser surface");
671
+ const back = safeChatReturn(body.get("return"));
672
+ const wanted = Number(body.get("turn") ?? "");
673
+ const live = store.liveMateTurnFor(who.name);
674
+ if (!Number.isInteger(wanted) || live === null || live.id !== wanted) {
675
+ noteMate(who.session.csrf, null, "that turn has already finished");
726
676
  return redirect(response, chatReturnWithLatest(back));
727
677
  }
728
- const mateProposal = /^\/chat\/proposal\/([0-9]{1,15})\/(confirm|dismiss)$/.exec(url.pathname);
729
- if (mateProposal !== null) {
730
- const body = readForm(posted, CONSOLE_FORMS.mateProposal);
731
- if (who.via !== "cookie")
732
- return refuse(response, who, 403, "the mate is a browser surface");
733
- const back = safeChatReturn(body.get("return"));
734
- const id = Number(mateProposal[1]);
735
- const proposalRow = store.getMateProposal(id);
736
- const shared = proposalRow && store.handle.prepare('SELECT id FROM team_conversation WHERE thread=?').get(proposalRow.thread);
737
- const proposalTaskHref = (taskId) => taskChatHref(taskId) + (shared ? '&conversation=' + encodeURIComponent(String(shared['id'])) : '');
738
- let principal = null;
739
- if (shared) {
740
- try {
741
- const access = team.domain.access({ name: who.name, generation: who.session.generation }, String(shared['id']), 'contributor');
742
- const proof = verifyApproverStanding(store, who.name, who.session.generation, access.conversation.projects);
743
- principal = proof.ok ? proof.who : null;
744
- }
745
- catch {
746
- return refuse(response, who, 404, 'This proposal is unavailable.', back);
747
- }
748
- }
749
- else
750
- principal = matePrincipal(who);
751
- // Confirm in place (chat cards): the same door and checks, answered in
752
- // JSON so the conversation stays where it is; the card's refreshed
753
- // state carries the result. The secure review screen stays a form.
754
- const cardJson = String(request.headers["accept"] ?? "").includes("application/json") && !body.has("nonce");
755
- const cardAnswer = (status, ok, said, taskId = null) => respond(response, status, "application/json", JSON.stringify({ ok, said, taskId }));
756
- if (principal === null)
757
- return cardJson ? cardAnswer(403, false, "Your approver standing changed. Sign in again.") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
758
- if (mateProposal[2] === "dismiss") {
759
- const dismissed = dismissMateProposal(store, principal, id, now);
760
- if (cardJson)
761
- return cardAnswer(dismissed ? 200 : 409, dismissed, dismissed ? "Dismissed." : "That card was already acted on.");
762
- if (!dismissed)
763
- noteMate(who.session.csrf, null, "that proposal was already acted on");
764
- return redirect(response, chatReturnWithLatest(back));
765
- }
766
- const outcome = confirmMateProposal(store, principal, id, now, { chatProvider: teamChatProvider, confirm: body.get("confirm") === "yes", via: "web", evidenceRoot, ...(body.has("nonce") ? { actionReview: { nonce: body.get("nonce") ?? "", password: body.get("token") ?? "" } } : {}) });
767
- if (cardJson) {
768
- if (!outcome.ok)
769
- return cardAnswer(outcome.reason === "standing" ? 403 : outcome.reason === "not-yours" ? 404 : 409, false, outcome.said);
770
- return cardAnswer(200, true, outcome.said, outcome.taskId);
771
- }
772
- if (!outcome.ok && (outcome.reason === "not-yours" || outcome.reason === "standing")) {
773
- return refuse(response, who, outcome.reason === "standing" ? 403 : 404, outcome.said, back);
678
+ // A stopped direct-API turn is conservatively charged its reserved
679
+ // worst case: dispatch may already have happened. Membership turns
680
+ // reserve zero. The conversation itself stays live.
681
+ store.failLiveMateTurnsFor(who.name, "stopped", now);
682
+ noteMate(who.session.csrf, live.id, "stopped — the conversation is still open");
683
+ return redirect(response, chatReturnWithLatest(back));
684
+ }
685
+ const chatProposalAct = (op) => async (ctx) => {
686
+ const { url, who, request, response, now, posted } = ctx;
687
+ const body = readForm(posted, CONSOLE_FORMS.mateProposal);
688
+ if (who.via !== "cookie")
689
+ return refuse(response, who, 403, "the mate is a browser surface");
690
+ const back = safeChatReturn(body.get("return"));
691
+ const id = Number(url.pathname.split("/")[3]);
692
+ const proposalRow = store.getMateProposal(id);
693
+ const shared = proposalRow && store.handle.prepare('SELECT id FROM team_conversation WHERE thread=?').get(proposalRow.thread);
694
+ const proposalTaskHref = (taskId) => taskChatHref(taskId) + (shared ? '&conversation=' + encodeURIComponent(String(shared['id'])) : '');
695
+ let principal = null;
696
+ if (shared) {
697
+ try {
698
+ const access = team.domain.access({ name: who.name, generation: who.session.generation }, String(shared['id']), 'contributor');
699
+ const proof = verifyApproverStanding(store, who.name, who.session.generation, access.conversation.projects);
700
+ principal = proof.ok ? proof.who : null;
774
701
  }
775
- if (outcome.kind === "action" && body.has("nonce")) {
776
- if (!outcome.ok && outcome.reason === "needs-confirm")
777
- return refuse(response, who, 409, outcome.said, sharedActionReviewPath(id));
778
- return redirect(response, sharedActionReviewPath(id));
702
+ catch {
703
+ return refuse(response, who, 404, 'This proposal is unavailable.', back);
779
704
  }
780
- if (!outcome.ok && shared)
781
- return redirect(response, chatReturnWithSaid(back, outcome.said));
705
+ }
706
+ else
707
+ principal = matePrincipal(who);
708
+ // Confirm in place (chat cards): the same door and checks, answered in
709
+ // JSON so the conversation stays where it is; the card's refreshed
710
+ // state carries the result. The secure review screen stays a form.
711
+ const cardJson = String(request.headers["accept"] ?? "").includes("application/json") && !body.has("nonce");
712
+ const cardAnswer = (status, ok, said, taskId = null) => respond(response, status, "application/json", JSON.stringify({ ok, said, taskId }));
713
+ if (principal === null)
714
+ return cardJson ? cardAnswer(403, false, "Your approver standing changed. Sign in again.") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
715
+ if (op === "dismiss") {
716
+ const dismissed = dismissMateProposal(store, principal, id, now);
717
+ if (cardJson)
718
+ return cardAnswer(dismissed ? 200 : 409, dismissed, dismissed ? "Dismissed." : "That card was already acted on.");
719
+ if (!dismissed)
720
+ noteMate(who.session.csrf, null, "that proposal was already acted on");
721
+ return redirect(response, chatReturnWithLatest(back));
722
+ }
723
+ const outcome = confirmMateProposal(store, principal, id, now, { chatProvider: teamChatProvider, confirm: body.get("confirm") === "yes", via: "web", evidenceRoot, ...(body.has("nonce") ? { actionReview: { nonce: body.get("nonce") ?? "", password: body.get("token") ?? "" } } : {}) });
724
+ if (cardJson) {
725
+ if (!outcome.ok)
726
+ return cardAnswer(outcome.reason === "standing" ? 403 : outcome.reason === "not-yours" ? 404 : 409, false, outcome.said);
727
+ return cardAnswer(200, true, outcome.said, outcome.taskId);
728
+ }
729
+ if (!outcome.ok && (outcome.reason === "not-yours" || outcome.reason === "standing")) {
730
+ return refuse(response, who, outcome.reason === "standing" ? 403 : 404, outcome.said, back);
731
+ }
732
+ if (outcome.kind === "action" && body.has("nonce")) {
782
733
  if (!outcome.ok && outcome.reason === "needs-confirm")
783
- noteMate(who.session.csrf, null, outcome.said);
784
- // A confirmed task proposal leads to the task it actually created
785
- // (package 2): the lens over this same conversation, whose journey
786
- // and plan are the next step. The id is the door's recorded
787
- // outcome, never a title or the assistant's prose; a refused,
788
- // replayed, or unavailable confirmation returns as before, with
789
- // the card carrying the door's words.
790
- if (outcome.ok && outcome.kind === "task" && outcome.taskId !== null && taskChatFocus(outcome.taskId, now, who, { mintNonce: false }) !== null) {
791
- return redirect(response, `${proposalTaskHref(outcome.taskId)}#task-chat-live`);
792
- }
793
- if (outcome.ok && outcome.kind === "review" && outcome.taskId !== null) {
794
- const proposal = store.getMateProposal(id);
795
- // A revision confirmed here returns to its receipt: the newest
796
- // reply, whose confirmed card names the revision and links to the
797
- // plan. Landing at the top left that message under the phone's
798
- // fixed composer (build #1604 feedback).
799
- return redirect(response, proposal.payload["operation"] === "revise"
800
- ? chatReturnWithLatest(revisionDestination(outcome.taskId, proposalTaskHref(outcome.taskId)))
801
- : `${proposalTaskHref(outcome.taskId)}&result=${Number(proposal.payload["run"])}#request-changes`);
802
- }
803
- if (outcome.ok && outcome.kind === "task_action" && outcome.taskId !== null) {
804
- const proposal = store.getMateProposal(id);
805
- if (proposal.payload["operation"] === "resume") {
806
- return armTaskResume(response, who, outcome.taskId, String(proposal.payload["run"]), "chat", now);
807
- }
808
- return redirect(response, `${proposalTaskHref(outcome.taskId)}#task-chat-live`);
734
+ return refuse(response, who, 409, outcome.said, sharedActionReviewPath(id));
735
+ return redirect(response, sharedActionReviewPath(id));
736
+ }
737
+ if (!outcome.ok && shared)
738
+ return redirect(response, chatReturnWithSaid(back, outcome.said));
739
+ if (!outcome.ok && outcome.reason === "needs-confirm")
740
+ noteMate(who.session.csrf, null, outcome.said);
741
+ // A confirmed task proposal leads to the task it actually created
742
+ // (package 2): the lens over this same conversation, whose journey
743
+ // and plan are the next step. The id is the door's recorded
744
+ // outcome, never a title or the assistant's prose; a refused,
745
+ // replayed, or unavailable confirmation returns as before, with
746
+ // the card carrying the door's words.
747
+ if (outcome.ok && outcome.kind === "task" && outcome.taskId !== null && taskChatFocus(outcome.taskId, now, who, { mintNonce: false }) !== null) {
748
+ return redirect(response, `${proposalTaskHref(outcome.taskId)}#task-chat-live`);
749
+ }
750
+ if (outcome.ok && outcome.kind === "review" && outcome.taskId !== null) {
751
+ const proposal = store.getMateProposal(id);
752
+ // A revision confirmed here returns to its receipt: the newest
753
+ // reply, whose confirmed card names the revision and links to the
754
+ // plan. Landing at the top left that message under the phone's
755
+ // fixed composer (build #1604 feedback).
756
+ return redirect(response, proposal.payload["operation"] === "revise"
757
+ ? chatReturnWithLatest(revisionDestination(outcome.taskId, proposalTaskHref(outcome.taskId)))
758
+ : `${proposalTaskHref(outcome.taskId)}&result=${Number(proposal.payload["run"])}#request-changes`);
759
+ }
760
+ if (outcome.ok && outcome.kind === "task_action" && outcome.taskId !== null) {
761
+ const proposal = store.getMateProposal(id);
762
+ if (proposal.payload["operation"] === "resume") {
763
+ return armTaskResume(response, who, outcome.taskId, String(proposal.payload["run"]), "chat", now);
809
764
  }
810
- return redirect(response, chatReturnWithLatest(back));
765
+ return redirect(response, `${proposalTaskHref(outcome.taskId)}#task-chat-live`);
811
766
  }
812
- // Coordinator proposals (mate arc v3): confirmed by any approver whose
813
- // ceiling admits the repo; the card lives on /chat and on the task.
814
- const coordinatorProposal = /^\/proposals\/([0-9]{1,15})\/(confirm|dismiss)$/.exec(url.pathname);
815
- if (coordinatorProposal !== null) {
816
- const body = readForm(posted, CONSOLE_FORMS.coordinatorProposal);
817
- if (who.via !== "cookie")
818
- return refuse(response, who, 403, "proposals are confirmed from the browser or the CLI");
767
+ return redirect(response, chatReturnWithLatest(back));
768
+ };
769
+ // Coordinator proposals (mate arc v3): confirmed by any approver whose
770
+ // ceiling admits the repo; the card lives on /chat and on the task.
771
+ const coordinatorProposalAct = (op) => async (ctx) => {
772
+ const { url, who, response, now, posted } = ctx;
773
+ const body = readForm(posted, CONSOLE_FORMS.coordinatorProposal);
774
+ if (who.via !== "cookie")
775
+ return refuse(response, who, 403, "proposals are confirmed from the browser or the CLI");
776
+ const principal = matePrincipal(who);
777
+ if (principal === null)
778
+ return refuse(response, who, 403, "your approver standing changed — sign in again", "/chat");
779
+ const id = Number(url.pathname.split("/")[2]);
780
+ store.sweepCoordinatorProposals(now);
781
+ const back = body.get("return") === null ? "/chat" : safeReturn(body.get("return"));
782
+ if (op === "dismiss") {
783
+ dismissCoordinatorProposal(store, principal, id, now);
784
+ return redirect(response, back);
785
+ }
786
+ const outcome = confirmCoordinatorProposal(store, principal, id, now, { confirm: body.get("confirm") === "yes", via: "web" });
787
+ if (!outcome.ok && (outcome.reason === "not-yours" || outcome.reason === "standing")) {
788
+ return refuse(response, who, outcome.reason === "standing" ? 403 : 404, outcome.said, back);
789
+ }
790
+ return redirect(response, outcome.ok || outcome.reason !== "needs-confirm" ? back : `${back}${back.includes("?") ? "&" : "?"}said=${encodeURIComponent(outcome.said)}`);
791
+ };
792
+ async function chatSend(ctx) {
793
+ const { who, request, response, now, posted } = ctx;
794
+ const body = readForm(posted, CONSOLE_FORMS.chat);
795
+ if (who.via !== "cookie")
796
+ return refuse(response, who, 403, "chat is a browser surface");
797
+ const requestedTask = body.get("task");
798
+ const focusTask = taskChatFocus(requestedTask, now, who);
799
+ const chatProject = focusTask !== null ? null : chatProjectOf(body.getAll("project"));
800
+ // The enhanced send (package 2): the SAME endpoint, fields, request
801
+ // receipt key, and session binding as the native form — only the
802
+ // answer differs. A fetch that asks for JSON gets the refusal's
803
+ // words or an acceptance instead of a redirect; nothing is retried,
804
+ // cleared, or authorized here that the native POST would not.
805
+ const wantsJson = String(request.headers["accept"] ?? "").includes("application/json");
806
+ if (requestedTask !== null && focusTask === null) {
807
+ return wantsJson
808
+ ? respond(response, 404, "application/json", JSON.stringify({ ok: false, said: "That task is not available in this workspace.", session: null }))
809
+ : redirect(response, chatReturnWithSaid("/chat", "That task is not available in this workspace."));
810
+ }
811
+ if (chatProject === undefined) {
812
+ return wantsJson
813
+ ? respond(response, 404, "application/json", JSON.stringify({ ok: false, said: "That project is not available in this workspace.", session: null }))
814
+ : redirect(response, chatReturnWithSaid("/chat", "That project is not available in this workspace."));
815
+ }
816
+ const back = focusTask !== null ? taskChatHref(focusTask.id) : chatProject !== null ? projectChatHref(chatProject) : "/chat";
817
+ const said = (status, words, session = null) => wantsJson ? respond(response, status, "application/json", JSON.stringify({ ok: false, said: words, session })) : redirect(response, chatReturnWithSaid(back, words));
818
+ const resultValue = body.get("result");
819
+ const viewedRun = resultValue === null ? null : /^[0-9]{1,15}$/.test(resultValue) ? store.getRun(Number(resultValue)) : null;
820
+ if (resultValue !== null && (body.getAll("result").length !== 1 || viewedRun === null || focusTask === null || !runVisible(viewedRun) || !focusTask.family.versions.some(one => one.refId === viewedRun.taskRef))) {
821
+ return said(409, "That result is no longer available for this task. Review the task before sending.");
822
+ }
823
+ const resultContext = viewedRun === null ? "" : ` The operator is viewing result #${viewedRun.id} from execution ${store.externalIdFor(viewedRun.taskRef)}. Use get_result for that exact execution and run when responding to feedback; do not substitute another result.`;
824
+ // The task composer's mode (console v2): fixed words for this turn only.
825
+ // It steers what the lead proposes and grants nothing: every action
826
+ // still arrives as a card the person confirms under its own rules.
827
+ const modeValue = body.get("mode");
828
+ if (modeValue !== null && (focusTask === null || body.getAll("mode").length !== 1 || !Object.hasOwn(TASK_COMPOSER_MODES, modeValue))) {
829
+ return said(400, "Choose Build, Plan only or Just answer, then send again. Your draft is saved.");
830
+ }
831
+ const modeContext = modeValue === null ? "" : ` ${TASK_COMPOSER_MODES[modeValue]}`;
832
+ const enabled = chatEnablement();
833
+ if (!enabled.ok)
834
+ return said(409, enabled.why);
835
+ // A live mate session: the message is a mate turn — no password, the
836
+ // session's ceremony already covered it (§1); the engine refuses on
837
+ // its own terms and the thread shows why.
838
+ const mateSession = who.role === "approver" ? store.activeMateSession(who.name) : null;
839
+ if (mateSession !== null) {
840
+ const requestId = body.get("request");
841
+ if (requestId !== null && (body.getAll("request").length !== 1 || body.getAll("request-session").length !== 1 || body.get("request-session") !== String(mateSession.id))) {
842
+ return said(409, "This conversation changed. Reload it before sending your message.");
843
+ }
819
844
  const principal = matePrincipal(who);
820
845
  if (principal === null)
821
- return refuse(response, who, 403, "your approver standing changed — sign in again", "/chat");
822
- const id = Number(coordinatorProposal[1]);
823
- store.sweepCoordinatorProposals(now);
824
- const back = body.get("return") === null ? "/chat" : safeReturn(body.get("return"));
825
- if (coordinatorProposal[2] === "dismiss") {
826
- dismissCoordinatorProposal(store, principal, id, now);
827
- return redirect(response, back);
828
- }
829
- const outcome = confirmCoordinatorProposal(store, principal, id, now, { confirm: body.get("confirm") === "yes", via: "web" });
830
- if (!outcome.ok && (outcome.reason === "not-yours" || outcome.reason === "standing")) {
831
- return refuse(response, who, outcome.reason === "standing" ? 403 : 404, outcome.said, back);
832
- }
833
- return redirect(response, outcome.ok || outcome.reason !== "needs-confirm" ? back : `${back}${back.includes("?") ? "&" : "?"}said=${encodeURIComponent(outcome.said)}`);
834
- }
835
- if (url.pathname === "/chat") {
836
- const body = readForm(posted, CONSOLE_FORMS.chat);
837
- if (who.via !== "cookie")
838
- return refuse(response, who, 403, "chat is a browser surface");
839
- const requestedTask = body.get("task");
840
- const focusTask = taskChatFocus(requestedTask, now, who);
841
- const chatProject = focusTask !== null ? null : chatProjectOf(body.getAll("project"));
842
- // The enhanced send (package 2): the SAME endpoint, fields, request
843
- // receipt key, and session binding as the native form — only the
844
- // answer differs. A fetch that asks for JSON gets the refusal's
845
- // words or an acceptance instead of a redirect; nothing is retried,
846
- // cleared, or authorized here that the native POST would not.
847
- const wantsJson = String(request.headers["accept"] ?? "").includes("application/json");
848
- if (requestedTask !== null && focusTask === null) {
849
- return wantsJson
850
- ? respond(response, 404, "application/json", JSON.stringify({ ok: false, said: "That task is not available in this workspace.", session: null }))
851
- : redirect(response, chatReturnWithSaid("/chat", "That task is not available in this workspace."));
852
- }
853
- if (chatProject === undefined) {
854
- return wantsJson
855
- ? respond(response, 404, "application/json", JSON.stringify({ ok: false, said: "That project is not available in this workspace.", session: null }))
856
- : redirect(response, chatReturnWithSaid("/chat", "That project is not available in this workspace."));
857
- }
858
- const back = focusTask !== null ? taskChatHref(focusTask.id) : chatProject !== null ? projectChatHref(chatProject) : "/chat";
859
- const said = (status, words, session = null) => wantsJson ? respond(response, status, "application/json", JSON.stringify({ ok: false, said: words, session })) : redirect(response, chatReturnWithSaid(back, words));
860
- const resultValue = body.get("result");
861
- const viewedRun = resultValue === null ? null : /^[0-9]{1,15}$/.test(resultValue) ? store.getRun(Number(resultValue)) : null;
862
- if (resultValue !== null && (body.getAll("result").length !== 1 || viewedRun === null || focusTask === null || !runVisible(viewedRun) || !focusTask.family.versions.some(one => one.refId === viewedRun.taskRef))) {
863
- return said(409, "That result is no longer available for this task. Review the task before sending.");
864
- }
865
- const resultContext = viewedRun === null ? "" : ` The operator is viewing result #${viewedRun.id} from execution ${store.externalIdFor(viewedRun.taskRef)}. Use get_result for that exact execution and run when responding to feedback; do not substitute another result.`;
866
- // The task composer's mode (console v2): fixed words for this turn only.
867
- // It steers what the lead proposes and grants nothing: every action
868
- // still arrives as a card the person confirms under its own rules.
869
- const modeValue = body.get("mode");
870
- if (modeValue !== null && (focusTask === null || body.getAll("mode").length !== 1 || !Object.hasOwn(TASK_COMPOSER_MODES, modeValue))) {
871
- return said(400, "Choose Build, Plan only or Just answer, then send again. Your draft is saved.");
872
- }
873
- const modeContext = modeValue === null ? "" : ` ${TASK_COMPOSER_MODES[modeValue]}`;
874
- const enabled = chatEnablement();
875
- if (!enabled.ok)
876
- return said(409, enabled.why);
877
- // A live mate session: the message is a mate turn — no password, the
878
- // session's ceremony already covered it (§1); the engine refuses on
879
- // its own terms and the thread shows why.
880
- const mateSession = who.role === "approver" ? store.activeMateSession(who.name) : null;
881
- if (mateSession !== null) {
882
- const requestId = body.get("request");
883
- if (requestId !== null && (body.getAll("request").length !== 1 || body.getAll("request-session").length !== 1 || body.get("request-session") !== String(mateSession.id))) {
884
- return said(409, "This conversation changed. Reload it before sending your message.");
885
- }
886
- const principal = matePrincipal(who);
887
- if (principal === null)
888
- return wantsJson ? said(403, "your approver standing changed — sign in again") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
889
- const message = (body.get("message") ?? "").trim();
890
- if (message === "" || message.length > MATE_MESSAGE_MAX_CHARS) {
891
- return said(400, `a message is 1 to ${MATE_MESSAGE_MAX_CHARS} characters`, mateSession.id);
892
- }
893
- const opened = store.openMateThread(who.name, principal.ceilingDigest, now, chatScopeOf(focusTask, chatProject));
894
- const onProgress = beginLiveTurn(opened.thread.id);
895
- void runMateTurn({ store, who: principal, session: mateSession, thread: opened.thread, config: enabled.config, key: enabled.key, message, onProgress, channel: "console", ...(requestId === null ? {} : { requestId }), ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}${modeContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot })
896
- .then(outcome => {
897
- // A turn that saved its outcome in the thread (one stopped at its deadline) is said there, once.
898
- if (!outcome.ok && !("saved" in outcome && outcome.saved === true))
899
- noteMate(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
900
- endLiveTurn(opened.thread.id, outcome.ok);
901
- })
902
- .catch(() => { noteMate(who.session.csrf, null, "the turn failed unexpectedly"); endLiveTurn(opened.thread.id, false); });
903
- // Accepted for the engine, not received: the receipt is written by
904
- // the turn's own admission, and the status poll is where the page
905
- // learns of it — the same road as after a native send.
906
- if (wantsJson)
907
- return respond(response, 202, "application/json", JSON.stringify({ ok: true, session: mateSession.id, task: focusTask?.id ?? "", ...(chatProject === null ? {} : { project: chatProject }), request: requestId }));
908
- return redirect(response, chatReturnWithLatest(back));
909
- }
910
- if (wantsJson)
911
- return said(409, "This conversation ended. Reload to continue.");
912
- if (focusTask !== null) {
913
- return redirect(response, chatReturnWithSaid(back, "Start the conversation first, then ask about this task without another password prompt."));
914
- }
915
- if (enabled.billing === "subscription") {
916
- return redirect(response, `/chat?said=${encodeURIComponent("start the conversation first — the one password ceremony opens the subscription-backed session")}`);
917
- }
918
- // The password, typed again, on EVERY message (v2 ruling 2): chat is
919
- // spend, and a seven-day cookie is not a spend credential.
920
- const password = body.get("token") ?? "";
921
- if (password === "" || !authenticateApprover(who, password).ok) {
922
- return redirect(response, `/chat?said=${encodeURIComponent("chat spends — your password, typed again, with every message")}`);
923
- }
846
+ return wantsJson ? said(403, "your approver standing changed — sign in again") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
924
847
  const message = (body.get("message") ?? "").trim();
925
- if (message === "" || message.length > 2_000) {
926
- return redirect(response, `/chat?said=${encodeURIComponent("a message is 1 to 2000 characters")}`);
927
- }
928
- // Secrets refuse BEFORE any row or request exists — nothing stored,
929
- // nothing sent (v3 brief; scanForSecrets is the same high-confidence
930
- // set the evidence path trusts).
931
- if (scanForSecrets(message).length > 0) {
932
- return redirect(response, `/chat?said=${encodeURIComponent("that looks like a credential — chat never forwards or stores those")}`);
933
- }
934
- store.sweepStaleChatTurns(now);
935
- const turnRepos = managedRepos();
936
- const snapshot = withDispatchDiagnoses(store, store.chatSnapshot(turnRepos, now), now);
937
- const { document } = buildDataDocument(snapshot);
938
- // The WHOLE outbound body is scanned — a token in a task title
939
- // refuses the turn exactly like one typed in the box (v2 ruling 4).
940
- const composed = composeRequest({
941
- provider: enabled.config.provider,
942
- model: enabled.config.model,
943
- key: "",
944
- dataDocument: document,
945
- userMessage: message,
946
- });
947
- if (scanForSecrets(composed.body).length > 0) {
948
- return redirect(response, `/chat?said=${encodeURIComponent("fleet context contains something credential-shaped — chat refuses to send it; find and remove it first")}`);
949
- }
950
- // Sprint 8: the organisation policy allows this chat's provider and model, or the turn doesn't start.
951
- const disallowed = store.agentPolicyRefusal(enabled.config.provider, enabled.config.model);
952
- if (disallowed !== null)
953
- return redirect(response, `/chat?said=${encodeURIComponent(disallowed)}`);
954
- const reserved = worstCaseForPrice(enabled.price, Buffer.byteLength(composed.body, "utf8"));
955
- const opened = store.openChatTurn({
956
- approver: who.name,
957
- credentialKey: enabled.credentialKey,
958
- provider: enabled.config.provider,
959
- model: enabled.config.model,
960
- reservedMicrousd: reserved,
961
- dailyTurns: enabled.config.dailyTurns,
962
- weeklyCeilingMicrousd: enabled.config.weeklyCeilingMicrousd,
963
- deadlineMs: TURN_WALL_CLOCK_MS + 10_000,
964
- }, now);
965
- if (!opened.ok) {
966
- const said = opened.reason === "latched"
967
- ? "a turn with unknown cost blocks this credential — acknowledge it below first"
968
- : opened.reason === "concurrent"
969
- ? "one turn at a time — this one is still running"
970
- : opened.reason === "daily-cap"
971
- ? "the daily turn cap is reached"
972
- : opened.reason === "monthly-budget"
973
- ? "a monthly budget this counts toward holds it: used up, or its cost can't be priced yet (the Spend page says which)"
974
- : "the weekly spend ceiling would be exceeded";
975
- return redirect(response, `/chat?said=${encodeURIComponent(said)}`);
976
- }
977
- void runChatTurn(opened.id, who.session, enabled, message, document, turnRepos);
978
- return redirect(response, "/chat#latest");
979
- }
980
- const chatFile = /^\/chat\/file\/([0-9a-f]{32})$/.exec(url.pathname);
981
- if (chatFile !== null) {
982
- const body = readForm(posted, CONSOLE_FORMS.chatFile);
983
- if (who.via !== "cookie")
984
- return refuse(response, who, 403, "chat is a browser surface");
985
- const key = chatFile[1];
986
- const chat = who.session.chat;
987
- const candidate = chat?.candidates.get(key);
988
- // The filing act creates durable rows from model text: password again.
989
- const password = body.get("token") ?? "";
990
- if (password === "" || !authenticateApprover(who, password).ok) {
991
- return redirect(response, `/chat?said=${encodeURIComponent("filing a draft takes your password, typed again")}`);
992
- }
993
- if (chat === undefined || candidate === undefined) {
994
- return refuse(response, who, 404, "that draft is gone — drafts live in the session and do not survive restarts", "/chat");
995
- }
996
- // Single-use CAS with no await between check and claim (v2 new 3).
997
- if (candidate.state !== "pending")
998
- return refuse(response, who, 409, "that draft is already being filed", "/chat");
999
- candidate.state = "filing";
1000
- const enabled = chatEnablement();
1001
- if (!enabled.ok || candidate.approver !== who.name || candidate.ceilingDigest !== chatCeilingDigest()) {
1002
- candidate.state = "pending";
1003
- return refuse(response, who, 409, "the world changed since this draft was made — it cannot be filed", "/chat");
1004
- }
1005
- // Re-validated at the act: the door runs every field check again, and
1006
- // the fields are scanned for secrets before they become durable.
1007
- const acceptanceFields = candidate.draft.acceptance.flatMap(c => [c.statement, ...(c.how === null ? [] : [c.how])]);
1008
- const fields = [candidate.draft.title, candidate.draft.goal, candidate.draft.outOfScope ?? "", ...candidate.draft.touches, ...acceptanceFields];
1009
- if (scanForSecrets(fields.join("\n")).length > 0) {
1010
- chat.candidates.delete(key);
1011
- return refuse(response, who, 400, "that draft contains something credential-shaped — discarded", "/chat");
1012
- }
1013
- const filedVia = `chat:${enabled.config.provider}`;
1014
- const made = fileTaskProposal(store, {
1015
- title: candidate.draft.title,
1016
- repo: candidate.repoPath,
1017
- goal: candidate.draft.goal,
1018
- outOfScope: candidate.draft.outOfScope,
1019
- touches: candidate.draft.touches,
1020
- acceptance: candidate.draft.acceptance,
1021
- filedVia, filedBy: { name: who.name, kind: "person" },
1022
- admittedRepos: managedRepos(),
1023
- }, now);
1024
- if (!made.ok) {
1025
- candidate.state = "pending";
1026
- return refuse(response, who, 400, `the door refused it: ${made.message}`, "/chat");
1027
- }
848
+ if (message === "" || message.length > MATE_MESSAGE_MAX_CHARS) {
849
+ return said(400, `a message is 1 to ${MATE_MESSAGE_MAX_CHARS} characters`, mateSession.id);
850
+ }
851
+ const opened = store.openMateThread(who.name, principal.ceilingDigest, now, chatScopeOf(focusTask, chatProject));
852
+ const onProgress = beginLiveTurn(opened.thread.id);
853
+ void runMateTurn({ store, who: principal, session: mateSession, thread: opened.thread, config: enabled.config, key: enabled.key, message, onProgress, channel: "console", ...(requestId === null ? {} : { requestId }), ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}${modeContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot })
854
+ .then(outcome => {
855
+ // A turn that saved its outcome in the thread (one stopped at its deadline) is said there, once.
856
+ if (!outcome.ok && !("saved" in outcome && outcome.saved === true))
857
+ noteMate(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
858
+ endLiveTurn(opened.thread.id, outcome.ok);
859
+ })
860
+ .catch(() => { noteMate(who.session.csrf, null, "the turn failed unexpectedly"); endLiveTurn(opened.thread.id, false); });
861
+ // Accepted for the engine, not received: the receipt is written by
862
+ // the turn's own admission, and the status poll is where the page
863
+ // learns of it — the same road as after a native send.
864
+ if (wantsJson)
865
+ return respond(response, 202, "application/json", JSON.stringify({ ok: true, session: mateSession.id, task: focusTask?.id ?? "", ...(chatProject === null ? {} : { project: chatProject }), request: requestId }));
866
+ return redirect(response, chatReturnWithLatest(back));
867
+ }
868
+ if (wantsJson)
869
+ return said(409, "This conversation ended. Reload to continue.");
870
+ if (focusTask !== null) {
871
+ return redirect(response, chatReturnWithSaid(back, "Start the conversation first, then ask about this task without another password prompt."));
872
+ }
873
+ if (enabled.billing === "subscription") {
874
+ return redirect(response, `/chat?said=${encodeURIComponent("start the conversation first — the one password ceremony opens the subscription-backed session")}`);
875
+ }
876
+ // The password, typed again, on EVERY message (v2 ruling 2): chat is
877
+ // spend, and a seven-day cookie is not a spend credential.
878
+ const password = body.get("token") ?? "";
879
+ if (password === "" || !authenticateApprover(who, password).ok) {
880
+ return redirect(response, `/chat?said=${encodeURIComponent("chat spends — your password, typed again, with every message")}`);
881
+ }
882
+ const message = (body.get("message") ?? "").trim();
883
+ if (message === "" || message.length > 2_000) {
884
+ return redirect(response, `/chat?said=${encodeURIComponent("a message is 1 to 2000 characters")}`);
885
+ }
886
+ // Secrets refuse BEFORE any row or request exists — nothing stored,
887
+ // nothing sent (v3 brief; scanForSecrets is the same high-confidence
888
+ // set the evidence path trusts).
889
+ if (scanForSecrets(message).length > 0) {
890
+ return redirect(response, `/chat?said=${encodeURIComponent("that looks like a credential — chat never forwards or stores those")}`);
891
+ }
892
+ store.sweepStaleChatTurns(now);
893
+ const turnRepos = managedRepos();
894
+ const snapshot = withDispatchDiagnoses(store, store.chatSnapshot(turnRepos, now), now);
895
+ const { document } = buildDataDocument(snapshot);
896
+ // The WHOLE outbound body is scanned — a token in a task title
897
+ // refuses the turn exactly like one typed in the box (v2 ruling 4).
898
+ const composed = composeRequest({
899
+ provider: enabled.config.provider,
900
+ model: enabled.config.model,
901
+ key: "",
902
+ dataDocument: document,
903
+ userMessage: message,
904
+ });
905
+ if (scanForSecrets(composed.body).length > 0) {
906
+ return redirect(response, `/chat?said=${encodeURIComponent("fleet context contains something credential-shaped — chat refuses to send it; find and remove it first")}`);
907
+ }
908
+ // Sprint 8: the organisation policy allows this chat's provider and model, or the turn doesn't start.
909
+ const disallowed = store.agentPolicyRefusal(enabled.config.provider, enabled.config.model);
910
+ if (disallowed !== null)
911
+ return redirect(response, `/chat?said=${encodeURIComponent(disallowed)}`);
912
+ const reserved = worstCaseForPrice(enabled.price, Buffer.byteLength(composed.body, "utf8"));
913
+ const opened = store.openChatTurn({
914
+ approver: who.name,
915
+ credentialKey: enabled.credentialKey,
916
+ provider: enabled.config.provider,
917
+ model: enabled.config.model,
918
+ reservedMicrousd: reserved,
919
+ dailyTurns: enabled.config.dailyTurns,
920
+ weeklyCeilingMicrousd: enabled.config.weeklyCeilingMicrousd,
921
+ deadlineMs: TURN_WALL_CLOCK_MS + 10_000,
922
+ }, now);
923
+ if (!opened.ok) {
924
+ const said = opened.reason === "latched"
925
+ ? "a turn with unknown cost blocks this credential — acknowledge it below first"
926
+ : opened.reason === "concurrent"
927
+ ? "one turn at a time — this one is still running"
928
+ : opened.reason === "daily-cap"
929
+ ? "the daily turn cap is reached"
930
+ : opened.reason === "monthly-budget"
931
+ ? "a monthly budget this counts toward holds it: used up, or its cost can't be priced yet (the Spend page says which)"
932
+ : "the weekly spend ceiling would be exceeded";
933
+ return redirect(response, `/chat?said=${encodeURIComponent(said)}`);
934
+ }
935
+ void runChatTurn(opened.id, who.session, enabled, message, document, turnRepos);
936
+ return redirect(response, "/chat#latest");
937
+ }
938
+ async function chatFile(ctx) {
939
+ const { url, who, response, now, posted } = ctx;
940
+ const body = readForm(posted, CONSOLE_FORMS.chatFile);
941
+ if (who.via !== "cookie")
942
+ return refuse(response, who, 403, "chat is a browser surface");
943
+ const key = url.pathname.split("/")[3];
944
+ const chat = who.session.chat;
945
+ const candidate = chat?.candidates.get(key);
946
+ // The filing act creates durable rows from model text: password again.
947
+ const password = body.get("token") ?? "";
948
+ if (password === "" || !authenticateApprover(who, password).ok) {
949
+ return redirect(response, `/chat?said=${encodeURIComponent("filing a draft takes your password, typed again")}`);
950
+ }
951
+ if (chat === undefined || candidate === undefined) {
952
+ return refuse(response, who, 404, "that draft is gone — drafts live in the session and do not survive restarts", "/chat");
953
+ }
954
+ // Single-use CAS with no await between check and claim (v2 new 3).
955
+ if (candidate.state !== "pending")
956
+ return refuse(response, who, 409, "that draft is already being filed", "/chat");
957
+ candidate.state = "filing";
958
+ const enabled = chatEnablement();
959
+ if (!enabled.ok || candidate.approver !== who.name || candidate.ceilingDigest !== chatCeilingDigest()) {
960
+ candidate.state = "pending";
961
+ return refuse(response, who, 409, "the world changed since this draft was made — it cannot be filed", "/chat");
962
+ }
963
+ // Re-validated at the act: the door runs every field check again, and
964
+ // the fields are scanned for secrets before they become durable.
965
+ const acceptanceFields = candidate.draft.acceptance.flatMap(c => [c.statement, ...(c.how === null ? [] : [c.how])]);
966
+ const fields = [candidate.draft.title, candidate.draft.goal, candidate.draft.outOfScope ?? "", ...candidate.draft.touches, ...acceptanceFields];
967
+ if (scanForSecrets(fields.join("\n")).length > 0) {
1028
968
  chat.candidates.delete(key);
1029
- return redirect(response, taskHref(made.id));
1030
- }
1031
- const chatAckPost = /^\/chat\/ack\/([0-9]{1,15})$/.exec(url.pathname);
1032
- if (chatAckPost !== null) {
1033
- const body = readForm(posted, CONSOLE_FORMS.chatAck);
1034
- if (who.via !== "cookie")
1035
- return refuse(response, who, 403, "acknowledgement is a browser ceremony");
1036
- const turn = store.getChatTurn(Number(chatAckPost[1]));
1037
- if (turn === null)
1038
- return refuse(response, who, 404, "no such turn", "/chat");
1039
- const password = body.get("token") ?? "";
1040
- if (password === "" || !authenticateApprover(who, password).ok) {
1041
- return refuse(response, who, 403, "acknowledging unknown spend takes your password", "/chat");
1042
- }
1043
- const nonce = body.get("nonce") ?? "";
1044
- if (!consumeApprovalNonce(nonce, who.name, `chat-ack-${turn.id}`, String(turn.reservedMicrousd))) {
1045
- return refuse(response, who, 409, "this screen expired — reopen it and read the terms again", "/chat");
1046
- }
1047
- if (!store.acknowledgeChatTurn(turn.id, who.name, now)) {
1048
- return refuse(response, who, 409, "already acknowledged", "/chat");
1049
- }
1050
- return redirect(response, "/chat");
969
+ return refuse(response, who, 400, "that draft contains something credential-shaped — discarded", "/chat");
970
+ }
971
+ const filedVia = `chat:${enabled.config.provider}`;
972
+ const made = fileTaskProposal(store, {
973
+ title: candidate.draft.title,
974
+ repo: candidate.repoPath,
975
+ goal: candidate.draft.goal,
976
+ outOfScope: candidate.draft.outOfScope,
977
+ touches: candidate.draft.touches,
978
+ acceptance: candidate.draft.acceptance,
979
+ filedVia, filedBy: { name: who.name, kind: "person" },
980
+ admittedRepos: managedRepos(),
981
+ }, now);
982
+ if (!made.ok) {
983
+ candidate.state = "pending";
984
+ return refuse(response, who, 400, `the door refused it: ${made.message}`, "/chat");
985
+ }
986
+ chat.candidates.delete(key);
987
+ return redirect(response, taskHref(made.id));
988
+ }
989
+ async function chatAckSend(ctx) {
990
+ const { url, who, response, now, posted } = ctx;
991
+ const body = readForm(posted, CONSOLE_FORMS.chatAck);
992
+ if (who.via !== "cookie")
993
+ return refuse(response, who, 403, "acknowledgement is a browser ceremony");
994
+ const turn = store.getChatTurn(Number(url.pathname.split("/")[3]));
995
+ if (turn === null)
996
+ return refuse(response, who, 404, "no such turn", "/chat");
997
+ const password = body.get("token") ?? "";
998
+ if (password === "" || !authenticateApprover(who, password).ok) {
999
+ return refuse(response, who, 403, "acknowledging unknown spend takes your password", "/chat");
1000
+ }
1001
+ const nonce = body.get("nonce") ?? "";
1002
+ if (!consumeApprovalNonce(nonce, who.name, `chat-ack-${turn.id}`, String(turn.reservedMicrousd))) {
1003
+ return refuse(response, who, 409, "this screen expired — reopen it and read the terms again", "/chat");
1004
+ }
1005
+ if (!store.acknowledgeChatTurn(turn.id, who.name, now)) {
1006
+ return refuse(response, who, 409, "already acknowledged", "/chat");
1051
1007
  }
1052
- return refuse(response, who, 404, "There's no page at this address.", "/chat");
1008
+ return redirect(response, "/chat");
1053
1009
  }
1054
1010
  /** Chat's first run: the steps, first tasks until the first task exists, and the sandbox while no agent is signed in. */
1055
1011
  function chatFirstRun(now, project) {
@@ -1094,6 +1050,8 @@ export function createChatHandlers(runtime) {
1094
1050
  clearTimeout(previous.expiry);
1095
1051
  const live = { steps: [], done: false, ok: false, listeners: previous?.listeners ?? new Set() };
1096
1052
  liveTurns.set(thread, live);
1053
+ // A page that has this conversation open follows the reply (GET /live, its chat room).
1054
+ runtime.liveTurnStarted(thread);
1097
1055
  const current = () => live.steps.at(-1) ?? (live.steps.push({ tools: [], toolCalls: [], text: "" }), live.steps.at(-1));
1098
1056
  return event => {
1099
1057
  if (live.done)
@@ -1406,30 +1364,35 @@ export function createChatHandlers(runtime) {
1406
1364
  if (oldest !== null)
1407
1365
  oldest.session.chat?.candidates.delete(oldest.key);
1408
1366
  }
1409
- const registrations = [
1410
- { id: "chat.stream", domain: "chat", stage: "console", method: "GET", handle: get },
1411
- { id: "chat.mate-status", domain: "chat", stage: "console", method: "GET", handle: get },
1412
- { id: "chat.demo-live", domain: "chat", stage: "console", method: "GET", handle: get },
1413
- { id: "chat.page", domain: "chat", stage: "console", method: "GET", handle: get },
1414
- { id: "chat.ack", domain: "chat", stage: "console", method: "GET", handle: get },
1415
- { id: "chat.action", domain: "chat", stage: "console", method: "GET", handle: get },
1416
- { id: "push.key", domain: "chat", stage: "console", method: "GET", handle: get },
1417
- { id: "lead.status", domain: "chat", stage: "console", method: "GET", handle: get },
1418
- { id: "chat.task-status", domain: "chat", stage: "console", method: "GET", handle: get },
1419
- { id: "chat.demo", domain: "chat", stage: "console", method: "POST", handle: post },
1420
- { id: "push.subscribe", domain: "chat", stage: "console", method: "POST", handle: post },
1421
- { id: "push.remove", domain: "chat", stage: "console", method: "POST", handle: post },
1422
- { id: "onboarding.phone-dismiss", domain: "chat", stage: "console", method: "POST", handle: post },
1423
- { id: "chat.config", domain: "chat", stage: "console", method: "POST", handle: post },
1424
- { id: "chat.mate-mint", domain: "chat", stage: "console", method: "POST", handle: post },
1425
- { id: "chat.mate-follow", domain: "chat", stage: "console", method: "POST", handle: post },
1426
- { id: "chat.mate-end", domain: "chat", stage: "console", method: "POST", handle: post },
1427
- { id: "chat.mate-stop", domain: "chat", stage: "console", method: "POST", handle: post },
1428
- { id: "chat.proposal", domain: "chat", stage: "console", method: "POST", handle: post },
1429
- { id: "coordinator.proposal", domain: "chat", stage: "console", method: "POST", handle: post },
1430
- { id: "chat.send", domain: "chat", stage: "console", method: "POST", handle: post },
1431
- { id: "chat.file", domain: "chat", stage: "console", method: "POST", handle: post },
1432
- { id: "chat.ack-send", domain: "chat", stage: "console", method: "POST", handle: post },
1433
- ];
1434
- return { registrations, get, post, chatFirstRun, phoneCard, chatProjectOf, beginLiveTurn, endLiveTurn, noteMate, takeMateNote, demoResultView, storeChatKey, forgetChatKey, sweepChatDrafts, runChatTurn, chatHomeOf, firstTasksFor, approverCandidateCount, evictOldestCandidate };
1367
+ const registrations = handlersOf("chat", {
1368
+ "chat.mate-status": mateStatus,
1369
+ "chat.demo-live": demoLive,
1370
+ "chat.page": chatView,
1371
+ "chat.ack": chatAck,
1372
+ "chat.action": sharedActionReview,
1373
+ "push.key": pushKey,
1374
+ "lead.status": leadStatus,
1375
+ "chat.task-status": taskStatus,
1376
+ "chat.demo.ask": demoAsk,
1377
+ "chat.demo.approve": demoAct("approve"),
1378
+ "chat.demo.change": demoAct("change"),
1379
+ "chat.demo.revise": demoAct("revise"),
1380
+ "chat.demo.complete": demoAct("complete"),
1381
+ "push.subscribe": pushSubscribe,
1382
+ "push.remove": pushRemove,
1383
+ "onboarding.phone-dismiss": phoneDismiss,
1384
+ "chat.config": chatConfig,
1385
+ "chat.mate-mint": mateMint,
1386
+ "chat.mate-follow": mateFollow,
1387
+ "chat.mate-end": mateEnd,
1388
+ "chat.mate-stop": mateStop,
1389
+ "chat.proposal.confirm": chatProposalAct("confirm"),
1390
+ "chat.proposal.dismiss": chatProposalAct("dismiss"),
1391
+ "coordinator.proposal.confirm": coordinatorProposalAct("confirm"),
1392
+ "coordinator.proposal.dismiss": coordinatorProposalAct("dismiss"),
1393
+ "chat.send": chatSend,
1394
+ "chat.file": chatFile,
1395
+ "chat.ack-send": chatAckSend,
1396
+ });
1397
+ return { registrations, chatFirstRun, phoneCard, chatProjectOf, beginLiveTurn, endLiveTurn, noteMate, takeMateNote, demoResultView, storeChatKey, forgetChatKey, sweepChatDrafts, runChatTurn, chatHomeOf, firstTasksFor, approverCandidateCount, evictOldestCandidate };
1435
1398
  }