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