toolroll 0.9.49 → 0.9.50

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 (51) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/cli-http.js +3 -0
  3. package/dist/flow-triggers.js +7 -0
  4. package/dist/mcp-http.js +6 -11
  5. package/dist/mcp-oauth.js +5 -21
  6. package/dist/mcp-person.d.ts +0 -9
  7. package/dist/mcp-person.js +2 -1
  8. package/dist/operate.js +4 -1
  9. package/dist/serve.d.ts +2 -605
  10. package/dist/serve.js +423 -23783
  11. package/dist/server/chat.d.ts +41 -0
  12. package/dist/server/chat.js +1458 -0
  13. package/dist/server/edge-refusal.d.ts +5 -0
  14. package/dist/server/edge-refusal.js +56 -0
  15. package/dist/server/flows.d.ts +12 -0
  16. package/dist/server/flows.js +832 -0
  17. package/dist/server/guards.d.ts +72 -0
  18. package/dist/server/guards.js +357 -0
  19. package/dist/server/handler-context.d.ts +22 -0
  20. package/dist/server/handler-context.js +1 -0
  21. package/dist/server/handler-registry.d.ts +21 -0
  22. package/dist/server/handler-registry.js +34 -0
  23. package/dist/server/pages-assets.d.ts +10 -0
  24. package/dist/server/pages-assets.js +103 -0
  25. package/dist/server/pages.d.ts +11 -0
  26. package/dist/server/pages.js +910 -0
  27. package/dist/server/people-tokens.d.ts +20 -0
  28. package/dist/server/people-tokens.js +694 -0
  29. package/dist/server/remote-hooks.d.ts +29 -0
  30. package/dist/server/remote-hooks.js +130 -0
  31. package/dist/server/remote.d.ts +59 -0
  32. package/dist/server/remote.js +185 -0
  33. package/dist/server/route-policy.d.ts +18 -0
  34. package/dist/server/route-policy.js +30 -0
  35. package/dist/server/route-table.d.ts +74 -0
  36. package/dist/server/route-table.js +350 -0
  37. package/dist/server/runtime.d.ts +280 -0
  38. package/dist/server/runtime.js +25 -0
  39. package/dist/server/settings.d.ts +22 -0
  40. package/dist/server/settings.js +2388 -0
  41. package/dist/server/shared.d.ts +3334 -0
  42. package/dist/server/shared.js +12708 -0
  43. package/dist/server/task-views.d.ts +82 -0
  44. package/dist/server/task-views.js +1099 -0
  45. package/dist/server/tasks.d.ts +87 -0
  46. package/dist/server/tasks.js +3579 -0
  47. package/dist/session-http.js +3 -0
  48. package/dist/team-http.d.ts +5 -1
  49. package/dist/team-http.js +6 -2
  50. package/dist/teams.js +3 -0
  51. package/package.json +1 -1
@@ -0,0 +1,1458 @@
1
+ /** chat handlers, moved without changing their route bodies. */
2
+ import { randomBytes } from "node:crypto";
3
+ import { chmodSync, rmSync as rmFileSync, writeFileSync as writeFsFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { assignmentCatchUp } from '../assignment-brief.js';
6
+ import { assignmentOf, checkAssignmentAsOperator } from '../assignment.js';
7
+ import { browserWorkActionHref } from '../browser-workspace.js';
8
+ import { CHAT_ACTIONS, mintSharedActionReview, sharedActionPayload, sharedActionReviewPath } from '../chat-actions.js';
9
+ import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
10
+ import { hiddenFields } from "../control-ui.js";
11
+ import { buildDataDocument, composeRequest, isSubscriptionChatProvider, parseAssistantEnvelope, performChatRequest, plausibleChatKey, priceOf, settleForPrice, TURN_WALL_CLOCK_MS, worstCaseForPrice } from "../converse.js";
12
+ import { DEMO_CHAT_SCRIPT, demoChatHtml, demoThreadHtml } from "../demo-chat.js";
13
+ import { withDispatchDiagnoses } from "../dispatch.js";
14
+ import { readVerifiedArtifact, scanForSecrets } from "../evidence.js";
15
+ import { run as execRun } from "../exec.js";
16
+ import { findFirstTasks, firstTaskSuggestions, HOW_IT_WORKS, SANDBOX_COMMAND } from "../first-run.js";
17
+ import { leadBriefHtml } from '../lead-context.js';
18
+ import { configureLeadFollow, leadFollowStatus } from '../lead-follow.js';
19
+ import { leadActivity } from "../lead-voice.js";
20
+ import { limitsView } from "../limits-ui.js";
21
+ import { confirmCoordinatorProposal, confirmMateProposal, dismissCoordinatorProposal, dismissMateProposal } from "../mate-doors.js";
22
+ import { MATE_MESSAGE_MAX_CHARS, runMateTurn } from "../mate.js";
23
+ import { livePin, modelOptions } from "../model-catalog.js";
24
+ import { verifyApproverByPassword, verifyApproverStanding } from "../principal.js";
25
+ import { projectName } from "../project.js";
26
+ import { fileRoutineProposal, fileTaskProposal } from "../proposal.js";
27
+ import { providerName } from "../provider-auth.js";
28
+ import { validModelId } from "../provider.js";
29
+ import { loadOrCreateVapidKeys, validatePushEndpoint } from "../push.js";
30
+ import { parseResultTab, RESULT_REVIEW_SCRIPT } from "../result-review.js";
31
+ import {} from "../store.js";
32
+ import { runActivityOf } from "../task-activity.js";
33
+ import { teamWorkspaceHtml } from '../team-ui.js';
34
+ import { loadBotToken, redactToken } from "../telegram.js";
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";
37
+ 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
+ }));
151
+ }
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
+ }));
162
+ }
163
+ if (url.pathname === "/chat" && demoLeadHere() !== null && who.via === "cookie" &&
164
+ !["task", "result", "project", "conversation", "team", "proposal"].some(name => url.searchParams.has(name))) {
165
+ const lead = demoLeadHere();
166
+ 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 }), {
168
+ chrome: chromeFor(null, "chat", undefined, "all"),
169
+ functional: { script: DEMO_CHAT_SCRIPT, fetches: true },
170
+ }));
171
+ }
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
+ },
278
+ }));
279
+ }
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)],
393
+ 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");
410
+ }
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
+ }
432
+ }
433
+ else
434
+ principal = matePrincipal(who);
435
+ if (principal === null)
436
+ return refuse(response, who, 403, 'Your access changed. Sign in again.', '/chat');
437
+ 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') }));
450
+ }
451
+ catch (error) {
452
+ return refuse(response, who, 409, error instanceof Error ? error.message : 'This action could not be reviewed.', '/chat');
453
+ }
454
+ }
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?.())]);
471
+ }
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");
485
+ }
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"));
648
+ }
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"));
651
+ }
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
+ }
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);
687
+ }
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);
709
+ }
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");
726
+ return redirect(response, chatReturnWithLatest(back));
727
+ }
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, held: options.attended?.coordinator, ...(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);
774
+ }
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));
779
+ }
780
+ if (!outcome.ok && shared)
781
+ return redirect(response, chatReturnWithSaid(back, outcome.said));
782
+ 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`);
809
+ }
810
+ return redirect(response, chatReturnWithLatest(back));
811
+ }
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");
819
+ const principal = matePrincipal(who);
820
+ 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
+ }
924
+ 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.kind === "task"
1009
+ ? [candidate.draft.title, candidate.draft.goal, candidate.draft.outOfScope ?? "", ...candidate.draft.touches, ...acceptanceFields]
1010
+ : [candidate.draft.name, candidate.draft.goal, candidate.draft.outOfScope ?? "", ...candidate.draft.touches, ...acceptanceFields];
1011
+ if (scanForSecrets(fields.join("\n")).length > 0) {
1012
+ chat.candidates.delete(key);
1013
+ return refuse(response, who, 400, "that draft contains something credential-shaped — discarded", "/chat");
1014
+ }
1015
+ const filedVia = `chat:${enabled.config.provider}`;
1016
+ if (candidate.draft.kind === "task") {
1017
+ const made = fileTaskProposal(store, {
1018
+ title: candidate.draft.title,
1019
+ repo: candidate.repoPath,
1020
+ goal: candidate.draft.goal,
1021
+ outOfScope: candidate.draft.outOfScope,
1022
+ touches: candidate.draft.touches,
1023
+ acceptance: candidate.draft.acceptance,
1024
+ filedVia, filedBy: { name: who.name, kind: "person" },
1025
+ admittedRepos: managedRepos(),
1026
+ }, now);
1027
+ if (!made.ok) {
1028
+ candidate.state = "pending";
1029
+ return refuse(response, who, 400, `the door refused it: ${made.message}`, "/chat");
1030
+ }
1031
+ chat.candidates.delete(key);
1032
+ return redirect(response, taskHref(made.id));
1033
+ }
1034
+ const made = fileRoutineProposal(store, {
1035
+ name: candidate.draft.name,
1036
+ repo: candidate.repoPath,
1037
+ goal: candidate.draft.goal,
1038
+ outOfScope: candidate.draft.outOfScope,
1039
+ touches: candidate.draft.touches,
1040
+ acceptance: candidate.draft.acceptance,
1041
+ requirements: [],
1042
+ schedule: candidate.draft.schedule,
1043
+ costCeilingUsd: null,
1044
+ filedVia, createdBy: who.name,
1045
+ admittedRepos: managedRepos(),
1046
+ }, now);
1047
+ if (!made.ok) {
1048
+ candidate.state = "pending";
1049
+ return refuse(response, who, 400, `the door refused it: ${made.message}`, "/chat");
1050
+ }
1051
+ chat.candidates.delete(key);
1052
+ return redirect(response, `/routines/${made.id}`);
1053
+ }
1054
+ const chatAckPost = /^\/chat\/ack\/([0-9]{1,15})$/.exec(url.pathname);
1055
+ if (chatAckPost !== null) {
1056
+ const body = readForm(posted, CONSOLE_FORMS.chatAck);
1057
+ if (who.via !== "cookie")
1058
+ return refuse(response, who, 403, "acknowledgement is a browser ceremony");
1059
+ const turn = store.getChatTurn(Number(chatAckPost[1]));
1060
+ if (turn === null)
1061
+ return refuse(response, who, 404, "no such turn", "/chat");
1062
+ const password = body.get("token") ?? "";
1063
+ if (password === "" || !authenticateApprover(who, password).ok) {
1064
+ return refuse(response, who, 403, "acknowledging unknown spend takes your password", "/chat");
1065
+ }
1066
+ const nonce = body.get("nonce") ?? "";
1067
+ if (!consumeApprovalNonce(nonce, who.name, `chat-ack-${turn.id}`, String(turn.reservedMicrousd))) {
1068
+ return refuse(response, who, 409, "this screen expired — reopen it and read the terms again", "/chat");
1069
+ }
1070
+ if (!store.acknowledgeChatTurn(turn.id, who.name, now)) {
1071
+ return refuse(response, who, 409, "already acknowledged", "/chat");
1072
+ }
1073
+ return redirect(response, "/chat");
1074
+ }
1075
+ return refuse(response, who, 404, "There's no page at this address.", "/chat");
1076
+ }
1077
+ /** Chat's first run: the steps, first tasks until the first task exists, and the sandbox while no agent is signed in. */
1078
+ function chatFirstRun(now, project) {
1079
+ const steps = firstRunStepsNow(now);
1080
+ if (steps === null)
1081
+ return undefined;
1082
+ const taskFiled = steps.find(one => one.key === "task")?.done ?? true;
1083
+ const repo = project ?? managedRepos()[0] ?? null;
1084
+ const agent = steps.find(one => one.key === "agent");
1085
+ const lead = leadWords();
1086
+ return {
1087
+ steps,
1088
+ suggestions: taskFiled || repo === null ? [] : firstTasksFor(repo),
1089
+ sandbox: agent !== undefined && !agent.done && !agent.checking ? SANDBOX_COMMAND : null,
1090
+ intro: HOW_IT_WORKS,
1091
+ lead: lead === null ? null : { words: `The lead uses ${lead}`, href: "/settings/lead" },
1092
+ // With no lead yet and no agent signed in, the page asks this computer again on its own.
1093
+ recheck: store.getChatConfig() === null && agent !== undefined && !agent.done ? "/lead/status" : null,
1094
+ };
1095
+ }
1096
+ /** After the first Ready result, once (onboarding): the phone, by a chat app or the console over Tailscale. */
1097
+ /** Chat's one-line pointer to the phone setup: after the first Ready result, until put away or Telegram is paired. */
1098
+ function phoneCard(who, now) {
1099
+ if (who.role !== "approver" || store.isDemo() || store.firstSuccessAt(now) === null || store.installationFact(PHONE_CARD_FACT) !== null)
1100
+ return undefined;
1101
+ const botId = options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile)?.botId ?? null;
1102
+ if (botId !== null && store.liveTelegramBindings(botId).length > 0)
1103
+ return undefined;
1104
+ return phoneSetup(who);
1105
+ }
1106
+ /** The project a chat request names: exactly one, visible to this person
1107
+ * and one of this console's projects; undefined when refused. */
1108
+ function chatProjectOf(values) {
1109
+ if (values.length === 0)
1110
+ return null;
1111
+ const wanted = values[0] ?? "";
1112
+ return values.length === 1 && visible(wanted) && managedRepos().includes(wanted) ? wanted : undefined;
1113
+ }
1114
+ function beginLiveTurn(thread) {
1115
+ const previous = liveTurns.get(thread);
1116
+ if (previous?.expiry)
1117
+ clearTimeout(previous.expiry);
1118
+ const live = { steps: [], done: false, ok: false, listeners: previous?.listeners ?? new Set() };
1119
+ liveTurns.set(thread, live);
1120
+ const current = () => live.steps.at(-1) ?? (live.steps.push({ tools: [], toolCalls: [], text: "" }), live.steps.at(-1));
1121
+ return event => {
1122
+ if (live.done)
1123
+ return;
1124
+ if (event.kind === "step")
1125
+ live.steps.push({ tools: [], toolCalls: [], text: "" });
1126
+ else if (event.kind === "tool") {
1127
+ current().tools.push(event.label);
1128
+ current().toolCalls.push({ id: event.id, label: event.label, state: "running" });
1129
+ }
1130
+ else if (event.kind === "tool-result") {
1131
+ for (const step of live.steps) {
1132
+ const index = step.toolCalls.findIndex(tool => tool.id === event.id);
1133
+ if (index !== -1) {
1134
+ step.toolCalls[index] = { ...step.toolCalls[index], ...event.outcome };
1135
+ break;
1136
+ }
1137
+ }
1138
+ }
1139
+ else if (event.kind === "text")
1140
+ current().text = event.text;
1141
+ for (const listener of live.listeners)
1142
+ listener();
1143
+ };
1144
+ }
1145
+ function endLiveTurn(thread, ok) {
1146
+ const live = liveTurns.get(thread);
1147
+ if (live === undefined || live.done)
1148
+ return;
1149
+ live.done = true;
1150
+ live.ok = ok;
1151
+ for (const listener of live.listeners)
1152
+ listener();
1153
+ live.expiry = setTimeout(() => { if (liveTurns.get(thread) === live)
1154
+ liveTurns.delete(thread); }, 60_000);
1155
+ live.expiry.unref();
1156
+ }
1157
+ function noteMate(csrf, turn, message) {
1158
+ if (mateSaid.size >= 500) {
1159
+ const oldest = mateSaid.keys().next().value;
1160
+ if (oldest !== undefined)
1161
+ mateSaid.delete(oldest);
1162
+ }
1163
+ mateSaid.set(csrf, { turn, message });
1164
+ }
1165
+ function takeMateNote(csrf, liveSession) {
1166
+ const noted = mateSaid.get(csrf);
1167
+ if (noted === undefined)
1168
+ return null;
1169
+ mateSaid.delete(csrf);
1170
+ if (noted.turn === null)
1171
+ return noted.message;
1172
+ const turn = store.getMateTurn(noted.turn);
1173
+ return turn !== null && turn.session === liveSession ? noted.message : null;
1174
+ }
1175
+ /** A finished demo exchange's result, read back from the evidence its run actually stored. */
1176
+ function demoResultView(exchange) {
1177
+ if (exchange.runId === null || exchange.taskId === null)
1178
+ return null;
1179
+ const artifacts = store.artifactsFor(exchange.runId);
1180
+ const text = (kind) => {
1181
+ const artifact = artifacts.find(one => one.kind === kind);
1182
+ if (artifact === undefined)
1183
+ return null;
1184
+ const read = readVerifiedArtifact(evidenceRoot, artifact);
1185
+ return read.ok ? read.content.toString("utf8") : null;
1186
+ };
1187
+ const shot = artifacts.find(one => one.kind === "screenshot");
1188
+ const stat = (() => { try {
1189
+ return JSON.parse(text("diff-stat") ?? "null");
1190
+ }
1191
+ catch {
1192
+ return null;
1193
+ } })();
1194
+ const assignment = assignmentOf(store, exchange.taskId, clock(), { principal: "operator", repos: managedRepos() }, evidenceRoot);
1195
+ const log = text("check-log");
1196
+ return {
1197
+ diff: text("terminal-diff"),
1198
+ checkLog: log === null ? null : log.slice(log.indexOf("$ ") === -1 ? 0 : log.indexOf("$ ")),
1199
+ checks: { status: assignment?.receipt?.checks.status ?? "unavailable", detail: assignment?.receipt?.checks.detail ?? "Saved checks are unavailable." },
1200
+ screenshot: shot === undefined ? null : { href: `/r/${exchange.runId}/evidence/${shot.id}`, caption: exchange.plan.screenshot.caption },
1201
+ additions: stat?.additions ?? 0,
1202
+ deletions: stat?.deletions ?? 0,
1203
+ files: stat?.fileCount ?? 0,
1204
+ taskHref: taskHref(exchange.taskId),
1205
+ };
1206
+ }
1207
+ function storeChatKey(provider, key) {
1208
+ if (options.configDir === undefined) {
1209
+ return { ok: false, message: "this server has no config directory — export the key in the serve environment instead" };
1210
+ }
1211
+ if (!plausibleChatKey(provider, key)) {
1212
+ return { ok: false, message: "that does not look like an API key (expected sk-…) — nothing was stored" };
1213
+ }
1214
+ const file = join(options.configDir, `chat-key-${provider}`);
1215
+ writeFsFileSync(file, `${key.trim()}\n`, { mode: 0o600 });
1216
+ // writeFileSync applies the mode only on creation; assert it regardless.
1217
+ chmodSync(file, 0o600);
1218
+ runtime.catalogCache = null; // a new key may see a different catalog
1219
+ return { ok: true };
1220
+ }
1221
+ function forgetChatKey(provider) {
1222
+ if (options.configDir === undefined)
1223
+ return;
1224
+ try {
1225
+ rmFileSync(join(options.configDir, `chat-key-${provider}`));
1226
+ }
1227
+ catch {
1228
+ // never stored — nothing to forget
1229
+ }
1230
+ runtime.catalogCache = null;
1231
+ }
1232
+ /** Session-memory hygiene: drafts age out; a filed or dead session frees
1233
+ * its bytes; the per-approver cap spans ALL that approver's sessions. */
1234
+ function sweepChatDrafts(nowMs) {
1235
+ for (const session of sessions.values()) {
1236
+ if (session.chat === undefined)
1237
+ continue;
1238
+ for (const [key, candidate] of session.chat.candidates) {
1239
+ if (nowMs - candidate.createdAt > CHAT_CANDIDATE_TTL_MS)
1240
+ session.chat.candidates.delete(key);
1241
+ }
1242
+ }
1243
+ }
1244
+ /** The one network call a turn makes, run detached from the request that
1245
+ * opened it. Every failure maps to the closed enum BEFORE anything can
1246
+ * log it; a turn that may have started but has no usable usage LATCHES
1247
+ * (unknown spend blocks the credential until acknowledged). */
1248
+ async function runChatTurn(turnId, session, enabled, userMessage, dataDocument, turnRepos) {
1249
+ const started = store.startChatTurn(turnId, new Date());
1250
+ if (!started.ok)
1251
+ return;
1252
+ const controller = new AbortController();
1253
+ const timer = setTimeout(() => controller.abort(), TURN_WALL_CLOCK_MS);
1254
+ let result;
1255
+ try {
1256
+ result = await performChatRequest({
1257
+ provider: enabled.config.provider,
1258
+ model: enabled.config.model,
1259
+ key: enabled.key,
1260
+ dataDocument,
1261
+ userMessage,
1262
+ signal: controller.signal,
1263
+ }, chatFetcher);
1264
+ }
1265
+ catch {
1266
+ result = { ok: false, problem: "network" };
1267
+ }
1268
+ finally {
1269
+ clearTimeout(timer);
1270
+ }
1271
+ const finish = (outcome) => store.finalizeChatTurn(turnId, started.generation, outcome, new Date());
1272
+ const tell = (reply, staticError, proposalsDiscarded = false) => {
1273
+ const chat = session.chat ?? { candidates: new Map(), lastTurn: null };
1274
+ chat.lastTurn = { id: turnId, reply, staticError, proposalsDiscarded };
1275
+ session.chat = chat;
1276
+ };
1277
+ if (!result.ok) {
1278
+ if (result.problem.startsWith("status-")) {
1279
+ // The provider ANSWERED with an error: nothing billed.
1280
+ finish({ state: "failed", failureReason: "provider-error", settledMicrousd: 0 });
1281
+ tell(null, "The chat provider turned the request down. Nothing was kept or charged. Try again in a minute.");
1282
+ }
1283
+ else if (result.problem === "timeout") {
1284
+ finish({ state: "failed", failureReason: "timeout", settledMicrousd: null, unknownSpend: true });
1285
+ tell(null, "The reply took too long and was stopped, and its cost isn't known yet, so chat is paused. Confirm the cost on this page to turn chat back on.");
1286
+ }
1287
+ else if (result.problem === "network") {
1288
+ finish({ state: "failed", failureReason: "provider-error", settledMicrousd: null, unknownSpend: true });
1289
+ tell(null, "The chat provider stopped responding partway through, and the cost isn't known, so chat is paused. Confirm the cost on this page to turn chat back on.");
1290
+ }
1291
+ else {
1292
+ // 200 with an unusable wrapper: billed, amount unproven.
1293
+ finish({ state: "failed", failureReason: "malformed-reply", settledMicrousd: null, unknownSpend: true });
1294
+ tell(null, "The chat provider sent back an answer that couldn't be read, and its cost isn't known, so chat is paused. Confirm the cost on this page to turn chat back on.");
1295
+ }
1296
+ return;
1297
+ }
1298
+ // The pinned math, or the provider's own reported charge when that is
1299
+ // HIGHER — the ledger never undercounts what actually left the wallet.
1300
+ const pinnedSettle = settleForPrice(enabled.price, result.answer.tokensIn, result.answer.tokensOut);
1301
+ const settled = Math.max(pinnedSettle, result.answer.reportedCostMicrousd ?? 0);
1302
+ const envelope = parseAssistantEnvelope(result.answer.text);
1303
+ if (!envelope.ok) {
1304
+ finish({
1305
+ state: "failed",
1306
+ failureReason: "malformed-reply",
1307
+ tokensIn: result.answer.tokensIn,
1308
+ tokensOut: result.answer.tokensOut,
1309
+ settledMicrousd: settled,
1310
+ });
1311
+ tell(null, "the model's answer was malformed and was discarded");
1312
+ return;
1313
+ }
1314
+ const chat = session.chat ?? { candidates: new Map(), lastTurn: null };
1315
+ session.chat = chat;
1316
+ let kept = 0;
1317
+ for (const draft of envelope.envelope.proposals) {
1318
+ const repoIndex = Number(draft.repoId.slice(1)) - 1;
1319
+ const repoPath = turnRepos[repoIndex];
1320
+ if (repoPath === undefined)
1321
+ continue;
1322
+ while (approverCandidateCount(session.name) >= CHAT_CANDIDATES_PER_APPROVER)
1323
+ evictOldestCandidate(session.name);
1324
+ const key = randomBytes(16).toString("hex");
1325
+ chat.candidates.set(key, {
1326
+ key,
1327
+ draft,
1328
+ repoPath,
1329
+ provider: enabled.config.provider,
1330
+ approver: session.name,
1331
+ ceilingDigest: chatCeilingDigest(turnRepos),
1332
+ createdAt: Date.now(),
1333
+ state: "pending",
1334
+ });
1335
+ kept++;
1336
+ }
1337
+ finish({
1338
+ state: "answered",
1339
+ tokensIn: result.answer.tokensIn,
1340
+ tokensOut: result.answer.tokensOut,
1341
+ settledMicrousd: settled,
1342
+ replyBytes: Buffer.byteLength(envelope.envelope.reply, "utf8"),
1343
+ candidateCount: kept,
1344
+ });
1345
+ tell(envelope.envelope.reply, null, envelope.proposalsDiscarded);
1346
+ }
1347
+ /** The Chat landing's live view: each agent at work now with its task and
1348
+ * phase, four counts, plan-window use (never dollars: subscriptions don't
1349
+ * bill per run) and Catch up's items, each tagged with its tab. Reads only. */
1350
+ function chatHomeOf(who, repos, now) {
1351
+ const access = workAccess();
1352
+ const admitted = (repo) => repo === null ? visible(null) : repos.includes(repo);
1353
+ const agents = store.liveRuns(now).filter(run => admitted(run.repo)).map(run => {
1354
+ const root = familyOf(run.taskId)?.root ?? null;
1355
+ return { runId: run.id, taskId: root?.id ?? run.taskId, title: root?.title ?? run.title, href: taskHref(root?.id ?? run.taskId),
1356
+ agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt,
1357
+ activity: runActivityOf(store, run, now) };
1358
+ });
1359
+ const all = workIndexPage(store, now, access, { view: "all", limit: WORK_INDEX_MAX_LIMIT });
1360
+ const needs = workIndexPage(store, now, access, { view: "needs-you", limit: WORK_INDEX_MAX_LIMIT });
1361
+ const done = workIndexPage(store, now, access, { view: "completed", limit: WORK_INDEX_MAX_LIMIT });
1362
+ const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
1363
+ const counts = [
1364
+ { key: "working", label: "Working now", value: agents.length, href: "/work?view=running" },
1365
+ // Each by its shared headline, as the Tasks list words it: a result whose checks failed waits on you, it isn't ready.
1366
+ { key: "waiting", label: "Waiting on you", value: needs.items.filter(one => one.status.label !== "Ready for review").length, href: "/work?view=needs-you" },
1367
+ { key: "ready", label: "Ready to review", value: needs.items.filter(one => one.status.label === "Ready for review").length, href: "/work?view=needs-you" },
1368
+ { key: "done", label: "Done this week", value: done.items.filter(one => (one.completion?.at ?? one.updatedAt) >= weekAgo).length, href: "/work?view=completed" },
1369
+ ];
1370
+ const tabOf = (one) => one.assignmentState === "ready-to-check" ? one.status.label === "Ready for review" ? "ready" : "needs-you"
1371
+ : one.assignmentState === "needs-decision" ? "needs-you" : one.assignmentState === "working" || one.assignmentState === "checking" ? "running" : "finished";
1372
+ const catchUp = all.items.filter(one => tabOf(one) !== "finished" || one.updatedAt >= weekAgo).slice(0, 40).map(one => ({
1373
+ id: one.rootId, title: one.title, href: taskHref(one.rootId), project: one.repo === null ? null : projectName(one.repo), tab: tabOf(one),
1374
+ label: one.status.label, tone: one.status.tone, detail: one.status.detail, at: one.updatedAt,
1375
+ action: (() => {
1376
+ const href = one.status.label === "Needs you" && one.primaryAction !== null ? browserWorkActionHref(one) : null;
1377
+ return href === null ? null : { label: one.primaryAction.label, href };
1378
+ })(),
1379
+ }));
1380
+ const windows = who.via === "cookie" && store.isInstanceOperator(who.name) ? limitsView(store.providerLimits(), [], { project: projectName, teammate: id => `Teammate ${id}` }, now) : null;
1381
+ // One line: what this person's lead is doing now and when it last acted.
1382
+ const activity = leadActivity(store, who.name);
1383
+ const leadTask = activity?.taskId == null ? null : store.lookupRef(activity.taskId);
1384
+ const lead = activity === null ? null : { name: activity.name, doing: activity.doing, at: activity.at,
1385
+ href: leadTask == null || !admitted(leadTask.repo) ? null : taskHref(familyOf(activity.taskId)?.root.id ?? activity.taskId) };
1386
+ return {
1387
+ agents, counts, catchUp, allHref: "/work", ...(lead === null ? {} : { lead }),
1388
+ planUse: (windows?.tiles ?? []).map(one => ({ name: one.name, window: one.window, percent: one.percent, detail: one.detail, tone: one.tone })),
1389
+ };
1390
+ }
1391
+ function firstTasksFor(repo) {
1392
+ const at = Date.now();
1393
+ let entry = firstTasks.get(repo);
1394
+ if (entry === undefined || (entry.until <= at && !entry.reading)) {
1395
+ const reading = { until: at + 10 * 60_000, found: entry?.found ?? null, reading: true };
1396
+ firstTasks.set(repo, reading);
1397
+ entry = reading;
1398
+ void findFirstTasks(repo, options.firstTaskRunner ?? execRun)
1399
+ .then(found => { reading.found = found; }, () => { })
1400
+ .finally(() => { reading.reading = false; });
1401
+ }
1402
+ return entry.found ?? firstTaskSuggestions({ issues: [], todos: [] });
1403
+ }
1404
+ function approverCandidateCount(approver) {
1405
+ let count = 0;
1406
+ for (const session of sessions.values()) {
1407
+ if (session.chat === undefined)
1408
+ continue;
1409
+ for (const candidate of session.chat.candidates.values()) {
1410
+ if (candidate.approver === approver)
1411
+ count++;
1412
+ }
1413
+ }
1414
+ return count;
1415
+ }
1416
+ function evictOldestCandidate(approver) {
1417
+ let oldest = null;
1418
+ for (const session of sessions.values()) {
1419
+ if (session.chat === undefined)
1420
+ continue;
1421
+ for (const candidate of session.chat.candidates.values()) {
1422
+ if (candidate.approver !== approver)
1423
+ continue;
1424
+ if (oldest === null || candidate.createdAt < oldest.at) {
1425
+ oldest = { session, key: candidate.key, at: candidate.createdAt };
1426
+ }
1427
+ }
1428
+ }
1429
+ if (oldest !== null)
1430
+ oldest.session.chat?.candidates.delete(oldest.key);
1431
+ }
1432
+ const registrations = [
1433
+ { id: "chat.stream", domain: "chat", stage: "console", method: "GET", handle: get },
1434
+ { id: "chat.mate-status", domain: "chat", stage: "console", method: "GET", handle: get },
1435
+ { id: "chat.demo-live", domain: "chat", stage: "console", method: "GET", handle: get },
1436
+ { id: "chat.page", domain: "chat", stage: "console", method: "GET", handle: get },
1437
+ { id: "chat.ack", domain: "chat", stage: "console", method: "GET", handle: get },
1438
+ { id: "chat.action", domain: "chat", stage: "console", method: "GET", handle: get },
1439
+ { id: "push.key", domain: "chat", stage: "console", method: "GET", handle: get },
1440
+ { id: "lead.status", domain: "chat", stage: "console", method: "GET", handle: get },
1441
+ { id: "chat.task-status", domain: "chat", stage: "console", method: "GET", handle: get },
1442
+ { id: "chat.demo", domain: "chat", stage: "console", method: "POST", handle: post },
1443
+ { id: "push.subscribe", domain: "chat", stage: "console", method: "POST", handle: post },
1444
+ { id: "push.remove", domain: "chat", stage: "console", method: "POST", handle: post },
1445
+ { id: "onboarding.phone-dismiss", domain: "chat", stage: "console", method: "POST", handle: post },
1446
+ { id: "chat.config", domain: "chat", stage: "console", method: "POST", handle: post },
1447
+ { id: "chat.mate-mint", domain: "chat", stage: "console", method: "POST", handle: post },
1448
+ { id: "chat.mate-follow", domain: "chat", stage: "console", method: "POST", handle: post },
1449
+ { id: "chat.mate-end", domain: "chat", stage: "console", method: "POST", handle: post },
1450
+ { id: "chat.mate-stop", domain: "chat", stage: "console", method: "POST", handle: post },
1451
+ { id: "chat.proposal", domain: "chat", stage: "console", method: "POST", handle: post },
1452
+ { id: "coordinator.proposal", domain: "chat", stage: "console", method: "POST", handle: post },
1453
+ { id: "chat.send", domain: "chat", stage: "console", method: "POST", handle: post },
1454
+ { id: "chat.file", domain: "chat", stage: "console", method: "POST", handle: post },
1455
+ { id: "chat.ack-send", domain: "chat", stage: "console", method: "POST", handle: post },
1456
+ ];
1457
+ return { registrations, get, post, chatFirstRun, phoneCard, chatProjectOf, beginLiveTurn, endLiveTurn, noteMate, takeMateNote, demoResultView, storeChatKey, forgetChatKey, sweepChatDrafts, runChatTurn, chatHomeOf, firstTasksFor, approverCandidateCount, evictOldestCandidate };
1458
+ }