toolroll 0.9.6 → 0.9.7
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 +38 -0
- package/dist/actor.d.ts +21 -0
- package/dist/actor.js +48 -0
- package/dist/assignment-adapters.d.ts +1 -0
- package/dist/assignment-ui.js +3 -1
- package/dist/assignment.d.ts +9 -0
- package/dist/assignment.js +62 -13
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +28 -24
- package/dist/browser-workspace.d.ts +117 -2
- package/dist/builder.d.ts +1 -0
- package/dist/builder.js +33 -13
- package/dist/chat-actions.js +2 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/chat-quiet.js +11 -1
- package/dist/chat-task-actions.d.ts +13 -0
- package/dist/chat-task-actions.js +19 -1
- package/dist/check-levels-ui.d.ts +21 -0
- package/dist/check-levels-ui.js +70 -0
- package/dist/check-levels.d.ts +67 -0
- package/dist/check-levels.js +171 -0
- package/dist/claim.d.ts +22 -0
- package/dist/claim.js +43 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +4 -0
- package/dist/demo-chat.js +4 -1
- package/dist/dispatch.js +7 -2
- package/dist/dispose.d.ts +8 -0
- package/dist/dispose.js +7 -1
- package/dist/export.js +3 -2
- package/dist/first-run.d.ts +34 -0
- package/dist/first-run.js +37 -1
- package/dist/flow-gallery-ui.d.ts +30 -0
- package/dist/flow-gallery-ui.js +85 -0
- package/dist/flow-gallery.d.ts +111 -0
- package/dist/flow-gallery.js +445 -0
- package/dist/flow-share.d.ts +102 -0
- package/dist/flow-share.js +614 -0
- package/dist/flow-starters.d.ts +2 -0
- package/dist/flow-starters.js +1 -1
- package/dist/flow-steps.js +7 -0
- package/dist/flow-triggers.js +2 -0
- package/dist/flows-cli.d.ts +56 -0
- package/dist/flows-cli.js +109 -3
- package/dist/flows-ui.d.ts +15 -1
- package/dist/flows-ui.js +31 -6
- package/dist/guides.js +27 -2
- package/dist/lead-context.js +6 -2
- package/dist/mate-doors.d.ts +0 -15
- package/dist/mate-doors.js +5 -1
- package/dist/mate-tools.js +9 -4
- package/dist/models-ui.js +1 -1
- package/dist/operate.d.ts +4 -1
- package/dist/operate.js +601 -85
- package/dist/project-concurrency.d.ts +62 -0
- package/dist/project-concurrency.js +116 -0
- package/dist/project-delete-ui.d.ts +7 -0
- package/dist/project-delete-ui.js +19 -2
- package/dist/project-delete.js +4 -0
- package/dist/pull-request-flow.d.ts +9 -0
- package/dist/pull-request-flow.js +21 -3
- package/dist/result-follow-ups.d.ts +101 -0
- package/dist/result-follow-ups.js +333 -0
- package/dist/scope.js +11 -0
- package/dist/serve.d.ts +211 -2
- package/dist/serve.js +1009 -141
- package/dist/store.d.ts +76 -3
- package/dist/store.js +311 -22
- package/dist/surface.js +31 -8
- package/dist/tailnet.d.ts +11 -0
- package/dist/tailnet.js +47 -0
- package/dist/task-outcome-cli.js +2 -2
- package/dist/task-status.d.ts +8 -1
- package/dist/task-status.js +28 -10
- package/dist/team-cli.js +3 -0
- package/dist/telegram-status.js +2 -2
- package/dist/verification-evidence.js +21 -1
- package/dist/webhooks.js +5 -0
- package/dist/work-index.js +39 -6
- package/dist/work-summary.d.ts +0 -4
- package/dist/work-summary.js +2 -0
- package/dist/workspace-ui.d.ts +3 -0
- package/dist/workspace-ui.js +5 -3
- package/package.json +1 -1
package/dist/serve.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { withActor } from "./actor.js";
|
|
1
2
|
import { repositoryContext, repositoryContextRead } from './repository-context.js';
|
|
3
|
+
import { ADD_TESTS_ACTION, followUpChecksOf, requestFollowUpChecks, fileAddTestsTask, resultCheckLevel } from './result-follow-ups.js';
|
|
4
|
+
import { CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, suggestQuickCommand } from './check-levels.js';
|
|
2
5
|
import { repositoryContextHtml } from './repository-context-ui.js';
|
|
3
6
|
import { browserAssetsAvailable, browserWorkspaceDocument, serveBrowserAsset } from './browser-shell.js';
|
|
4
7
|
import { browserCrewOf, browserCrewFromIndex, browserWorkActionHref, browserProjectsOf, browserNavigationOf } from './browser-workspace.js';
|
|
@@ -19,13 +22,16 @@ import { CHAT_ACTIONS, sharedActionPayload, sharedActionNeedsReview, sharedActio
|
|
|
19
22
|
import { changeSkills, githubSkill, importSkill, readSkillsSnapshot, reviseSkillTest, skillTestResult, skillsView, testSkill } from "./project-skills.js";
|
|
20
23
|
import { skillsHtml, skillsScript, skillsSnapshotHtml, skillTestFeedbackHtml, SKILLS_CSS } from "./skills-ui.js";
|
|
21
24
|
import { toolsHtml, TOOLS_CSS } from "./tools-ui.js";
|
|
22
|
-
import { flowFallbackHtml, flowsListHtml, flowView, FLOWS_CSS } from "./flows-ui.js";
|
|
25
|
+
import { flowFallbackHtml, flowImportHtml, flowsListHtml, flowView, FLOW_IMPORT_SCRIPT, FLOWS_CSS } from "./flows-ui.js";
|
|
26
|
+
import { exportFlow, fetchFlowFile, FlowFileError, importFlow, parseFlowFile, planFlowImport } from "./flow-share.js";
|
|
23
27
|
import { BLANK_SOUL, TEAMMATE_CSS, teammatePageHtml, teammatesListHtml } from "./teammates-ui.js";
|
|
24
28
|
import { grantTool, revokeTool, rulesFromForm, setToolRules } from "./teammate-tools.js";
|
|
25
29
|
import { addKitGithubTrigger, addKitSample, kitInstalled, kitOf, setUpKit } from "./kits.js";
|
|
26
30
|
import { CONNECT_CALLBACK, connectionsOf, finishConnect, oneClickOf, startConnect } from "./mcp-connect.js";
|
|
27
31
|
import { KITS_CSS, kitPageHtml, kitsGalleryHtml } from "./kits-ui.js";
|
|
28
32
|
import { STARTERS_CSS, startersHtml } from "./flow-starters-ui.js";
|
|
33
|
+
import { GALLERY_CSS, galleryHtml, galleryUseHtml } from "./flow-gallery-ui.js";
|
|
34
|
+
import { galleryDefaults, galleryDiagram, galleryTemplateOf, previewGallery, useGalleryTemplate } from "./flow-gallery.js";
|
|
29
35
|
import { starterForWork, starterOf, startersFor, switchOnStarter } from "./flow-starters.js";
|
|
30
36
|
import { createTeammateFrom, labelOf, nameOf, saveSoul, setTeammateState, teammateSettings, sendTeammateSummaries } from "./teammate-admin.js";
|
|
31
37
|
import { editMemory, forgetMemory, tellTeammate } from "./teammate-memory.js";
|
|
@@ -55,16 +61,18 @@ import { handleTeamHttp } from './team-http.js';
|
|
|
55
61
|
import { teamWorkspaceHtml } from './team-ui.js';
|
|
56
62
|
import { createTeamRuntime } from './team-runtime.js';
|
|
57
63
|
import { prepareWorkspaceRevision, WorkspaceValidatorCache } from "./workspace-revision.js";
|
|
58
|
-
import { workIndexPage, workCountsByProject, WorkIndexCursorError } from "./work-index.js";
|
|
64
|
+
import { workIndexPage, workCountsByProject, WorkIndexCursorError, WORK_INDEX_MAX_LIMIT } from "./work-index.js";
|
|
59
65
|
import { openWorkDecisionOf } from "./work-summary.js";
|
|
60
66
|
import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
|
|
61
67
|
import { demoChatHtml, demoThreadHtml, DEMO_CHAT_SCRIPT } from "./demo-chat.js";
|
|
62
68
|
import { pullRequestSettingsHtml, PULL_REQUEST_SETTINGS_CSS } from './pull-request-ui.js';
|
|
69
|
+
import { checkSettingsHtml, CHECK_SETTINGS_CSS } from './check-levels-ui.js';
|
|
70
|
+
import { hasDisguisedText } from './decision.js';
|
|
63
71
|
import { checkPublishing, completeAndOpenPullRequest, mergeAsPerson, pullRequestBlocker, publishingOf, saveMergeSettings, savePublishing, newestPullRequestOf, MERGE_METHODS } from './pull-request-flow.js';
|
|
64
72
|
import { leadBriefHtml, LEAD_CONTEXT_CSS } from './lead-context.js';
|
|
65
73
|
import { assignmentCatchUp } from './assignment-brief.js';
|
|
66
74
|
import { assignmentCardOf, assignmentStatusOf, assignmentSummaryHtml, assignmentWithEvidence, ASSIGNMENT_CSS } from './assignment-ui.js';
|
|
67
|
-
import { pullRequestFactOf, stageOfDispatch, statusDetailsHtml, statusIconSvg, statusWhyHtml, taskStatusOf, TASK_STATUS_CSS } from './task-status.js';
|
|
75
|
+
import { assignmentStageOf, pullRequestFactOf, stageOfCode, stageOfDispatch, statusDetailsHtml, statusIconSvg, statusWhyHtml, taskStatusOf, TASK_STATUS_CSS } from './task-status.js';
|
|
68
76
|
import { assignmentPresentationOf, historicalAssessmentReason, shortenedMaterialReason } from './assignment-presentation.js';
|
|
69
77
|
import { ledgerBody } from "./ledger-view.js";
|
|
70
78
|
import { EVIDENCE_PACK_CSS, evidencePack, evidencePackHtml, exportDay, ledgerExportChunks } from "./evidence-pack.js";
|
|
@@ -126,7 +134,7 @@ import { projectAuthority } from "./project-access.js";
|
|
|
126
134
|
* repo, only runs whose task belongs to that repo (or to no repo yet).
|
|
127
135
|
*/
|
|
128
136
|
import { createConnectionChecker } from "./provider-connection.js";
|
|
129
|
-
import { liftAuthPause, noteSignInProbe, signInNotices } from "./provider-auth.js";
|
|
137
|
+
import { liftAuthPause, noteSignInProbe, providerName, signInNotices } from "./provider-auth.js";
|
|
130
138
|
import { openRouterModelsCache, openRouterPickerScript } from "./openrouter-models.js";
|
|
131
139
|
import { checkModels, isNewModel, livePin, modelOptions, modelWords, RUNTIME_TOOLS, runtimeStates, seenModels, setWatch, updateRuntime, watchState } from "./model-catalog.js";
|
|
132
140
|
import { MODELS_CSS, modelsHtml, modelsScript } from "./models-ui.js";
|
|
@@ -177,7 +185,7 @@ import { verifiedAuthor, LEAD_THREAD, isDigestTime } from "./store.js";
|
|
|
177
185
|
import { digestTimes } from "./digest-times.js";
|
|
178
186
|
import { updateRepos, addRepos, removeRepos } from "./repos.js";
|
|
179
187
|
import { run as execRun } from "./exec.js";
|
|
180
|
-
import { findFirstTasks, firstResultWords, firstRunSteps, firstTaskSuggestions, SANDBOX_COMMAND } from "./first-run.js";
|
|
188
|
+
import { findFirstTasks, firstResultWords, firstRunSteps, firstTaskJourney, firstTaskSuggestions, HOW_IT_WORKS, SANDBOX_COMMAND, signInCommandFor, START_COMMAND } from "./first-run.js";
|
|
181
189
|
/** A user agent, reduced to safe display words — never echoed raw. */
|
|
182
190
|
function oneLineUa(raw) {
|
|
183
191
|
const text = Array.isArray(raw) ? (raw[0] ?? "") : (raw ?? "");
|
|
@@ -225,6 +233,7 @@ import { RETENTION_KINDS, lastSweepAt, parsePeriod, retentionPlan } from "./rete
|
|
|
225
233
|
import { EXPORT_CSS, dataExportHtml } from "./export-ui.js";
|
|
226
234
|
import { buildExport, exportZip } from "./export.js";
|
|
227
235
|
import { PROJECT_DELETE_CSS, projectDeleteConfirmHtml, projectSettingsHtml } from "./project-delete-ui.js";
|
|
236
|
+
import { parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, projectConcurrency, saveProjectConcurrency } from "./project-concurrency.js";
|
|
228
237
|
import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
|
|
229
238
|
import { POLICY_CSS, policyHtml } from "./policy-ui.js";
|
|
230
239
|
import { checkPolicy, parseList, policyParts } from "./policy.js";
|
|
@@ -252,6 +261,13 @@ import { PACKAGE_VERSION } from "./version.js";
|
|
|
252
261
|
import { updateNoticeWords } from "./update-notice.js";
|
|
253
262
|
import { whenHtml } from "./when-html.js";
|
|
254
263
|
const SESSION_COOKIE = "standing-orders_session";
|
|
264
|
+
/** Where `up`'s one-time sign-in link points, and how long it works. */
|
|
265
|
+
export const SIGN_IN_LINK_PATH = "/login/once/";
|
|
266
|
+
export const SIGN_IN_LINK_MS = 10 * 60_000;
|
|
267
|
+
/** The installation fact that the lead was turned on by default, once (its value: the provider). */
|
|
268
|
+
const LEAD_BY_DEFAULT_FACT = "lead-on-by-default";
|
|
269
|
+
/** The installation fact that the phone card was put away. */
|
|
270
|
+
const PHONE_CARD_FACT = "phone-card-dismissed";
|
|
255
271
|
const BODY_CAP = 16 * 1024;
|
|
256
272
|
// URL encoding can triple UTF-8 bytes. Admit the existing bounded task
|
|
257
273
|
// fields (including paths and rubric) before canonical text validation.
|
|
@@ -515,6 +531,30 @@ export function createDecisionServer(options) {
|
|
|
515
531
|
const ssoOffer = () => { const settings = ssoSettings(); return settings === null ? null : { label: settings.label, operatorsOnly: settings.passwords === "operators" }; };
|
|
516
532
|
/** A step-up within this long of the provider checking someone needs no password. */
|
|
517
533
|
const SSO_FRESH_MS = 10 * 60_000;
|
|
534
|
+
// The one-time sign-in link `up` opens in the browser: single use, ten minutes, from this computer only. Only a hash
|
|
535
|
+
// of the code is kept, in memory; the code itself is never written to a log or the ledger.
|
|
536
|
+
const signInLinks = new Map();
|
|
537
|
+
const linkKey = (code) => createHash("sha256").update(code).digest("hex");
|
|
538
|
+
function mintSignInLink(account) {
|
|
539
|
+
const known = store.accountOf(account);
|
|
540
|
+
if (known === null || known.role !== "approver" || known.revokedAt !== null)
|
|
541
|
+
return null;
|
|
542
|
+
for (const [key, one] of signInLinks)
|
|
543
|
+
if (one.expires < Date.now())
|
|
544
|
+
signInLinks.delete(key);
|
|
545
|
+
while (signInLinks.size >= 20)
|
|
546
|
+
signInLinks.delete(signInLinks.keys().next().value);
|
|
547
|
+
const code = randomBytes(32).toString("base64url");
|
|
548
|
+
signInLinks.set(linkKey(code), { account, expires: Date.now() + SIGN_IN_LINK_MS });
|
|
549
|
+
return `${SIGN_IN_LINK_PATH}${code}`;
|
|
550
|
+
}
|
|
551
|
+
/** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no forwarding proxy. */
|
|
552
|
+
const fromThisComputer = (request) => {
|
|
553
|
+
const peer = request.socket.remoteAddress ?? "";
|
|
554
|
+
const loopback = peer === "127.0.0.1" || peer === "::1" || peer === "::ffff:127.0.0.1";
|
|
555
|
+
return loopback && request.headers["x-forwarded-for"] === undefined && request.headers["forwarded"] === undefined
|
|
556
|
+
&& /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(request.headers.host ?? "");
|
|
557
|
+
};
|
|
518
558
|
async function providerFor(issuer) {
|
|
519
559
|
if (ssoProvider !== null && ssoProvider.issuer === issuer && Date.now() - ssoProvider.at < 10 * 60_000)
|
|
520
560
|
return { ok: true, provider: ssoProvider.provider };
|
|
@@ -607,7 +647,18 @@ export function createDecisionServer(options) {
|
|
|
607
647
|
const projectViewOf = (repo, who) => ({
|
|
608
648
|
repo, name: projectName(repo), holdings: projectHoldings(store, repo), running: projectRunning(store, repo, clock()),
|
|
609
649
|
canDelete: who.via === "cookie" && store.isInstanceOperator(who.name),
|
|
650
|
+
builds: projectBuildsOf(repo, who),
|
|
610
651
|
});
|
|
652
|
+
/** Settings → Project → Builds at once: the saved number, its workers' capacity, and what builds now. */
|
|
653
|
+
const projectBuildsOf = (repo, who) => {
|
|
654
|
+
const capacities = store.listRunners().filter(one => one.retiredAt === null && one.repos.includes(repo)).map(one => one.capacity);
|
|
655
|
+
return {
|
|
656
|
+
setting: (() => { const file = store.databaseFile(); return file === null ? PROJECT_CONCURRENCY_DEFAULT : projectConcurrency(file, repo); })(),
|
|
657
|
+
capacity: capacities.length === 0 ? null : Math.max(...capacities),
|
|
658
|
+
building: store.runningBuildsByRepo(clock()).get(repo) ?? 0,
|
|
659
|
+
canChange: who.via === "cookie" && who.role === "approver" && store.accountCanAccess(who.name, repo),
|
|
660
|
+
};
|
|
661
|
+
};
|
|
611
662
|
/** Codex's own MCP servers for a project (`codex mcp list --json`), for "Found on this computer"; null when Codex can't say. */
|
|
612
663
|
const codexServers = async (repo) => {
|
|
613
664
|
try {
|
|
@@ -695,7 +746,7 @@ export function createDecisionServer(options) {
|
|
|
695
746
|
const server = createServer((request, response) => {
|
|
696
747
|
void handle(request, response).catch(error => {
|
|
697
748
|
// Every unhandled error is logged (v99): the path and the message, never the request's body or query.
|
|
698
|
-
logEvent("error", "serve.error", { method: request.method, path: new URL(request.url ?? "/", "http://placeholder").pathname, error: error instanceof Error ? error.message : String(error) });
|
|
749
|
+
logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error) });
|
|
699
750
|
if (envValue(process.env, "SERVE_DEBUG") === "1")
|
|
700
751
|
console.error("SERVE ERROR:", error);
|
|
701
752
|
if (!response.headersSent) {
|
|
@@ -730,15 +781,43 @@ export function createDecisionServer(options) {
|
|
|
730
781
|
const cookieSecure = publicOrigin === null ? "" : "; Secure";
|
|
731
782
|
/** Where Google may send someone back to (v89): the public https address, or this computer's own. */
|
|
732
783
|
const consoleOrigin = (host) => host === undefined ? null : publicOrigin !== null && host === publicOrigin.host ? publicOrigin.origin : /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(host) ? `http://${host}` : null;
|
|
784
|
+
/** This computer's tailnet names, the last ones read (onboarding). */
|
|
785
|
+
let tailnet = [];
|
|
786
|
+
if (options.tailnetNames !== undefined) {
|
|
787
|
+
const readTailnet = options.tailnetNames;
|
|
788
|
+
const refresh = () => { void readTailnet().then(names => { tailnet = names; }, () => { }); };
|
|
789
|
+
refresh();
|
|
790
|
+
const timer = setInterval(refresh, 5 * 60_000);
|
|
791
|
+
timer.unref?.();
|
|
792
|
+
server.on("close", () => clearInterval(timer));
|
|
793
|
+
}
|
|
794
|
+
const servedPort = () => {
|
|
795
|
+
const address = server.address();
|
|
796
|
+
return typeof address === "object" && address !== null ? address.port : null;
|
|
797
|
+
};
|
|
733
798
|
/** The names this server answers as. Anything else is a rebind, refused. */
|
|
734
799
|
const allowedHost = (host) => {
|
|
735
800
|
if (host === undefined)
|
|
736
801
|
return false;
|
|
737
|
-
const
|
|
738
|
-
const
|
|
739
|
-
const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
|
|
802
|
+
const port = servedPort();
|
|
803
|
+
const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`, ...tailnet.map(name => `${name}:${port}`)];
|
|
740
804
|
return [...locals, ...(options.allowedHosts ?? []), ...(publicOrigin === null ? [] : [publicOrigin.host])].includes(host);
|
|
741
805
|
};
|
|
806
|
+
/** The exact command that starts this console again as it runs now, plus a listening address or one more allowed host. */
|
|
807
|
+
const consoleCommand = (change) => {
|
|
808
|
+
const port = servedPort() ?? 4180;
|
|
809
|
+
const address = server.address();
|
|
810
|
+
const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
|
|
811
|
+
const host = change.host ?? (bound === "127.0.0.1" || bound === "::1" ? null : bound === "::" ? "0.0.0.0" : bound);
|
|
812
|
+
const allowed = [...(options.allowedHosts ?? []), ...(change.allow === undefined ? [] : [change.allow])];
|
|
813
|
+
return [`toolroll ${options.upConsole === true ? "up" : "serve"}`, ...(host === null ? [] : [`--host ${host}`]), ...(port === 4180 ? [] : [`--port ${port}`]),
|
|
814
|
+
...(allowed.length === 0 ? [] : [`--allow-host ${allowed.join(",")}`])].join(" ");
|
|
815
|
+
};
|
|
816
|
+
/** The wrong-host page's facts: the address opened, where the console answers, and the exact command that admits it. */
|
|
817
|
+
const wrongHost = (host) => {
|
|
818
|
+
const opened = host !== undefined && /^[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$|^\[[0-9A-Fa-f:.]{2,45}\](:[0-9]{1,5})?$/.test(host) ? host : null;
|
|
819
|
+
return { opened, served: `127.0.0.1:${servedPort() ?? 4180}`, command: opened === null ? null : consoleCommand({ allow: opened }) };
|
|
820
|
+
};
|
|
742
821
|
/**
|
|
743
822
|
* The one gate every POST passes — parse elsewhere, authorize here. A
|
|
744
823
|
* refusal names its status; null means proceed. Duplicated security fields
|
|
@@ -1065,7 +1144,7 @@ export function createDecisionServer(options) {
|
|
|
1065
1144
|
if (hook.pathname.startsWith("/hooks/"))
|
|
1066
1145
|
return flowHook(request, response, hook);
|
|
1067
1146
|
if (!allowedHost(request.headers.host)) {
|
|
1068
|
-
return respond(response, 421, "text/
|
|
1147
|
+
return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
|
|
1069
1148
|
}
|
|
1070
1149
|
const url = new URL(request.url ?? "/", "http://placeholder");
|
|
1071
1150
|
// A token in a URL is a token in history, logs, and referers. Refused
|
|
@@ -1147,6 +1226,33 @@ export function createDecisionServer(options) {
|
|
|
1147
1226
|
}
|
|
1148
1227
|
return page(response, 200, loginPage(null, loginReturn(url.searchParams.get("return")), ssoOffer()));
|
|
1149
1228
|
}
|
|
1229
|
+
// The one-time link `up` opens (onboarding): it signs the browser on this computer in and lands on Chat. Spent on
|
|
1230
|
+
// the first visit whatever happens; the ledger notes the sign-in, never the link.
|
|
1231
|
+
if (url.pathname.startsWith(SIGN_IN_LINK_PATH) && method === "GET") {
|
|
1232
|
+
const code = url.pathname.slice(SIGN_IN_LINK_PATH.length);
|
|
1233
|
+
const held = /^[A-Za-z0-9_-]{43}$/.test(code) ? signInLinks.get(linkKey(code)) : undefined;
|
|
1234
|
+
if (held !== undefined)
|
|
1235
|
+
signInLinks.delete(linkKey(code));
|
|
1236
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
1237
|
+
if (!fromThisComputer(request)) {
|
|
1238
|
+
recordSignIn(held?.account ?? "unknown account", "sign-in refused", "one-time link", "opened from another device");
|
|
1239
|
+
return page(response, 403, loginPage("That sign-in link works only in a browser on the computer running Toolroll. Sign in with your password.", "/chat", ssoOffer()));
|
|
1240
|
+
}
|
|
1241
|
+
if (held === undefined || held.expires < Date.now()) {
|
|
1242
|
+
return page(response, 410, loginPage("That sign-in link has been used or has expired. Sign in with your password, or run toolroll up again.", "/chat", ssoOffer()));
|
|
1243
|
+
}
|
|
1244
|
+
const account = store.accountOf(held.account);
|
|
1245
|
+
if (account === null || account.role !== "approver" || account.revokedAt !== null)
|
|
1246
|
+
return page(response, 403, loginPage("That account can't sign in here any more.", "/chat", ssoOffer()));
|
|
1247
|
+
if (!passwordAllowed(held.account))
|
|
1248
|
+
return page(response, 403, loginPage(`Sign in with ${ssoSettings()?.label ?? "your identity provider"}.`, "/chat", ssoOffer()));
|
|
1249
|
+
const id = randomBytes(32).toString("hex");
|
|
1250
|
+
sessions.set(id, { ...arrival(request), name: held.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
|
|
1251
|
+
project: defaultProject, projectRevision: 1 });
|
|
1252
|
+
recordSignIn(held.account, "signed in", "one-time link");
|
|
1253
|
+
response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
|
|
1254
|
+
return redirect(response, "/chat");
|
|
1255
|
+
}
|
|
1150
1256
|
// v100: sign-in with the identity provider. ?reauth=1 asks it to check the person again (a step-up);
|
|
1151
1257
|
// ?link=1 links the signed-in account to it. The browser that starts it holds the visit's state.
|
|
1152
1258
|
if (url.pathname === "/login/sso" && method === "GET") {
|
|
@@ -1426,7 +1532,7 @@ export function createDecisionServer(options) {
|
|
|
1426
1532
|
if (method === "GET" || method === "POST")
|
|
1427
1533
|
return freshIdentitySignIn.run({ actor: requestFacts.sso?.fresh === true ? who.name : null }, () => requestContext.run(requestFacts, async () => {
|
|
1428
1534
|
const taskTextForm = url.pathname === "/tasks/add" || /^\/t\/[^/]+\/scope$/.test(url.pathname);
|
|
1429
|
-
const body = method === "POST" ? await form(request, url.pathname === "/settings/skills/import" ? 2 * 1024 * 1024 : taskTextForm ? TASK_FORM_BODY_CAP : BODY_CAP) : null;
|
|
1535
|
+
const body = method === "POST" ? await form(request, url.pathname === "/settings/skills/import" ? 2 * 1024 * 1024 : url.pathname === "/flows/import" ? 1024 * 1024 : taskTextForm ? TASK_FORM_BODY_CAP : BODY_CAP) : null;
|
|
1430
1536
|
const target = actionTarget(url, who, request, body);
|
|
1431
1537
|
const execute = async () => {
|
|
1432
1538
|
if (!projectRequestAllowed(url, who, request, response))
|
|
@@ -1591,8 +1697,8 @@ export function createDecisionServer(options) {
|
|
|
1591
1697
|
const write = new Set(["/settings/flows/on", "/settings/skills/import", "/settings/skills/change", "/settings/skills/revise", "/settings/knowledge/change", "/settings/knowledge/refresh", "/settings/learning/change", "/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/projects/select", "/tasks/add", "/routines/add"]);
|
|
1592
1698
|
const task = matchTaskPath(path, request.method === "GET" ? "(/evidence)?$" : "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|next|reopen|steer|accept-proof|accept-revision|reject-revision|route|retry-review|complete|merge|stop|resume-arm|resume)$");
|
|
1593
1699
|
const resource = request.method === "GET"
|
|
1594
|
-
? /^\/(?:r|d)\/[0-9]{1,15}(?:\/evidence\/[0-9]{1,15})?$/.test(path) || /^\/routines\/[0-9]{1,15}$/.test(path) || path === "/flows" || /^\/flows\/[0-9]{1,15}(\/insights|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(path)
|
|
1595
|
-
: /^\/d\/[0-9]{1,15}\/answer$/.test(path) || /^\/routines\/[0-9]{1,15}\/(approve|refresh|pause|resume|run-now)$/.test(path) || path === "/flows/new" || /^\/flows\/[0-9]{1,15}\/(save|cards|archive|scripts)$/.test(path) || /^\/flows\/[0-9]{1,15}\/cards\/[0-9]{1,15}\/(move|decide|cancel|comment|assign|watch)$/.test(path) || /^\/flows\/[0-9]{1,15}\/triggers(\/[0-9]{1,15}\/(pause|resume|remove|check|press|renew|secret|share|unshare))?$/.test(path) || /^\/r\/[0-9]{1,15}\/(note|comment|revise|draft-repair)$/.test(path);
|
|
1700
|
+
? /^\/(?:r|d)\/[0-9]{1,15}(?:\/evidence\/[0-9]{1,15})?$/.test(path) || /^\/routines\/[0-9]{1,15}$/.test(path) || path === "/flows" || /^\/flows\/[0-9]{1,15}(\/insights|\/export|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(path) || path === "/flows/new" || /^\/flows\/new\/[a-z-]{1,40}$/.test(path)
|
|
1701
|
+
: /^\/d\/[0-9]{1,15}\/answer$/.test(path) || /^\/routines\/[0-9]{1,15}\/(approve|refresh|pause|resume|run-now)$/.test(path) || path === "/flows/new" || /^\/flows\/new\/[a-z-]{1,40}$/.test(path) || path === "/flows/import" || /^\/flows\/[0-9]{1,15}\/(save|cards|archive|scripts)$/.test(path) || /^\/flows\/[0-9]{1,15}\/cards\/[0-9]{1,15}\/(move|decide|cancel|comment|assign|watch)$/.test(path) || /^\/flows\/[0-9]{1,15}\/triggers(\/[0-9]{1,15}\/(pause|resume|remove|check|press|renew|secret|share|unshare))?$/.test(path) || /^\/r\/[0-9]{1,15}\/(note|comment|revise|draft-repair|checks|add-tests)$/.test(path);
|
|
1596
1702
|
if (!(request.method === "GET" ? read : write).has(path) && task === null && !resource) {
|
|
1597
1703
|
refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects");
|
|
1598
1704
|
return false;
|
|
@@ -1617,6 +1723,20 @@ export function createDecisionServer(options) {
|
|
|
1617
1723
|
return true;
|
|
1618
1724
|
}
|
|
1619
1725
|
// ---- reads ---------------------------------------------------------------
|
|
1726
|
+
/** A gallery template's page (Flows → New → Use this): its questions, the preview of these answers, and Create. */
|
|
1727
|
+
function sendGalleryUse(response, who, template, projects, repo, given, name, notice) {
|
|
1728
|
+
const answers = given ?? galleryDefaults(store, template, repo);
|
|
1729
|
+
let preview = null, problem = notice;
|
|
1730
|
+
try {
|
|
1731
|
+
preview = previewGallery(store, template, repo, answers, who.name, clock());
|
|
1732
|
+
}
|
|
1733
|
+
catch (error) {
|
|
1734
|
+
problem = error instanceof Error ? error.message : "That can't be made here.";
|
|
1735
|
+
}
|
|
1736
|
+
const html = galleryUseHtml({ template, projects: projects.map(path => ({ path, name: projectName(path) })), repo, answers, name, preview, problem,
|
|
1737
|
+
csrf: who.via === "cookie" ? who.session.csrf : "", diagram: preview?.built.definition ?? galleryDiagram(template) });
|
|
1738
|
+
return sendScreen(response, problem !== null && preview === null && notice !== null ? 400 : 200, screen(template.name, `<p><a href="/flows/new">New flow</a></p><h1>${escape(template.name)}</h1>${html}`, { chrome: chromeFor(repo, "flows") }));
|
|
1739
|
+
}
|
|
1620
1740
|
async function handleGet(url, who, request, response) {
|
|
1621
1741
|
const now = clock();
|
|
1622
1742
|
let project = projectOf(who, request);
|
|
@@ -1716,7 +1836,9 @@ export function createDecisionServer(options) {
|
|
|
1716
1836
|
url.pathname !== "/menu" &&
|
|
1717
1837
|
url.pathname !== "/recipes" &&
|
|
1718
1838
|
// A flow names its own project; the list spans every project.
|
|
1719
|
-
url.pathname !== "/flows" && !/^\/flows\/[0-9]{1,15}(\/insights|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(url.pathname) &&
|
|
1839
|
+
url.pathname !== "/flows" && url.pathname !== "/flows/import" && !/^\/flows\/[0-9]{1,15}(\/insights|\/export|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(url.pathname) &&
|
|
1840
|
+
// The gallery (Flows → New) asks for its project on each template's page.
|
|
1841
|
+
url.pathname !== "/flows/new" && !/^\/flows\/new\/[a-z-]{1,40}$/.test(url.pathname) &&
|
|
1720
1842
|
// Teammates (v92) name their own project, like flows.
|
|
1721
1843
|
!url.pathname.startsWith("/teammates") &&
|
|
1722
1844
|
// New work names its project in the form (a dropdown of known
|
|
@@ -1729,6 +1851,7 @@ export function createDecisionServer(options) {
|
|
|
1729
1851
|
url.pathname !== "/projects/browse" && url.pathname !== "/projects/github" && url.pathname !== "/workbench" &&
|
|
1730
1852
|
url.pathname !== "/fleet" &&
|
|
1731
1853
|
url.pathname !== "/chat" && url.pathname !== "/chat/mate/status" && url.pathname !== "/chat/task-status" && url.pathname !== "/chat/stream" &&
|
|
1854
|
+
url.pathname !== "/lead/status" && url.pathname !== "/onboarding/phone/dismiss" &&
|
|
1732
1855
|
!url.pathname.startsWith("/chat/demo/") &&
|
|
1733
1856
|
!/^\/chat\/action\/[0-9]{1,15}$/.test(url.pathname) &&
|
|
1734
1857
|
!url.pathname.startsWith("/settings") && url.pathname !== "/logout" && url.pathname !== "/people" && url.pathname !== "/ledger" && url.pathname !== "/ledger/export" && url.pathname !== "/metrics" && url.pathname !== "/spend" && url.pathname !== "/spend/budget" &&
|
|
@@ -1881,7 +2004,7 @@ export function createDecisionServer(options) {
|
|
|
1881
2004
|
const cancelled = store
|
|
1882
2005
|
.listCancelledBlockersScoped(project, 10, admission)
|
|
1883
2006
|
.filter(one => visible(one.repo) && visible(one.blockerRepo));
|
|
1884
|
-
|
|
2007
|
+
const inboxData = {
|
|
1885
2008
|
csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
1886
2009
|
revision: who.via === "cookie" ? who.session.projectRevision : 0,
|
|
1887
2010
|
rollup,
|
|
@@ -1923,7 +2046,26 @@ export function createDecisionServer(options) {
|
|
|
1923
2046
|
return { answering: answering.length, registered: runners.length, lastHeard };
|
|
1924
2047
|
})(),
|
|
1925
2048
|
now,
|
|
1926
|
-
|
|
2049
|
+
// Console v2: results ready to review and work running now, from the same admitted work index.
|
|
2050
|
+
...(() => {
|
|
2051
|
+
const row = (one) => ({ taskId: one.rootId, title: one.title, detail: one.status.detail, repo: one.repo });
|
|
2052
|
+
const needsYou = workIndexPage(store, now, workAccess(), { view: "needs-you", limit: 40, project });
|
|
2053
|
+
const runningNow = workIndexPage(store, now, workAccess(), { view: "running", limit: 40, project });
|
|
2054
|
+
return { ready: needsYou.items.filter(one => one.assignmentState === "ready-to-check").map(row), running: runningNow.items.map(row) };
|
|
2055
|
+
})(),
|
|
2056
|
+
};
|
|
2057
|
+
// Which tabs hold something new since this browser last looked (the dots on a phone).
|
|
2058
|
+
const tab = parseInboxTab(url.searchParams.get("tab")) ?? "all";
|
|
2059
|
+
const prints = inboxFingerprints(inboxData);
|
|
2060
|
+
const seenWords = /(?:^|;\s*)so-inbox-seen=([0-9a-f]{8}(?:\.[0-9a-f]{8}){3})(?:;|$)/.exec(request.headers.cookie ?? "")?.[1]?.split(".") ?? null;
|
|
2061
|
+
const order = ["needs-you", "ready", "running", "all"];
|
|
2062
|
+
const seen = Object.fromEntries(order.map((one, index) => [one, seenWords?.[index] ?? ""]));
|
|
2063
|
+
const unread = order.filter(one => seenWords !== null && seen[one] !== prints[one]);
|
|
2064
|
+
for (const one of order)
|
|
2065
|
+
if (tab === "all" || one === tab)
|
|
2066
|
+
seen[one] = prints[one];
|
|
2067
|
+
response.setHeader("Set-Cookie", `so-inbox-seen=${order.map(one => seen[one] || prints[one]).join(".")}; SameSite=Lax; Path=/; Max-Age=31536000`);
|
|
2068
|
+
return sendScreen(response, 200, inboxPage(chromeFor(project, "inbox"), { ...inboxData, tab, unread }));
|
|
1927
2069
|
}
|
|
1928
2070
|
if (url.pathname === "/work") {
|
|
1929
2071
|
// The Work destination (workspace package 1): every task in view as
|
|
@@ -2598,7 +2740,7 @@ export function createDecisionServer(options) {
|
|
|
2598
2740
|
}
|
|
2599
2741
|
: null;
|
|
2600
2742
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
2601
|
-
return sendScreen(response, 200, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project, wanted === null ? undefined : wanted).slice(0, 200), wanted, csrf, null, project, prefill, store.permissionDefault().mode, store.qualityDefault().mode));
|
|
2743
|
+
return sendScreen(response, 200, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project, wanted === null ? undefined : wanted).slice(0, 200), wanted, csrf, null, project, prefill, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
|
|
2602
2744
|
}
|
|
2603
2745
|
if (url.pathname === "/queue") {
|
|
2604
2746
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
@@ -2942,10 +3084,31 @@ export function createDecisionServer(options) {
|
|
|
2942
3084
|
}
|
|
2943
3085
|
return sendScreen(response, 200, capsPage(chromeFor(project, "caps"), store.listCapabilities(project), computeGaps(store, project, now), project, now));
|
|
2944
3086
|
}
|
|
3087
|
+
// Flows → New: the gallery, and each template's page.
|
|
3088
|
+
if (url.pathname === "/flows/new") {
|
|
3089
|
+
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
3090
|
+
const canUse = who.via === "cookie" && who.role === "approver" && projects.length > 0;
|
|
3091
|
+
const repo = url.searchParams.get("repo");
|
|
3092
|
+
return sendScreen(response, 200, screen("New flow", `<p><a href="/flows">Flows</a></p><h1>New flow</h1>${galleryHtml({ repo: repo !== null && projects.includes(repo) ? repo : null, canUse })}`, { chrome: chromeFor(project, "flows") }));
|
|
3093
|
+
}
|
|
3094
|
+
const galleryRead = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
|
|
3095
|
+
if (galleryRead !== null) {
|
|
3096
|
+
const template = galleryTemplateOf(galleryRead[1]);
|
|
3097
|
+
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
3098
|
+
if (template === null)
|
|
3099
|
+
return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
|
|
3100
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
3101
|
+
return refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows/new");
|
|
3102
|
+
if (projects.length === 0)
|
|
3103
|
+
return refuse(response, who, 409, "Add a project first.", "/projects");
|
|
3104
|
+
const asked = url.searchParams.get("repo");
|
|
3105
|
+
const repo = asked !== null && projects.includes(asked) ? asked : project !== null && projects.includes(project) ? project : projects[0];
|
|
3106
|
+
return sendGalleryUse(response, who, template, projects, repo, null, template.name, null);
|
|
3107
|
+
}
|
|
2945
3108
|
if (url.pathname === "/flows") {
|
|
2946
3109
|
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
2947
3110
|
const flows = store.listFlows(projects);
|
|
2948
|
-
return sendScreen(response, 200, screen("Flows", `<h1>Flows</h1>${flowsListHtml(store, flows, projects, who.via === "cookie" ? who.session.csrf : "", who.via === "cookie" && who.role === "approver", url.searchParams.get("problem"))}`, { chrome: chromeFor(project, "flows") }));
|
|
3111
|
+
return sendScreen(response, 200, screen("Flows", `<h1>Flows</h1>${flowsListHtml(store, flows, projects, who.via === "cookie" ? who.session.csrf : "", who.via === "cookie" && who.role === "approver", url.searchParams.get("problem"))}`, { chrome: chromeFor(project, "flows"), functional: { script: FLOW_IMPORT_SCRIPT } }));
|
|
2949
3112
|
}
|
|
2950
3113
|
// starter kits — the gallery, and each kit's checklist in a project.
|
|
2951
3114
|
if (url.pathname === "/kits" || /^\/kits\/[a-z-]{1,40}$/.test(url.pathname)) {
|
|
@@ -2991,6 +3154,22 @@ export function createDecisionServer(options) {
|
|
|
2991
3154
|
return json(404, { said: "No such run." });
|
|
2992
3155
|
return json(200, { card: run.card, entry: run.entry, script: run.script, version: run.scriptVersion, state: run.state, result: run.result, exitCode: run.exitCode, durationMs: run.durationMs, at: run.finishedAt ?? run.startedAt, log: run.log ?? "" });
|
|
2993
3156
|
}
|
|
3157
|
+
const flowExport = /^\/flows\/([1-9][0-9]{0,9})\/export$/.exec(url.pathname);
|
|
3158
|
+
if (flowExport !== null) {
|
|
3159
|
+
// A flow as a file (flow-share.ts): zones, paths, trigger settings and scripts; never secrets, addresses, names or cards.
|
|
3160
|
+
const flow = store.getFlow(Number(flowExport[1]));
|
|
3161
|
+
if (flow === null || flow.state !== "active" || !visible(flow.repo))
|
|
3162
|
+
return refuse(response, who, 404, "No such flow in your projects.", "/flows");
|
|
3163
|
+
let exported;
|
|
3164
|
+
try {
|
|
3165
|
+
exported = exportFlow(store, flow, options.configDir ?? null);
|
|
3166
|
+
}
|
|
3167
|
+
catch (error) {
|
|
3168
|
+
return refuse(response, who, 409, error instanceof Error ? error.message : "This flow can't be exported.", `/flows/${flow.id}`);
|
|
3169
|
+
}
|
|
3170
|
+
response.setHeader("Content-Disposition", `attachment; filename="${exported.fileName}"`);
|
|
3171
|
+
return respond(response, 200, "application/json; charset=utf-8", exported.json);
|
|
3172
|
+
}
|
|
2994
3173
|
const flowLive = /^\/flows\/([1-9][0-9]{0,9})\/live$/.exec(url.pathname);
|
|
2995
3174
|
if (flowLive !== null) {
|
|
2996
3175
|
// The live canvas (v88): a nudge the moment the flow changes, and who
|
|
@@ -3427,7 +3606,11 @@ export function createDecisionServer(options) {
|
|
|
3427
3606
|
}
|
|
3428
3607
|
// The first run belongs to the lead conversation, for someone who can act on it.
|
|
3429
3608
|
const firstRun = focusTask === null && who.role === "approver" ? chatFirstRun(now, chatProject) : undefined;
|
|
3430
|
-
const
|
|
3609
|
+
const phone = focusTask === null && who.via === "cookie" ? phoneCard(who, now) : undefined;
|
|
3610
|
+
// Console v2: the landing's live view (who is working, four counts, Catch up in tabs).
|
|
3611
|
+
const home = focusTask === null && chatProject === null && !roomId ? chatHomeOf(who, repos, now) : null;
|
|
3612
|
+
const withFirstRun = (shown) => firstRun === undefined && phone === undefined && home === null ? shown
|
|
3613
|
+
: { ...shown, workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
|
|
3431
3614
|
if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
|
|
3432
3615
|
{
|
|
3433
3616
|
const said = takeMateNote(who.session.csrf, mateSession.id);
|
|
@@ -3454,6 +3637,7 @@ export function createDecisionServer(options) {
|
|
|
3454
3637
|
}
|
|
3455
3638
|
return sendScreen(response, 200, withFirstRun(chatPage(chromeFor(null, "chat", undefined, "all"), {
|
|
3456
3639
|
settingsOpen: settingsView,
|
|
3640
|
+
firstRunShown: firstRun !== undefined,
|
|
3457
3641
|
enabled,
|
|
3458
3642
|
pending,
|
|
3459
3643
|
latched,
|
|
@@ -3676,7 +3860,9 @@ export function createDecisionServer(options) {
|
|
|
3676
3860
|
repo: chosen, projects: projects.map(path => ({ path, name: projectName(path) })), starters: startersFor(store, chosen), csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
3677
3861
|
canSwitch: who.via === "cookie" && who.role === "approver" && !store.isDemo(), suggested: starterOf(url.searchParams.get("starter") ?? "")?.id ?? null,
|
|
3678
3862
|
said: url.searchParams.get("said"), problem: url.searchParams.get("problem")
|
|
3679
|
-
})
|
|
3863
|
+
}) +
|
|
3864
|
+
// Every template, beside the starters: Use this opens its page for this project.
|
|
3865
|
+
`<h2 class="gallery-heading">Templates</h2>${galleryHtml({ repo: chosen, canUse: who.via === "cookie" && who.role === "approver" && !store.isDemo() })}`;
|
|
3680
3866
|
return sendScreen(response, 200, screen("Flows", `<p><a href="/settings">Settings</a></p><h1>Flows</h1>${content}`, { chrome: chromeFor(chosen || project, "settings") }));
|
|
3681
3867
|
}
|
|
3682
3868
|
// Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
|
|
@@ -3738,6 +3924,18 @@ export function createDecisionServer(options) {
|
|
|
3738
3924
|
said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
3739
3925
|
return sendScreen(response, 200, screen("Pull requests", `<p><a href="/projects">Projects</a></p><h1>Pull requests</h1>${html}`, { chrome: chromeFor(project, "projects") }));
|
|
3740
3926
|
}
|
|
3927
|
+
// Settings → Projects → Checks: how much checks after each build, and the quick command beside the full one.
|
|
3928
|
+
if (url.pathname === "/settings/checks") {
|
|
3929
|
+
const repo = url.searchParams.get("repo") ?? "";
|
|
3930
|
+
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
3931
|
+
if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
|
|
3932
|
+
return refuse(response, who, 404, "No such project.", "/projects");
|
|
3933
|
+
const quick = liveQuickCommand(store, repo);
|
|
3934
|
+
const html = checkSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange: who.role === "approver" && !store.isDemo(),
|
|
3935
|
+
level: projectCheckLevel(store, repo).level, full: store.liveVerifyCommand(repo), quick, suggestion: quick === null ? suggestQuickCommand(repo) : null,
|
|
3936
|
+
said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
3937
|
+
return sendScreen(response, 200, screen("Checks", `<p><a href="/projects">Projects</a></p><h1>Checks</h1>${html}`, { chrome: chromeFor(project, "projects") }));
|
|
3938
|
+
}
|
|
3741
3939
|
// Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
|
|
3742
3940
|
if (url.pathname === "/settings/backups") {
|
|
3743
3941
|
const databaseFile = store.databaseFile();
|
|
@@ -3831,6 +4029,27 @@ export function createDecisionServer(options) {
|
|
|
3831
4029
|
}
|
|
3832
4030
|
return sendScreen(response, 200, screen("Learning", `<p><a href="/settings">Settings</a></p><h1>Learning</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings") }));
|
|
3833
4031
|
}
|
|
4032
|
+
// Settings → Lead (onboarding): what runs the lead in one line, and the full form under Advanced.
|
|
4033
|
+
if (url.pathname === "/settings/lead") {
|
|
4034
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
4035
|
+
return refuse(response, who, 403, "An approver sets up the lead.", "/settings");
|
|
4036
|
+
if (localSignIn === null)
|
|
4037
|
+
await Promise.race([checkLocalAgents(), new Promise(done => setTimeout(done, 6_000).unref?.())]);
|
|
4038
|
+
const config = store.getChatConfig();
|
|
4039
|
+
const facts = {
|
|
4040
|
+
keyFacts: ["anthropic-api", "openrouter-api"].map(one => {
|
|
4041
|
+
const found = chatKeyFor(one);
|
|
4042
|
+
return { provider: one, state: found === null ? "none" : found.source, tail: found === null || found.source === "environment" ? null : redactToken(found.key) };
|
|
4043
|
+
}),
|
|
4044
|
+
openrouterModels: (await chatCatalog())?.map(one => one.id) ?? null,
|
|
4045
|
+
liveModels: [...modelOptions(store, "claude", now, providerHome), ...modelOptions(store, "codex", now, providerHome)],
|
|
4046
|
+
csrf: who.session.csrf,
|
|
4047
|
+
returnTo: "/settings/lead",
|
|
4048
|
+
};
|
|
4049
|
+
const signedIn = localSignIn?.states.claude === "connected" ? "Claude Code" : localSignIn?.states.codex === "connected" ? "Codex" : null;
|
|
4050
|
+
return sendScreen(response, 200, screen("Lead", leadSettingsHtml({ config, facts, words: leadWords(), signedIn, command: agentSignInCommand(),
|
|
4051
|
+
said: url.searchParams.get("said") }), { chrome: chromeFor(project, "settings") }));
|
|
4052
|
+
}
|
|
3834
4053
|
if (url.pathname === "/settings/telegram") {
|
|
3835
4054
|
// Any approver pairs their OWN phone here; the bot token stays on /settings.
|
|
3836
4055
|
if (who.via !== "cookie" || who.role !== "approver")
|
|
@@ -3914,7 +4133,7 @@ export function createDecisionServer(options) {
|
|
|
3914
4133
|
return at === null || since === null ? null : firstResultWords(since, at);
|
|
3915
4134
|
})(), (() => {
|
|
3916
4135
|
const chosen = store.notificationPreference(who.name);
|
|
3917
|
-
return { mode: chosen.mode, digestAt: chosen.digestAt };
|
|
4136
|
+
return { mode: chosen.mode, digestAt: chosen.digestAt, projects: notificationProjects(store, who.name) };
|
|
3918
4137
|
})()));
|
|
3919
4138
|
}
|
|
3920
4139
|
if (url.pathname === "/push/key") {
|
|
@@ -3939,6 +4158,18 @@ export function createDecisionServer(options) {
|
|
|
3939
4158
|
const back = decisionReturn === null ? null : safeChatReturn(decisionReturn);
|
|
3940
4159
|
return sendScreen(response, 200, decisionPage(chromeFor(project, "none"), decision, taskId, store.evidenceFor(decision.id), who, now, back));
|
|
3941
4160
|
}
|
|
4161
|
+
// While no agent is signed in, Chat asks this computer again on its own (onboarding): a fresh, non-spending sign-in
|
|
4162
|
+
// check at most every few seconds, and the lead turns on as soon as one is found.
|
|
4163
|
+
if (url.pathname === "/lead/status") {
|
|
4164
|
+
if (who.via !== "cookie")
|
|
4165
|
+
return respond(response, 403, "application/json", JSON.stringify({ error: "sign in" }));
|
|
4166
|
+
if (Date.now() - leadRecheckedAt > 4_000) {
|
|
4167
|
+
leadRecheckedAt = Date.now();
|
|
4168
|
+
await Promise.race([checkLocalAgents(true), new Promise(done => setTimeout(done, 8_000).unref?.())]);
|
|
4169
|
+
}
|
|
4170
|
+
response.setHeader("cache-control", "no-store");
|
|
4171
|
+
return respond(response, 200, "application/json", JSON.stringify({ lead: store.getChatConfig() === null ? "off" : "on", agent: localSignIn?.signedIn ?? null, command: agentSignInCommand() }));
|
|
4172
|
+
}
|
|
3942
4173
|
if (url.pathname === "/chat/task-status") {
|
|
3943
4174
|
if (who.via !== "cookie")
|
|
3944
4175
|
return respond(response, 403, "text/plain; charset=utf-8", "sign in to see this task");
|
|
@@ -3964,7 +4195,7 @@ export function createDecisionServer(options) {
|
|
|
3964
4195
|
function firstRunStepsNow(now) {
|
|
3965
4196
|
if (restricted() || store.isDemo() || store.firstSuccessAt(now) !== null)
|
|
3966
4197
|
return null;
|
|
3967
|
-
return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null });
|
|
4198
|
+
return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null, signInCommand: agentSignInCommand() });
|
|
3968
4199
|
}
|
|
3969
4200
|
/**
|
|
3970
4201
|
* Whether a coding agent is signed in where the work runs. This machine's
|
|
@@ -3984,17 +4215,54 @@ export function createDecisionServer(options) {
|
|
|
3984
4215
|
return options.localRunner !== undefined || registered.length === 0 || workers.some(one => one.host === here) ? localAgentSignedIn() : false;
|
|
3985
4216
|
}
|
|
3986
4217
|
let localSignIn = null;
|
|
3987
|
-
let localSignInChecking =
|
|
4218
|
+
let localSignInChecking = null;
|
|
4219
|
+
let leadRecheckedAt = 0;
|
|
4220
|
+
/** Ask this computer's agent CLIs again (no model, nothing spent); `fresh` skips their 30-second cache. */
|
|
4221
|
+
function checkLocalAgents(fresh = false) {
|
|
4222
|
+
if (localSignInChecking !== null)
|
|
4223
|
+
return localSignInChecking;
|
|
4224
|
+
localSignInChecking = Promise.all(["claude", "codex"].map(one => connectionCheck(one, fresh).then(value => value.state, () => "unverified")))
|
|
4225
|
+
.then(([claude, codex]) => {
|
|
4226
|
+
localSignIn = { at: Date.now(), signedIn: [claude, codex].some(state => state === "connected" || state === "key-works" || state === "key-present"), states: { claude: claude, codex: codex } };
|
|
4227
|
+
leadByDefault();
|
|
4228
|
+
})
|
|
4229
|
+
.catch(() => { })
|
|
4230
|
+
.finally(() => { localSignInChecking = null; });
|
|
4231
|
+
return localSignInChecking;
|
|
4232
|
+
}
|
|
3988
4233
|
function localAgentSignedIn() {
|
|
3989
|
-
if (
|
|
3990
|
-
|
|
3991
|
-
void Promise.all(["claude", "codex"].map(one => connectionCheck(one).then(value => value.state, () => "unverified")))
|
|
3992
|
-
.then(states => { localSignIn = { at: Date.now(), signedIn: states.some(state => state === "connected" || state === "key-works" || state === "key-present") }; })
|
|
3993
|
-
.catch(() => { })
|
|
3994
|
-
.finally(() => { localSignInChecking = false; });
|
|
3995
|
-
}
|
|
4234
|
+
if (localSignInChecking === null && (localSignIn === null || Date.now() - localSignIn.at > 30_000))
|
|
4235
|
+
void checkLocalAgents();
|
|
3996
4236
|
return localSignIn?.signedIn ?? null;
|
|
3997
4237
|
}
|
|
4238
|
+
/**
|
|
4239
|
+
* The lead on by default (onboarding): with no lead set up yet, the agent CLI signed in on this computer runs it
|
|
4240
|
+
* (Claude Code first, else Codex) on its membership with today's safe defaults: the CLI's own default model, 50 turns
|
|
4241
|
+
* a day, no dollar spend, and every action it proposes still a card a person confirms. Once only, ever: a lead
|
|
4242
|
+
* someone turned off stays off, and Settings → Lead changes it.
|
|
4243
|
+
*/
|
|
4244
|
+
function leadByDefault() {
|
|
4245
|
+
if (options.leadByDefault !== true || store.isDemo() || store.getChatConfig() !== null || store.installationFact(LEAD_BY_DEFAULT_FACT) !== null)
|
|
4246
|
+
return;
|
|
4247
|
+
const provider = localSignIn?.states.claude === "connected" ? "claude-subscription" : localSignIn?.states.codex === "connected" ? "codex-subscription" : null;
|
|
4248
|
+
if (provider === null)
|
|
4249
|
+
return;
|
|
4250
|
+
const now = clock();
|
|
4251
|
+
store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, "toolroll", now);
|
|
4252
|
+
store.recordInstallationFact(LEAD_BY_DEFAULT_FACT, provider, now);
|
|
4253
|
+
}
|
|
4254
|
+
// Look once at start, so the lead is on by the time the browser opens.
|
|
4255
|
+
if (options.leadByDefault === true && !store.isDemo() && store.getChatConfig() === null)
|
|
4256
|
+
void checkLocalAgents();
|
|
4257
|
+
/** What the lead runs on, in words for one line ("your Claude Code sign-in"); null for a direct API or no lead. */
|
|
4258
|
+
function leadWords() {
|
|
4259
|
+
const config = store.getChatConfig();
|
|
4260
|
+
return config?.provider === "claude-subscription" ? "your Claude Code sign-in" : config?.provider === "codex-subscription" ? "your Codex sign-in" : null;
|
|
4261
|
+
}
|
|
4262
|
+
/** The one command that gets an agent signed in here, for this operating system: sign in when its CLI is installed, else install Claude Code and sign in. */
|
|
4263
|
+
function agentSignInCommand() {
|
|
4264
|
+
return signInCommandFor(localSignIn?.states ?? null, options.platform ?? process.platform);
|
|
4265
|
+
}
|
|
3998
4266
|
/** First tasks per project: the last ones found (safe generic ones until then), re-read in the background at most
|
|
3999
4267
|
* every ten minutes. A render never waits on `gh` or `git grep`. */
|
|
4000
4268
|
const firstTasks = new Map();
|
|
@@ -4018,10 +4286,43 @@ export function createDecisionServer(options) {
|
|
|
4018
4286
|
return undefined;
|
|
4019
4287
|
const taskFiled = steps.find(one => one.key === "task")?.done ?? true;
|
|
4020
4288
|
const repo = project ?? managedRepos()[0] ?? null;
|
|
4289
|
+
const agent = steps.find(one => one.key === "agent");
|
|
4290
|
+
const lead = leadWords();
|
|
4021
4291
|
return {
|
|
4022
4292
|
steps,
|
|
4023
4293
|
suggestions: taskFiled || repo === null ? [] : firstTasksFor(repo),
|
|
4024
|
-
sandbox:
|
|
4294
|
+
sandbox: agent !== undefined && !agent.done && !agent.checking ? SANDBOX_COMMAND : null,
|
|
4295
|
+
intro: HOW_IT_WORKS,
|
|
4296
|
+
lead: lead === null ? null : { words: `The lead uses ${lead}`, href: "/settings/lead" },
|
|
4297
|
+
// With no lead yet and no agent signed in, the page asks this computer again on its own.
|
|
4298
|
+
recheck: store.getChatConfig() === null && agent !== undefined && !agent.done ? "/lead/status" : null,
|
|
4299
|
+
};
|
|
4300
|
+
}
|
|
4301
|
+
/** The first task's way to Ready shows until the first Ready result, and stays on the installation's first task. */
|
|
4302
|
+
function guided(rootId, now) {
|
|
4303
|
+
if (store.isDemo())
|
|
4304
|
+
return false;
|
|
4305
|
+
if (store.firstSuccessAt(now) === null)
|
|
4306
|
+
return true;
|
|
4307
|
+
const first = store.handle.prepare("SELECT id FROM task ORDER BY created_at, rowid LIMIT 1").get();
|
|
4308
|
+
return first !== undefined && String(first["id"]) === rootId;
|
|
4309
|
+
}
|
|
4310
|
+
/** After the first Ready result, once (onboarding): the phone, by a chat app or the console over Tailscale. */
|
|
4311
|
+
function phoneCard(who, now) {
|
|
4312
|
+
if (who.role !== "approver" || store.isDemo() || store.firstSuccessAt(now) === null || store.installationFact(PHONE_CARD_FACT) !== null)
|
|
4313
|
+
return undefined;
|
|
4314
|
+
const botId = options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile)?.botId ?? null;
|
|
4315
|
+
if (botId !== null && store.liveTelegramBindings(botId).length > 0)
|
|
4316
|
+
return undefined;
|
|
4317
|
+
const port = servedPort() ?? 4180;
|
|
4318
|
+
const address = server.address();
|
|
4319
|
+
const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
|
|
4320
|
+
const name = tailnet[0] ?? null;
|
|
4321
|
+
const reachable = bound !== "127.0.0.1" && bound !== "::1";
|
|
4322
|
+
return {
|
|
4323
|
+
chatApps: [{ label: "Telegram", href: "/settings/telegram" }, { label: "Slack", href: "/settings/slack" }, { label: "Discord", href: "/settings/discord" }, { label: "Teams", href: "/settings/teams" }],
|
|
4324
|
+
tailnet: name === null ? null : { address: `http://${name}:${port}/`, restart: reachable ? null : consoleCommand({ host: "0.0.0.0" }) },
|
|
4325
|
+
dismissHref: "/onboarding/phone/dismiss",
|
|
4025
4326
|
};
|
|
4026
4327
|
}
|
|
4027
4328
|
// ---- fleet chat (v13) ----------------------------------------------------
|
|
@@ -4219,7 +4520,7 @@ export function createDecisionServer(options) {
|
|
|
4219
4520
|
return { ok: false, code: "empty", why: "add a project first — chat will include it automatically" };
|
|
4220
4521
|
const config = store.getChatConfig();
|
|
4221
4522
|
if (config === null)
|
|
4222
|
-
return { ok: false, code: "unconfigured", why: "
|
|
4523
|
+
return { ok: false, code: "unconfigured", why: "the lead is off — turn it on in Settings → Lead, or from the terminal: toolroll config set chat" };
|
|
4223
4524
|
if (isSubscriptionChatProvider(config.provider)) {
|
|
4224
4525
|
return {
|
|
4225
4526
|
ok: true,
|
|
@@ -4643,6 +4944,8 @@ export function createDecisionServer(options) {
|
|
|
4643
4944
|
...(s.chrome.signIn === undefined ? {} : { signIn: s.chrome.signIn }),
|
|
4644
4945
|
...(s.chrome.update === undefined ? {} : { update: s.chrome.update }),
|
|
4645
4946
|
...(extras.firstRun === undefined ? {} : { firstRun: extras.firstRun }),
|
|
4947
|
+
...(extras.phone === undefined ? {} : { phone: extras.phone }),
|
|
4948
|
+
...(extras.home == null ? {} : { home: extras.home }),
|
|
4646
4949
|
};
|
|
4647
4950
|
if (requestFacts.workspaceRead) {
|
|
4648
4951
|
const validator = requestFacts.workspaceValidator;
|
|
@@ -4696,6 +4999,38 @@ export function createDecisionServer(options) {
|
|
|
4696
4999
|
* package 1): Work's own Needs-you membership, cached per viewer and
|
|
4697
5000
|
* project, so the rail, the phone tab, the scope bar, the project cards,
|
|
4698
5001
|
* the chat overview, and the Needs-you tab never disagree. */
|
|
5002
|
+
/** The Chat landing's live view: each agent at work now with its task and
|
|
5003
|
+
* phase, four counts, plan-window use (never dollars: subscriptions don't
|
|
5004
|
+
* bill per run) and Catch up's items, each tagged with its tab. Reads only. */
|
|
5005
|
+
function chatHomeOf(who, repos, now) {
|
|
5006
|
+
const access = { principal: "operator", repos: admissionList(), includeUnplaced: visible(null) };
|
|
5007
|
+
const admitted = (repo) => repo === null ? visible(null) : repos.includes(repo);
|
|
5008
|
+
const agents = store.liveRuns(now).filter(run => admitted(run.repo)).map(run => {
|
|
5009
|
+
const root = familyOf(run.taskId)?.root ?? null;
|
|
5010
|
+
return { runId: run.id, taskId: root?.id ?? run.taskId, title: root?.title ?? run.title, href: taskHref(root?.id ?? run.taskId),
|
|
5011
|
+
agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt };
|
|
5012
|
+
});
|
|
5013
|
+
const all = workIndexPage(store, now, access, { view: "all", limit: WORK_INDEX_MAX_LIMIT });
|
|
5014
|
+
const needs = workIndexPage(store, now, access, { view: "needs-you", limit: WORK_INDEX_MAX_LIMIT });
|
|
5015
|
+
const done = workIndexPage(store, now, access, { view: "completed", limit: WORK_INDEX_MAX_LIMIT });
|
|
5016
|
+
const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
|
|
5017
|
+
const counts = [
|
|
5018
|
+
{ key: "working", label: "Working now", value: agents.length, href: "/work?view=running" },
|
|
5019
|
+
{ key: "waiting", label: "Waiting on you", value: needs.items.filter(one => one.assignmentState === "needs-decision").length, href: "/work?view=needs-you" },
|
|
5020
|
+
{ key: "ready", label: "Ready to review", value: needs.items.filter(one => one.assignmentState === "ready-to-check").length, href: "/work?view=needs-you" },
|
|
5021
|
+
{ key: "done", label: "Done this week", value: done.items.filter(one => (one.completion?.at ?? one.updatedAt) >= weekAgo).length, href: "/work?view=completed" },
|
|
5022
|
+
];
|
|
5023
|
+
const tabOf = (state) => state === "needs-decision" ? "needs-you" : state === "ready-to-check" ? "ready" : state === "working" || state === "checking" ? "running" : "finished";
|
|
5024
|
+
const catchUp = all.items.filter(one => tabOf(one.assignmentState) !== "finished" || one.updatedAt >= weekAgo).slice(0, 40).map(one => ({
|
|
5025
|
+
id: one.rootId, title: one.title, href: taskHref(one.rootId), project: one.repo === null ? null : projectName(one.repo), tab: tabOf(one.assignmentState),
|
|
5026
|
+
label: one.status.label, tone: one.status.tone, detail: one.status.detail, at: one.updatedAt,
|
|
5027
|
+
}));
|
|
5028
|
+
const windows = who.via === "cookie" && store.isInstanceOperator(who.name) ? limitsView(store.providerLimits(), [], { project: projectName, teammate: id => `Teammate ${id}` }, now) : null;
|
|
5029
|
+
return {
|
|
5030
|
+
agents, counts, catchUp, allHref: "/work",
|
|
5031
|
+
planUse: (windows?.tiles ?? []).map(one => ({ name: one.name, window: one.window, percent: one.percent, detail: one.detail, tone: one.tone })),
|
|
5032
|
+
};
|
|
5033
|
+
}
|
|
4699
5034
|
function needsYouBadge(project) {
|
|
4700
5035
|
const actor = requestContext.getStore()?.actor;
|
|
4701
5036
|
const key = `${actor ?? ""}:${actor === undefined ? "" : store.accountOf(actor)?.generation}:${project ?? ""}`;
|
|
@@ -4892,6 +5227,7 @@ export function createDecisionServer(options) {
|
|
|
4892
5227
|
verdict: verdict?.verdict ?? null,
|
|
4893
5228
|
reasons: verdict?.reasons ?? [],
|
|
4894
5229
|
accepted: store.proofAcceptance(latest.id) !== null,
|
|
5230
|
+
checkLevel: resultCheckLevel(store, latest.id, now),
|
|
4895
5231
|
review: reviewFactsFor(latest.id),
|
|
4896
5232
|
...(latest.outcome !== "no-change"
|
|
4897
5233
|
? {}
|
|
@@ -5019,7 +5355,7 @@ export function createDecisionServer(options) {
|
|
|
5019
5355
|
peeks[one.path] = peekOf(one.path);
|
|
5020
5356
|
for (const path of candidates)
|
|
5021
5357
|
peeks[path] = peekOf(path);
|
|
5022
|
-
return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, csrf, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined));
|
|
5358
|
+
return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, csrf, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined, who.via === "cookie" ? (path) => projectCheckLevel(store, path).level : undefined));
|
|
5023
5359
|
}
|
|
5024
5360
|
/** The task screen, shared by the GET and by every refusal that re-renders it. */
|
|
5025
5361
|
/**
|
|
@@ -5142,6 +5478,7 @@ export function createDecisionServer(options) {
|
|
|
5142
5478
|
const assignment = freshAssignment(recordedAssignment, completion?.receipt ?? null);
|
|
5143
5479
|
const progressRun = runs.find(one => runIsLive(one))?.id ?? completion?.runId ?? null;
|
|
5144
5480
|
return {
|
|
5481
|
+
guide: guided(family?.root.id ?? taskId, now),
|
|
5145
5482
|
assignment,
|
|
5146
5483
|
checkProgress: progressRun === null ? null : store.checkProgress(progressRun),
|
|
5147
5484
|
task: found,
|
|
@@ -5433,6 +5770,7 @@ export function createDecisionServer(options) {
|
|
|
5433
5770
|
id: family.root.id,
|
|
5434
5771
|
executionId: task.id,
|
|
5435
5772
|
family,
|
|
5773
|
+
guide: guided(family.root.id, now),
|
|
5436
5774
|
history: familyHistory(family),
|
|
5437
5775
|
title: family.root.title,
|
|
5438
5776
|
state: task.state,
|
|
@@ -5524,6 +5862,12 @@ export function createDecisionServer(options) {
|
|
|
5524
5862
|
if (cancelDraft !== undefined)
|
|
5525
5863
|
presentedData.cancelDraft = cancelDraft;
|
|
5526
5864
|
presentedData.pullRequest = taskPullRequestOf(taskId, who);
|
|
5865
|
+
// The thread reads the whole family: the original, its revisions, and every attempt across them.
|
|
5866
|
+
if (family !== null && family.problem === null) {
|
|
5867
|
+
presentedData.family = { root: { id: family.root.id, title: family.root.title, createdAt: family.root.createdAt, goal: store.getScope(family.root.id)?.goal ?? null },
|
|
5868
|
+
versions: family.versions.map(one => ({ id: one.id, title: one.title, state: one.state })),
|
|
5869
|
+
runs: family.versions.flatMap(version => version.id === data.task.id ? data.runs.map(run => ({ ...run, taskId: version.id })) : store.runsFor(version.refId).map(run => ({ ...run, taskId: version.id }))) };
|
|
5870
|
+
}
|
|
5527
5871
|
const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
|
|
5528
5872
|
const page = taskPage(paneProject === null && !unscopedMode
|
|
5529
5873
|
? chromeFor(paneProject, "tasks")
|
|
@@ -5531,10 +5875,13 @@ export function createDecisionServer(options) {
|
|
|
5531
5875
|
// The task's own conversation, docked beside the page (v77).
|
|
5532
5876
|
const focus = taskChatFocus(family?.root.id ?? taskId, clock(), who, { mintNonce: false });
|
|
5533
5877
|
const docked = focus === null ? null : dockedConversation(who, focus, null, clock(), taskHref(focus.id));
|
|
5534
|
-
// The
|
|
5535
|
-
|
|
5878
|
+
// The conversation is the page's own thread here, so the page drops its Ask tab;
|
|
5879
|
+
// only an approver is offered a road to message the agent.
|
|
5880
|
+
const taskView = page.workspace?.view?.kind === "task" ? { ...page.workspace.view, ...(docked === null ? {} : { tabs: [] }), ...(who.role === "approver" ? {} : { chatHref: null }) } : page.workspace?.view;
|
|
5536
5881
|
if (docked !== null)
|
|
5537
5882
|
page.workspace = { ...page.workspace, ...(taskView === undefined ? {} : { view: taskView }), conversation: docked, pageHtml: page.body };
|
|
5883
|
+
else if (taskView !== undefined && page.workspace !== undefined)
|
|
5884
|
+
page.workspace = { ...page.workspace, view: taskView };
|
|
5538
5885
|
return sendScreen(response, status, page);
|
|
5539
5886
|
}
|
|
5540
5887
|
/** The routine screen: the standing order restated, its verbs, its ledger. */
|
|
@@ -6357,14 +6704,45 @@ export function createDecisionServer(options) {
|
|
|
6357
6704
|
return redirect(response, `/teammates?said=${encodeURIComponent(done.said)}`);
|
|
6358
6705
|
return redirect(response, `${back}?${done.ok ? "said" : "problem"}=${encodeURIComponent(done.said)}${action === "note" ? "#memory" : ""}`);
|
|
6359
6706
|
}
|
|
6707
|
+
// Flows → New → a template: preview these answers, or create exactly what was previewed.
|
|
6708
|
+
const galleryPost = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
|
|
6709
|
+
if (galleryPost !== null) {
|
|
6710
|
+
const template = galleryTemplateOf(galleryPost[1]);
|
|
6711
|
+
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
6712
|
+
if (template === null)
|
|
6713
|
+
return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
|
|
6714
|
+
if (who.via !== "cookie" || who.role !== "approver" || store.isDemo())
|
|
6715
|
+
return refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows/new");
|
|
6716
|
+
const repo = body.get("repo") ?? "";
|
|
6717
|
+
if (!projects.includes(repo))
|
|
6718
|
+
return refuse(response, who, 404, "Choose one of your projects.", "/flows/new");
|
|
6719
|
+
const answers = Object.fromEntries(template.asks.map(ask => [ask.key, body.get(ask.key) ?? ""]));
|
|
6720
|
+
const name = (body.get("name") ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80) || template.name;
|
|
6721
|
+
if (body.get("intent") !== "create")
|
|
6722
|
+
return sendGalleryUse(response, who, template, projects, repo, answers, name, null);
|
|
6723
|
+
// What's made is what was previewed: changed answers are previewed again first.
|
|
6724
|
+
let digest = null;
|
|
6725
|
+
try {
|
|
6726
|
+
digest = previewGallery(store, template, repo, answers, who.name, clock()).digest;
|
|
6727
|
+
}
|
|
6728
|
+
catch {
|
|
6729
|
+
digest = null;
|
|
6730
|
+
}
|
|
6731
|
+
if (digest === null || digest !== body.get("previewed"))
|
|
6732
|
+
return sendGalleryUse(response, who, template, projects, repo, answers, name, digest === null ? null : "Your answers changed. Check the preview, then create the flow.");
|
|
6733
|
+
const used = useGalleryTemplate(store, template, repo, answers, { name, by: who.name, now: clock(), dir: options.configDir ?? null });
|
|
6734
|
+
if (!used.ok)
|
|
6735
|
+
return sendGalleryUse(response, who, template, projects, repo, answers, name, used.said);
|
|
6736
|
+
return redirect(response, `/flows/${used.flow}`);
|
|
6737
|
+
}
|
|
6360
6738
|
const flowPost = /^\/flows\/([1-9][0-9]{0,9})\/(save|cards|archive|triggers|linear-key|hooks-address|scripts|secrets)$/.exec(url.pathname) ?? /^\/flows\/([1-9][0-9]{0,9})\/cards\/([1-9][0-9]{0,9})\/(move|decide|cancel|comment|assign|watch)$/.exec(url.pathname);
|
|
6361
6739
|
const triggerPost = /^\/flows\/([1-9][0-9]{0,9})\/triggers\/([1-9][0-9]{0,9})\/(pause|resume|remove|check|press|renew|secret|share|unshare)$/.exec(url.pathname);
|
|
6362
|
-
if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || flowPost !== null || triggerPost !== null) {
|
|
6740
|
+
if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" || flowPost !== null || triggerPost !== null) {
|
|
6363
6741
|
const now = clock();
|
|
6364
6742
|
const answer = (status, payload) => respond(response, status, "application/json; charset=utf-8", JSON.stringify(payload));
|
|
6365
6743
|
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
6366
6744
|
if (who.via !== "cookie" || who.role !== "approver")
|
|
6367
|
-
return url.pathname === "/flows/new" || url.pathname === "/flows/example" ? refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows") : answer(403, { ok: false, said: "Sign in as an approver to change flows." });
|
|
6745
|
+
return url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" ? refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows") : answer(403, { ok: false, said: "Sign in as an approver to change flows." });
|
|
6368
6746
|
if (url.pathname === "/flows/example") {
|
|
6369
6747
|
// A first look (v88): the Email replies template with one sample question, which Claude drafts a reply to straight away.
|
|
6370
6748
|
const repo = body.get("repo") ?? "";
|
|
@@ -6375,6 +6753,52 @@ export function createDecisionServer(options) {
|
|
|
6375
6753
|
addCardToFlow(store, store.getFlow(id), { title: "Do you ship to Canada?", description: "Hi! I'm thinking of ordering but I live in Toronto. Do you ship there, and how long does it take? — sam@example.com", stage: null }, who.name, now);
|
|
6376
6754
|
return redirect(response, `/flows/${id}`);
|
|
6377
6755
|
}
|
|
6756
|
+
if (url.pathname === "/flows/import") {
|
|
6757
|
+
// A flow file, chosen or fetched from a gist: previewed in plain words, then made with its triggers off and its scripts held.
|
|
6758
|
+
const repo = body.get("repo") ?? "";
|
|
6759
|
+
const back = (problem) => redirect(response, `/flows?problem=${encodeURIComponent(problem)}`);
|
|
6760
|
+
if (!projects.includes(repo))
|
|
6761
|
+
return back("Choose one of your projects.");
|
|
6762
|
+
let document = body.get("document") ?? "";
|
|
6763
|
+
const address = (body.get("url") ?? "").trim();
|
|
6764
|
+
let file;
|
|
6765
|
+
try {
|
|
6766
|
+
if (document.trim() === "" && address !== "")
|
|
6767
|
+
document = await fetchFlowFile(address, options.flowFetch);
|
|
6768
|
+
if (document.trim() === "")
|
|
6769
|
+
return back("Choose a flow file or give its address.");
|
|
6770
|
+
file = parseFlowFile(document);
|
|
6771
|
+
}
|
|
6772
|
+
catch (error) {
|
|
6773
|
+
if (error instanceof FlowFileError)
|
|
6774
|
+
return back(error.message);
|
|
6775
|
+
throw error;
|
|
6776
|
+
}
|
|
6777
|
+
const given = Object.fromEntries(file.parameters.flatMap(one => body.has(`param.${one.id}`) ? [[one.id, body.get(`param.${one.id}`) ?? ""]] : []));
|
|
6778
|
+
// What was previewed: the file and every value it resolved to (a default the first preview filled in included).
|
|
6779
|
+
const previewedOf = (plan) => createHash("sha256").update(`${document}\0${JSON.stringify(plan?.values ?? given)}`).digest("hex").slice(0, 32);
|
|
6780
|
+
const page = (plan, problem, status) => sendScreen(response, status, screen(`Import ${file.name}`, `<p><a href="/flows">Flows</a></p><h1>Import ${escape(file.name)}</h1>${flowImportHtml({ plan, file, document, repo, csrf: who.session.csrf, values: given, previewed: previewedOf(plan), problem })}`, { chrome: chromeFor(repo, "flows") }));
|
|
6781
|
+
let plan;
|
|
6782
|
+
try {
|
|
6783
|
+
plan = planFlowImport(store, repo, file, given, who.name);
|
|
6784
|
+
}
|
|
6785
|
+
catch (error) {
|
|
6786
|
+
if (error instanceof FlowFileError)
|
|
6787
|
+
return page(null, error.message, 400);
|
|
6788
|
+
throw error;
|
|
6789
|
+
}
|
|
6790
|
+
if (body.get("confirm") === "yes") {
|
|
6791
|
+
if (body.get("previewed") !== previewedOf(plan))
|
|
6792
|
+
return page(plan, "What you filled in changed. Check the preview again, then import.", 409);
|
|
6793
|
+
try {
|
|
6794
|
+
return redirect(response, `/flows/${importFlow(store, plan, who.name, now, options.configDir ?? null).id}`);
|
|
6795
|
+
}
|
|
6796
|
+
catch (error) {
|
|
6797
|
+
return page(plan, error instanceof Error ? error.message : "That flow couldn't be imported.", 400);
|
|
6798
|
+
}
|
|
6799
|
+
}
|
|
6800
|
+
return page(plan, null, 200);
|
|
6801
|
+
}
|
|
6378
6802
|
if (url.pathname === "/flows/new") {
|
|
6379
6803
|
const repo = body.get("repo") ?? "";
|
|
6380
6804
|
if (!projects.includes(repo))
|
|
@@ -6470,6 +6894,8 @@ export function createDecisionServer(options) {
|
|
|
6470
6894
|
}
|
|
6471
6895
|
if (action === "scripts") {
|
|
6472
6896
|
// The project's script library, reached from any of its flows.
|
|
6897
|
+
if (body.get("approve") === "yes")
|
|
6898
|
+
return store.approveFlowScript(flow.repo, body.get("name") ?? "") ? settle("Approved. Zones that run it go on.") : answer(404, { ok: false, said: "That script isn't waiting for approval." });
|
|
6473
6899
|
if (body.get("remove") === "yes")
|
|
6474
6900
|
return store.removeFlowScript(flow.repo, body.get("name") ?? "") ? settle("Script removed. Zones that ran it wait until it's back.") : answer(404, { ok: false, said: "There's no script by that name." });
|
|
6475
6901
|
const saved = saveScript(store, flow.repo, { name: body.get("name"), about: body.get("about"), body: body.get("body"), timeoutMinutes: body.get("timeoutMinutes"), language: body.get("language"), file: body.get("file") }, who.name, now);
|
|
@@ -6594,6 +7020,26 @@ export function createDecisionServer(options) {
|
|
|
6594
7020
|
return back("said", `${label}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", API work stops at 100%" : ", alerts only"}.`);
|
|
6595
7021
|
}
|
|
6596
7022
|
// Delete a project, in two steps: its name, then what goes and the password. An instance operator; the ledger keeps who and what.
|
|
7023
|
+
// Settings → Project → Builds at once: an approver for the project sets it; the ledger keeps before → after.
|
|
7024
|
+
if (url.pathname === "/settings/project/concurrency") {
|
|
7025
|
+
const repo = body.get("repo") ?? "";
|
|
7026
|
+
const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
7027
|
+
if (!known.includes(repo))
|
|
7028
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
|
|
7029
|
+
if (who.via !== "cookie" || who.role !== "approver" || !store.accountCanAccess(who.name, repo))
|
|
7030
|
+
return refuse(response, who, 403, "An approver for this project sets how many tasks build at once.", "/settings/project");
|
|
7031
|
+
const back = (key, words) => redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
7032
|
+
const n = parseProjectConcurrency(body.get("concurrency") ?? "");
|
|
7033
|
+
if (n === null)
|
|
7034
|
+
return back("problem", "Builds at once is a whole number from 1 to 64. Nothing changed.");
|
|
7035
|
+
const databaseFile = store.databaseFile();
|
|
7036
|
+
if (databaseFile === null)
|
|
7037
|
+
return back("problem", "This database keeps no settings files. Nothing changed.");
|
|
7038
|
+
const changed = saveProjectConcurrency(databaseFile, repo, n);
|
|
7039
|
+
store.recordProjectConcurrency(who.name, repo, changed.before, changed.after, now);
|
|
7040
|
+
store.bumpWake();
|
|
7041
|
+
return back("said", changed.before === changed.after ? "Nothing changed." : `Saved. Up to ${n} at once.`);
|
|
7042
|
+
}
|
|
6597
7043
|
if (url.pathname === "/settings/project/delete") {
|
|
6598
7044
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
6599
7045
|
return refuse(response, who, 403, "An instance operator deletes projects.", "/settings/project");
|
|
@@ -6895,6 +7341,48 @@ export function createDecisionServer(options) {
|
|
|
6895
7341
|
}
|
|
6896
7342
|
return back("problem", "That change isn't available.");
|
|
6897
7343
|
}
|
|
7344
|
+
// Checks for one project: the level, or the quick command (approved like `verify set --quick`). Each takes
|
|
7345
|
+
// the password and lands in the ledger.
|
|
7346
|
+
if (url.pathname === "/settings/checks") {
|
|
7347
|
+
const repo = body.get("repo") ?? "";
|
|
7348
|
+
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
7349
|
+
if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
|
|
7350
|
+
return refuse(response, who, 404, "No such project.", "/projects");
|
|
7351
|
+
if (who.role !== "approver" || store.isDemo())
|
|
7352
|
+
return refuse(response, who, 403, "An approver sets a project's checks.", "/projects");
|
|
7353
|
+
const back = (key, words) => redirect(response, `/settings/checks?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
7354
|
+
if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
|
|
7355
|
+
return back("problem", "Enter your Toolroll password to change checks.");
|
|
7356
|
+
const act = body.get("act");
|
|
7357
|
+
if (act === "level") {
|
|
7358
|
+
const level = body.get("level");
|
|
7359
|
+
if (!isCheckLevel(level))
|
|
7360
|
+
return back("problem", "Choose Quick, Full or Off.");
|
|
7361
|
+
const changed = setProjectCheckLevel(store, repo, level, who.name, now);
|
|
7362
|
+
return back("said", changed.changed ? `Checks are ${CHECK_LEVEL_WORDS[level]}.` : "Saved.");
|
|
7363
|
+
}
|
|
7364
|
+
if (act === "quick") {
|
|
7365
|
+
const command = (body.get("command") ?? "").trim();
|
|
7366
|
+
const seconds = Number(body.get("timeout") ?? "180");
|
|
7367
|
+
if (command === "" || command.length > 2000 || hasDisguisedText(command))
|
|
7368
|
+
return back("problem", "Enter the quick command: one line of up to 2000 characters.");
|
|
7369
|
+
if (/([A-Za-z0-9_-]*(?:token|secret|password|passwd|apikey|api_key|authorization|bearer|credential)[A-Za-z0-9_-]*\s*[=:]\s*)(?![$"']?\$)\S+/i.test(command) || /\/\/[^\s/@]+:[^\s/@]+@/.test(command)) {
|
|
7370
|
+
return back("problem", "That command seems to hold a credential. Use an environment variable instead.");
|
|
7371
|
+
}
|
|
7372
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 3600)
|
|
7373
|
+
return back("problem", "Choose a time limit from 1 to 3600 seconds.");
|
|
7374
|
+
store.setVerifyCommand({ repo: quickVerifyKey(repo), command, timeoutMs: seconds * 1000, approvedBy: who.name }, now);
|
|
7375
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "quick check approved", outcome: "approved", source: "policy", detail: command.slice(0, 200) });
|
|
7376
|
+
return back("said", "Quick check approved.");
|
|
7377
|
+
}
|
|
7378
|
+
if (act === "quick-clear") {
|
|
7379
|
+
const cleared = store.clearVerifyCommand(quickVerifyKey(repo), who.name, now);
|
|
7380
|
+
if (cleared)
|
|
7381
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "quick check removed", outcome: "removed", source: "policy" });
|
|
7382
|
+
return back("said", cleared ? "Quick check removed. Quick builds run the full check." : "There was no quick check.");
|
|
7383
|
+
}
|
|
7384
|
+
return back("problem", "That change isn't available.");
|
|
7385
|
+
}
|
|
6898
7386
|
// Sprint 8: back up now. An instance operator; the outcome is kept like a scheduled one's.
|
|
6899
7387
|
if (url.pathname === "/settings/backups/now") {
|
|
6900
7388
|
const databaseFile = store.databaseFile();
|
|
@@ -7543,6 +8031,16 @@ export function createDecisionServer(options) {
|
|
|
7543
8031
|
: after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
|
|
7544
8032
|
return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
|
|
7545
8033
|
}
|
|
8034
|
+
if (url.pathname === "/settings/notifications/mute") {
|
|
8035
|
+
// Each person's own pings: a muted project stays in Tasks and the evening digest.
|
|
8036
|
+
const repo = body.get("repo") ?? "";
|
|
8037
|
+
const project = notificationProjects(store, who.name).find(one => one.repo === repo);
|
|
8038
|
+
if (project === undefined)
|
|
8039
|
+
return refuse(response, who, 400, "that project isn't one you can see", "/settings");
|
|
8040
|
+
const muted = body.get("pings") !== "on";
|
|
8041
|
+
store.setProjectMuted(who.name, repo, muted, now);
|
|
8042
|
+
return redirect(response, `/settings?said=${encodeURIComponent(muted ? `${project.name} muted. It still shows in Tasks and your evening digest.` : `${project.name} pings you again.`)}#notifications`);
|
|
8043
|
+
}
|
|
7546
8044
|
if (url.pathname === "/settings/telegram-digest" && options.telegramTokenFile !== undefined) {
|
|
7547
8045
|
// The cadence is a closed list of minutes — never a free number from
|
|
7548
8046
|
// a form; "off" clears it. Any approver session may set it.
|
|
@@ -7750,7 +8248,7 @@ export function createDecisionServer(options) {
|
|
|
7750
8248
|
// under a ceiling. Unscoped mode keeps its historic unplaced filings.
|
|
7751
8249
|
if (!unscopedMode && effective === "") {
|
|
7752
8250
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
7753
|
-
return sendScreen(response, 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, "name a repository — no project is open, so the task must say where it belongs", project, null, store.permissionDefault().mode, store.qualityDefault().mode));
|
|
8251
|
+
return sendScreen(response, 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, "name a repository — no project is open, so the task must say where it belongs", project, null, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
|
|
7754
8252
|
}
|
|
7755
8253
|
let repo = effective;
|
|
7756
8254
|
let admitted = unscopedMode ? null : admissionList() ?? [];
|
|
@@ -7759,7 +8257,7 @@ export function createDecisionServer(options) {
|
|
|
7759
8257
|
const canonical = (await authorizedProject(liveCeiling(), effective)) ? canonicalProject(effective) : null;
|
|
7760
8258
|
if (canonical === null || canonical === undefined) {
|
|
7761
8259
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
7762
|
-
return sendScreen(response, 403, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, `${effective} is outside what this server was configured to show`, project, null, store.permissionDefault().mode, store.qualityDefault().mode));
|
|
8260
|
+
return sendScreen(response, 403, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, `${effective} is outside what this server was configured to show`, project, null, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
|
|
7763
8261
|
}
|
|
7764
8262
|
repo = canonical;
|
|
7765
8263
|
admitted = [canonical];
|
|
@@ -7816,7 +8314,7 @@ export function createDecisionServer(options) {
|
|
|
7816
8314
|
});
|
|
7817
8315
|
if (!made.ok) {
|
|
7818
8316
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
7819
|
-
return sendScreen(response, made.reason === "backlog-full" ? 429 : 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, made.message, project, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body }, store.permissionDefault().mode, store.qualityDefault().mode));
|
|
8317
|
+
return sendScreen(response, made.reason === "backlog-full" ? 429 : 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, made.message, project, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body }, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
|
|
7820
8318
|
}
|
|
7821
8319
|
const actionContext = requestContext.getStore();
|
|
7822
8320
|
if (actionContext !== undefined)
|
|
@@ -8291,7 +8789,7 @@ export function createDecisionServer(options) {
|
|
|
8291
8789
|
if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
|
|
8292
8790
|
return taskScreen(response, who, act.taskId, "That pull request isn't part of this task.", 409);
|
|
8293
8791
|
}
|
|
8294
|
-
const merged = await mergeAsPerson(store, { runId: run.id, name: who.name, password: body.get("token") ?? "", ...(options.publishExec === undefined ? {} : { exec: options.publishExec }), clock });
|
|
8792
|
+
const merged = await mergeAsPerson(store, { runId: run.id, name: who.name, password: body.get("token") ?? "", ...(body.get("anyway") === "1" ? { anyway: true } : {}), ...(options.publishExec === undefined ? {} : { exec: options.publishExec }), clock });
|
|
8295
8793
|
if (!merged.ok)
|
|
8296
8794
|
return taskScreen(response, who, act.taskId, merged.message, merged.reason === "password" ? 403 : 409);
|
|
8297
8795
|
return redirect(response, `${taskHref(act.taskId)}#merge`);
|
|
@@ -8475,6 +8973,24 @@ export function createDecisionServer(options) {
|
|
|
8475
8973
|
store.retirePushSubscription(mine.id, `removed by ${who.name}`, clock());
|
|
8476
8974
|
return redirect(response, `/settings?said=${encodeURIComponent("removed — that device stops receiving pushes")}`);
|
|
8477
8975
|
}
|
|
8976
|
+
if (url.pathname === "/onboarding/phone/dismiss") {
|
|
8977
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
8978
|
+
return refuse(response, who, 403, "An approver puts this away.", "/chat");
|
|
8979
|
+
store.recordInstallationFact(PHONE_CARD_FACT, who.name, now);
|
|
8980
|
+
return body.get("quiet") === "1" ? respond(response, 204, "text/plain; charset=utf-8", "") : redirect(response, "/chat");
|
|
8981
|
+
}
|
|
8982
|
+
// Turn the lead on with the agent signed in on this computer: its membership spends no dollars, so like starting a
|
|
8983
|
+
// conversation it needs no second password; the full form (Advanced) still asks for one.
|
|
8984
|
+
if (url.pathname === "/settings/lead/on") {
|
|
8985
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
8986
|
+
return refuse(response, who, 403, "An approver turns the lead on.", "/settings/lead");
|
|
8987
|
+
await checkLocalAgents(true);
|
|
8988
|
+
const provider = localSignIn?.states.claude === "connected" ? "claude-subscription" : localSignIn?.states.codex === "connected" ? "codex-subscription" : null;
|
|
8989
|
+
if (provider === null)
|
|
8990
|
+
return redirect(response, chatReturnWithSaid("/settings/lead", `No agent is signed in on this computer yet. Run ${agentSignInCommand()}, then try again.`));
|
|
8991
|
+
store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, who.name, now);
|
|
8992
|
+
return redirect(response, "/chat");
|
|
8993
|
+
}
|
|
8478
8994
|
if (url.pathname === "/chat/config") {
|
|
8479
8995
|
// The console's own door into `config set chat` (operator request:
|
|
8480
8996
|
// chat lives mainly in the web UI). Same ceremony weight as the CLI
|
|
@@ -8483,7 +8999,7 @@ export function createDecisionServer(options) {
|
|
|
8483
8999
|
// touches a form or this database — environment only.
|
|
8484
9000
|
if (who.via !== "cookie")
|
|
8485
9001
|
return refuse(response, who, 403, "chat setup is a browser surface");
|
|
8486
|
-
const back = safeChatReturn(body.get("return"));
|
|
9002
|
+
const back = body.get("return") === "/settings/lead" ? "/settings/lead" : safeChatReturn(body.get("return"));
|
|
8487
9003
|
const password = body.get("token") ?? "";
|
|
8488
9004
|
if (password === "" || !authenticateApprover(store, who.name, password).ok) {
|
|
8489
9005
|
return redirect(response, chatReturnWithSaid(back, "configuring chat spend takes your password, typed again"));
|
|
@@ -8749,6 +9265,14 @@ export function createDecisionServer(options) {
|
|
|
8749
9265
|
return said(409, "That result is no longer available for this task. Review the task before sending.");
|
|
8750
9266
|
}
|
|
8751
9267
|
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.`;
|
|
9268
|
+
// The task composer's mode (console v2): fixed words for this turn only.
|
|
9269
|
+
// It steers what the lead proposes and grants nothing: every action
|
|
9270
|
+
// still arrives as a card the person confirms under its own rules.
|
|
9271
|
+
const modeValue = body.get("mode");
|
|
9272
|
+
if (modeValue !== null && (focusTask === null || body.getAll("mode").length !== 1 || !Object.hasOwn(TASK_COMPOSER_MODES, modeValue))) {
|
|
9273
|
+
return said(400, "Choose Build, Plan only or Just answer, then send again. Your draft is saved.");
|
|
9274
|
+
}
|
|
9275
|
+
const modeContext = modeValue === null ? "" : ` ${TASK_COMPOSER_MODES[modeValue]}`;
|
|
8752
9276
|
const enabled = chatEnablement();
|
|
8753
9277
|
if (!enabled.ok)
|
|
8754
9278
|
return said(409, enabled.why);
|
|
@@ -8770,7 +9294,7 @@ export function createDecisionServer(options) {
|
|
|
8770
9294
|
}
|
|
8771
9295
|
const opened = store.openMateThread(who.name, principal.ceilingDigest, now, chatScopeOf(focusTask, chatProject));
|
|
8772
9296
|
const onProgress = beginLiveTurn(opened.thread.id);
|
|
8773
|
-
void runMateTurn({ store, who: principal, session: mateSession, thread: opened.thread, config: enabled.config, key: enabled.key, message, onProgress, ...(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}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot })
|
|
9297
|
+
void runMateTurn({ store, who: principal, session: mateSession, thread: opened.thread, config: enabled.config, key: enabled.key, message, onProgress, ...(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 })
|
|
8774
9298
|
.then(outcome => {
|
|
8775
9299
|
if (!outcome.ok)
|
|
8776
9300
|
noteMate(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
|
|
@@ -9280,6 +9804,33 @@ export function createDecisionServer(options) {
|
|
|
9280
9804
|
return refuse(response, who, result.status, result.message, back);
|
|
9281
9805
|
return redirect(response, revisionDestination(result.id, back));
|
|
9282
9806
|
}
|
|
9807
|
+
// Follow-ups on a result: Run checks on its exact commit (a worker runs the
|
|
9808
|
+
// approved command once and seals the log), or file a task to add tests.
|
|
9809
|
+
const followUp = /^\/r\/([0-9]{1,15})\/(checks|add-tests)$/.exec(url.pathname);
|
|
9810
|
+
if (followUp !== null) {
|
|
9811
|
+
const id = Number(followUp[1]);
|
|
9812
|
+
const found = store.getRun(id);
|
|
9813
|
+
if (found === null || !visible(taskRepoOf(found.taskRef)))
|
|
9814
|
+
return refuse(response, who, 404, "no such run");
|
|
9815
|
+
const back = resultReturnTarget(body.get("return"), id);
|
|
9816
|
+
if (who.via !== "cookie")
|
|
9817
|
+
return refuse(response, who, 403, "Sign in with a browser session to do this.", back);
|
|
9818
|
+
if (followUp[2] === "checks") {
|
|
9819
|
+
if (who.role !== "approver")
|
|
9820
|
+
return refuse(response, who, 403, "An approver runs checks.", back);
|
|
9821
|
+
if (store.isDemo())
|
|
9822
|
+
return refuse(response, who, 403, "The demo runs no checks.", back);
|
|
9823
|
+
const level = body.get("level") === "full" ? "full" : "quick";
|
|
9824
|
+
const asked = requestFollowUpChecks(store, { runId: id, level, actor: who.name }, now);
|
|
9825
|
+
if (!asked.ok)
|
|
9826
|
+
return refuse(response, who, 409, asked.message, back);
|
|
9827
|
+
return redirect(response, `${back.split("#")[0]}#follow-ups`);
|
|
9828
|
+
}
|
|
9829
|
+
const filed = fileAddTestsTask(store, evidenceRoot, { runId: id, actor: who.name, filedVia: "console", ...(admissionList() === null ? {} : { admittedRepos: admissionList() }) }, now);
|
|
9830
|
+
if (!filed.ok)
|
|
9831
|
+
return refuse(response, who, 409, filed.message, back);
|
|
9832
|
+
return redirect(response, taskHref(filed.id));
|
|
9833
|
+
}
|
|
9283
9834
|
const draftRepair = /^\/r\/([0-9]{1,15})\/draft-repair$/.exec(url.pathname);
|
|
9284
9835
|
if (draftRepair !== null) {
|
|
9285
9836
|
// CI repair, suggestion-first (M8.18): a red episode never spawns an
|
|
@@ -9953,7 +10504,7 @@ export function createDecisionServer(options) {
|
|
|
9953
10504
|
}
|
|
9954
10505
|
case "cancel": {
|
|
9955
10506
|
const reason = body.get("reason") ?? undefined;
|
|
9956
|
-
const cancelled = store.cancelTask(taskId, now, reason);
|
|
10507
|
+
const cancelled = withActor({ account: who.name, lead: false }, () => store.cancelTask(taskId, now, reason));
|
|
9957
10508
|
if (!cancelled.ok) {
|
|
9958
10509
|
if (cancelled.reason === "reason-required" || cancelled.reason === "bad-reason") {
|
|
9959
10510
|
return taskScreen(response, who, taskId, cancelled.reason === "reason-required"
|
|
@@ -10881,6 +11432,7 @@ export function createDecisionServer(options) {
|
|
|
10881
11432
|
return [{ id: one.id, title: one.title, state: one.state, approved: approvalOf(store.getScope(one.id)).approved, standing: status.label, tone: status.tone }];
|
|
10882
11433
|
}),
|
|
10883
11434
|
sourceDigest: scope?.digest ?? null,
|
|
11435
|
+
followUps: followUpsFor(store, evidenceRoot, run, now),
|
|
10884
11436
|
route: store.runRoute(run.id),
|
|
10885
11437
|
// Editor links (arc 6): the deployment capability, THIS machine's
|
|
10886
11438
|
// runner owning the run, the session's own device-side yes, and a
|
|
@@ -11060,7 +11612,7 @@ export function createDecisionServer(options) {
|
|
|
11060
11612
|
});
|
|
11061
11613
|
return server;
|
|
11062
11614
|
});
|
|
11063
|
-
return server;
|
|
11615
|
+
return Object.assign(server, { mintSignInLink });
|
|
11064
11616
|
}
|
|
11065
11617
|
// ---- path plumbing ---------------------------------------------------------
|
|
11066
11618
|
/**
|
|
@@ -11749,7 +12301,7 @@ function agentsStripHtml(view, taskId) {
|
|
|
11749
12301
|
return `<details class="task-chat-agents"><summary>Agent setup</summary><p>${escape(agentsSummaryWords(view))}</p><a href="${taskHref(taskId)}#agents">View agents</a></details>`;
|
|
11750
12302
|
}
|
|
11751
12303
|
/** The demo's one-line promise, on every page. */
|
|
11752
|
-
const DEMO_BANNER =
|
|
12304
|
+
const DEMO_BANNER = `Demo: a scripted lead and sample projects. Nothing calls a model, reaches outside or spends. For your own project, run ${START_COMMAND} in its folder.`;
|
|
11753
12305
|
/** Every character that could open a tag or an attribute, dead at the sink. */
|
|
11754
12306
|
function escape(text) {
|
|
11755
12307
|
return text
|
|
@@ -12743,6 +13295,8 @@ ${THEME_DARK}
|
|
|
12743
13295
|
@keyframes demo-pulse { 50% { opacity: .35; } }
|
|
12744
13296
|
@media (prefers-reduced-motion: reduce) { .demo-steps li.now::before { animation: none; } }
|
|
12745
13297
|
.demo-state { display: flex; align-items: center; gap: .5rem; margin: 0 !important; }
|
|
13298
|
+
.demo-chat .demo-handoff { border-color: var(--so-input-line); }
|
|
13299
|
+
.demo-command { margin: .4rem 0 .5rem; padding: .6rem .8rem; border-radius: calc(var(--radius) - 3px); background: var(--so-raised); font: 500 .8125rem/1.5 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
12746
13300
|
.demo-chat .badge.demo-ready { background: var(--so-info-soft); color: var(--so-info); }
|
|
12747
13301
|
.demo-chat .badge.demo-complete { background: var(--so-success-soft); color: var(--so-success); }
|
|
12748
13302
|
.demo-pass { color: var(--so-success); font-weight: 500; }
|
|
@@ -14523,7 +15077,15 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
|
|
|
14523
15077
|
const THEME_CONTROLS_CSS = `.task-repo select{width:100%;min-height:2.75rem;font-size:1rem}.task-repo-add{margin:.35rem .1rem .5rem}.task-repo-add a{display:inline-flex;align-items:center;min-height:2.25rem}details.result-request-open.result-request-form>summary{border:0;background:transparent;padding:.5rem 0;min-height:2.75rem;font-weight:600;display:list-item;list-style:revert}details.result-request-open.result-request-form>summary::-webkit-details-marker{display:revert}form.js-autosave button[type=submit]{display:none}.provider-row{border-bottom:1px solid var(--so-line);padding:.35rem 0}.provider-row:first-of-type{border-top:1px solid var(--so-line)}.provider-head{display:flex;align-items:center;gap:.75rem;margin:.4rem 0 0}.provider-status{display:inline-flex;align-items:center;gap:.4rem;color:var(--so-muted);font-size:.875rem}.provider-status i{width:.5rem;height:.5rem;border-radius:50%;background:var(--so-muted)}.provider-status--ok i{background:var(--so-success)}.provider-status--warn i{background:var(--so-attention)}.provider-status--off i{background:transparent;border:1.5px solid var(--so-muted)}details.provider-manage>summary{cursor:pointer;color:var(--so-accent-text);font-size:.875rem;min-height:2.5rem;display:list-item;padding-block:.5rem}.card.props .row{display:grid;gap:.1rem;margin:0 0 .75rem}.card.props .row>.meta{display:block;font-size:.75rem}.card.props .row>.meta::first-letter{text-transform:uppercase}.card.props .row>.mono{font-family:var(--font-sans);font-size:.875rem}.card.props .row>.mono .seal{font-family:var(--font-mono);font-size:.8125rem}details.evidence-files{margin:1rem 0}details.evidence-files>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:600}details.evidence-files ul{list-style:none;margin:0;padding:0}details.evidence-files li{display:flex;justify-content:space-between;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--so-line)}.result-action .result-feedback-link{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;text-decoration:none}@media(hover:hover) and (pointer:fine){.result-action .result-feedback-link:hover{background:var(--so-raised)}}.so-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.verdict{margin:.5rem 0 .75rem}.verdict-chips{display:flex;flex-wrap:wrap;gap:.4rem;list-style:none;padding:0;margin:0}.verdict-chip{display:inline-flex;align-items:center;gap:.3rem;min-height:1.75rem;padding:.2rem .65rem;border-radius:999px;font-size:.8125rem;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.verdict-chip svg{width:.9rem;height:.9rem}.verdict-chip--success{background:var(--so-success-soft);color:var(--so-success)}.verdict-chip--danger{background:var(--so-danger-soft);color:var(--so-danger)}.verdict-chip--warning{background:var(--so-warning-soft);color:var(--so-warning)}.verdict-chip--info{background:var(--so-info-soft);color:var(--so-info)}.verdict-by{margin:.4rem 0 0}details.result-request-open{margin:.5rem 0}details.result-request-open>summary{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;cursor:pointer;list-style:none}details.result-request-open>summary::-webkit-details-marker{display:none}details.result-request-open[open]>summary{margin-bottom:.75rem}.settings-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr));gap:.5rem;margin:0 0 2rem}.settings-tiles a{display:flex;align-items:center;gap:.6rem;min-height:3rem;padding:.65rem .8rem;border:1px solid var(--so-line);border-radius:.625rem;background:var(--so-paper);color:var(--so-ink);text-decoration:none;font-weight:550;font-size:.875rem}@media(hover:hover) and (pointer:fine){.settings-tiles a:hover{border-color:var(--so-input-line);background:var(--so-raised)}}.settings-tiles svg{width:1.1rem;height:1.1rem;flex-shrink:0;color:var(--so-accent-text)}details.settings-more{margin:.25rem 0 1.25rem}details.settings-more>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:550}details.settings-more>summary .meta{font-weight:400;margin-left:.35rem}.settings-changed{margin-top:-.25rem}.appearance{margin:0 0 28px}.appearance h2{margin:0 0 10px}.theme-switch{display:inline-flex;flex-wrap:nowrap;max-width:100%;gap:4px;padding:4px;margin:0;border:1px solid var(--so-line);border-radius:10px;background:var(--so-raised)}.theme-switch .theme-choice,.so-native-region .theme-switch .theme-choice{flex:1 1 0;width:auto;white-space:nowrap;min-height:40px;padding:8px 16px;border:0;border-radius:7px;background:transparent;color:var(--so-muted);font:inherit;font-weight:550;box-shadow:none;cursor:pointer}@media(hover:hover) and (pointer:fine){.theme-switch .theme-choice:hover{color:var(--so-ink)}}.theme-switch .theme-choice[aria-pressed="true"]{background:var(--so-paper);color:var(--so-ink);box-shadow:0 1px 2px rgb(0 0 0 / .1)}.appearance .meta{margin:8px 0 0}@media(max-width:600px){.theme-switch .theme-choice{min-height:44px}}.update-notes{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;max-height:18rem;overflow:auto}`;
|
|
14524
15078
|
/** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
|
|
14525
15079
|
export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
|
|
14526
|
-
|
|
15080
|
+
/** The Inbox tabs (console v2): a segmented track of real links; the dot marks a tab with something new, on phones only. */
|
|
15081
|
+
const INBOX_TABS_CSS = '.inbox-tabs{display:inline-flex;gap:2px;max-width:100%;overflow-x:auto;margin:4px 0 14px;padding:2px;border-radius:12px;background:var(--so-raised);scrollbar-width:none}' +
|
|
15082
|
+
'.inbox-tabs a{position:relative;display:inline-flex;align-items:center;gap:6px;min-height:28px;padding:0 10px;border-radius:8px;color:var(--so-muted);font-size:13px;font-weight:500;text-decoration:none;white-space:nowrap}' +
|
|
15083
|
+
'.inbox-tabs a[aria-current="page"]{background:var(--so-paper);color:var(--so-ink);box-shadow:var(--so-pill-shadow)}' +
|
|
15084
|
+
'.inbox-tab-count{min-width:18px;padding:0 5px;border-radius:9999px;font:500 11px/18px var(--so-mono,ui-monospace,monospace);font-variant-numeric:tabular-nums;text-align:center}' +
|
|
15085
|
+
'.inbox-tab-count--needs{background:var(--so-signal);color:var(--so-on-signal)}' +
|
|
15086
|
+
'.inbox-unread{display:none;position:absolute;top:4px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--so-signal)}' +
|
|
15087
|
+
'@media (max-width:760px){.inbox-tabs{display:flex;width:100%}.inbox-tabs a{flex:1;justify-content:center;min-height:44px;padding:0 6px}.inbox-unread{display:block}}';
|
|
15088
|
+
const WORKSPACE_STYLE = styleAsset(STYLE + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
|
|
14527
15089
|
/**
|
|
14528
15090
|
* The board's liveness: fetch this page's own fragment on a timer and swap
|
|
14529
15091
|
* it in place — no flicker, no scroll reset, no long-lived stream to manage,
|
|
@@ -15073,6 +15635,37 @@ function loginPage(problem, returnTo = "/", sso = null) {
|
|
|
15073
15635
|
`</div></div>`,
|
|
15074
15636
|
].join("\n"), { nav: false });
|
|
15075
15637
|
}
|
|
15638
|
+
/**
|
|
15639
|
+
* The page for an address the console doesn't answer to (onboarding): what was opened, where it answers, and the exact
|
|
15640
|
+
* command that admits this address. It stands alone (styles inline, no script, no font), because every other asset
|
|
15641
|
+
* would be refused at this address too; and it holds nothing a stranger's page doesn't already know.
|
|
15642
|
+
*/
|
|
15643
|
+
export function wrongHostPage(facts) {
|
|
15644
|
+
const code = (text) => `<code>${escape(text)}</code>`;
|
|
15645
|
+
return [
|
|
15646
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
15647
|
+
`<meta name="color-scheme" content="light dark"><title>Toolroll isn't set up for this address</title>`,
|
|
15648
|
+
`<style>`,
|
|
15649
|
+
`:root{color-scheme:light dark;--ground:#efefef;--paper:#fff;--ink:#171717;--muted:#666;--line:#e6e6e6;--soft:#f2f2f2}`,
|
|
15650
|
+
`@media (prefers-color-scheme:dark){:root{--ground:#0b0b0b;--paper:#161616;--ink:#ededed;--muted:#a1a1a1;--line:#262626;--soft:#1f1f1f}}`,
|
|
15651
|
+
`*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px 16px;background:var(--ground);color:var(--ink);`,
|
|
15652
|
+
`font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}`,
|
|
15653
|
+
`main{width:100%;max-width:520px;background:var(--paper);border:1px solid var(--line);border-radius:12px;padding:28px}`,
|
|
15654
|
+
`.mark{font-weight:600;letter-spacing:-.01em;color:var(--muted);margin:0 0 16px}h1{font-size:18px;line-height:1.375;margin:0 0 8px;letter-spacing:-.01em}`,
|
|
15655
|
+
`p{margin:0 0 12px}code{font:12.5px/1.5 ui-monospace,"SF Mono",Menlo,Consolas,monospace;background:var(--soft);border-radius:5px;padding:1px 5px;overflow-wrap:anywhere}`,
|
|
15656
|
+
`pre{margin:0 0 16px;background:var(--soft);border:1px solid var(--line);border-radius:8px;padding:12px 14px;white-space:pre-wrap;overflow-wrap:anywhere}pre code{background:none;padding:0}`,
|
|
15657
|
+
`.meta{color:var(--muted);font-size:13px;margin:0}`,
|
|
15658
|
+
`</style></head><body><main>`,
|
|
15659
|
+
`<p class="mark">Toolroll</p>`,
|
|
15660
|
+
`<h1>Toolroll isn't set up for this address</h1>`,
|
|
15661
|
+
facts.opened === null
|
|
15662
|
+
? `<p>It answers at ${code(facts.served)} on the computer running it.</p>`
|
|
15663
|
+
: `<p>You opened it at ${code(facts.opened)}. It answers at ${code(facts.served)} on the computer running it.</p>`,
|
|
15664
|
+
facts.command === null ? "" : `<p>To use this address, stop Toolroll (Ctrl-C) and start it again with:</p><pre><code>${escape(facts.command)}</code></pre>`,
|
|
15665
|
+
`<p class="meta">On that computer, ${code(`http://${facts.served}/`)} always works. Other addresses need your say-so, so no other website can reach it.</p>`,
|
|
15666
|
+
`</main></body></html>`,
|
|
15667
|
+
].join("\n");
|
|
15668
|
+
}
|
|
15076
15669
|
/** The first-account page (setup review): shown only while no approver exists. */
|
|
15077
15670
|
function signupPage(problem, attemptsLeft) {
|
|
15078
15671
|
return shell("Toolroll", [
|
|
@@ -15096,12 +15689,22 @@ function signupPage(problem, attemptsLeft) {
|
|
|
15096
15689
|
`</div></div>`,
|
|
15097
15690
|
].join("\n"), { nav: false });
|
|
15098
15691
|
}
|
|
15099
|
-
|
|
15100
|
-
|
|
15101
|
-
|
|
15102
|
-
|
|
15103
|
-
|
|
15104
|
-
|
|
15692
|
+
const INBOX_TABS = [
|
|
15693
|
+
{ id: "needs-you", label: "Needs you" }, { id: "ready", label: "Ready" }, { id: "running", label: "Running" }, { id: "all", label: "All" },
|
|
15694
|
+
];
|
|
15695
|
+
export function parseInboxTab(raw) {
|
|
15696
|
+
return INBOX_TABS.some(one => one.id === raw) ? raw : undefined;
|
|
15697
|
+
}
|
|
15698
|
+
/** What each tab holds, as a short fingerprint: a tab is unread when its
|
|
15699
|
+
* fingerprint differs from the one this browser saw there last. */
|
|
15700
|
+
export function inboxFingerprints(data) {
|
|
15701
|
+
const print = (keys) => createHash("sha256").update([...keys].sort().join("\n")).digest("hex").slice(0, 8);
|
|
15702
|
+
const needs = [...data.decisions.map(one => `d${one.id}`), ...data.approvals.map(one => `a${one.taskId}@${one.proposedAt}`), ...data.requeueables.map(one => `q${one.taskId}:${one.strikes}`),
|
|
15703
|
+
...data.cancelledBlockers.map(one => `c${one.blockerId}`), ...data.gaps.map(one => `g${one.key}`)];
|
|
15704
|
+
const ready = [...data.needsVerification.map(one => `v${one.taskId}`), ...(data.ready ?? []).map(one => `r${one.taskId}`)];
|
|
15705
|
+
const running = (data.running ?? []).map(one => `u${one.taskId}`);
|
|
15706
|
+
return { "needs-you": print(needs), ready: print(ready), running: print(running), all: print([...needs, ...ready, ...running]) };
|
|
15707
|
+
}
|
|
15105
15708
|
function inboxPage(chrome, data) {
|
|
15106
15709
|
/** The row's project, worn openly in the roll-up — null is UNPLACED,
|
|
15107
15710
|
* said as such, never a silent missing chip (finding 13). */
|
|
@@ -15195,19 +15798,41 @@ function inboxPage(chrome, data) {
|
|
|
15195
15798
|
? `<strong>No builder is connected yet.</strong> Toolroll is open, but no machine is connected to do project work. On the machine where the project lives, open that folder and run <span class="mono">toolroll up</span>. Keep Toolroll running; approved work starts automatically.`
|
|
15196
15799
|
: `<strong>Builder disconnected.</strong> ${data.worker.registered} builder${data.worker.registered === 1 ? " is" : "s are"} configured, last checked in ${data.worker.lastHeard === null ? "never" : whenTime(data.worker.lastHeard)}. Reopen Toolroll on that machine. Queued work starts automatically when a builder reconnects.`) +
|
|
15197
15800
|
`</div>`;
|
|
15801
|
+
// Console v2: Needs you · Ready · Running · All, as real links (Back and
|
|
15802
|
+
// bookmarks work). Each section belongs to one tab; All shows every one.
|
|
15803
|
+
const tab = data.tab ?? "all";
|
|
15804
|
+
const shows = (one) => tab === "all" || tab === one;
|
|
15805
|
+
const listRows = (rows) => rows.map(one => `<p class="row"><a href="${taskHref(one.taskId)}">${escape(one.title)}</a>${chip(one.repo)} <span class="meta">${escape(one.detail)}</span></p>`).join("\n");
|
|
15806
|
+
const ready = (data.ready ?? []).length === 0 ? "" : `<h2>ready to review</h2>` + listRows(data.ready ?? []);
|
|
15807
|
+
const running = (data.running ?? []).length === 0 ? "" : `<h2>running now</h2>` + listRows(data.running ?? []);
|
|
15808
|
+
const counts = {
|
|
15809
|
+
"needs-you": data.decisions.length + data.approvals.length + data.requeueables.length + data.cancelledBlockers.length + data.gaps.length,
|
|
15810
|
+
ready: data.needsVerification.length + (data.ready ?? []).length,
|
|
15811
|
+
running: (data.running ?? []).length,
|
|
15812
|
+
all: 0,
|
|
15813
|
+
};
|
|
15814
|
+
counts.all = counts["needs-you"] + counts.ready + counts.running;
|
|
15815
|
+
const tabs = data.tab === undefined ? "" : `<nav class="inbox-tabs" aria-label="Inbox views">` + INBOX_TABS.map(one => `<a href="/inbox?tab=${one.id}"${one.id === tab ? ` aria-current="page"` : ""} data-inbox-tab="${one.id}">${one.label}` +
|
|
15816
|
+
`<span class="inbox-tab-count${one.id === "needs-you" && counts[one.id] > 0 ? " inbox-tab-count--needs" : ""}">${counts[one.id]}</span>` +
|
|
15817
|
+
`${one.id !== tab && (data.unread ?? []).includes(one.id) ? `<span class="inbox-unread" aria-label="new"></span>` : ""}</a>`).join("") + `</nav>`;
|
|
15818
|
+
const tabEmpty = data.tab !== undefined && tab !== "all" && counts[tab] === 0
|
|
15819
|
+
? `<div class="card"><p class="meta">${tab === "needs-you" ? "Nothing needs you." : tab === "ready" ? "No results are waiting for review." : "Nothing is running."}</p></div>` : "";
|
|
15198
15820
|
return screen("inbox", [
|
|
15199
15821
|
`<h1>Inbox</h1>`,
|
|
15200
15822
|
`<p class="meta">everything that waits on you \u2014 empty means the fleet is working</p>`,
|
|
15823
|
+
tabs,
|
|
15201
15824
|
noWorker,
|
|
15202
15825
|
wizard,
|
|
15203
|
-
empty ? "" : `<p><a class="new-task" style="display:inline-block" href="/next">clear the queue \u2192 one thing at a time</a></p>`,
|
|
15204
|
-
empty && data.wizard === null ? `<div class="card"><p><strong>Nothing needs you.</strong></p><p class="meta">The queue is either working or waiting on its own timers. <a href="/board">Watch the board</a> or <a href="/activity">read the activity report</a>.</p></div>` :
|
|
15205
|
-
decisions,
|
|
15206
|
-
approvals,
|
|
15207
|
-
requeueables,
|
|
15208
|
-
needsVerification,
|
|
15209
|
-
|
|
15210
|
-
|
|
15826
|
+
empty || !shows("needs-you") ? "" : `<p><a class="new-task" style="display:inline-block" href="/next">clear the queue \u2192 one thing at a time</a></p>`,
|
|
15827
|
+
empty && data.wizard === null && shows("needs-you") && counts.all === 0 ? `<div class="card"><p><strong>Nothing needs you.</strong></p><p class="meta">The queue is either working or waiting on its own timers. <a href="/board">Watch the board</a> or <a href="/activity">read the activity report</a>.</p></div>` : tabEmpty,
|
|
15828
|
+
shows("needs-you") ? decisions : "",
|
|
15829
|
+
shows("needs-you") ? approvals : "",
|
|
15830
|
+
shows("needs-you") ? requeueables : "",
|
|
15831
|
+
shows("ready") ? needsVerification : "",
|
|
15832
|
+
shows("ready") ? ready : "",
|
|
15833
|
+
shows("running") ? running : "",
|
|
15834
|
+
shows("needs-you") ? cancelled : "",
|
|
15835
|
+
shows("needs-you") ? gaps : "",
|
|
15211
15836
|
data.rollup
|
|
15212
15837
|
? `<p class="meta">requirement gaps are checked one project at a time \u2014 open a project to see and fill its gaps · <a href="/projects">open a project</a></p>`
|
|
15213
15838
|
: "",
|
|
@@ -15632,6 +16257,11 @@ function approvalFormDigest(scopeDigest, raceDigest, planSha) {
|
|
|
15632
16257
|
.digest("hex");
|
|
15633
16258
|
}
|
|
15634
16259
|
const taskChatHref = (taskId) => `/chat?task=${encodeURIComponent(taskId)}`;
|
|
16260
|
+
export const TASK_COMPOSER_MODES = {
|
|
16261
|
+
build: "The operator chose Build: if they ask for a change to the result, read it with get_result and propose a revision of this task with propose_review (operation revise) for them to confirm. Nothing builds until they confirm the card.",
|
|
16262
|
+
plan: "The operator chose Plan only: answer with a short plan for what they ask. Do not propose a revision, a new task or any other action this turn.",
|
|
16263
|
+
answer: "The operator chose Just answer: answer from what you can read. Do not propose any action this turn.",
|
|
16264
|
+
};
|
|
15635
16265
|
/** A project's own lead thread (v77). */
|
|
15636
16266
|
const projectChatHref = (repo) => `/chat?project=${encodeURIComponent(repo)}`;
|
|
15637
16267
|
/** The one task-level road from a truthful dispatch diagnosis to its nearest
|
|
@@ -15913,6 +16543,7 @@ function taskChatLiveRegion(focus, csrf, fragment = false, inert = false, approv
|
|
|
15913
16543
|
? `<details class="task-secondary-approval"><summary>Updated approval terms</summary>${approvalContent}</details>` : approvalContent;
|
|
15914
16544
|
const polling = !inert && ((focus.approval === null || focus.plan === "requested") && focus.state !== "done" && focus.state !== "cancelled" || focus.control.kind === "stopping" || focus.control.kind === "stop");
|
|
15915
16545
|
return (`<section id="task-chat-live" aria-live="polite" data-task="${escape(focus.id)}" data-execution="${escape(focus.executionId)}" data-source="/chat/task-status?task=${encodeURIComponent(focus.id)}" data-poll="${polling ? "1" : "0"}" data-approval="${escape(focus.approval?.digest ?? "")}" data-plan="${escape(focus.plan ?? "")}">` +
|
|
16546
|
+
(focus.guide === true && focus.state !== "cancelled" ? firstTaskJourneyHtml(focus) : "") +
|
|
15916
16547
|
`<div class="task-live-summary">` +
|
|
15917
16548
|
(focus.assignment !== null ? assignmentSummaryHtml(focus.assignment, { workStatus: focus.status, hideAction: focus.approval !== null && focus.dispatch?.action === "approve-scope", ...(receiptLeads ? { resultHref: chatResultHref(focus.id, focus.result.runId) } : {}) }) + (receiptLeads ? completionReceiptCard(focus.result, focus.id, "chat", focus.status, focus.assignment, false) : "") : receiptLeads ? completionReceiptCard(focus.result, focus.id, "chat", focus.status) : taskStatusCard(focus.status, focus.id, focus.dispatch, focus.liveRun?.id ?? null, focus.approval !== null && focus.dispatch?.action === "approve-scope")) +
|
|
15918
16549
|
checkProgressHtml(focus.checkProgress) +
|
|
@@ -15936,6 +16567,16 @@ function taskChatLiveRegion(focus, csrf, fragment = false, inert = false, approv
|
|
|
15936
16567
|
(focus.publication === null ? "" : `<p class="chat-publication meta">Published as ${safePrUrl(focus.publication.prUrl) === null ? `<span class="mono">PR #${focus.publication.prNumber ?? "?"}</span>` : `<a href="${escape(safePrUrl(focus.publication.prUrl))}">PR #${focus.publication.prNumber ?? "?"}</a>`} · ${escape(focus.publication.state)}${focus.publication.lastCheckState === null ? "" : ` · CI ${escape(focus.publication.lastCheckState)}`}</p>`) +
|
|
15937
16568
|
`</section>`);
|
|
15938
16569
|
}
|
|
16570
|
+
/** The first task's way to Ready, filled in as it moves (onboarding): the same stage every surface reads. */
|
|
16571
|
+
function firstTaskJourneyHtml(focus) {
|
|
16572
|
+
const planning = focus.plan === "requested" && focus.approval === null;
|
|
16573
|
+
const read = focus.assignment !== null ? assignmentStageOf(focus.assignment, { token: focus.status.token }, planning) : stageOfCode(focus.status.token, { planning });
|
|
16574
|
+
const steps = firstTaskJourney(read, focus.scope === "approved", focus.state === "done");
|
|
16575
|
+
const current = steps.find(one => one.state === "current" || one.state === "stuck");
|
|
16576
|
+
return `<ol class="first-task-journey" aria-label="Where this task is${current === undefined ? ": Ready" : `: ${escape(current.label)}`}" data-first-task-journey>` +
|
|
16577
|
+
steps.map(one => `<li data-step="${one.key}" data-state="${one.state}"${one.state === "current" || one.state === "stuck" ? ` aria-current="step"` : ""}><span class="first-task-journey-mark" aria-hidden="true"></span><span>${escape(one.label)}</span></li>`).join("") +
|
|
16578
|
+
`</ol>`;
|
|
16579
|
+
}
|
|
15939
16580
|
function taskChatHeading(focus) {
|
|
15940
16581
|
return (`<div class="chat-head task-chat-head"><div>` +
|
|
15941
16582
|
`<p class="meta chat-task-back"><a href="${taskHref(focus.id)}">← task overview</a></p>` +
|
|
@@ -16195,55 +16836,83 @@ function chatLimitsHtml(facts) {
|
|
|
16195
16836
|
rows.push(`<span>this week ${chatMoney(facts.weekly.spent)} of ${chatMoney(facts.weekly.ceiling)}</span>`);
|
|
16196
16837
|
return `<details class="chat-limits"><summary>Model & limits<span class="meta">${escape(facts.subscription ? "membership" : facts.provider)}</span></summary><div class="chat-budget">${rows.join("")}</div></details>`;
|
|
16197
16838
|
}
|
|
16839
|
+
/** Settings → Lead: one line for what runs the lead and one action; the full form, turning it off and stored keys under Advanced. */
|
|
16840
|
+
function leadSettingsHtml(data) {
|
|
16841
|
+
const { config, facts } = data;
|
|
16842
|
+
const hidden = `<input type="hidden" name="csrf" value="${escape(facts.csrf)}"><input type="hidden" name="return" value="/settings/lead">`;
|
|
16843
|
+
const summary = config === null
|
|
16844
|
+
? data.signedIn !== null
|
|
16845
|
+
? `<p>The lead is off.</p><form method="post" action="/settings/lead/on">${hidden}<button type="submit">Use your ${escape(data.signedIn)} sign-in</button></form>`
|
|
16846
|
+
: `<p>The lead is off. Sign in an agent on this computer to turn it on:</p><p><code>${escape(data.command)}</code></p>`
|
|
16847
|
+
: data.words !== null
|
|
16848
|
+
? `<p>The lead uses ${escape(data.words)}.</p><p class="meta">${config.model === "default" ? "Its default model" : `Model ${escape(config.model)}`} · up to ${config.dailyTurns} turns a day · no dollar spend. Every action it proposes waits for you to confirm it.</p>`
|
|
16849
|
+
: `<p>The lead uses the ${escape(config.provider === "anthropic-api" ? "Anthropic" : "OpenRouter")} API with ${escape(config.model)}.</p><p class="meta">Up to ${chatMoney(config.weeklyCeilingMicrousd)} a week · up to ${config.dailyTurns} turns a day.</p>`;
|
|
16850
|
+
const forget = facts.keyFacts.filter(one => one.state === "stored").map(one => `<form method="post" action="/chat/config" class="inline">${hidden}<input type="hidden" name="forget-key" value="${escape(one.provider)}">` +
|
|
16851
|
+
`<input type="password" name="token" placeholder="your password" autocomplete="current-password" aria-label="Your password"><button type="submit" class="secondary">Forget the stored ${escape(one.provider)} key</button></form>`).join("");
|
|
16852
|
+
return [
|
|
16853
|
+
`<p><a href="/settings">Settings</a></p><h1>Lead</h1>`,
|
|
16854
|
+
data.said === null ? "" : `<p class="problem" role="status">${escape(data.said)}</p>`,
|
|
16855
|
+
`<section class="card lead-settings" data-lead-settings>${summary}</section>`,
|
|
16856
|
+
`<details class="lead-advanced" data-lead-advanced><summary>Advanced</summary>`,
|
|
16857
|
+
leadConfigForm(config, facts),
|
|
16858
|
+
config === null ? "" : `<form method="post" action="/chat/config" class="inline">${hidden}<input type="hidden" name="off" value="1">` +
|
|
16859
|
+
`<input type="password" name="token" placeholder="your password" autocomplete="current-password" aria-label="Your password"><button type="submit" class="secondary">Turn the lead off</button></form>`,
|
|
16860
|
+
forget,
|
|
16861
|
+
`</details>`,
|
|
16862
|
+
].join("\n");
|
|
16863
|
+
}
|
|
16864
|
+
/** The lead's full settings (Settings → Lead → Advanced): provider, model, limits and a direct API key. */
|
|
16865
|
+
function leadConfigForm(current, data) {
|
|
16866
|
+
const anthropicModels = PRICED_MODELS.filter(one => !one.includes("/"));
|
|
16867
|
+
const openrouterModels = data.openrouterModels ?? PRICED_MODELS.filter(one => one.includes("/"));
|
|
16868
|
+
const currentSubscription = current !== null && isSubscriptionChatProvider(current.provider);
|
|
16869
|
+
const labels = new Map((data.liveModels ?? []).map(one => [one.value, one.label]));
|
|
16870
|
+
const models = [...new Set(["default", ...labels.keys(), ...anthropicModels, ...openrouterModels, ...(current === null ? [] : [current.model])])];
|
|
16871
|
+
return [
|
|
16872
|
+
`<form method="post" action="/chat/config" class="card">`,
|
|
16873
|
+
`<input type="hidden" name="csrf" value="${escape(data.csrf)}">`,
|
|
16874
|
+
`<input type="hidden" name="return" value="${escape(data.returnTo)}">`,
|
|
16875
|
+
`<label>provider<select name="provider">`,
|
|
16876
|
+
`<option value="codex-subscription"${current?.provider === "codex-subscription" ? " selected" : ""}>Codex membership (logged-in CLI)</option>`,
|
|
16877
|
+
`<option value="claude-subscription"${current?.provider === "claude-subscription" ? " selected" : ""}>Anthropic membership (logged-in CLI)</option>`,
|
|
16878
|
+
`<option value="anthropic-api"${current?.provider === "anthropic-api" ? " selected" : ""}>anthropic-api (direct API)</option>`,
|
|
16879
|
+
`<option value="openrouter-api"${current?.provider === "openrouter-api" ? " selected" : ""}>openrouter-api (direct API)</option>`,
|
|
16880
|
+
`</select></label>`,
|
|
16881
|
+
`<label>model <span class="meta">(use default for your membership's current model; direct API models need a pinned price)</span>` +
|
|
16882
|
+
`<input name="model" list="chat-models" value="${escape(current?.model ?? "default")}"><datalist id="chat-models">` +
|
|
16883
|
+
`${models.map(model => `<option value="${escape(model)}">${escape(labels.get(model) ?? "")}</option>`).join("")}</datalist></label>`,
|
|
16884
|
+
data.openrouterModels === null
|
|
16885
|
+
? `<p class="meta">with OPENROUTER_API_KEY in the serve environment, this list becomes OpenRouter's full live catalog — each model priced by the party that bills it</p>`
|
|
16886
|
+
: `<p class="meta">${data.openrouterModels.length} models live from OpenRouter's catalog; saving pins today's price — re-save to re-pin</p>`,
|
|
16887
|
+
currentSubscription
|
|
16888
|
+
? `<p class="meta"><strong>no dollar maximum.</strong> Membership chat uses the plan attached to the logged-in CLI; the conversation stays live until you end it, and the daily turn limit still applies.</p>`
|
|
16889
|
+
: `<label>weekly ceiling <span class="meta">(direct API only; leave blank when choosing a membership)</span>` +
|
|
16890
|
+
`<input type="text" name="weekly-usd" inputmode="decimal" style="width:8rem" value="${current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2)}"></label>`,
|
|
16891
|
+
`<label>daily turns <span class="meta">(default 50)</span>` +
|
|
16892
|
+
`<input type="text" name="daily-turns" inputmode="numeric" style="width:8rem" value="${current === null ? "" : String(current.dailyTurns)}"></label>`,
|
|
16893
|
+
currentSubscription
|
|
16894
|
+
? `<p class="meta">Authenticate on this machine first with ${current?.provider === "codex-subscription" ? `<span class="mono">codex login</span>` : `the <span class="mono">claude</span> CLI`}. Toolroll reuses that cached login and never stores it.</p>`
|
|
16895
|
+
: `<label>API key <span class="meta">(${data.keyFacts
|
|
16896
|
+
.map(one => one.state === "none"
|
|
16897
|
+
? `${escape(one.provider)}: none yet`
|
|
16898
|
+
: one.state === "environment"
|
|
16899
|
+
? `${escape(one.provider)}: from the environment`
|
|
16900
|
+
: `${escape(one.provider)}: stored ${escape(one.tail ?? "")}`)
|
|
16901
|
+
.join(" · ")})</span>` +
|
|
16902
|
+
`<input type="password" name="key" placeholder="direct API only — leave empty to keep" autocomplete="off"></label>`,
|
|
16903
|
+
`<label>your password <span class="meta">(typed again to change the provider)</span>` +
|
|
16904
|
+
`<input type="password" name="token" autocomplete="current-password"></label>`,
|
|
16905
|
+
`<button type="submit">${current === null ? "turn chat on" : "save"}</button>`,
|
|
16906
|
+
`</form>`,
|
|
16907
|
+
currentSubscription
|
|
16908
|
+
? `<p class="meta">The membership provider runs without repository tools in a temporary directory; Toolroll remains the only layer that can turn a proposed action into a confirmation card.</p>`
|
|
16909
|
+
: `<p class="meta">a pasted key is written once to a mode-0600 file beside the database — never INTO the database, never shown again beyond its last characters; an environment variable (` +
|
|
16910
|
+
`<span class="mono">ANTHROPIC_API_KEY</span> / <span class="mono">OPENROUTER_API_KEY</span>) always wins when set</p>`,
|
|
16911
|
+
].join("\n");
|
|
16912
|
+
}
|
|
16198
16913
|
function chatPage(chrome, data) {
|
|
16199
|
-
const
|
|
16200
|
-
|
|
16201
|
-
const openrouterModels = data.openrouterModels ?? PRICED_MODELS.filter(one => one.includes("/"));
|
|
16202
|
-
const currentSubscription = current !== null && isSubscriptionChatProvider(current.provider);
|
|
16203
|
-
const labels = new Map((data.liveModels ?? []).map(one => [one.value, one.label]));
|
|
16204
|
-
const models = [...new Set(["default", ...labels.keys(), ...anthropicModels, ...openrouterModels, ...(current === null ? [] : [current.model])])];
|
|
16205
|
-
return [
|
|
16206
|
-
`<form method="post" action="/chat/config" class="card">`,
|
|
16207
|
-
`<input type="hidden" name="csrf" value="${escape(data.csrf)}">`,
|
|
16208
|
-
`<input type="hidden" name="return" value="${escape(data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id))}">`,
|
|
16209
|
-
`<label>provider<select name="provider">`,
|
|
16210
|
-
`<option value="codex-subscription"${current?.provider === "codex-subscription" ? " selected" : ""}>Codex membership (logged-in CLI)</option>`,
|
|
16211
|
-
`<option value="claude-subscription"${current?.provider === "claude-subscription" ? " selected" : ""}>Anthropic membership (logged-in CLI)</option>`,
|
|
16212
|
-
`<option value="anthropic-api"${current?.provider === "anthropic-api" ? " selected" : ""}>anthropic-api (direct API)</option>`,
|
|
16213
|
-
`<option value="openrouter-api"${current?.provider === "openrouter-api" ? " selected" : ""}>openrouter-api (direct API)</option>`,
|
|
16214
|
-
`</select></label>`,
|
|
16215
|
-
`<label>model <span class="meta">(use default for your membership's current model; direct API models need a pinned price)</span>` +
|
|
16216
|
-
`<input name="model" list="chat-models" value="${escape(current?.model ?? "default")}"><datalist id="chat-models">` +
|
|
16217
|
-
`${models.map(model => `<option value="${escape(model)}">${escape(labels.get(model) ?? "")}</option>`).join("")}</datalist></label>`,
|
|
16218
|
-
data.openrouterModels === null
|
|
16219
|
-
? `<p class="meta">with OPENROUTER_API_KEY in the serve environment, this list becomes OpenRouter's full live catalog — each model priced by the party that bills it</p>`
|
|
16220
|
-
: `<p class="meta">${data.openrouterModels.length} models live from OpenRouter's catalog; saving pins today's price — re-save to re-pin</p>`,
|
|
16221
|
-
currentSubscription
|
|
16222
|
-
? `<p class="meta"><strong>no dollar maximum.</strong> Membership chat uses the plan attached to the logged-in CLI; the conversation stays live until you end it, and the daily turn limit still applies.</p>`
|
|
16223
|
-
: `<label>weekly ceiling <span class="meta">(direct API only; leave blank when choosing a membership)</span>` +
|
|
16224
|
-
`<input type="text" name="weekly-usd" inputmode="decimal" style="width:8rem" value="${current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2)}"></label>`,
|
|
16225
|
-
`<label>daily turns <span class="meta">(default 50)</span>` +
|
|
16226
|
-
`<input type="text" name="daily-turns" inputmode="numeric" style="width:8rem" value="${current === null ? "" : String(current.dailyTurns)}"></label>`,
|
|
16227
|
-
currentSubscription
|
|
16228
|
-
? `<p class="meta">Authenticate on this machine first with ${current?.provider === "codex-subscription" ? `<span class="mono">codex login</span>` : `the <span class="mono">claude</span> CLI`}. Toolroll reuses that cached login and never stores it.</p>`
|
|
16229
|
-
: `<label>API key <span class="meta">(${data.keyFacts
|
|
16230
|
-
.map(one => one.state === "none"
|
|
16231
|
-
? `${escape(one.provider)}: none yet`
|
|
16232
|
-
: one.state === "environment"
|
|
16233
|
-
? `${escape(one.provider)}: from the environment`
|
|
16234
|
-
: `${escape(one.provider)}: stored ${escape(one.tail ?? "")}`)
|
|
16235
|
-
.join(" · ")})</span>` +
|
|
16236
|
-
`<input type="password" name="key" placeholder="direct API only — leave empty to keep" autocomplete="off"></label>`,
|
|
16237
|
-
`<label>your password <span class="meta">(typed again to change the provider)</span>` +
|
|
16238
|
-
`<input type="password" name="token" autocomplete="current-password"></label>`,
|
|
16239
|
-
`<button type="submit">${current === null ? "turn chat on" : "save"}</button>`,
|
|
16240
|
-
`</form>`,
|
|
16241
|
-
currentSubscription
|
|
16242
|
-
? `<p class="meta">The membership provider runs without repository tools in a temporary directory; Toolroll remains the only layer that can turn a proposed action into a confirmation card.</p>`
|
|
16243
|
-
: `<p class="meta">a pasted key is written once to a mode-0600 file beside the database — never INTO the database, never shown again beyond its last characters; an environment variable (` +
|
|
16244
|
-
`<span class="mono">ANTHROPIC_API_KEY</span> / <span class="mono">OPENROUTER_API_KEY</span>) always wins when set</p>`,
|
|
16245
|
-
].join("\n");
|
|
16246
|
-
};
|
|
16914
|
+
const formFacts = { keyFacts: data.keyFacts, openrouterModels: data.openrouterModels, ...(data.liveModels === undefined ? {} : { liveModels: data.liveModels }), csrf: data.csrf,
|
|
16915
|
+
returnTo: data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id) };
|
|
16247
16916
|
const parts = [
|
|
16248
16917
|
data.focusTask === null
|
|
16249
16918
|
? chatHeading("", data.projects.length, data.enabled.ok)
|
|
@@ -16256,14 +16925,17 @@ function chatPage(chrome, data) {
|
|
|
16256
16925
|
const code = data.enabled.code;
|
|
16257
16926
|
// The sandbox shows no conversation at all: chat evidence is a real
|
|
16258
16927
|
// subscription-backed plane, never a seeded transcript (v48 authority repair).
|
|
16259
|
-
|
|
16260
|
-
|
|
16261
|
-
|
|
16262
|
-
|
|
16263
|
-
|
|
16264
|
-
|
|
16265
|
-
|
|
16266
|
-
|
|
16928
|
+
// With no lead yet, the first run says what happens next (it turns on with the signed-in agent, or shows the one
|
|
16929
|
+
// command to run); otherwise one line, and its settings live in Settings → Lead (onboarding).
|
|
16930
|
+
const settingsLink = data.canManage ? ` <a href="/settings/lead">Settings → Lead</a>` : "";
|
|
16931
|
+
if (!(code === "unconfigured" && data.firstRunShown === true))
|
|
16932
|
+
parts.push(code === "demo"
|
|
16933
|
+
? `<div class="card" id="latest"><p><strong>Chat isn’t available in demo mode</strong></p><p class="meta">Demo data never contacts an external model. Start Toolroll with a real project to use chat: <code>${escape(START_COMMAND)}</code> in your repository.</p></div>`
|
|
16934
|
+
: code === "unconfigured"
|
|
16935
|
+
? `<div class="card" id="latest" data-lead-off><p><strong>The lead is off.</strong></p><p class="meta">${data.canManage ? `Turn it on in${settingsLink}.` : "An approver can turn it on."}</p></div>`
|
|
16936
|
+
: code === "unpriced" || code === "no-key"
|
|
16937
|
+
? `<div class="card" id="latest"><p><strong>The lead can’t run yet.</strong></p><p class="meta">${escape(data.enabled.why)}${data.canManage ? ` ·${settingsLink}` : ""}</p></div>`
|
|
16938
|
+
: `<div class="card" id="latest"><p><strong>chat is off.</strong></p><p class="meta">${escape(data.enabled.why)}</p></div>`);
|
|
16267
16939
|
return screen("chat", chatWorkspace(parts.join("\n"), data.projects, data.csrf, true, data.focusTask, data.resultPanel ?? null), { chrome, functional: { script: CHAT_UI_SCRIPT + (data.focusTask === null ? "" : RESULT_REVIEW_SCRIPT), fetches: data.focusTask !== null } });
|
|
16268
16940
|
}
|
|
16269
16941
|
const config = data.enabled.config;
|
|
@@ -16319,15 +16991,7 @@ function chatPage(chrome, data) {
|
|
|
16319
16991
|
parts.push(`<h2>ask</h2>`, `<form method="post" action="/chat" class="card">`, `<input type="hidden" name="csrf" value="${escape(data.csrf)}">`, `<label>message<textarea name="message" rows="3" maxlength="2000"></textarea></label>`, `<label>your password <span class="meta">(every message — chat spends)</span><input type="password" name="token" autocomplete="current-password"></label>`, `<button type="submit">ask</button>`, `</form>`);
|
|
16320
16992
|
}
|
|
16321
16993
|
if (data.canManage) {
|
|
16322
|
-
parts.push(`<
|
|
16323
|
-
.filter(one => one.state === "stored")
|
|
16324
|
-
.map(one => `<form method="post" action="/chat/config" class="inline">` +
|
|
16325
|
-
`<input type="hidden" name="csrf" value="${escape(data.csrf)}">` +
|
|
16326
|
-
`<input type="hidden" name="return" value="${escape(data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id))}">` +
|
|
16327
|
-
`<input type="hidden" name="forget-key" value="${escape(one.provider)}">` +
|
|
16328
|
-
`<input type="password" name="token" placeholder="your password" autocomplete="current-password">` +
|
|
16329
|
-
`<button type="submit">forget the stored ${escape(one.provider)} key</button></form>`)
|
|
16330
|
-
.join("\n"), `</details>`);
|
|
16994
|
+
parts.push(`<p class="meta" id="chat-settings"><a href="/settings/lead">Lead settings</a> · provider, model and limits</p>`);
|
|
16331
16995
|
}
|
|
16332
16996
|
if (data.recent.length > 0) {
|
|
16333
16997
|
parts.push(`<details class="chat-turn-history"><summary>Conversation activity <span class="meta">${data.recent.length} recent turns</span></summary>`);
|
|
@@ -16444,7 +17108,7 @@ function proposalCardParts(view, csrf, inert, decision, returnTo = null) {
|
|
|
16444
17108
|
: "inspect first when the work needs repository context";
|
|
16445
17109
|
what =
|
|
16446
17110
|
`<h3>${escape(text("title"))}</h3><p class="proposal-summary">${escape(text("goal"))}</p>` +
|
|
16447
|
-
facts(["project", `<span class="mono">${escape(repoId)}</span>`], ["deliverable", payload["report"] === true ? "report only" : "branch"], ["planning", planningWords], ["out of scope", escape(text("not"))], ["may touch", Array.isArray(payload["touches"]) ? payload["touches"].map(one => `<span class="mono">${escape(one)}</span>`).join("<br>") : ""]);
|
|
17111
|
+
facts(["project", `<span class="mono">${escape(repoId)}</span>`], ["deliverable", payload["report"] === true ? "report only" : "branch"], ["planning", planningWords], ["checks", isCheckLevel(payload["checks"]) ? { quick: "Quick checks", full: "Full checks", off: "Off — built, not checked" }[payload["checks"]] : ""], ["out of scope", escape(text("not"))], ["may touch", Array.isArray(payload["touches"]) ? payload["touches"].map(one => `<span class="mono">${escape(one)}</span>`).join("<br>") : ""]);
|
|
16448
17112
|
}
|
|
16449
17113
|
else if (view.kind === "action") {
|
|
16450
17114
|
const action = sharedActionPayload(payload);
|
|
@@ -16807,7 +17471,7 @@ function matePage(chrome, data) {
|
|
|
16807
17471
|
: `<p class="meta">recent turns: ${data.recent
|
|
16808
17472
|
.map(turn => `<span class="mono">#${turn.id}</span> ${escape(turn.state)}${turn.failureReason === null ? "" : ` · ${escape(turn.failureReason)}`} · ${subscription ? "membership" : chatMoney(turn.settledMicrousd ?? turn.reservedMicrousd)}`)
|
|
16809
17473
|
.join(" · ")}</p>`,
|
|
16810
|
-
`<p class="meta"><a href="/
|
|
17474
|
+
`<p class="meta"><a href="/settings/lead">Lead settings</a> · provider, model and limits</p>`,
|
|
16811
17475
|
`</details>`,
|
|
16812
17476
|
].join("\n");
|
|
16813
17477
|
const conversation = [
|
|
@@ -17303,7 +17967,7 @@ function workPage(chrome, data) {
|
|
|
17303
17967
|
};
|
|
17304
17968
|
return screen('work', `<div class="work-head"><h1>Tasks</h1>${tools}</div>${limitsHtml(data.limits ?? null)}${tabs}${list}${pages}`, { chrome, workspace: { view } });
|
|
17305
17969
|
}
|
|
17306
|
-
function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = null, permissionDefault = "auto", qualityDefault = "default") {
|
|
17970
|
+
function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = null, permissionDefault = "auto", qualityDefault = "default", replaced = new Map()) {
|
|
17307
17971
|
const filters = TASK_STATES.map(one => (one === state ? `<strong>${one}</strong>` : `<a href="/tasks?state=${one}">${one}</a>`)).join(" · ");
|
|
17308
17972
|
const rows = tasks.length === 0
|
|
17309
17973
|
? `<p class="meta">${state === null
|
|
@@ -17311,7 +17975,7 @@ function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = n
|
|
|
17311
17975
|
: `Nothing is ${escape(state)}.`}</p>`
|
|
17312
17976
|
: tasks
|
|
17313
17977
|
.map(task => `<a class="row" href="${taskHref(task.id)}"><span class="mono">${escape(task.id)}</span> ` +
|
|
17314
|
-
`${escape(task.title)} <span class="right badge badge-${escape(task.state)}">${escape(task.state)}</span></a>`)
|
|
17978
|
+
`${escape(task.title)} <span class="right badge badge-${escape(task.state)}">${escape(task.state === "cancelled" && replaced.has(task.id) ? `replaced by ${replaced.get(task.id)}` : task.state)}</span></a>`)
|
|
17315
17979
|
.join("\n");
|
|
17316
17980
|
return screen("tasks", [
|
|
17317
17981
|
"<h1>Tasks</h1>",
|
|
@@ -17982,7 +18646,7 @@ function githubReposPage(chrome, data) {
|
|
|
17982
18646
|
`<p class="row" style="margin-top:.6rem"><a class="badge" href="/projects">← back to projects</a></p>`,
|
|
17983
18647
|
].join("\n"), { chrome });
|
|
17984
18648
|
}
|
|
17985
|
-
function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn) {
|
|
18649
|
+
function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn, checkLevelOf) {
|
|
17986
18650
|
// The onboarding card (repo onboarding, findings 1-39): preview first,
|
|
17987
18651
|
// then a password-confirmed clone into a configured root. Disabled
|
|
17988
18652
|
// states explain themselves in words (finding 28/39).
|
|
@@ -18073,6 +18737,7 @@ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscoped
|
|
|
18073
18737
|
name, path, shortPath: path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path, open: open !== null && open === path, openedAt,
|
|
18074
18738
|
knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(path)}`,
|
|
18075
18739
|
...(pullRequestsOn === undefined ? {} : { pullRequests: { href: `/settings/pull-requests?repo=${encodeURIComponent(path)}`, on: pullRequestsOn(path) } }),
|
|
18740
|
+
...(checkLevelOf === undefined ? {} : { checks: { href: `/settings/checks?repo=${encodeURIComponent(path)}`, level: CHECK_LEVEL_WORDS[checkLevelOf(path)] } }),
|
|
18076
18741
|
peek: peek === null ? null : [
|
|
18077
18742
|
...(peek.waiting > 0 ? [{ label: `${peek.waiting} waiting on you`, href: "/", tone: "attention" }] : []),
|
|
18078
18743
|
...(peek.running > 0 ? [{ label: `${peek.running} running`, href: "/runs", tone: "neutral" }] : []),
|
|
@@ -18223,6 +18888,10 @@ function loginReturn(raw) {
|
|
|
18223
18888
|
const query = parsed.searchParams.toString();
|
|
18224
18889
|
return `${parsed.pathname}${query === "" ? "" : `?${query}`}`;
|
|
18225
18890
|
}
|
|
18891
|
+
/** A request path fit for a log: the one-time sign-in link's code is left out. */
|
|
18892
|
+
export function redactedPath(path) {
|
|
18893
|
+
return path.startsWith(SIGN_IN_LINK_PATH) ? `${SIGN_IN_LINK_PATH}…` : path;
|
|
18894
|
+
}
|
|
18226
18895
|
/** The sign-in page that comes back to `path` afterwards, or plain /login when the path is not one to come back to. */
|
|
18227
18896
|
function loginHref(path) {
|
|
18228
18897
|
const back = loginReturn(path);
|
|
@@ -19506,7 +20175,7 @@ function taskBodyParts(data) {
|
|
|
19506
20175
|
facts.push({ label: "Queue", parts: [`${data.position.position} of ${data.position.total}${data.position.column === null ? " in the shared queue" : ` in ${data.position.column}'s queue`} · `, { label: "Reorder", href: "/board?view=order" }] });
|
|
19507
20176
|
}
|
|
19508
20177
|
facts.push(scope === null ? { label: "Scope", parts: ["none yet"] }
|
|
19509
|
-
: approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by} ·
|
|
20178
|
+
: approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}`, ...(approval.at === null ? [] : [" · ", { at: approval.at }])] }
|
|
19510
20179
|
: { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
|
|
19511
20180
|
facts.push({ label: "Publishes as", parts: [publishesAs] });
|
|
19512
20181
|
// v102: who asked for it, and what the project's approval rules need.
|
|
@@ -19824,6 +20493,135 @@ function taskBodyParts(data) {
|
|
|
19824
20493
|
cancelAct,
|
|
19825
20494
|
`</div><aside class="task-rail">${rail}</aside></div>`,
|
|
19826
20495
|
].join("\n");
|
|
20496
|
+
// Console v2: one thread in time order (the plan, the agent's notes and
|
|
20497
|
+
// results, its questions, the person's replies) and the metadata grouped
|
|
20498
|
+
// for a Details panel. Every form below keeps its own route and fields.
|
|
20499
|
+
const familyRuns = data.family?.runs ?? data.runs.map(run => ({ ...run, taskId: task.id }));
|
|
20500
|
+
const versionLabelOf = (taskId) => {
|
|
20501
|
+
const index = data.family?.versions.findIndex(one => one.id === taskId) ?? -1;
|
|
20502
|
+
return index <= 0 ? "" : ` · revision ${index}`;
|
|
20503
|
+
};
|
|
20504
|
+
const thread = [];
|
|
20505
|
+
const filedBy = data.filer?.name ?? data.coordinator?.label ?? null;
|
|
20506
|
+
const rootFiled = data.family?.root.createdAt ?? task.createdAt;
|
|
20507
|
+
thread.push({ key: "filed", at: rootFiled, kind: "filed", who: "person", author: filedBy ?? "", title: "Filed the task",
|
|
20508
|
+
text: (() => { const goal = data.family?.root.goal ?? scope?.goal ?? null; return goal === null ? null : oneLineOf(goal, 280); })(), link: null, html: "", more: null });
|
|
20509
|
+
const newestBuilt = familyRuns.filter(run => run.outcome === "built" && run.role !== "planner" && run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
|
|
20510
|
+
for (const run of familyRuns) {
|
|
20511
|
+
const live = run.id === liveHistoryRunId || (run.outcome === null && run.id === liveRunId);
|
|
20512
|
+
const noun = runNoun(run);
|
|
20513
|
+
const href = { label: `${noun[0].toUpperCase()}${noun.slice(1)} #${run.id}`, href: `/r/${run.id}` };
|
|
20514
|
+
const revision = versionLabelOf(run.taskId);
|
|
20515
|
+
if (run.role === "planner") {
|
|
20516
|
+
thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "plan", who: "agent", author: run.runner,
|
|
20517
|
+
title: live ? "Planning" : run.reason === "plan-drafted" ? "Drafted a plan" : `Planning ended · ${run.outcome === null ? "never finished" : run.reason === null ? run.outcome : reasonWords(run.reason)}`,
|
|
20518
|
+
text: null, link: href, html: "", more: null });
|
|
20519
|
+
continue;
|
|
20520
|
+
}
|
|
20521
|
+
if (live) {
|
|
20522
|
+
thread.push({ key: `run-${run.id}`, at: run.startedAt, kind: "progress", who: "agent", author: run.runner,
|
|
20523
|
+
title: `${run.role === "reviewer" ? "Reviewing" : run.role === "scout" ? "Investigating" : "Building"}${revision}`,
|
|
20524
|
+
text: `${homePhaseWords(run)}.`, link: href, html: "", more: null });
|
|
20525
|
+
continue;
|
|
20526
|
+
}
|
|
20527
|
+
const title = run.role === "reviewer" ? "Reviewed the result"
|
|
20528
|
+
: run.outcome === "built" ? (run.role === "scout" ? "Report ready" : "Result ready")
|
|
20529
|
+
: run.outcome === "no-change" ? "Finished with no change"
|
|
20530
|
+
: run.outcome === "parked" ? "Stopped to ask"
|
|
20531
|
+
: run.outcome === null ? "Attempt never finished"
|
|
20532
|
+
: `${noun[0].toUpperCase()}${noun.slice(1)} ${run.outcome}`;
|
|
20533
|
+
thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "result", who: "agent", author: run.runner,
|
|
20534
|
+
title: `${title}${revision}`, text: run.handoff === null ? (run.reason === null || run.outcome === "built" ? null : reasonWords(run.reason)) : oneLineOf(run.handoff, 600),
|
|
20535
|
+
link: href, html: "",
|
|
20536
|
+
more: previousResult !== null && run.id === newestBuilt?.id && run.taskId === task.id ? { summary: "Result details", html: previousResult.html } : null });
|
|
20537
|
+
}
|
|
20538
|
+
// What the agent is working through now, and how its plan changed.
|
|
20539
|
+
const liveAt = liveRun?.startedAt ?? task.updatedAt;
|
|
20540
|
+
if (planCard !== "")
|
|
20541
|
+
thread.push({ key: "plan", at: familyRuns.filter(run => run.role === "planner").map(run => run.finishedAt ?? run.startedAt).sort().at(-1) ?? task.updatedAt,
|
|
20542
|
+
kind: "plan", who: "agent", author: "", title: "The plan", text: null, link: null, html: planCard, more: null });
|
|
20543
|
+
if (progressCard !== "")
|
|
20544
|
+
thread.push({ key: "progress", at: liveAt, kind: "progress", who: "agent", author: "", title: "Progress", text: null, link: null, html: progressCard, more: null });
|
|
20545
|
+
if (revisionLedgerCard !== "")
|
|
20546
|
+
thread.push({ key: "plan-revisions", at: liveAt, kind: "progress", who: "agent", author: "", title: "Plan changes", text: null, link: null, html: revisionLedgerCard, more: null });
|
|
20547
|
+
// The change a person asked for: the revision's own brief.
|
|
20548
|
+
if (data.revision != null && !("problem" in data.revision)) {
|
|
20549
|
+
const feedback = data.revision;
|
|
20550
|
+
thread.push({ key: "revision", at: task.createdAt, kind: "reply", who: "person", author: feedback.comments[0]?.author ?? "You",
|
|
20551
|
+
title: feedback.kind === "ci-repair" ? "Asked for a CI repair" : feedback.kind === "criterion-repair" ? "Asked for a repair" : "Asked for changes",
|
|
20552
|
+
text: feedback.comments.map(one => `${one.path === null ? "" : `${one.path}${one.line === null ? "" : `:${one.line}`} — `}${one.note}`).join("\n"),
|
|
20553
|
+
link: { label: `From build #${feedback.sourceRun}`, href: `/r/${feedback.sourceRun}` }, html: "", more: null });
|
|
20554
|
+
}
|
|
20555
|
+
for (const decision of data.decisions) {
|
|
20556
|
+
if (decision.state === "open" || decision.state === "expired")
|
|
20557
|
+
continue;
|
|
20558
|
+
thread.push({ key: `question-${decision.id}`, at: decision.createdAt, kind: "question", who: "agent", author: "", title: "Asked",
|
|
20559
|
+
text: decision.question, link: { label: "Question", href: `/d/${decision.id}` }, html: "", more: null });
|
|
20560
|
+
if (decision.answeredAt !== null) {
|
|
20561
|
+
const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice;
|
|
20562
|
+
thread.push({ key: `answer-${decision.id}`, at: decision.answeredAt, kind: "reply", who: "person", author: decision.answeredBy ?? "You", title: "Answered",
|
|
20563
|
+
text: [chosen, decision.note].filter((one) => one !== null && one !== "").join(" — ") || null, link: null, html: "", more: null });
|
|
20564
|
+
}
|
|
20565
|
+
}
|
|
20566
|
+
// Open questions keep their answer card (and its #task-questions anchor).
|
|
20567
|
+
if (decisionRail !== "")
|
|
20568
|
+
thread.push({ key: "questions", at: openDecisions.map(one => one.createdAt).sort()[0] ?? task.updatedAt, kind: "question", who: "agent", author: "",
|
|
20569
|
+
title: openDecisions.length === 1 ? "Asked you a question" : `Asked you ${openDecisions.length} questions`, text: null, link: null, html: decisionRail, more: null });
|
|
20570
|
+
for (const note of data.steering ?? []) {
|
|
20571
|
+
thread.push({ key: `steer-${note.id}`, at: note.createdAt, kind: "reply", who: "person", author: note.author, title: "Note to the agent", text: note.note, link: null, html: "", more: null });
|
|
20572
|
+
}
|
|
20573
|
+
thread.sort((a, b) => a.at === b.at ? 0 : a.at < b.at ? -1 : 1);
|
|
20574
|
+
// The Details panel: the same facts, grouped and said once.
|
|
20575
|
+
const lastRunForAgent = liveRun ?? familyRuns.filter(run => run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
|
|
20576
|
+
const work = [];
|
|
20577
|
+
work.push({ label: "Status", parts: [data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions).status.headline : status.label] });
|
|
20578
|
+
if (data.repo !== null)
|
|
20579
|
+
work.push({ label: "Project", parts: [projectName(data.repo)] });
|
|
20580
|
+
work.push({ label: "Agent", parts: [lastRunForAgent !== undefined ? `${lastRunForAgent.provider}${lastRunForAgent.model === null ? "" : ` · ${lastRunForAgent.model}`} on ${lastRunForAgent.runner}`
|
|
20581
|
+
: data.route?.legacy != null ? `${data.route.legacy.provider} · ${data.route.legacy.model}` : "The project's default"] });
|
|
20582
|
+
if (scope !== null)
|
|
20583
|
+
work.push({ label: "Checks level", parts: [qualityModeTitle(scope.qualityMode ?? "default")] });
|
|
20584
|
+
for (const label of ["Worker", "Last attempt", "Queue", "Budget", "Policy", "Strikes"]) {
|
|
20585
|
+
const fact = facts.find(one => one.label === label);
|
|
20586
|
+
if (fact !== undefined)
|
|
20587
|
+
work.push(fact);
|
|
20588
|
+
}
|
|
20589
|
+
const links = [];
|
|
20590
|
+
if (data.family != null && data.family.root.id !== task.id)
|
|
20591
|
+
links.push({ label: "Parent", parts: [{ label: data.family.root.title, href: `${taskHref(data.family.root.id)}?version=${encodeURIComponent(data.family.root.id)}` }] });
|
|
20592
|
+
const blockers = data.waitsFor ?? [];
|
|
20593
|
+
if (blockers.length > 0)
|
|
20594
|
+
links.push({ label: "Blocked by", parts: blockers.flatMap((one, index) => [...(index === 0 ? [] : [", "]), one.admitted ? { label: one.title ?? one.id, href: taskHref(one.id) } : one.id]) });
|
|
20595
|
+
const laterVersions = (data.family?.versions ?? []).filter((one, index, all) => index > all.findIndex(version => version.id === task.id) && all.findIndex(version => version.id === task.id) >= 0);
|
|
20596
|
+
if (laterVersions.length > 0)
|
|
20597
|
+
links.push({ label: "Follow-ups", parts: laterVersions.flatMap((one, index) => [...(index === 0 ? [] : [", "]), { label: one.title, href: `${taskHref(data.family.root.id)}?version=${encodeURIComponent(one.id)}` }]) });
|
|
20598
|
+
const published = facts.find(one => one.label === "Published");
|
|
20599
|
+
if (published !== undefined)
|
|
20600
|
+
links.push({ ...published, label: "Pull request" });
|
|
20601
|
+
else if (data.pullRequest?.view != null) {
|
|
20602
|
+
const pr = data.pullRequest.view;
|
|
20603
|
+
const prHref = safePrUrl(pr.prUrl);
|
|
20604
|
+
links.push({ label: "Pull request", parts: [prHref === null ? `#${pr.prNumber ?? "?"}` : { label: `#${pr.prNumber ?? "?"}`, href: prHref }, ` · ${pr.label}`] });
|
|
20605
|
+
}
|
|
20606
|
+
const review = [];
|
|
20607
|
+
const approvedScope = facts.find(one => one.label === "Approved scope" || one.label === "Scope");
|
|
20608
|
+
if (approvedScope !== undefined)
|
|
20609
|
+
review.push({ ...approvedScope, label: approvedScope.label === "Approved scope" ? "Approvals" : "Scope" });
|
|
20610
|
+
review.push({ label: "Who reviews", parts: [facts.find(one => one.label === "Approval rules")?.parts[0] ?? "Any approver in this project"] });
|
|
20611
|
+
review.push({ label: "Publishes as", parts: [publishesAs] });
|
|
20612
|
+
const about = [];
|
|
20613
|
+
const filedFact = facts.find(one => one.label === "Filed by");
|
|
20614
|
+
about.push(filedFact ?? { label: "Filed by", parts: [filedBy ?? (data.filedVia == null ? "Not recorded" : `via ${data.filedVia}`)] });
|
|
20615
|
+
about.push({ label: "Filed", parts: [{ at: rootFiled }] });
|
|
20616
|
+
about.push({ label: "Updated", parts: [{ at: task.updatedAt }] });
|
|
20617
|
+
for (const label of ["Audit", "This attempt", "Task total"]) {
|
|
20618
|
+
const fact = facts.find(one => one.label === label);
|
|
20619
|
+
if (fact !== undefined)
|
|
20620
|
+
about.push({ ...fact, label: label === "Audit" ? "Audit" : label === "This attempt" ? "Usage, this attempt" : "Usage, all attempts" });
|
|
20621
|
+
}
|
|
20622
|
+
const details = [
|
|
20623
|
+
{ title: "Work", facts: work }, { title: "Links", facts: links }, { title: "Review", facts: review }, { title: "About", facts: about },
|
|
20624
|
+
];
|
|
19827
20625
|
// The rebuilt page (shadcn/ui): the same parts in a calmer order — what
|
|
19828
20626
|
// needs a person, then facts, then folds; the mechanics under Manage.
|
|
19829
20627
|
const MANAGE = new Set(["steering", "waits-for", "holds"]);
|
|
@@ -19842,25 +20640,35 @@ function taskBodyParts(data) {
|
|
|
19842
20640
|
status: data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions) : null,
|
|
19843
20641
|
statusHtml,
|
|
19844
20642
|
approval: approvalHtml,
|
|
20643
|
+
// The plan, progress and plan changes are thread entries now; the rest still needs a person here.
|
|
19845
20644
|
lead: [
|
|
19846
20645
|
{ key: "history", html: data.history ?? "" }, { key: "control", html: controlHtml }, { key: "problem", html: problemHtml },
|
|
19847
20646
|
{ key: "pull-request", html: pullRequestCard },
|
|
19848
|
-
{ key: "needs-scope", html: needsScopeCard },
|
|
19849
|
-
{ key: "
|
|
20647
|
+
{ key: "needs-scope", html: needsScopeCard },
|
|
20648
|
+
{ key: "mirror", html: mirrorCard }, { key: "contest", html: contestCard }, { key: "attempt", html: attemptPanel },
|
|
19850
20649
|
].filter(one => one.html !== ""),
|
|
19851
|
-
questions
|
|
20650
|
+
// Open questions are answered in the thread.
|
|
20651
|
+
questions: "",
|
|
19852
20652
|
facts,
|
|
19853
20653
|
sections: [
|
|
19854
|
-
...(previousResult === null ? [] : [{ id: "result", title: previousResult.title, html: previousResult.html, open: false, count: null }]),
|
|
19855
|
-
//
|
|
19856
|
-
...sectionParts.filter(one => !MANAGE.has(one.id)).map(one => one.id === "scope" &&
|
|
20654
|
+
...(previousResult === null || thread.some(one => one.more !== null) ? [] : [{ id: "result", title: previousResult.title, html: previousResult.html, open: false, count: null }]),
|
|
20655
|
+
// Questions are in the thread; the scope and the ledgers fold here until asked.
|
|
20656
|
+
...sectionParts.filter(one => !MANAGE.has(one.id) && one.id !== "decisions").map(one => one.id === "scope" && data.scopeDraft === undefined && (finished || approval.approved) ? { ...one, open: false } : one),
|
|
19857
20657
|
],
|
|
19858
20658
|
manage: [
|
|
19859
20659
|
...sectionParts.filter(one => MANAGE.has(one.id)),
|
|
19860
20660
|
{ id: "task-diagnostics", title: "Task options", html: optionsHtml, open: optionsOpen, count: null },
|
|
19861
20661
|
],
|
|
19862
20662
|
cancel: cancelForm === "" ? null : { html: cancelForm, open: data.cancelDraft !== undefined },
|
|
20663
|
+
journey: data.guide !== true || task.state === "cancelled" || data.status === undefined ? null : (() => {
|
|
20664
|
+
const planning = data.plan === "requested" && approveForm === "";
|
|
20665
|
+
const read = data.assignment != null ? assignmentStageOf(data.assignment, { token: data.status.token }, planning) : stageOfCode(data.status.token, { planning });
|
|
20666
|
+
return firstTaskJourney(read, scope !== null && approvalOf(scope).approved, task.state === "done");
|
|
20667
|
+
})(),
|
|
19863
20668
|
// "Do this every time…": the starter flow that does this kind of work on its own, one yes away.
|
|
20669
|
+
thread,
|
|
20670
|
+
details,
|
|
20671
|
+
chatHref: data.csrf === "" ? null : taskChatHref(data.rootId ?? task.id),
|
|
19864
20672
|
everyTime: data.repo === null || data.csrf === "" ? null : (() => {
|
|
19865
20673
|
const starter = starterForWork(`${task.title}\n${scope?.goal ?? ""}`);
|
|
19866
20674
|
return { href: `/settings/flows?repo=${encodeURIComponent(data.repo)}&starter=${starter.id}#starter-${starter.id}`, starter: starter.name };
|
|
@@ -20403,7 +21211,7 @@ function pullRequestCardHtml(pr, csrf) {
|
|
|
20403
21211
|
`<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="run" value="${view.runId}">` +
|
|
20404
21212
|
`<p class="meta">${escape(view.mergeMethod === "squash" ? "Squash-merges" : view.mergeMethod === "rebase" ? "Rebase-merges" : "Merges")} PR #${view.prNumber} into ${escape(target)} and deletes its branch.</p>` +
|
|
20405
21213
|
`<label>Password<input type="password" name="token" autocomplete="current-password" required></label>` +
|
|
20406
|
-
`<button type="submit">Merge</button
|
|
21214
|
+
(view.fullCheck === null ? `<button type="submit">Merge</button>` : `<input type="hidden" name="anyway" value="1"><button type="submit" class="secondary">Merge anyway</button>`) + `</form>`;
|
|
20407
21215
|
return `<section class="card pull-request pull-request--${tone}" id="merge" data-pull-request="${escape(view.state)}">` +
|
|
20408
21216
|
`<div class="pull-request-head"><strong>${escape(view.label)}</strong>${link}</div>` +
|
|
20409
21217
|
`<p class="meta">${escape(view.detail)}${revision}</p>${merged}${act}</section>`;
|
|
@@ -20446,6 +21254,18 @@ const PHASE_WORDS = {
|
|
|
20446
21254
|
"capturing-evidence": "capturing evidence",
|
|
20447
21255
|
committing: "committing",
|
|
20448
21256
|
};
|
|
21257
|
+
/** An agent's phase on the home card, from the machine's own vocabulary. */
|
|
21258
|
+
function homePhaseWords(run) {
|
|
21259
|
+
if (run.role === "planner")
|
|
21260
|
+
return "Planning";
|
|
21261
|
+
if (run.role === "reviewer")
|
|
21262
|
+
return "Reviewing";
|
|
21263
|
+
if (run.role === "scout")
|
|
21264
|
+
return "Investigating";
|
|
21265
|
+
const words = { "agent-running": "Writing the change", "validating-handoff": "Checking its handoff", "capturing-evidence": "Saving its evidence",
|
|
21266
|
+
committing: "Committing", "verifying-proof": "Running the checks", "correcting-proof": "Correcting its evidence" };
|
|
21267
|
+
return run.phase === null ? "Starting" : words[run.phase] ?? "Working";
|
|
21268
|
+
}
|
|
20449
21269
|
function phaseWords(phase) {
|
|
20450
21270
|
return PHASE_WORDS[phase] ?? "the agent is working";
|
|
20451
21271
|
}
|
|
@@ -21102,6 +21922,34 @@ function evidenceBundleCard(view, run, publication = null, review = null) {
|
|
|
21102
21922
|
* the operator's acceptance alone — shared with the review cockpit's
|
|
21103
21923
|
* header chip so the two surfaces never name one state differently. */
|
|
21104
21924
|
/** The receipt's facts as the shared projection reads them. */
|
|
21925
|
+
/** A result's follow-ups: checks run on its commit since it finished, and its tests task. */
|
|
21926
|
+
function followUpsFor(store, evidenceRoot, run, now) {
|
|
21927
|
+
if (run.role !== "builder" || run.finishedAt === null || run.headRevision === null)
|
|
21928
|
+
return null;
|
|
21929
|
+
const repo = store.refById(run.taskRef)?.repo ?? null;
|
|
21930
|
+
const tests = store.handle.prepare("SELECT outcome FROM action_ledger WHERE run_id = ? AND action = ? ORDER BY id DESC LIMIT 1").get(run.id, ADD_TESTS_ACTION);
|
|
21931
|
+
return { repo, checks: followUpChecksOf(store, run.id, now, evidenceRoot), testsTask: tests === undefined ? null : String(tests["outcome"]),
|
|
21932
|
+
quick: repo !== null && liveQuickCommand(store, repo) !== null, full: repo !== null && store.liveVerifyCommand(repo) !== null };
|
|
21933
|
+
}
|
|
21934
|
+
const FOLLOW_UP_WORDS = { waiting: "waiting for a worker", running: "running", passed: "passed", failed: "failed", "not-run": "didn't run" };
|
|
21935
|
+
/** Run checks and Add tests, under the result's Checks: what ran since, then the two acts. */
|
|
21936
|
+
function followUpsHtml(followUps, runId, o) {
|
|
21937
|
+
const rows = followUps.checks.map(one => `<li data-follow-up-check="${one.state}">${escape(CHECK_LEVEL_WORDS[one.level])} checks ${escape(FOLLOW_UP_WORDS[one.state])}` +
|
|
21938
|
+
`${one.exitCode === null || one.state === "passed" ? "" : ` (exit ${one.exitCode})`} on <span class="mono">${escape(one.head.slice(0, 7))}</span>` +
|
|
21939
|
+
`<span class="meta"> · ${one.why === "pull-request" ? "for the pull request" : escape(one.actor)}${one.note === null ? "" : ` · ${escape(one.note)}`}</span>` +
|
|
21940
|
+
`${one.logArtifactId === null ? "" : ` <a href="/r/${runId}/evidence/${one.logArtifactId}">Log</a>`}</li>`).join("");
|
|
21941
|
+
const hidden = `<input type="hidden" name="csrf" value="${escape(o.csrf)}"><input type="hidden" name="return" value="${escape(o.returnTo)}">`;
|
|
21942
|
+
const canCheck = followUps.quick || followUps.full;
|
|
21943
|
+
const checkActs = !canCheck || o.csrf === "" ? "" : `<form method="post" action="/r/${runId}/checks" class="follow-up-act">${hidden}` +
|
|
21944
|
+
(followUps.quick && followUps.full ? `<button type="submit" name="level" value="quick">Run quick checks</button><button type="submit" name="level" value="full" class="secondary">Run full checks</button>`
|
|
21945
|
+
: `<button type="submit" name="level" value="${followUps.quick ? "quick" : "full"}">Run checks</button>`) + `</form>`;
|
|
21946
|
+
const tests = followUps.testsTask !== null ? `<p class="meta">Tests task: <a href="${taskHref(followUps.testsTask)}">${escape(followUps.testsTask)}</a></p>`
|
|
21947
|
+
: o.csrf === "" ? "" : `<form method="post" action="/r/${runId}/add-tests" class="follow-up-act">${hidden}<button type="submit" class="secondary">Add tests</button></form>`;
|
|
21948
|
+
return `<section class="result-section follow-ups" id="follow-ups" data-follow-ups><strong>Follow-ups</strong>` +
|
|
21949
|
+
(rows === "" ? "" : `<ul class="follow-up-list">${rows}</ul>`) +
|
|
21950
|
+
(canCheck || followUps.repo === null ? "" : `<p class="meta">No approved check to run. <a href="/settings/checks?repo=${encodeURIComponent(followUps.repo)}">Set one up</a>.</p>`) +
|
|
21951
|
+
(checkActs === "" && tests === "" ? "" : `<div class="follow-up-acts">${checkActs}${tests}</div>`) + `</section>`;
|
|
21952
|
+
}
|
|
21105
21953
|
function receiptStatusOf(view, review = view.review) {
|
|
21106
21954
|
// The evidence health rides the status (repair 2026-09-14): a result
|
|
21107
21955
|
// whose stored records are damaged is never called ready on the strength
|
|
@@ -21222,7 +22070,9 @@ function resultPanelParts(detail, o) {
|
|
|
21222
22070
|
const { run, receipt, proof, terminal, handoff } = detail;
|
|
21223
22071
|
const facts = receipt.facts;
|
|
21224
22072
|
const current = detail.assignment?.receipt?.runId === run.id ? detail.assignment : null;
|
|
21225
|
-
const
|
|
22073
|
+
const followUps = detail.followUps ?? null;
|
|
22074
|
+
const resultLinks = { result: o.hrefFor("summary"), checks: o.hrefFor("checks"), pullRequest: `${taskHref(detail.rootId ?? detail.taskId)}#merge`,
|
|
22075
|
+
...(followUps !== null && (followUps.quick || followUps.full) && o.csrf !== "" ? { runChecks: `${o.hrefFor("checks")}#follow-ups` } : {}) };
|
|
21226
22076
|
const presentation = current == null ? null : assignmentPresentationOf(current, { additionalAttention: [...facts.evidenceProblems, ...receipt.caveats],
|
|
21227
22077
|
...(detail.publication === null ? {} : { pullRequest: pullRequestFactOf({ ...detail.publication, lastCheckState: detail.ciFailing ? "failing" : detail.publication.lastCheckState }) }),
|
|
21228
22078
|
evidence: { damaged: facts.evidenceHealth.damaged, missing: facts.evidenceHealth.missing, shortened: facts.evidenceHealth.shortened }, links: resultLinks });
|
|
@@ -21463,7 +22313,7 @@ function resultPanelParts(detail, o) {
|
|
|
21463
22313
|
}
|
|
21464
22314
|
if (proof.proofProblem !== null)
|
|
21465
22315
|
checkParts.push(`<p class="problem">Verification details are unavailable: ${escape(proof.proofProblem)}</p>`);
|
|
21466
|
-
checkParts.push(proof.checkLog === null ? `<p class="meta" data-cockpit-source="machine"
|
|
22316
|
+
checkParts.push(proof.checkLog === null ? `<p class="meta" data-cockpit-source="machine">${current?.receipt?.checks.level === "off" ? "Checks were off for this build." : "No automated check was configured for this build."}</p>` : checkLogHtml(proof.checkLog, runId, ' data-cockpit-source="machine"'));
|
|
21467
22317
|
checkParts.push(proof.proof === null || proof.proof.checks.length === 0
|
|
21468
22318
|
? directAssessment ? "" : `<p class="meta" data-cockpit-source="agent">The agent reported no checks.</p>`
|
|
21469
22319
|
: `<details class="cockpit-proof-group" data-cockpit-source="agent"><summary>Agent checks · ${proof.proof.checks.length}</summary><div class="result-section"><ul>` +
|
|
@@ -21481,6 +22331,8 @@ function resultPanelParts(detail, o) {
|
|
|
21481
22331
|
? directAssessment ? "" : `<p class="meta" data-cockpit-source="caveats">No caveats were reported.</p>`
|
|
21482
22332
|
: `<details class="cockpit-proof-group" data-cockpit-source="caveats"><summary>Agent caveats · ${receipt.caveats.length}</summary><ul>${receipt.caveats.map(one => `<li>${escape(one)}</li>`).join("")}</ul></details>`);
|
|
21483
22333
|
}
|
|
22334
|
+
if (followUps !== null && run.role === "builder")
|
|
22335
|
+
checkParts.push(followUpsHtml(followUps, runId, o));
|
|
21484
22336
|
// The handoff's own account of its checks — the agent's words, labeled
|
|
21485
22337
|
// as such, whether or not a proof exists.
|
|
21486
22338
|
if (!directAssessment && handoff !== null && handoff.verification.length > 0)
|
|
@@ -22001,6 +22853,13 @@ function telegramDeliveryWords(store, bot) {
|
|
|
22001
22853
|
return `Telegram pushes new messages to ${state.url}${state.problem === null ? "." : `, but ${state.problem.charAt(0).toLowerCase()}${state.problem.slice(1)}.`}`;
|
|
22002
22854
|
return `Toolroll asks Telegram for new messages every few seconds${state?.problem ? ` (${state.problem})` : ""}.`;
|
|
22003
22855
|
}
|
|
22856
|
+
/** The projects this person can see, by name, and whether they muted each one's pings. */
|
|
22857
|
+
function notificationProjects(store, account) {
|
|
22858
|
+
const muted = new Set(store.mutedProjects(account));
|
|
22859
|
+
return store.listProjects().filter(one => store.accountCanAccess(account, one.path))
|
|
22860
|
+
.map(one => ({ repo: one.path, name: one.name, muted: muted.has(one.path) }))
|
|
22861
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
22862
|
+
}
|
|
22004
22863
|
function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null, push = null, providerKeys = null, digest = null, permissionDefault = null, qualityDefault = null, email = null, telegramDelivery = null, workers = null, updates = null, firstResult = null, chatNotices = null) {
|
|
22005
22864
|
const permissionCard = permissionDefault === null
|
|
22006
22865
|
? ""
|
|
@@ -22045,6 +22904,14 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
|
|
|
22045
22904
|
`<p class="meta">One message: what finished, what waits, what failed.</p>`,
|
|
22046
22905
|
`<button type="submit">Save</button>`,
|
|
22047
22906
|
`</form>`,
|
|
22907
|
+
...((chatNotices.projects ?? []).length === 0 ? [] : [
|
|
22908
|
+
"<h3>Projects</h3>",
|
|
22909
|
+
`<ul class="card">${chatNotices.projects.map(one => `<li><form method="post" action="/settings/notifications/mute">` +
|
|
22910
|
+
`<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="repo" value="${escape(one.repo)}">` +
|
|
22911
|
+
(one.muted ? "" : `<input type="hidden" name="pings" value="on">`) +
|
|
22912
|
+
`${escape(one.name)} <span class="meta">· ${one.muted ? "muted" : "pings on"}</span> <button type="submit">${one.muted ? "Unmute" : "Mute"}</button></form></li>`).join("")}</ul>`,
|
|
22913
|
+
`<p class="meta">Muted projects still show in Tasks and the evening digest.</p>`,
|
|
22914
|
+
]),
|
|
22048
22915
|
].join("\n");
|
|
22049
22916
|
const digestCard = digest === null || csrf === "" || chatNotices?.mode === "quiet"
|
|
22050
22917
|
? ""
|
|
@@ -22326,6 +23193,7 @@ function settingsTiles() {
|
|
|
22326
23193
|
const SETTINGS_TILE_ICONS = [
|
|
22327
23194
|
["/settings/flows", "Flows", `<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><path d="M10 6.5h4a3 3 0 0 1 3 3V14"/>`],
|
|
22328
23195
|
["/settings/integrations", "Integrations", `<path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/>`],
|
|
23196
|
+
["/settings/lead", "Lead", `<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 9h8M8 13h5"/>`],
|
|
22329
23197
|
["/settings/models", "Models", `<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/>`],
|
|
22330
23198
|
["/settings/skills", "Skills", `<path d="m12 3 1.9 5.8L20 10l-5 3.6L16.8 20 12 16.4 7.2 20 9 13.6 4 10l6.1-1.2z"/>`],
|
|
22331
23199
|
["/settings/tools", "Tools", `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>`],
|