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.
Files changed (84) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +1 -0
  5. package/dist/assignment-ui.js +3 -1
  6. package/dist/assignment.d.ts +9 -0
  7. package/dist/assignment.js +62 -13
  8. package/dist/browser/workspace.css +2 -2
  9. package/dist/browser/workspace.js +28 -24
  10. package/dist/browser-workspace.d.ts +117 -2
  11. package/dist/builder.d.ts +1 -0
  12. package/dist/builder.js +33 -13
  13. package/dist/chat-actions.js +2 -1
  14. package/dist/chat-delivery.js +2 -0
  15. package/dist/chat-quiet.js +11 -1
  16. package/dist/chat-task-actions.d.ts +13 -0
  17. package/dist/chat-task-actions.js +19 -1
  18. package/dist/check-levels-ui.d.ts +21 -0
  19. package/dist/check-levels-ui.js +70 -0
  20. package/dist/check-levels.d.ts +67 -0
  21. package/dist/check-levels.js +171 -0
  22. package/dist/claim.d.ts +22 -0
  23. package/dist/claim.js +43 -0
  24. package/dist/cli.d.ts +1 -1
  25. package/dist/cli.js +4 -0
  26. package/dist/demo-chat.js +4 -1
  27. package/dist/dispatch.js +7 -2
  28. package/dist/dispose.d.ts +8 -0
  29. package/dist/dispose.js +7 -1
  30. package/dist/export.js +3 -2
  31. package/dist/first-run.d.ts +34 -0
  32. package/dist/first-run.js +37 -1
  33. package/dist/flow-gallery-ui.d.ts +30 -0
  34. package/dist/flow-gallery-ui.js +85 -0
  35. package/dist/flow-gallery.d.ts +111 -0
  36. package/dist/flow-gallery.js +445 -0
  37. package/dist/flow-share.d.ts +102 -0
  38. package/dist/flow-share.js +614 -0
  39. package/dist/flow-starters.d.ts +2 -0
  40. package/dist/flow-starters.js +1 -1
  41. package/dist/flow-steps.js +7 -0
  42. package/dist/flow-triggers.js +2 -0
  43. package/dist/flows-cli.d.ts +56 -0
  44. package/dist/flows-cli.js +109 -3
  45. package/dist/flows-ui.d.ts +15 -1
  46. package/dist/flows-ui.js +31 -6
  47. package/dist/guides.js +27 -2
  48. package/dist/lead-context.js +6 -2
  49. package/dist/mate-doors.d.ts +0 -15
  50. package/dist/mate-doors.js +5 -1
  51. package/dist/mate-tools.js +9 -4
  52. package/dist/models-ui.js +1 -1
  53. package/dist/operate.d.ts +4 -1
  54. package/dist/operate.js +601 -85
  55. package/dist/project-concurrency.d.ts +62 -0
  56. package/dist/project-concurrency.js +116 -0
  57. package/dist/project-delete-ui.d.ts +7 -0
  58. package/dist/project-delete-ui.js +19 -2
  59. package/dist/project-delete.js +4 -0
  60. package/dist/pull-request-flow.d.ts +9 -0
  61. package/dist/pull-request-flow.js +21 -3
  62. package/dist/result-follow-ups.d.ts +101 -0
  63. package/dist/result-follow-ups.js +333 -0
  64. package/dist/scope.js +11 -0
  65. package/dist/serve.d.ts +211 -2
  66. package/dist/serve.js +1009 -141
  67. package/dist/store.d.ts +76 -3
  68. package/dist/store.js +311 -22
  69. package/dist/surface.js +31 -8
  70. package/dist/tailnet.d.ts +11 -0
  71. package/dist/tailnet.js +47 -0
  72. package/dist/task-outcome-cli.js +2 -2
  73. package/dist/task-status.d.ts +8 -1
  74. package/dist/task-status.js +28 -10
  75. package/dist/team-cli.js +3 -0
  76. package/dist/telegram-status.js +2 -2
  77. package/dist/verification-evidence.js +21 -1
  78. package/dist/webhooks.js +5 -0
  79. package/dist/work-index.js +39 -6
  80. package/dist/work-summary.d.ts +0 -4
  81. package/dist/work-summary.js +2 -0
  82. package/dist/workspace-ui.d.ts +3 -0
  83. package/dist/workspace-ui.js +5 -3
  84. 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 address = server.address();
738
- const port = typeof address === "object" && address !== null ? address.port : null;
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/plain; charset=utf-8", "wrong host");
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
- return sendScreen(response, 200, inboxPage(chromeFor(project, "inbox"), {
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 withFirstRun = (shown) => firstRun === undefined ? shown : { ...shown, workspace: { ...shown.workspace, firstRun } };
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 = false;
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 (!localSignInChecking && (localSignIn === null || Date.now() - localSignIn.at > 30_000)) {
3990
- localSignInChecking = true;
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: steps.find(one => one.key === "agent" && !one.done && !one.checking) ? SANDBOX_COMMAND : null,
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: "chat is not configured yet — set it up below, or from the terminal: toolroll config set chat" };
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 panel is the Ask view here, so the page drops its own Ask tab.
5535
- const taskView = page.workspace?.view?.kind === "task" ? { ...page.workspace.view, tabs: [] } : page.workspace?.view;
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 = "Demo: a scripted lead and sample projects. Nothing calls a model, reaches outside or spends.";
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
- const WORKSPACE_STYLE = styleAsset(STYLE + 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 + 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 + '.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}');
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
- * The inbox (v3): only things stalling progress without a person, one card
15101
- * per underlying stall, each with its verb inline or one step away. No
15102
- * auto-refresh — approval links lead to the step-up screen, and a page
15103
- * that might hold typed input never re-renders itself.
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
- cancelled,
15210
- gaps,
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 &amp; 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 configForm = (current) => {
16200
- const anthropicModels = PRICED_MODELS.filter(one => !one.includes("/"));
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
- parts.push(code === "demo"
16260
- ? `<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.</p></div>`
16261
- : `<div class="card" id="latest"><p><strong>chat is off.</strong></p><p class="meta">${escape(data.enabled.why)}</p></div>`);
16262
- // The ceiling refusals need a restart to fix; configuration does not —
16263
- // it is a first-class act of this console (operator request).
16264
- if (data.canManage && (code === "unconfigured" || code === "unpriced" || code === "no-key")) {
16265
- parts.push(`<h2>${code === "unconfigured" ? "set it up" : "reconfigure"}</h2>`, configForm(data.config));
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(`<details id="chat-settings"${data.settingsOpen ? " open" : ""}><summary class="meta">Chat settings</summary>`, configForm(data.config), `<form method="post" action="/chat/config" class="inline">`, `<input type="hidden" name="csrf" value="${escape(data.csrf)}">`, `<input type="hidden" name="return" value="${escape(data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id))}">`, `<input type="hidden" name="off" value="1">`, `<input type="password" name="token" placeholder="your password" autocomplete="current-password">`, `<button type="submit">turn chat off</button>`, `</form>`, data.keyFacts
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="/chat?settings=1#chat-settings">Chat settings</a> · provider, model and limits</p>`,
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} · ${when(approval.at)}`] }
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 }, { key: "progress", html: progressCard }, { key: "revisions", html: revisionLedgerCard },
19849
- { key: "plan", html: planCard }, { key: "mirror", html: mirrorCard }, { key: "contest", html: contestCard }, { key: "attempt", html: attemptPanel },
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: decisionRail,
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
- // A finished task leads with its result; its scope folds until asked.
19856
- ...sectionParts.filter(one => !MANAGE.has(one.id)).map(one => one.id === "scope" && finished && data.scopeDraft === undefined ? { ...one, open: false } : one),
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></form>`;
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 resultLinks = { result: o.hrefFor("summary"), checks: o.hrefFor("checks"), pullRequest: `${taskHref(detail.rootId ?? detail.taskId)}#merge` };
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">No automated check was configured for this build.</p>` : checkLogHtml(proof.checkLog, runId, ' 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"/>`],