toolroll 0.9.48 → 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.
- package/CHANGELOG.md +17 -0
- package/dist/cli-http.js +3 -0
- package/dist/desktop-update.d.ts +2 -2
- package/dist/flow-triggers.js +7 -0
- package/dist/mcp-http.d.ts +1 -0
- package/dist/mcp-http.js +12 -15
- package/dist/mcp-oauth.d.ts +3 -0
- package/dist/mcp-oauth.js +13 -21
- package/dist/mcp-person.d.ts +0 -9
- package/dist/mcp-person.js +2 -1
- package/dist/operate.js +4 -1
- package/dist/public-access.d.ts +3 -1
- package/dist/public-access.js +5 -1
- package/dist/request-budget.d.ts +30 -3
- package/dist/request-budget.js +44 -1
- package/dist/serve.d.ts +2 -605
- package/dist/serve.js +462 -23733
- package/dist/server/chat.d.ts +41 -0
- package/dist/server/chat.js +1458 -0
- package/dist/server/edge-refusal.d.ts +5 -0
- package/dist/server/edge-refusal.js +56 -0
- package/dist/server/flows.d.ts +12 -0
- package/dist/server/flows.js +832 -0
- package/dist/server/guards.d.ts +72 -0
- package/dist/server/guards.js +357 -0
- package/dist/server/handler-context.d.ts +22 -0
- package/dist/server/handler-context.js +1 -0
- package/dist/server/handler-registry.d.ts +21 -0
- package/dist/server/handler-registry.js +34 -0
- package/dist/server/pages-assets.d.ts +10 -0
- package/dist/server/pages-assets.js +103 -0
- package/dist/server/pages.d.ts +11 -0
- package/dist/server/pages.js +910 -0
- package/dist/server/people-tokens.d.ts +20 -0
- package/dist/server/people-tokens.js +694 -0
- package/dist/server/remote-hooks.d.ts +29 -0
- package/dist/server/remote-hooks.js +130 -0
- package/dist/server/remote.d.ts +59 -0
- package/dist/server/remote.js +185 -0
- package/dist/server/route-policy.d.ts +18 -0
- package/dist/server/route-policy.js +30 -0
- package/dist/server/route-table.d.ts +74 -0
- package/dist/server/route-table.js +350 -0
- package/dist/server/runtime.d.ts +280 -0
- package/dist/server/runtime.js +25 -0
- package/dist/server/settings.d.ts +22 -0
- package/dist/server/settings.js +2388 -0
- package/dist/server/shared.d.ts +3334 -0
- package/dist/server/shared.js +12708 -0
- package/dist/server/task-views.d.ts +82 -0
- package/dist/server/task-views.js +1099 -0
- package/dist/server/tasks.d.ts +87 -0
- package/dist/server/tasks.js +3579 -0
- package/dist/session-http.d.ts +5 -0
- package/dist/session-http.js +16 -0
- package/dist/session-server.d.ts +5 -0
- package/dist/session-server.js +2 -0
- package/dist/team-cli.js +28 -6
- package/dist/team-contract.d.ts +7 -0
- package/dist/team-contract.js +7 -0
- package/dist/team-http.d.ts +10 -1
- package/dist/team-http.js +23 -3
- package/dist/team-leads.d.ts +5 -1
- package/dist/team-leads.js +42 -4
- package/dist/team-runtime.d.ts +1 -1
- package/dist/team-runtime.js +3 -0
- package/dist/teams.d.ts +3 -0
- package/dist/teams.js +18 -0
- 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
|
+
}
|