toolroll 0.9.6 → 0.9.8

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 (102) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +2 -0
  5. package/dist/assignment-adapters.js +2 -0
  6. package/dist/assignment-ui.js +3 -1
  7. package/dist/assignment.d.ts +15 -0
  8. package/dist/assignment.js +80 -16
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +28 -24
  11. package/dist/browser-workspace.d.ts +117 -2
  12. package/dist/build-review.d.ts +38 -0
  13. package/dist/build-review.js +341 -0
  14. package/dist/builder.d.ts +1 -0
  15. package/dist/builder.js +33 -13
  16. package/dist/chat-actions.js +2 -1
  17. package/dist/chat-delivery.js +2 -0
  18. package/dist/chat-quiet.js +11 -1
  19. package/dist/chat-task-actions.d.ts +13 -0
  20. package/dist/chat-task-actions.js +19 -1
  21. package/dist/check-levels-ui.d.ts +26 -0
  22. package/dist/check-levels-ui.js +84 -0
  23. package/dist/check-levels.d.ts +67 -0
  24. package/dist/check-levels.js +171 -0
  25. package/dist/claim.d.ts +22 -0
  26. package/dist/claim.js +43 -0
  27. package/dist/cli.d.ts +1 -1
  28. package/dist/cli.js +6 -0
  29. package/dist/demo-chat.js +4 -1
  30. package/dist/dispatch.js +9 -3
  31. package/dist/dispose.d.ts +12 -3
  32. package/dist/dispose.js +19 -5
  33. package/dist/exec.d.ts +5 -0
  34. package/dist/exec.js +30 -4
  35. package/dist/export.js +3 -2
  36. package/dist/first-run.d.ts +34 -0
  37. package/dist/first-run.js +37 -1
  38. package/dist/flow-gallery-ui.d.ts +30 -0
  39. package/dist/flow-gallery-ui.js +85 -0
  40. package/dist/flow-gallery.d.ts +111 -0
  41. package/dist/flow-gallery.js +445 -0
  42. package/dist/flow-share.d.ts +102 -0
  43. package/dist/flow-share.js +614 -0
  44. package/dist/flow-starters.d.ts +2 -0
  45. package/dist/flow-starters.js +1 -1
  46. package/dist/flow-steps.js +7 -0
  47. package/dist/flow-triggers.js +2 -0
  48. package/dist/flows-cli.d.ts +56 -0
  49. package/dist/flows-cli.js +109 -3
  50. package/dist/flows-ui.d.ts +15 -1
  51. package/dist/flows-ui.js +31 -6
  52. package/dist/guides.js +27 -2
  53. package/dist/invoke.js +5 -0
  54. package/dist/lead-context.js +6 -2
  55. package/dist/mate-doors.d.ts +0 -15
  56. package/dist/mate-doors.js +5 -1
  57. package/dist/mate-tools.js +9 -4
  58. package/dist/models-ui.js +1 -1
  59. package/dist/modes.d.ts +3 -1
  60. package/dist/modes.js +3 -1
  61. package/dist/operate.d.ts +4 -1
  62. package/dist/operate.js +711 -98
  63. package/dist/process-custody.js +8 -0
  64. package/dist/project-concurrency.d.ts +62 -0
  65. package/dist/project-concurrency.js +116 -0
  66. package/dist/project-delete-ui.d.ts +7 -0
  67. package/dist/project-delete-ui.js +19 -2
  68. package/dist/project-delete.js +4 -0
  69. package/dist/provider.js +16 -0
  70. package/dist/pull-request-flow.d.ts +9 -0
  71. package/dist/pull-request-flow.js +21 -3
  72. package/dist/result-follow-ups.d.ts +101 -0
  73. package/dist/result-follow-ups.js +333 -0
  74. package/dist/retention-ui.d.ts +3 -1
  75. package/dist/retention-ui.js +4 -4
  76. package/dist/retention.d.ts +13 -6
  77. package/dist/retention.js +50 -12
  78. package/dist/review-switch.d.ts +41 -0
  79. package/dist/review-switch.js +88 -0
  80. package/dist/reviewer.d.ts +24 -2
  81. package/dist/reviewer.js +49 -1
  82. package/dist/scope.js +11 -0
  83. package/dist/serve.d.ts +211 -2
  84. package/dist/serve.js +1038 -145
  85. package/dist/store.d.ts +112 -4
  86. package/dist/store.js +447 -27
  87. package/dist/surface.js +36 -9
  88. package/dist/tailnet.d.ts +11 -0
  89. package/dist/tailnet.js +47 -0
  90. package/dist/task-outcome-cli.js +2 -2
  91. package/dist/task-status.d.ts +13 -3
  92. package/dist/task-status.js +38 -16
  93. package/dist/team-cli.js +3 -0
  94. package/dist/telegram-status.js +2 -2
  95. package/dist/verification-evidence.js +21 -1
  96. package/dist/webhooks.js +5 -0
  97. package/dist/work-index.js +39 -6
  98. package/dist/work-summary.d.ts +0 -4
  99. package/dist/work-summary.js +4 -1
  100. package/dist/workspace-ui.d.ts +6 -1
  101. package/dist/workspace-ui.js +14 -11
  102. 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 ?? "");
@@ -221,10 +229,12 @@ import { checkoutPlan, cleanCheckouts, discardCheckout, previewDigest } from "./
221
229
  import { UPDATES_CSS, newerThan, updateStepsHtml, updatesHtml, updatesScript } from "./toolroll-update-ui.js";
222
230
  import { abandonRuntimeUpdate, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal, currentRuntime } from "./toolroll-update.js";
223
231
  import { latestVersionNow } from "./releases.js";
224
- import { RETENTION_KINDS, lastSweepAt, parsePeriod, retentionPlan } from "./retention.js";
232
+ import { RETENTION_KINDS, lastSweepAt, parsePeriod, periodChoices, retentionPlan } from "./retention.js";
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";
237
+ import { buildReviewOf, findingWords } from "./review-switch.js";
228
238
  import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
229
239
  import { POLICY_CSS, policyHtml } from "./policy-ui.js";
230
240
  import { checkPolicy, parseList, policyParts } from "./policy.js";
@@ -252,6 +262,13 @@ import { PACKAGE_VERSION } from "./version.js";
252
262
  import { updateNoticeWords } from "./update-notice.js";
253
263
  import { whenHtml } from "./when-html.js";
254
264
  const SESSION_COOKIE = "standing-orders_session";
265
+ /** Where `up`'s one-time sign-in link points, and how long it works. */
266
+ export const SIGN_IN_LINK_PATH = "/login/once/";
267
+ export const SIGN_IN_LINK_MS = 10 * 60_000;
268
+ /** The installation fact that the lead was turned on by default, once (its value: the provider). */
269
+ const LEAD_BY_DEFAULT_FACT = "lead-on-by-default";
270
+ /** The installation fact that the phone card was put away. */
271
+ const PHONE_CARD_FACT = "phone-card-dismissed";
255
272
  const BODY_CAP = 16 * 1024;
256
273
  // URL encoding can triple UTF-8 bytes. Admit the existing bounded task
257
274
  // fields (including paths and rubric) before canonical text validation.
@@ -515,6 +532,30 @@ export function createDecisionServer(options) {
515
532
  const ssoOffer = () => { const settings = ssoSettings(); return settings === null ? null : { label: settings.label, operatorsOnly: settings.passwords === "operators" }; };
516
533
  /** A step-up within this long of the provider checking someone needs no password. */
517
534
  const SSO_FRESH_MS = 10 * 60_000;
535
+ // The one-time sign-in link `up` opens in the browser: single use, ten minutes, from this computer only. Only a hash
536
+ // of the code is kept, in memory; the code itself is never written to a log or the ledger.
537
+ const signInLinks = new Map();
538
+ const linkKey = (code) => createHash("sha256").update(code).digest("hex");
539
+ function mintSignInLink(account) {
540
+ const known = store.accountOf(account);
541
+ if (known === null || known.role !== "approver" || known.revokedAt !== null)
542
+ return null;
543
+ for (const [key, one] of signInLinks)
544
+ if (one.expires < Date.now())
545
+ signInLinks.delete(key);
546
+ while (signInLinks.size >= 20)
547
+ signInLinks.delete(signInLinks.keys().next().value);
548
+ const code = randomBytes(32).toString("base64url");
549
+ signInLinks.set(linkKey(code), { account, expires: Date.now() + SIGN_IN_LINK_MS });
550
+ return `${SIGN_IN_LINK_PATH}${code}`;
551
+ }
552
+ /** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no forwarding proxy. */
553
+ const fromThisComputer = (request) => {
554
+ const peer = request.socket.remoteAddress ?? "";
555
+ const loopback = peer === "127.0.0.1" || peer === "::1" || peer === "::ffff:127.0.0.1";
556
+ return loopback && request.headers["x-forwarded-for"] === undefined && request.headers["forwarded"] === undefined
557
+ && /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(request.headers.host ?? "");
558
+ };
518
559
  async function providerFor(issuer) {
519
560
  if (ssoProvider !== null && ssoProvider.issuer === issuer && Date.now() - ssoProvider.at < 10 * 60_000)
520
561
  return { ok: true, provider: ssoProvider.provider };
@@ -607,7 +648,18 @@ export function createDecisionServer(options) {
607
648
  const projectViewOf = (repo, who) => ({
608
649
  repo, name: projectName(repo), holdings: projectHoldings(store, repo), running: projectRunning(store, repo, clock()),
609
650
  canDelete: who.via === "cookie" && store.isInstanceOperator(who.name),
651
+ builds: projectBuildsOf(repo, who),
610
652
  });
653
+ /** Settings → Project → Builds at once: the saved number, its workers' capacity, and what builds now. */
654
+ const projectBuildsOf = (repo, who) => {
655
+ const capacities = store.listRunners().filter(one => one.retiredAt === null && one.repos.includes(repo)).map(one => one.capacity);
656
+ return {
657
+ setting: (() => { const file = store.databaseFile(); return file === null ? PROJECT_CONCURRENCY_DEFAULT : projectConcurrency(file, repo); })(),
658
+ capacity: capacities.length === 0 ? null : Math.max(...capacities),
659
+ building: store.runningBuildsByRepo(clock()).get(repo) ?? 0,
660
+ canChange: who.via === "cookie" && who.role === "approver" && store.accountCanAccess(who.name, repo),
661
+ };
662
+ };
611
663
  /** Codex's own MCP servers for a project (`codex mcp list --json`), for "Found on this computer"; null when Codex can't say. */
612
664
  const codexServers = async (repo) => {
613
665
  try {
@@ -695,7 +747,7 @@ export function createDecisionServer(options) {
695
747
  const server = createServer((request, response) => {
696
748
  void handle(request, response).catch(error => {
697
749
  // 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) });
750
+ 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
751
  if (envValue(process.env, "SERVE_DEBUG") === "1")
700
752
  console.error("SERVE ERROR:", error);
701
753
  if (!response.headersSent) {
@@ -730,15 +782,43 @@ export function createDecisionServer(options) {
730
782
  const cookieSecure = publicOrigin === null ? "" : "; Secure";
731
783
  /** Where Google may send someone back to (v89): the public https address, or this computer's own. */
732
784
  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;
785
+ /** This computer's tailnet names, the last ones read (onboarding). */
786
+ let tailnet = [];
787
+ if (options.tailnetNames !== undefined) {
788
+ const readTailnet = options.tailnetNames;
789
+ const refresh = () => { void readTailnet().then(names => { tailnet = names; }, () => { }); };
790
+ refresh();
791
+ const timer = setInterval(refresh, 5 * 60_000);
792
+ timer.unref?.();
793
+ server.on("close", () => clearInterval(timer));
794
+ }
795
+ const servedPort = () => {
796
+ const address = server.address();
797
+ return typeof address === "object" && address !== null ? address.port : null;
798
+ };
733
799
  /** The names this server answers as. Anything else is a rebind, refused. */
734
800
  const allowedHost = (host) => {
735
801
  if (host === undefined)
736
802
  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}`];
803
+ const port = servedPort();
804
+ const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`, ...tailnet.map(name => `${name}:${port}`)];
740
805
  return [...locals, ...(options.allowedHosts ?? []), ...(publicOrigin === null ? [] : [publicOrigin.host])].includes(host);
741
806
  };
807
+ /** The exact command that starts this console again as it runs now, plus a listening address or one more allowed host. */
808
+ const consoleCommand = (change) => {
809
+ const port = servedPort() ?? 4180;
810
+ const address = server.address();
811
+ const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
812
+ const host = change.host ?? (bound === "127.0.0.1" || bound === "::1" ? null : bound === "::" ? "0.0.0.0" : bound);
813
+ const allowed = [...(options.allowedHosts ?? []), ...(change.allow === undefined ? [] : [change.allow])];
814
+ return [`toolroll ${options.upConsole === true ? "up" : "serve"}`, ...(host === null ? [] : [`--host ${host}`]), ...(port === 4180 ? [] : [`--port ${port}`]),
815
+ ...(allowed.length === 0 ? [] : [`--allow-host ${allowed.join(",")}`])].join(" ");
816
+ };
817
+ /** The wrong-host page's facts: the address opened, where the console answers, and the exact command that admits it. */
818
+ const wrongHost = (host) => {
819
+ 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;
820
+ return { opened, served: `127.0.0.1:${servedPort() ?? 4180}`, command: opened === null ? null : consoleCommand({ allow: opened }) };
821
+ };
742
822
  /**
743
823
  * The one gate every POST passes — parse elsewhere, authorize here. A
744
824
  * refusal names its status; null means proceed. Duplicated security fields
@@ -1065,7 +1145,7 @@ export function createDecisionServer(options) {
1065
1145
  if (hook.pathname.startsWith("/hooks/"))
1066
1146
  return flowHook(request, response, hook);
1067
1147
  if (!allowedHost(request.headers.host)) {
1068
- return respond(response, 421, "text/plain; charset=utf-8", "wrong host");
1148
+ return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
1069
1149
  }
1070
1150
  const url = new URL(request.url ?? "/", "http://placeholder");
1071
1151
  // A token in a URL is a token in history, logs, and referers. Refused
@@ -1147,6 +1227,33 @@ export function createDecisionServer(options) {
1147
1227
  }
1148
1228
  return page(response, 200, loginPage(null, loginReturn(url.searchParams.get("return")), ssoOffer()));
1149
1229
  }
1230
+ // The one-time link `up` opens (onboarding): it signs the browser on this computer in and lands on Chat. Spent on
1231
+ // the first visit whatever happens; the ledger notes the sign-in, never the link.
1232
+ if (url.pathname.startsWith(SIGN_IN_LINK_PATH) && method === "GET") {
1233
+ const code = url.pathname.slice(SIGN_IN_LINK_PATH.length);
1234
+ const held = /^[A-Za-z0-9_-]{43}$/.test(code) ? signInLinks.get(linkKey(code)) : undefined;
1235
+ if (held !== undefined)
1236
+ signInLinks.delete(linkKey(code));
1237
+ response.setHeader("Referrer-Policy", "no-referrer");
1238
+ if (!fromThisComputer(request)) {
1239
+ recordSignIn(held?.account ?? "unknown account", "sign-in refused", "one-time link", "opened from another device");
1240
+ 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()));
1241
+ }
1242
+ if (held === undefined || held.expires < Date.now()) {
1243
+ 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()));
1244
+ }
1245
+ const account = store.accountOf(held.account);
1246
+ if (account === null || account.role !== "approver" || account.revokedAt !== null)
1247
+ return page(response, 403, loginPage("That account can't sign in here any more.", "/chat", ssoOffer()));
1248
+ if (!passwordAllowed(held.account))
1249
+ return page(response, 403, loginPage(`Sign in with ${ssoSettings()?.label ?? "your identity provider"}.`, "/chat", ssoOffer()));
1250
+ const id = randomBytes(32).toString("hex");
1251
+ 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(),
1252
+ project: defaultProject, projectRevision: 1 });
1253
+ recordSignIn(held.account, "signed in", "one-time link");
1254
+ response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
1255
+ return redirect(response, "/chat");
1256
+ }
1150
1257
  // v100: sign-in with the identity provider. ?reauth=1 asks it to check the person again (a step-up);
1151
1258
  // ?link=1 links the signed-in account to it. The browser that starts it holds the visit's state.
1152
1259
  if (url.pathname === "/login/sso" && method === "GET") {
@@ -1426,7 +1533,7 @@ export function createDecisionServer(options) {
1426
1533
  if (method === "GET" || method === "POST")
1427
1534
  return freshIdentitySignIn.run({ actor: requestFacts.sso?.fresh === true ? who.name : null }, () => requestContext.run(requestFacts, async () => {
1428
1535
  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;
1536
+ 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
1537
  const target = actionTarget(url, who, request, body);
1431
1538
  const execute = async () => {
1432
1539
  if (!projectRequestAllowed(url, who, request, response))
@@ -1591,8 +1698,8 @@ export function createDecisionServer(options) {
1591
1698
  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
1699
  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
1700
  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);
1701
+ ? /^\/(?: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)
1702
+ : /^\/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
1703
  if (!(request.method === "GET" ? read : write).has(path) && task === null && !resource) {
1597
1704
  refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects");
1598
1705
  return false;
@@ -1617,6 +1724,20 @@ export function createDecisionServer(options) {
1617
1724
  return true;
1618
1725
  }
1619
1726
  // ---- reads ---------------------------------------------------------------
1727
+ /** A gallery template's page (Flows → New → Use this): its questions, the preview of these answers, and Create. */
1728
+ function sendGalleryUse(response, who, template, projects, repo, given, name, notice) {
1729
+ const answers = given ?? galleryDefaults(store, template, repo);
1730
+ let preview = null, problem = notice;
1731
+ try {
1732
+ preview = previewGallery(store, template, repo, answers, who.name, clock());
1733
+ }
1734
+ catch (error) {
1735
+ problem = error instanceof Error ? error.message : "That can't be made here.";
1736
+ }
1737
+ const html = galleryUseHtml({ template, projects: projects.map(path => ({ path, name: projectName(path) })), repo, answers, name, preview, problem,
1738
+ csrf: who.via === "cookie" ? who.session.csrf : "", diagram: preview?.built.definition ?? galleryDiagram(template) });
1739
+ 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") }));
1740
+ }
1620
1741
  async function handleGet(url, who, request, response) {
1621
1742
  const now = clock();
1622
1743
  let project = projectOf(who, request);
@@ -1716,7 +1837,9 @@ export function createDecisionServer(options) {
1716
1837
  url.pathname !== "/menu" &&
1717
1838
  url.pathname !== "/recipes" &&
1718
1839
  // 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) &&
1840
+ 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) &&
1841
+ // The gallery (Flows → New) asks for its project on each template's page.
1842
+ url.pathname !== "/flows/new" && !/^\/flows\/new\/[a-z-]{1,40}$/.test(url.pathname) &&
1720
1843
  // Teammates (v92) name their own project, like flows.
1721
1844
  !url.pathname.startsWith("/teammates") &&
1722
1845
  // New work names its project in the form (a dropdown of known
@@ -1729,6 +1852,7 @@ export function createDecisionServer(options) {
1729
1852
  url.pathname !== "/projects/browse" && url.pathname !== "/projects/github" && url.pathname !== "/workbench" &&
1730
1853
  url.pathname !== "/fleet" &&
1731
1854
  url.pathname !== "/chat" && url.pathname !== "/chat/mate/status" && url.pathname !== "/chat/task-status" && url.pathname !== "/chat/stream" &&
1855
+ url.pathname !== "/lead/status" && url.pathname !== "/onboarding/phone/dismiss" &&
1732
1856
  !url.pathname.startsWith("/chat/demo/") &&
1733
1857
  !/^\/chat\/action\/[0-9]{1,15}$/.test(url.pathname) &&
1734
1858
  !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 +2005,7 @@ export function createDecisionServer(options) {
1881
2005
  const cancelled = store
1882
2006
  .listCancelledBlockersScoped(project, 10, admission)
1883
2007
  .filter(one => visible(one.repo) && visible(one.blockerRepo));
1884
- return sendScreen(response, 200, inboxPage(chromeFor(project, "inbox"), {
2008
+ const inboxData = {
1885
2009
  csrf: who.via === "cookie" ? who.session.csrf : "",
1886
2010
  revision: who.via === "cookie" ? who.session.projectRevision : 0,
1887
2011
  rollup,
@@ -1923,7 +2047,26 @@ export function createDecisionServer(options) {
1923
2047
  return { answering: answering.length, registered: runners.length, lastHeard };
1924
2048
  })(),
1925
2049
  now,
1926
- }));
2050
+ // Console v2: results ready to review and work running now, from the same admitted work index.
2051
+ ...(() => {
2052
+ const row = (one) => ({ taskId: one.rootId, title: one.title, detail: one.status.detail, repo: one.repo });
2053
+ const needsYou = workIndexPage(store, now, workAccess(), { view: "needs-you", limit: 40, project });
2054
+ const runningNow = workIndexPage(store, now, workAccess(), { view: "running", limit: 40, project });
2055
+ return { ready: needsYou.items.filter(one => one.assignmentState === "ready-to-check").map(row), running: runningNow.items.map(row) };
2056
+ })(),
2057
+ };
2058
+ // Which tabs hold something new since this browser last looked (the dots on a phone).
2059
+ const tab = parseInboxTab(url.searchParams.get("tab")) ?? "all";
2060
+ const prints = inboxFingerprints(inboxData);
2061
+ const seenWords = /(?:^|;\s*)so-inbox-seen=([0-9a-f]{8}(?:\.[0-9a-f]{8}){3})(?:;|$)/.exec(request.headers.cookie ?? "")?.[1]?.split(".") ?? null;
2062
+ const order = ["needs-you", "ready", "running", "all"];
2063
+ const seen = Object.fromEntries(order.map((one, index) => [one, seenWords?.[index] ?? ""]));
2064
+ const unread = order.filter(one => seenWords !== null && seen[one] !== prints[one]);
2065
+ for (const one of order)
2066
+ if (tab === "all" || one === tab)
2067
+ seen[one] = prints[one];
2068
+ response.setHeader("Set-Cookie", `so-inbox-seen=${order.map(one => seen[one] || prints[one]).join(".")}; SameSite=Lax; Path=/; Max-Age=31536000`);
2069
+ return sendScreen(response, 200, inboxPage(chromeFor(project, "inbox"), { ...inboxData, tab, unread }));
1927
2070
  }
1928
2071
  if (url.pathname === "/work") {
1929
2072
  // The Work destination (workspace package 1): every task in view as
@@ -2598,7 +2741,7 @@ export function createDecisionServer(options) {
2598
2741
  }
2599
2742
  : null;
2600
2743
  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));
2744
+ 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
2745
  }
2603
2746
  if (url.pathname === "/queue") {
2604
2747
  const csrf = who.via === "cookie" ? who.session.csrf : "";
@@ -2942,10 +3085,31 @@ export function createDecisionServer(options) {
2942
3085
  }
2943
3086
  return sendScreen(response, 200, capsPage(chromeFor(project, "caps"), store.listCapabilities(project), computeGaps(store, project, now), project, now));
2944
3087
  }
3088
+ // Flows → New: the gallery, and each template's page.
3089
+ if (url.pathname === "/flows/new") {
3090
+ const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3091
+ const canUse = who.via === "cookie" && who.role === "approver" && projects.length > 0;
3092
+ const repo = url.searchParams.get("repo");
3093
+ 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") }));
3094
+ }
3095
+ const galleryRead = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
3096
+ if (galleryRead !== null) {
3097
+ const template = galleryTemplateOf(galleryRead[1]);
3098
+ const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3099
+ if (template === null)
3100
+ return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
3101
+ if (who.via !== "cookie" || who.role !== "approver")
3102
+ return refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows/new");
3103
+ if (projects.length === 0)
3104
+ return refuse(response, who, 409, "Add a project first.", "/projects");
3105
+ const asked = url.searchParams.get("repo");
3106
+ const repo = asked !== null && projects.includes(asked) ? asked : project !== null && projects.includes(project) ? project : projects[0];
3107
+ return sendGalleryUse(response, who, template, projects, repo, null, template.name, null);
3108
+ }
2945
3109
  if (url.pathname === "/flows") {
2946
3110
  const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
2947
3111
  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") }));
3112
+ 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
3113
  }
2950
3114
  // starter kits — the gallery, and each kit's checklist in a project.
2951
3115
  if (url.pathname === "/kits" || /^\/kits\/[a-z-]{1,40}$/.test(url.pathname)) {
@@ -2991,6 +3155,22 @@ export function createDecisionServer(options) {
2991
3155
  return json(404, { said: "No such run." });
2992
3156
  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
3157
  }
3158
+ const flowExport = /^\/flows\/([1-9][0-9]{0,9})\/export$/.exec(url.pathname);
3159
+ if (flowExport !== null) {
3160
+ // A flow as a file (flow-share.ts): zones, paths, trigger settings and scripts; never secrets, addresses, names or cards.
3161
+ const flow = store.getFlow(Number(flowExport[1]));
3162
+ if (flow === null || flow.state !== "active" || !visible(flow.repo))
3163
+ return refuse(response, who, 404, "No such flow in your projects.", "/flows");
3164
+ let exported;
3165
+ try {
3166
+ exported = exportFlow(store, flow, options.configDir ?? null);
3167
+ }
3168
+ catch (error) {
3169
+ return refuse(response, who, 409, error instanceof Error ? error.message : "This flow can't be exported.", `/flows/${flow.id}`);
3170
+ }
3171
+ response.setHeader("Content-Disposition", `attachment; filename="${exported.fileName}"`);
3172
+ return respond(response, 200, "application/json; charset=utf-8", exported.json);
3173
+ }
2994
3174
  const flowLive = /^\/flows\/([1-9][0-9]{0,9})\/live$/.exec(url.pathname);
2995
3175
  if (flowLive !== null) {
2996
3176
  // The live canvas (v88): a nudge the moment the flow changes, and who
@@ -3427,7 +3607,11 @@ export function createDecisionServer(options) {
3427
3607
  }
3428
3608
  // The first run belongs to the lead conversation, for someone who can act on it.
3429
3609
  const firstRun = focusTask === null && who.role === "approver" ? chatFirstRun(now, chatProject) : undefined;
3430
- const withFirstRun = (shown) => firstRun === undefined ? shown : { ...shown, workspace: { ...shown.workspace, firstRun } };
3610
+ const phone = focusTask === null && who.via === "cookie" ? phoneCard(who, now) : undefined;
3611
+ // Console v2: the landing's live view (who is working, four counts, Catch up in tabs).
3612
+ const home = focusTask === null && chatProject === null && !roomId ? chatHomeOf(who, repos, now) : null;
3613
+ const withFirstRun = (shown) => firstRun === undefined && phone === undefined && home === null ? shown
3614
+ : { ...shown, workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
3431
3615
  if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
3432
3616
  {
3433
3617
  const said = takeMateNote(who.session.csrf, mateSession.id);
@@ -3454,6 +3638,7 @@ export function createDecisionServer(options) {
3454
3638
  }
3455
3639
  return sendScreen(response, 200, withFirstRun(chatPage(chromeFor(null, "chat", undefined, "all"), {
3456
3640
  settingsOpen: settingsView,
3641
+ firstRunShown: firstRun !== undefined,
3457
3642
  enabled,
3458
3643
  pending,
3459
3644
  latched,
@@ -3676,7 +3861,9 @@ export function createDecisionServer(options) {
3676
3861
  repo: chosen, projects: projects.map(path => ({ path, name: projectName(path) })), starters: startersFor(store, chosen), csrf: who.via === "cookie" ? who.session.csrf : "",
3677
3862
  canSwitch: who.via === "cookie" && who.role === "approver" && !store.isDemo(), suggested: starterOf(url.searchParams.get("starter") ?? "")?.id ?? null,
3678
3863
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem")
3679
- });
3864
+ }) +
3865
+ // Every template, beside the starters: Use this opens its page for this project.
3866
+ `<h2 class="gallery-heading">Templates</h2>${galleryHtml({ repo: chosen, canUse: who.via === "cookie" && who.role === "approver" && !store.isDemo() })}`;
3680
3867
  return sendScreen(response, 200, screen("Flows", `<p><a href="/settings">Settings</a></p><h1>Flows</h1>${content}`, { chrome: chromeFor(chosen || project, "settings") }));
3681
3868
  }
3682
3869
  // Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
@@ -3712,7 +3899,7 @@ export function createDecisionServer(options) {
3712
3899
  if (url.pathname === "/settings/retention") {
3713
3900
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
3714
3901
  return refuse(response, who, 403, "An instance operator sets retention.", "/settings");
3715
- const view = { periods: store.retentionPeriods(), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
3902
+ const view = { periods: store.retentionPeriods(), chosen: Object.keys(store.retentionChosen()), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
3716
3903
  return sendScreen(response, 200, screen("Retention", `<p><a href="/settings">Settings</a></p><h1>Retention</h1>${retentionHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3717
3904
  }
3718
3905
  // Settings → Storage: what task checkouts use, cleaning up now (the preview behind the password), and when a
@@ -3738,6 +3925,19 @@ export function createDecisionServer(options) {
3738
3925
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
3739
3926
  return sendScreen(response, 200, screen("Pull requests", `<p><a href="/projects">Projects</a></p><h1>Pull requests</h1>${html}`, { chrome: chromeFor(project, "projects") }));
3740
3927
  }
3928
+ // Settings → Projects → Checks: how much checks after each build, and the quick command beside the full one.
3929
+ if (url.pathname === "/settings/checks") {
3930
+ const repo = url.searchParams.get("repo") ?? "";
3931
+ const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
3932
+ if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
3933
+ return refuse(response, who, 404, "No such project.", "/projects");
3934
+ const quick = liveQuickCommand(store, repo);
3935
+ const html = checkSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange: who.role === "approver" && !store.isDemo(),
3936
+ level: projectCheckLevel(store, repo).level, full: store.liveVerifyCommand(repo), quick, suggestion: quick === null ? suggestQuickCommand(repo) : null,
3937
+ review: (({ on, source }) => ({ on, source }))(store.reviewSwitch(repo, clock())),
3938
+ said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
3939
+ return sendScreen(response, 200, screen("Checks", `<p><a href="/projects">Projects</a></p><h1>Checks</h1>${html}`, { chrome: chromeFor(project, "projects") }));
3940
+ }
3741
3941
  // Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
3742
3942
  if (url.pathname === "/settings/backups") {
3743
3943
  const databaseFile = store.databaseFile();
@@ -3831,6 +4031,27 @@ export function createDecisionServer(options) {
3831
4031
  }
3832
4032
  return sendScreen(response, 200, screen("Learning", `<p><a href="/settings">Settings</a></p><h1>Learning</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings") }));
3833
4033
  }
4034
+ // Settings → Lead (onboarding): what runs the lead in one line, and the full form under Advanced.
4035
+ if (url.pathname === "/settings/lead") {
4036
+ if (who.via !== "cookie" || who.role !== "approver")
4037
+ return refuse(response, who, 403, "An approver sets up the lead.", "/settings");
4038
+ if (localSignIn === null)
4039
+ await Promise.race([checkLocalAgents(), new Promise(done => setTimeout(done, 6_000).unref?.())]);
4040
+ const config = store.getChatConfig();
4041
+ const facts = {
4042
+ keyFacts: ["anthropic-api", "openrouter-api"].map(one => {
4043
+ const found = chatKeyFor(one);
4044
+ return { provider: one, state: found === null ? "none" : found.source, tail: found === null || found.source === "environment" ? null : redactToken(found.key) };
4045
+ }),
4046
+ openrouterModels: (await chatCatalog())?.map(one => one.id) ?? null,
4047
+ liveModels: [...modelOptions(store, "claude", now, providerHome), ...modelOptions(store, "codex", now, providerHome)],
4048
+ csrf: who.session.csrf,
4049
+ returnTo: "/settings/lead",
4050
+ };
4051
+ const signedIn = localSignIn?.states.claude === "connected" ? "Claude Code" : localSignIn?.states.codex === "connected" ? "Codex" : null;
4052
+ return sendScreen(response, 200, screen("Lead", leadSettingsHtml({ config, facts, words: leadWords(), signedIn, command: agentSignInCommand(),
4053
+ said: url.searchParams.get("said") }), { chrome: chromeFor(project, "settings") }));
4054
+ }
3834
4055
  if (url.pathname === "/settings/telegram") {
3835
4056
  // Any approver pairs their OWN phone here; the bot token stays on /settings.
3836
4057
  if (who.via !== "cookie" || who.role !== "approver")
@@ -3914,7 +4135,7 @@ export function createDecisionServer(options) {
3914
4135
  return at === null || since === null ? null : firstResultWords(since, at);
3915
4136
  })(), (() => {
3916
4137
  const chosen = store.notificationPreference(who.name);
3917
- return { mode: chosen.mode, digestAt: chosen.digestAt };
4138
+ return { mode: chosen.mode, digestAt: chosen.digestAt, projects: notificationProjects(store, who.name) };
3918
4139
  })()));
3919
4140
  }
3920
4141
  if (url.pathname === "/push/key") {
@@ -3939,6 +4160,18 @@ export function createDecisionServer(options) {
3939
4160
  const back = decisionReturn === null ? null : safeChatReturn(decisionReturn);
3940
4161
  return sendScreen(response, 200, decisionPage(chromeFor(project, "none"), decision, taskId, store.evidenceFor(decision.id), who, now, back));
3941
4162
  }
4163
+ // While no agent is signed in, Chat asks this computer again on its own (onboarding): a fresh, non-spending sign-in
4164
+ // check at most every few seconds, and the lead turns on as soon as one is found.
4165
+ if (url.pathname === "/lead/status") {
4166
+ if (who.via !== "cookie")
4167
+ return respond(response, 403, "application/json", JSON.stringify({ error: "sign in" }));
4168
+ if (Date.now() - leadRecheckedAt > 4_000) {
4169
+ leadRecheckedAt = Date.now();
4170
+ await Promise.race([checkLocalAgents(true), new Promise(done => setTimeout(done, 8_000).unref?.())]);
4171
+ }
4172
+ response.setHeader("cache-control", "no-store");
4173
+ return respond(response, 200, "application/json", JSON.stringify({ lead: store.getChatConfig() === null ? "off" : "on", agent: localSignIn?.signedIn ?? null, command: agentSignInCommand() }));
4174
+ }
3942
4175
  if (url.pathname === "/chat/task-status") {
3943
4176
  if (who.via !== "cookie")
3944
4177
  return respond(response, 403, "text/plain; charset=utf-8", "sign in to see this task");
@@ -3964,7 +4197,7 @@ export function createDecisionServer(options) {
3964
4197
  function firstRunStepsNow(now) {
3965
4198
  if (restricted() || store.isDemo() || store.firstSuccessAt(now) !== null)
3966
4199
  return null;
3967
- return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null });
4200
+ return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null, signInCommand: agentSignInCommand() });
3968
4201
  }
3969
4202
  /**
3970
4203
  * Whether a coding agent is signed in where the work runs. This machine's
@@ -3984,17 +4217,54 @@ export function createDecisionServer(options) {
3984
4217
  return options.localRunner !== undefined || registered.length === 0 || workers.some(one => one.host === here) ? localAgentSignedIn() : false;
3985
4218
  }
3986
4219
  let localSignIn = null;
3987
- let localSignInChecking = false;
4220
+ let localSignInChecking = null;
4221
+ let leadRecheckedAt = 0;
4222
+ /** Ask this computer's agent CLIs again (no model, nothing spent); `fresh` skips their 30-second cache. */
4223
+ function checkLocalAgents(fresh = false) {
4224
+ if (localSignInChecking !== null)
4225
+ return localSignInChecking;
4226
+ localSignInChecking = Promise.all(["claude", "codex"].map(one => connectionCheck(one, fresh).then(value => value.state, () => "unverified")))
4227
+ .then(([claude, codex]) => {
4228
+ localSignIn = { at: Date.now(), signedIn: [claude, codex].some(state => state === "connected" || state === "key-works" || state === "key-present"), states: { claude: claude, codex: codex } };
4229
+ leadByDefault();
4230
+ })
4231
+ .catch(() => { })
4232
+ .finally(() => { localSignInChecking = null; });
4233
+ return localSignInChecking;
4234
+ }
3988
4235
  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
- }
4236
+ if (localSignInChecking === null && (localSignIn === null || Date.now() - localSignIn.at > 30_000))
4237
+ void checkLocalAgents();
3996
4238
  return localSignIn?.signedIn ?? null;
3997
4239
  }
4240
+ /**
4241
+ * The lead on by default (onboarding): with no lead set up yet, the agent CLI signed in on this computer runs it
4242
+ * (Claude Code first, else Codex) on its membership with today's safe defaults: the CLI's own default model, 50 turns
4243
+ * a day, no dollar spend, and every action it proposes still a card a person confirms. Once only, ever: a lead
4244
+ * someone turned off stays off, and Settings → Lead changes it.
4245
+ */
4246
+ function leadByDefault() {
4247
+ if (options.leadByDefault !== true || store.isDemo() || store.getChatConfig() !== null || store.installationFact(LEAD_BY_DEFAULT_FACT) !== null)
4248
+ return;
4249
+ const provider = localSignIn?.states.claude === "connected" ? "claude-subscription" : localSignIn?.states.codex === "connected" ? "codex-subscription" : null;
4250
+ if (provider === null)
4251
+ return;
4252
+ const now = clock();
4253
+ store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, "toolroll", now);
4254
+ store.recordInstallationFact(LEAD_BY_DEFAULT_FACT, provider, now);
4255
+ }
4256
+ // Look once at start, so the lead is on by the time the browser opens.
4257
+ if (options.leadByDefault === true && !store.isDemo() && store.getChatConfig() === null)
4258
+ void checkLocalAgents();
4259
+ /** What the lead runs on, in words for one line ("your Claude Code sign-in"); null for a direct API or no lead. */
4260
+ function leadWords() {
4261
+ const config = store.getChatConfig();
4262
+ return config?.provider === "claude-subscription" ? "your Claude Code sign-in" : config?.provider === "codex-subscription" ? "your Codex sign-in" : null;
4263
+ }
4264
+ /** 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. */
4265
+ function agentSignInCommand() {
4266
+ return signInCommandFor(localSignIn?.states ?? null, options.platform ?? process.platform);
4267
+ }
3998
4268
  /** First tasks per project: the last ones found (safe generic ones until then), re-read in the background at most
3999
4269
  * every ten minutes. A render never waits on `gh` or `git grep`. */
4000
4270
  const firstTasks = new Map();
@@ -4018,10 +4288,43 @@ export function createDecisionServer(options) {
4018
4288
  return undefined;
4019
4289
  const taskFiled = steps.find(one => one.key === "task")?.done ?? true;
4020
4290
  const repo = project ?? managedRepos()[0] ?? null;
4291
+ const agent = steps.find(one => one.key === "agent");
4292
+ const lead = leadWords();
4021
4293
  return {
4022
4294
  steps,
4023
4295
  suggestions: taskFiled || repo === null ? [] : firstTasksFor(repo),
4024
- sandbox: steps.find(one => one.key === "agent" && !one.done && !one.checking) ? SANDBOX_COMMAND : null,
4296
+ sandbox: agent !== undefined && !agent.done && !agent.checking ? SANDBOX_COMMAND : null,
4297
+ intro: HOW_IT_WORKS,
4298
+ lead: lead === null ? null : { words: `The lead uses ${lead}`, href: "/settings/lead" },
4299
+ // With no lead yet and no agent signed in, the page asks this computer again on its own.
4300
+ recheck: store.getChatConfig() === null && agent !== undefined && !agent.done ? "/lead/status" : null,
4301
+ };
4302
+ }
4303
+ /** The first task's way to Ready shows until the first Ready result, and stays on the installation's first task. */
4304
+ function guided(rootId, now) {
4305
+ if (store.isDemo())
4306
+ return false;
4307
+ if (store.firstSuccessAt(now) === null)
4308
+ return true;
4309
+ const first = store.handle.prepare("SELECT id FROM task ORDER BY created_at, rowid LIMIT 1").get();
4310
+ return first !== undefined && String(first["id"]) === rootId;
4311
+ }
4312
+ /** After the first Ready result, once (onboarding): the phone, by a chat app or the console over Tailscale. */
4313
+ function phoneCard(who, now) {
4314
+ if (who.role !== "approver" || store.isDemo() || store.firstSuccessAt(now) === null || store.installationFact(PHONE_CARD_FACT) !== null)
4315
+ return undefined;
4316
+ const botId = options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile)?.botId ?? null;
4317
+ if (botId !== null && store.liveTelegramBindings(botId).length > 0)
4318
+ return undefined;
4319
+ const port = servedPort() ?? 4180;
4320
+ const address = server.address();
4321
+ const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
4322
+ const name = tailnet[0] ?? null;
4323
+ const reachable = bound !== "127.0.0.1" && bound !== "::1";
4324
+ return {
4325
+ chatApps: [{ label: "Telegram", href: "/settings/telegram" }, { label: "Slack", href: "/settings/slack" }, { label: "Discord", href: "/settings/discord" }, { label: "Teams", href: "/settings/teams" }],
4326
+ tailnet: name === null ? null : { address: `http://${name}:${port}/`, restart: reachable ? null : consoleCommand({ host: "0.0.0.0" }) },
4327
+ dismissHref: "/onboarding/phone/dismiss",
4025
4328
  };
4026
4329
  }
4027
4330
  // ---- fleet chat (v13) ----------------------------------------------------
@@ -4219,7 +4522,7 @@ export function createDecisionServer(options) {
4219
4522
  return { ok: false, code: "empty", why: "add a project first — chat will include it automatically" };
4220
4523
  const config = store.getChatConfig();
4221
4524
  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" };
4525
+ 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
4526
  if (isSubscriptionChatProvider(config.provider)) {
4224
4527
  return {
4225
4528
  ok: true,
@@ -4643,6 +4946,8 @@ export function createDecisionServer(options) {
4643
4946
  ...(s.chrome.signIn === undefined ? {} : { signIn: s.chrome.signIn }),
4644
4947
  ...(s.chrome.update === undefined ? {} : { update: s.chrome.update }),
4645
4948
  ...(extras.firstRun === undefined ? {} : { firstRun: extras.firstRun }),
4949
+ ...(extras.phone === undefined ? {} : { phone: extras.phone }),
4950
+ ...(extras.home == null ? {} : { home: extras.home }),
4646
4951
  };
4647
4952
  if (requestFacts.workspaceRead) {
4648
4953
  const validator = requestFacts.workspaceValidator;
@@ -4696,6 +5001,38 @@ export function createDecisionServer(options) {
4696
5001
  * package 1): Work's own Needs-you membership, cached per viewer and
4697
5002
  * project, so the rail, the phone tab, the scope bar, the project cards,
4698
5003
  * the chat overview, and the Needs-you tab never disagree. */
5004
+ /** The Chat landing's live view: each agent at work now with its task and
5005
+ * phase, four counts, plan-window use (never dollars: subscriptions don't
5006
+ * bill per run) and Catch up's items, each tagged with its tab. Reads only. */
5007
+ function chatHomeOf(who, repos, now) {
5008
+ const access = { principal: "operator", repos: admissionList(), includeUnplaced: visible(null) };
5009
+ const admitted = (repo) => repo === null ? visible(null) : repos.includes(repo);
5010
+ const agents = store.liveRuns(now).filter(run => admitted(run.repo)).map(run => {
5011
+ const root = familyOf(run.taskId)?.root ?? null;
5012
+ return { runId: run.id, taskId: root?.id ?? run.taskId, title: root?.title ?? run.title, href: taskHref(root?.id ?? run.taskId),
5013
+ agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt };
5014
+ });
5015
+ const all = workIndexPage(store, now, access, { view: "all", limit: WORK_INDEX_MAX_LIMIT });
5016
+ const needs = workIndexPage(store, now, access, { view: "needs-you", limit: WORK_INDEX_MAX_LIMIT });
5017
+ const done = workIndexPage(store, now, access, { view: "completed", limit: WORK_INDEX_MAX_LIMIT });
5018
+ const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
5019
+ const counts = [
5020
+ { key: "working", label: "Working now", value: agents.length, href: "/work?view=running" },
5021
+ { key: "waiting", label: "Waiting on you", value: needs.items.filter(one => one.assignmentState === "needs-decision").length, href: "/work?view=needs-you" },
5022
+ { key: "ready", label: "Ready to review", value: needs.items.filter(one => one.assignmentState === "ready-to-check").length, href: "/work?view=needs-you" },
5023
+ { key: "done", label: "Done this week", value: done.items.filter(one => (one.completion?.at ?? one.updatedAt) >= weekAgo).length, href: "/work?view=completed" },
5024
+ ];
5025
+ const tabOf = (state) => state === "needs-decision" ? "needs-you" : state === "ready-to-check" ? "ready" : state === "working" || state === "checking" ? "running" : "finished";
5026
+ const catchUp = all.items.filter(one => tabOf(one.assignmentState) !== "finished" || one.updatedAt >= weekAgo).slice(0, 40).map(one => ({
5027
+ id: one.rootId, title: one.title, href: taskHref(one.rootId), project: one.repo === null ? null : projectName(one.repo), tab: tabOf(one.assignmentState),
5028
+ label: one.status.label, tone: one.status.tone, detail: one.status.detail, at: one.updatedAt,
5029
+ }));
5030
+ const windows = who.via === "cookie" && store.isInstanceOperator(who.name) ? limitsView(store.providerLimits(), [], { project: projectName, teammate: id => `Teammate ${id}` }, now) : null;
5031
+ return {
5032
+ agents, counts, catchUp, allHref: "/work",
5033
+ planUse: (windows?.tiles ?? []).map(one => ({ name: one.name, window: one.window, percent: one.percent, detail: one.detail, tone: one.tone })),
5034
+ };
5035
+ }
4699
5036
  function needsYouBadge(project) {
4700
5037
  const actor = requestContext.getStore()?.actor;
4701
5038
  const key = `${actor ?? ""}:${actor === undefined ? "" : store.accountOf(actor)?.generation}:${project ?? ""}`;
@@ -4852,7 +5189,7 @@ export function createDecisionServer(options) {
4852
5189
  const reviewFactsFor = (runId) => reviewFactsOf(store.reviewRetryStateOf(runId), runner => {
4853
5190
  const one = store.getRunner(runner)?.runner;
4854
5191
  return one !== undefined && runnerAlive(one, clock());
4855
- });
5192
+ }, buildReviewOf(store, runId)?.state === "pending");
4856
5193
  /** Keep detailed receipt diagnostics; shared assignment reads own readiness. */
4857
5194
  function freshAssignment(assignment, receipt) {
4858
5195
  if (assignment === null)
@@ -4892,6 +5229,7 @@ export function createDecisionServer(options) {
4892
5229
  verdict: verdict?.verdict ?? null,
4893
5230
  reasons: verdict?.reasons ?? [],
4894
5231
  accepted: store.proofAcceptance(latest.id) !== null,
5232
+ checkLevel: resultCheckLevel(store, latest.id, now),
4895
5233
  review: reviewFactsFor(latest.id),
4896
5234
  ...(latest.outcome !== "no-change"
4897
5235
  ? {}
@@ -5019,7 +5357,7 @@ export function createDecisionServer(options) {
5019
5357
  peeks[one.path] = peekOf(one.path);
5020
5358
  for (const path of candidates)
5021
5359
  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));
5360
+ 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
5361
  }
5024
5362
  /** The task screen, shared by the GET and by every refusal that re-renders it. */
5025
5363
  /**
@@ -5142,6 +5480,7 @@ export function createDecisionServer(options) {
5142
5480
  const assignment = freshAssignment(recordedAssignment, completion?.receipt ?? null);
5143
5481
  const progressRun = runs.find(one => runIsLive(one))?.id ?? completion?.runId ?? null;
5144
5482
  return {
5483
+ guide: guided(family?.root.id ?? taskId, now),
5145
5484
  assignment,
5146
5485
  checkProgress: progressRun === null ? null : store.checkProgress(progressRun),
5147
5486
  task: found,
@@ -5433,6 +5772,7 @@ export function createDecisionServer(options) {
5433
5772
  id: family.root.id,
5434
5773
  executionId: task.id,
5435
5774
  family,
5775
+ guide: guided(family.root.id, now),
5436
5776
  history: familyHistory(family),
5437
5777
  title: family.root.title,
5438
5778
  state: task.state,
@@ -5524,6 +5864,12 @@ export function createDecisionServer(options) {
5524
5864
  if (cancelDraft !== undefined)
5525
5865
  presentedData.cancelDraft = cancelDraft;
5526
5866
  presentedData.pullRequest = taskPullRequestOf(taskId, who);
5867
+ // The thread reads the whole family: the original, its revisions, and every attempt across them.
5868
+ if (family !== null && family.problem === null) {
5869
+ presentedData.family = { root: { id: family.root.id, title: family.root.title, createdAt: family.root.createdAt, goal: store.getScope(family.root.id)?.goal ?? null },
5870
+ versions: family.versions.map(one => ({ id: one.id, title: one.title, state: one.state })),
5871
+ 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 }))) };
5872
+ }
5527
5873
  const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
5528
5874
  const page = taskPage(paneProject === null && !unscopedMode
5529
5875
  ? chromeFor(paneProject, "tasks")
@@ -5531,10 +5877,13 @@ export function createDecisionServer(options) {
5531
5877
  // The task's own conversation, docked beside the page (v77).
5532
5878
  const focus = taskChatFocus(family?.root.id ?? taskId, clock(), who, { mintNonce: false });
5533
5879
  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;
5880
+ // The conversation is the page's own thread here, so the page drops its Ask tab;
5881
+ // only an approver is offered a road to message the agent.
5882
+ const taskView = page.workspace?.view?.kind === "task" ? { ...page.workspace.view, ...(docked === null ? {} : { tabs: [] }), ...(who.role === "approver" ? {} : { chatHref: null }) } : page.workspace?.view;
5536
5883
  if (docked !== null)
5537
5884
  page.workspace = { ...page.workspace, ...(taskView === undefined ? {} : { view: taskView }), conversation: docked, pageHtml: page.body };
5885
+ else if (taskView !== undefined && page.workspace !== undefined)
5886
+ page.workspace = { ...page.workspace, view: taskView };
5538
5887
  return sendScreen(response, status, page);
5539
5888
  }
5540
5889
  /** The routine screen: the standing order restated, its verbs, its ledger. */
@@ -6357,14 +6706,45 @@ export function createDecisionServer(options) {
6357
6706
  return redirect(response, `/teammates?said=${encodeURIComponent(done.said)}`);
6358
6707
  return redirect(response, `${back}?${done.ok ? "said" : "problem"}=${encodeURIComponent(done.said)}${action === "note" ? "#memory" : ""}`);
6359
6708
  }
6709
+ // Flows → New → a template: preview these answers, or create exactly what was previewed.
6710
+ const galleryPost = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
6711
+ if (galleryPost !== null) {
6712
+ const template = galleryTemplateOf(galleryPost[1]);
6713
+ const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
6714
+ if (template === null)
6715
+ return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
6716
+ if (who.via !== "cookie" || who.role !== "approver" || store.isDemo())
6717
+ return refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows/new");
6718
+ const repo = body.get("repo") ?? "";
6719
+ if (!projects.includes(repo))
6720
+ return refuse(response, who, 404, "Choose one of your projects.", "/flows/new");
6721
+ const answers = Object.fromEntries(template.asks.map(ask => [ask.key, body.get(ask.key) ?? ""]));
6722
+ const name = (body.get("name") ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80) || template.name;
6723
+ if (body.get("intent") !== "create")
6724
+ return sendGalleryUse(response, who, template, projects, repo, answers, name, null);
6725
+ // What's made is what was previewed: changed answers are previewed again first.
6726
+ let digest = null;
6727
+ try {
6728
+ digest = previewGallery(store, template, repo, answers, who.name, clock()).digest;
6729
+ }
6730
+ catch {
6731
+ digest = null;
6732
+ }
6733
+ if (digest === null || digest !== body.get("previewed"))
6734
+ return sendGalleryUse(response, who, template, projects, repo, answers, name, digest === null ? null : "Your answers changed. Check the preview, then create the flow.");
6735
+ const used = useGalleryTemplate(store, template, repo, answers, { name, by: who.name, now: clock(), dir: options.configDir ?? null });
6736
+ if (!used.ok)
6737
+ return sendGalleryUse(response, who, template, projects, repo, answers, name, used.said);
6738
+ return redirect(response, `/flows/${used.flow}`);
6739
+ }
6360
6740
  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
6741
  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) {
6742
+ if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" || flowPost !== null || triggerPost !== null) {
6363
6743
  const now = clock();
6364
6744
  const answer = (status, payload) => respond(response, status, "application/json; charset=utf-8", JSON.stringify(payload));
6365
6745
  const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
6366
6746
  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." });
6747
+ 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
6748
  if (url.pathname === "/flows/example") {
6369
6749
  // A first look (v88): the Email replies template with one sample question, which Claude drafts a reply to straight away.
6370
6750
  const repo = body.get("repo") ?? "";
@@ -6375,6 +6755,52 @@ export function createDecisionServer(options) {
6375
6755
  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
6756
  return redirect(response, `/flows/${id}`);
6377
6757
  }
6758
+ if (url.pathname === "/flows/import") {
6759
+ // A flow file, chosen or fetched from a gist: previewed in plain words, then made with its triggers off and its scripts held.
6760
+ const repo = body.get("repo") ?? "";
6761
+ const back = (problem) => redirect(response, `/flows?problem=${encodeURIComponent(problem)}`);
6762
+ if (!projects.includes(repo))
6763
+ return back("Choose one of your projects.");
6764
+ let document = body.get("document") ?? "";
6765
+ const address = (body.get("url") ?? "").trim();
6766
+ let file;
6767
+ try {
6768
+ if (document.trim() === "" && address !== "")
6769
+ document = await fetchFlowFile(address, options.flowFetch);
6770
+ if (document.trim() === "")
6771
+ return back("Choose a flow file or give its address.");
6772
+ file = parseFlowFile(document);
6773
+ }
6774
+ catch (error) {
6775
+ if (error instanceof FlowFileError)
6776
+ return back(error.message);
6777
+ throw error;
6778
+ }
6779
+ const given = Object.fromEntries(file.parameters.flatMap(one => body.has(`param.${one.id}`) ? [[one.id, body.get(`param.${one.id}`) ?? ""]] : []));
6780
+ // What was previewed: the file and every value it resolved to (a default the first preview filled in included).
6781
+ const previewedOf = (plan) => createHash("sha256").update(`${document}\0${JSON.stringify(plan?.values ?? given)}`).digest("hex").slice(0, 32);
6782
+ 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") }));
6783
+ let plan;
6784
+ try {
6785
+ plan = planFlowImport(store, repo, file, given, who.name);
6786
+ }
6787
+ catch (error) {
6788
+ if (error instanceof FlowFileError)
6789
+ return page(null, error.message, 400);
6790
+ throw error;
6791
+ }
6792
+ if (body.get("confirm") === "yes") {
6793
+ if (body.get("previewed") !== previewedOf(plan))
6794
+ return page(plan, "What you filled in changed. Check the preview again, then import.", 409);
6795
+ try {
6796
+ return redirect(response, `/flows/${importFlow(store, plan, who.name, now, options.configDir ?? null).id}`);
6797
+ }
6798
+ catch (error) {
6799
+ return page(plan, error instanceof Error ? error.message : "That flow couldn't be imported.", 400);
6800
+ }
6801
+ }
6802
+ return page(plan, null, 200);
6803
+ }
6378
6804
  if (url.pathname === "/flows/new") {
6379
6805
  const repo = body.get("repo") ?? "";
6380
6806
  if (!projects.includes(repo))
@@ -6470,6 +6896,8 @@ export function createDecisionServer(options) {
6470
6896
  }
6471
6897
  if (action === "scripts") {
6472
6898
  // The project's script library, reached from any of its flows.
6899
+ if (body.get("approve") === "yes")
6900
+ 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
6901
  if (body.get("remove") === "yes")
6474
6902
  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
6903
  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 +7022,26 @@ export function createDecisionServer(options) {
6594
7022
  return back("said", `${label}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", API work stops at 100%" : ", alerts only"}.`);
6595
7023
  }
6596
7024
  // Delete a project, in two steps: its name, then what goes and the password. An instance operator; the ledger keeps who and what.
7025
+ // Settings → Project → Builds at once: an approver for the project sets it; the ledger keeps before → after.
7026
+ if (url.pathname === "/settings/project/concurrency") {
7027
+ const repo = body.get("repo") ?? "";
7028
+ const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
7029
+ if (!known.includes(repo))
7030
+ return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
7031
+ if (who.via !== "cookie" || who.role !== "approver" || !store.accountCanAccess(who.name, repo))
7032
+ return refuse(response, who, 403, "An approver for this project sets how many tasks build at once.", "/settings/project");
7033
+ const back = (key, words) => redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
7034
+ const n = parseProjectConcurrency(body.get("concurrency") ?? "");
7035
+ if (n === null)
7036
+ return back("problem", "Builds at once is a whole number from 1 to 64. Nothing changed.");
7037
+ const databaseFile = store.databaseFile();
7038
+ if (databaseFile === null)
7039
+ return back("problem", "This database keeps no settings files. Nothing changed.");
7040
+ const changed = saveProjectConcurrency(databaseFile, repo, n);
7041
+ store.recordProjectConcurrency(who.name, repo, changed.before, changed.after, now);
7042
+ store.bumpWake();
7043
+ return back("said", changed.before === changed.after ? "Nothing changed." : `Saved. Up to ${n} at once.`);
7044
+ }
6597
7045
  if (url.pathname === "/settings/project/delete") {
6598
7046
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
6599
7047
  return refuse(response, who, 403, "An instance operator deletes projects.", "/settings/project");
@@ -6818,7 +7266,9 @@ export function createDecisionServer(options) {
6818
7266
  if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
6819
7267
  return back("problem", "Enter your Toolroll password to change retention.");
6820
7268
  const before = store.retentionPeriods();
6821
- const chosen = RETENTION_KINDS.map(({ kind }) => ({ kind, days: body.has(kind) ? parsePeriod(body.get(kind) ?? "") : before[kind] }));
7269
+ // Only what the page offers (or the period already set): a hand-made value doesn't save.
7270
+ const offered = (kind, days) => days !== undefined && periodChoices(kind, before[kind]).includes(days) ? days : undefined;
7271
+ const chosen = RETENTION_KINDS.map(({ kind }) => ({ kind, days: body.has(kind) ? offered(kind, parsePeriod(body.get(kind) ?? "")) : before[kind] }));
6822
7272
  if (chosen.some(one => one.days === undefined))
6823
7273
  return back("problem", "Choose a period for each kind of data.");
6824
7274
  const changed = chosen.filter(one => one.days !== before[one.kind]);
@@ -6895,6 +7345,54 @@ export function createDecisionServer(options) {
6895
7345
  }
6896
7346
  return back("problem", "That change isn't available.");
6897
7347
  }
7348
+ // Checks for one project: the level, or the quick command (approved like `verify set --quick`). Each takes
7349
+ // the password and lands in the ledger.
7350
+ if (url.pathname === "/settings/checks") {
7351
+ const repo = body.get("repo") ?? "";
7352
+ const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
7353
+ if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
7354
+ return refuse(response, who, 404, "No such project.", "/projects");
7355
+ if (who.role !== "approver" || store.isDemo())
7356
+ return refuse(response, who, 403, "An approver sets a project's checks.", "/projects");
7357
+ const back = (key, words) => redirect(response, `/settings/checks?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
7358
+ if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
7359
+ return back("problem", "Enter your Toolroll password to change checks.");
7360
+ const act = body.get("act");
7361
+ // The automatic review switch: the policy log keeps before → after.
7362
+ if (act === "review") {
7363
+ const on = body.get("on") === "1";
7364
+ store.setReviewSwitch(repo, on, who.name, now);
7365
+ return back("said", on ? "Automatic review is on." : "Automatic review is off.");
7366
+ }
7367
+ if (act === "level") {
7368
+ const level = body.get("level");
7369
+ if (!isCheckLevel(level))
7370
+ return back("problem", "Choose Quick, Full or Off.");
7371
+ const changed = setProjectCheckLevel(store, repo, level, who.name, now);
7372
+ return back("said", changed.changed ? `Checks are ${CHECK_LEVEL_WORDS[level]}.` : "Saved.");
7373
+ }
7374
+ if (act === "quick") {
7375
+ const command = (body.get("command") ?? "").trim();
7376
+ const seconds = Number(body.get("timeout") ?? "180");
7377
+ if (command === "" || command.length > 2000 || hasDisguisedText(command))
7378
+ return back("problem", "Enter the quick command: one line of up to 2000 characters.");
7379
+ 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)) {
7380
+ return back("problem", "That command seems to hold a credential. Use an environment variable instead.");
7381
+ }
7382
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > 3600)
7383
+ return back("problem", "Choose a time limit from 1 to 3600 seconds.");
7384
+ store.setVerifyCommand({ repo: quickVerifyKey(repo), command, timeoutMs: seconds * 1000, approvedBy: who.name }, now);
7385
+ 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) });
7386
+ return back("said", "Quick check approved.");
7387
+ }
7388
+ if (act === "quick-clear") {
7389
+ const cleared = store.clearVerifyCommand(quickVerifyKey(repo), who.name, now);
7390
+ if (cleared)
7391
+ store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "quick check removed", outcome: "removed", source: "policy" });
7392
+ return back("said", cleared ? "Quick check removed. Quick builds run the full check." : "There was no quick check.");
7393
+ }
7394
+ return back("problem", "That change isn't available.");
7395
+ }
6898
7396
  // Sprint 8: back up now. An instance operator; the outcome is kept like a scheduled one's.
6899
7397
  if (url.pathname === "/settings/backups/now") {
6900
7398
  const databaseFile = store.databaseFile();
@@ -7543,6 +8041,16 @@ export function createDecisionServer(options) {
7543
8041
  : after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
7544
8042
  return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
7545
8043
  }
8044
+ if (url.pathname === "/settings/notifications/mute") {
8045
+ // Each person's own pings: a muted project stays in Tasks and the evening digest.
8046
+ const repo = body.get("repo") ?? "";
8047
+ const project = notificationProjects(store, who.name).find(one => one.repo === repo);
8048
+ if (project === undefined)
8049
+ return refuse(response, who, 400, "that project isn't one you can see", "/settings");
8050
+ const muted = body.get("pings") !== "on";
8051
+ store.setProjectMuted(who.name, repo, muted, now);
8052
+ 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`);
8053
+ }
7546
8054
  if (url.pathname === "/settings/telegram-digest" && options.telegramTokenFile !== undefined) {
7547
8055
  // The cadence is a closed list of minutes — never a free number from
7548
8056
  // a form; "off" clears it. Any approver session may set it.
@@ -7750,7 +8258,7 @@ export function createDecisionServer(options) {
7750
8258
  // under a ceiling. Unscoped mode keeps its historic unplaced filings.
7751
8259
  if (!unscopedMode && effective === "") {
7752
8260
  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));
8261
+ 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
8262
  }
7755
8263
  let repo = effective;
7756
8264
  let admitted = unscopedMode ? null : admissionList() ?? [];
@@ -7759,7 +8267,7 @@ export function createDecisionServer(options) {
7759
8267
  const canonical = (await authorizedProject(liveCeiling(), effective)) ? canonicalProject(effective) : null;
7760
8268
  if (canonical === null || canonical === undefined) {
7761
8269
  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));
8270
+ 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
8271
  }
7764
8272
  repo = canonical;
7765
8273
  admitted = [canonical];
@@ -7816,7 +8324,7 @@ export function createDecisionServer(options) {
7816
8324
  });
7817
8325
  if (!made.ok) {
7818
8326
  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));
8327
+ 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
8328
  }
7821
8329
  const actionContext = requestContext.getStore();
7822
8330
  if (actionContext !== undefined)
@@ -8291,7 +8799,7 @@ export function createDecisionServer(options) {
8291
8799
  if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
8292
8800
  return taskScreen(response, who, act.taskId, "That pull request isn't part of this task.", 409);
8293
8801
  }
8294
- const merged = await mergeAsPerson(store, { runId: run.id, name: who.name, password: body.get("token") ?? "", ...(options.publishExec === undefined ? {} : { exec: options.publishExec }), clock });
8802
+ 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
8803
  if (!merged.ok)
8296
8804
  return taskScreen(response, who, act.taskId, merged.message, merged.reason === "password" ? 403 : 409);
8297
8805
  return redirect(response, `${taskHref(act.taskId)}#merge`);
@@ -8475,6 +8983,24 @@ export function createDecisionServer(options) {
8475
8983
  store.retirePushSubscription(mine.id, `removed by ${who.name}`, clock());
8476
8984
  return redirect(response, `/settings?said=${encodeURIComponent("removed — that device stops receiving pushes")}`);
8477
8985
  }
8986
+ if (url.pathname === "/onboarding/phone/dismiss") {
8987
+ if (who.via !== "cookie" || who.role !== "approver")
8988
+ return refuse(response, who, 403, "An approver puts this away.", "/chat");
8989
+ store.recordInstallationFact(PHONE_CARD_FACT, who.name, now);
8990
+ return body.get("quiet") === "1" ? respond(response, 204, "text/plain; charset=utf-8", "") : redirect(response, "/chat");
8991
+ }
8992
+ // Turn the lead on with the agent signed in on this computer: its membership spends no dollars, so like starting a
8993
+ // conversation it needs no second password; the full form (Advanced) still asks for one.
8994
+ if (url.pathname === "/settings/lead/on") {
8995
+ if (who.via !== "cookie" || who.role !== "approver")
8996
+ return refuse(response, who, 403, "An approver turns the lead on.", "/settings/lead");
8997
+ await checkLocalAgents(true);
8998
+ const provider = localSignIn?.states.claude === "connected" ? "claude-subscription" : localSignIn?.states.codex === "connected" ? "codex-subscription" : null;
8999
+ if (provider === null)
9000
+ return redirect(response, chatReturnWithSaid("/settings/lead", `No agent is signed in on this computer yet. Run ${agentSignInCommand()}, then try again.`));
9001
+ store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, who.name, now);
9002
+ return redirect(response, "/chat");
9003
+ }
8478
9004
  if (url.pathname === "/chat/config") {
8479
9005
  // The console's own door into `config set chat` (operator request:
8480
9006
  // chat lives mainly in the web UI). Same ceremony weight as the CLI
@@ -8483,7 +9009,7 @@ export function createDecisionServer(options) {
8483
9009
  // touches a form or this database — environment only.
8484
9010
  if (who.via !== "cookie")
8485
9011
  return refuse(response, who, 403, "chat setup is a browser surface");
8486
- const back = safeChatReturn(body.get("return"));
9012
+ const back = body.get("return") === "/settings/lead" ? "/settings/lead" : safeChatReturn(body.get("return"));
8487
9013
  const password = body.get("token") ?? "";
8488
9014
  if (password === "" || !authenticateApprover(store, who.name, password).ok) {
8489
9015
  return redirect(response, chatReturnWithSaid(back, "configuring chat spend takes your password, typed again"));
@@ -8749,6 +9275,14 @@ export function createDecisionServer(options) {
8749
9275
  return said(409, "That result is no longer available for this task. Review the task before sending.");
8750
9276
  }
8751
9277
  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.`;
9278
+ // The task composer's mode (console v2): fixed words for this turn only.
9279
+ // It steers what the lead proposes and grants nothing: every action
9280
+ // still arrives as a card the person confirms under its own rules.
9281
+ const modeValue = body.get("mode");
9282
+ if (modeValue !== null && (focusTask === null || body.getAll("mode").length !== 1 || !Object.hasOwn(TASK_COMPOSER_MODES, modeValue))) {
9283
+ return said(400, "Choose Build, Plan only or Just answer, then send again. Your draft is saved.");
9284
+ }
9285
+ const modeContext = modeValue === null ? "" : ` ${TASK_COMPOSER_MODES[modeValue]}`;
8752
9286
  const enabled = chatEnablement();
8753
9287
  if (!enabled.ok)
8754
9288
  return said(409, enabled.why);
@@ -8770,7 +9304,7 @@ export function createDecisionServer(options) {
8770
9304
  }
8771
9305
  const opened = store.openMateThread(who.name, principal.ceilingDigest, now, chatScopeOf(focusTask, chatProject));
8772
9306
  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 })
9307
+ 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
9308
  .then(outcome => {
8775
9309
  if (!outcome.ok)
8776
9310
  noteMate(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
@@ -9280,6 +9814,33 @@ export function createDecisionServer(options) {
9280
9814
  return refuse(response, who, result.status, result.message, back);
9281
9815
  return redirect(response, revisionDestination(result.id, back));
9282
9816
  }
9817
+ // Follow-ups on a result: Run checks on its exact commit (a worker runs the
9818
+ // approved command once and seals the log), or file a task to add tests.
9819
+ const followUp = /^\/r\/([0-9]{1,15})\/(checks|add-tests)$/.exec(url.pathname);
9820
+ if (followUp !== null) {
9821
+ const id = Number(followUp[1]);
9822
+ const found = store.getRun(id);
9823
+ if (found === null || !visible(taskRepoOf(found.taskRef)))
9824
+ return refuse(response, who, 404, "no such run");
9825
+ const back = resultReturnTarget(body.get("return"), id);
9826
+ if (who.via !== "cookie")
9827
+ return refuse(response, who, 403, "Sign in with a browser session to do this.", back);
9828
+ if (followUp[2] === "checks") {
9829
+ if (who.role !== "approver")
9830
+ return refuse(response, who, 403, "An approver runs checks.", back);
9831
+ if (store.isDemo())
9832
+ return refuse(response, who, 403, "The demo runs no checks.", back);
9833
+ const level = body.get("level") === "full" ? "full" : "quick";
9834
+ const asked = requestFollowUpChecks(store, { runId: id, level, actor: who.name }, now);
9835
+ if (!asked.ok)
9836
+ return refuse(response, who, 409, asked.message, back);
9837
+ return redirect(response, `${back.split("#")[0]}#follow-ups`);
9838
+ }
9839
+ const filed = fileAddTestsTask(store, evidenceRoot, { runId: id, actor: who.name, filedVia: "console", ...(admissionList() === null ? {} : { admittedRepos: admissionList() }) }, now);
9840
+ if (!filed.ok)
9841
+ return refuse(response, who, 409, filed.message, back);
9842
+ return redirect(response, taskHref(filed.id));
9843
+ }
9283
9844
  const draftRepair = /^\/r\/([0-9]{1,15})\/draft-repair$/.exec(url.pathname);
9284
9845
  if (draftRepair !== null) {
9285
9846
  // CI repair, suggestion-first (M8.18): a red episode never spawns an
@@ -9953,7 +10514,7 @@ export function createDecisionServer(options) {
9953
10514
  }
9954
10515
  case "cancel": {
9955
10516
  const reason = body.get("reason") ?? undefined;
9956
- const cancelled = store.cancelTask(taskId, now, reason);
10517
+ const cancelled = withActor({ account: who.name, lead: false }, () => store.cancelTask(taskId, now, reason));
9957
10518
  if (!cancelled.ok) {
9958
10519
  if (cancelled.reason === "reason-required" || cancelled.reason === "bad-reason") {
9959
10520
  return taskScreen(response, who, taskId, cancelled.reason === "reason-required"
@@ -10872,6 +11433,7 @@ export function createDecisionServer(options) {
10872
11433
  comments: store.liveDiffComments(run.id).filter(isRevisionFeedback),
10873
11434
  pastComments: store.allDiffComments(run.id).filter(one => one.reviewerRun === null && (one.consumedBy !== null || one.supersededBy !== null)),
10874
11435
  reviewerFindings: store.allDiffComments(run.id).filter(one => one.reviewerRun !== null),
11436
+ automaticReview: buildReviewOf(store, run.id),
10875
11437
  revisions: store.revisionsFromRun(run.id).flatMap(one => {
10876
11438
  const child = store.getTask(one.id);
10877
11439
  if (child === null || !visible(store.lookupRef(one.id)?.repo ?? null))
@@ -10881,6 +11443,7 @@ export function createDecisionServer(options) {
10881
11443
  return [{ id: one.id, title: one.title, state: one.state, approved: approvalOf(store.getScope(one.id)).approved, standing: status.label, tone: status.tone }];
10882
11444
  }),
10883
11445
  sourceDigest: scope?.digest ?? null,
11446
+ followUps: followUpsFor(store, evidenceRoot, run, now),
10884
11447
  route: store.runRoute(run.id),
10885
11448
  // Editor links (arc 6): the deployment capability, THIS machine's
10886
11449
  // runner owning the run, the session's own device-side yes, and a
@@ -11060,7 +11623,7 @@ export function createDecisionServer(options) {
11060
11623
  });
11061
11624
  return server;
11062
11625
  });
11063
- return server;
11626
+ return Object.assign(server, { mintSignInLink });
11064
11627
  }
11065
11628
  // ---- path plumbing ---------------------------------------------------------
11066
11629
  /**
@@ -11749,7 +12312,7 @@ function agentsStripHtml(view, taskId) {
11749
12312
  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
12313
  }
11751
12314
  /** 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.";
12315
+ 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
12316
  /** Every character that could open a tag or an attribute, dead at the sink. */
11754
12317
  function escape(text) {
11755
12318
  return text
@@ -12743,6 +13306,8 @@ ${THEME_DARK}
12743
13306
  @keyframes demo-pulse { 50% { opacity: .35; } }
12744
13307
  @media (prefers-reduced-motion: reduce) { .demo-steps li.now::before { animation: none; } }
12745
13308
  .demo-state { display: flex; align-items: center; gap: .5rem; margin: 0 !important; }
13309
+ .demo-chat .demo-handoff { border-color: var(--so-input-line); }
13310
+ .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
13311
  .demo-chat .badge.demo-ready { background: var(--so-info-soft); color: var(--so-info); }
12747
13312
  .demo-chat .badge.demo-complete { background: var(--so-success-soft); color: var(--so-success); }
12748
13313
  .demo-pass { color: var(--so-success); font-weight: 500; }
@@ -14523,7 +15088,15 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
14523
15088
  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
15089
  /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
14525
15090
  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}');
15091
+ /** The Inbox tabs (console v2): a segmented track of real links; the dot marks a tab with something new, on phones only. */
15092
+ 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}' +
15093
+ '.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}' +
15094
+ '.inbox-tabs a[aria-current="page"]{background:var(--so-paper);color:var(--so-ink);box-shadow:var(--so-pill-shadow)}' +
15095
+ '.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}' +
15096
+ '.inbox-tab-count--needs{background:var(--so-signal);color:var(--so-on-signal)}' +
15097
+ '.inbox-unread{display:none;position:absolute;top:4px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--so-signal)}' +
15098
+ '@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}}';
15099
+ 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
15100
  /**
14528
15101
  * The board's liveness: fetch this page's own fragment on a timer and swap
14529
15102
  * it in place — no flicker, no scroll reset, no long-lived stream to manage,
@@ -15073,6 +15646,37 @@ function loginPage(problem, returnTo = "/", sso = null) {
15073
15646
  `</div></div>`,
15074
15647
  ].join("\n"), { nav: false });
15075
15648
  }
15649
+ /**
15650
+ * The page for an address the console doesn't answer to (onboarding): what was opened, where it answers, and the exact
15651
+ * command that admits this address. It stands alone (styles inline, no script, no font), because every other asset
15652
+ * would be refused at this address too; and it holds nothing a stranger's page doesn't already know.
15653
+ */
15654
+ export function wrongHostPage(facts) {
15655
+ const code = (text) => `<code>${escape(text)}</code>`;
15656
+ return [
15657
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
15658
+ `<meta name="color-scheme" content="light dark"><title>Toolroll isn't set up for this address</title>`,
15659
+ `<style>`,
15660
+ `:root{color-scheme:light dark;--ground:#efefef;--paper:#fff;--ink:#171717;--muted:#666;--line:#e6e6e6;--soft:#f2f2f2}`,
15661
+ `@media (prefers-color-scheme:dark){:root{--ground:#0b0b0b;--paper:#161616;--ink:#ededed;--muted:#a1a1a1;--line:#262626;--soft:#1f1f1f}}`,
15662
+ `*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px 16px;background:var(--ground);color:var(--ink);`,
15663
+ `font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}`,
15664
+ `main{width:100%;max-width:520px;background:var(--paper);border:1px solid var(--line);border-radius:12px;padding:28px}`,
15665
+ `.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}`,
15666
+ `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}`,
15667
+ `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}`,
15668
+ `.meta{color:var(--muted);font-size:13px;margin:0}`,
15669
+ `</style></head><body><main>`,
15670
+ `<p class="mark">Toolroll</p>`,
15671
+ `<h1>Toolroll isn't set up for this address</h1>`,
15672
+ facts.opened === null
15673
+ ? `<p>It answers at ${code(facts.served)} on the computer running it.</p>`
15674
+ : `<p>You opened it at ${code(facts.opened)}. It answers at ${code(facts.served)} on the computer running it.</p>`,
15675
+ 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>`,
15676
+ `<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>`,
15677
+ `</main></body></html>`,
15678
+ ].join("\n");
15679
+ }
15076
15680
  /** The first-account page (setup review): shown only while no approver exists. */
15077
15681
  function signupPage(problem, attemptsLeft) {
15078
15682
  return shell("Toolroll", [
@@ -15096,12 +15700,22 @@ function signupPage(problem, attemptsLeft) {
15096
15700
  `</div></div>`,
15097
15701
  ].join("\n"), { nav: false });
15098
15702
  }
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
- */
15703
+ const INBOX_TABS = [
15704
+ { id: "needs-you", label: "Needs you" }, { id: "ready", label: "Ready" }, { id: "running", label: "Running" }, { id: "all", label: "All" },
15705
+ ];
15706
+ export function parseInboxTab(raw) {
15707
+ return INBOX_TABS.some(one => one.id === raw) ? raw : undefined;
15708
+ }
15709
+ /** What each tab holds, as a short fingerprint: a tab is unread when its
15710
+ * fingerprint differs from the one this browser saw there last. */
15711
+ export function inboxFingerprints(data) {
15712
+ const print = (keys) => createHash("sha256").update([...keys].sort().join("\n")).digest("hex").slice(0, 8);
15713
+ 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}`),
15714
+ ...data.cancelledBlockers.map(one => `c${one.blockerId}`), ...data.gaps.map(one => `g${one.key}`)];
15715
+ const ready = [...data.needsVerification.map(one => `v${one.taskId}`), ...(data.ready ?? []).map(one => `r${one.taskId}`)];
15716
+ const running = (data.running ?? []).map(one => `u${one.taskId}`);
15717
+ return { "needs-you": print(needs), ready: print(ready), running: print(running), all: print([...needs, ...ready, ...running]) };
15718
+ }
15105
15719
  function inboxPage(chrome, data) {
15106
15720
  /** The row's project, worn openly in the roll-up — null is UNPLACED,
15107
15721
  * said as such, never a silent missing chip (finding 13). */
@@ -15195,19 +15809,41 @@ function inboxPage(chrome, data) {
15195
15809
  ? `<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
15810
  : `<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
15811
  `</div>`;
15812
+ // Console v2: Needs you · Ready · Running · All, as real links (Back and
15813
+ // bookmarks work). Each section belongs to one tab; All shows every one.
15814
+ const tab = data.tab ?? "all";
15815
+ const shows = (one) => tab === "all" || tab === one;
15816
+ 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");
15817
+ const ready = (data.ready ?? []).length === 0 ? "" : `<h2>ready to review</h2>` + listRows(data.ready ?? []);
15818
+ const running = (data.running ?? []).length === 0 ? "" : `<h2>running now</h2>` + listRows(data.running ?? []);
15819
+ const counts = {
15820
+ "needs-you": data.decisions.length + data.approvals.length + data.requeueables.length + data.cancelledBlockers.length + data.gaps.length,
15821
+ ready: data.needsVerification.length + (data.ready ?? []).length,
15822
+ running: (data.running ?? []).length,
15823
+ all: 0,
15824
+ };
15825
+ counts.all = counts["needs-you"] + counts.ready + counts.running;
15826
+ 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}` +
15827
+ `<span class="inbox-tab-count${one.id === "needs-you" && counts[one.id] > 0 ? " inbox-tab-count--needs" : ""}">${counts[one.id]}</span>` +
15828
+ `${one.id !== tab && (data.unread ?? []).includes(one.id) ? `<span class="inbox-unread" aria-label="new"></span>` : ""}</a>`).join("") + `</nav>`;
15829
+ const tabEmpty = data.tab !== undefined && tab !== "all" && counts[tab] === 0
15830
+ ? `<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
15831
  return screen("inbox", [
15199
15832
  `<h1>Inbox</h1>`,
15200
15833
  `<p class="meta">everything that waits on you \u2014 empty means the fleet is working</p>`,
15834
+ tabs,
15201
15835
  noWorker,
15202
15836
  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,
15837
+ 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>`,
15838
+ 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,
15839
+ shows("needs-you") ? decisions : "",
15840
+ shows("needs-you") ? approvals : "",
15841
+ shows("needs-you") ? requeueables : "",
15842
+ shows("ready") ? needsVerification : "",
15843
+ shows("ready") ? ready : "",
15844
+ shows("running") ? running : "",
15845
+ shows("needs-you") ? cancelled : "",
15846
+ shows("needs-you") ? gaps : "",
15211
15847
  data.rollup
15212
15848
  ? `<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
15849
  : "",
@@ -15632,6 +16268,11 @@ function approvalFormDigest(scopeDigest, raceDigest, planSha) {
15632
16268
  .digest("hex");
15633
16269
  }
15634
16270
  const taskChatHref = (taskId) => `/chat?task=${encodeURIComponent(taskId)}`;
16271
+ export const TASK_COMPOSER_MODES = {
16272
+ 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.",
16273
+ 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.",
16274
+ answer: "The operator chose Just answer: answer from what you can read. Do not propose any action this turn.",
16275
+ };
15635
16276
  /** A project's own lead thread (v77). */
15636
16277
  const projectChatHref = (repo) => `/chat?project=${encodeURIComponent(repo)}`;
15637
16278
  /** The one task-level road from a truthful dispatch diagnosis to its nearest
@@ -15913,6 +16554,7 @@ function taskChatLiveRegion(focus, csrf, fragment = false, inert = false, approv
15913
16554
  ? `<details class="task-secondary-approval"><summary>Updated approval terms</summary>${approvalContent}</details>` : approvalContent;
15914
16555
  const polling = !inert && ((focus.approval === null || focus.plan === "requested") && focus.state !== "done" && focus.state !== "cancelled" || focus.control.kind === "stopping" || focus.control.kind === "stop");
15915
16556
  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 ?? "")}">` +
16557
+ (focus.guide === true && focus.state !== "cancelled" ? firstTaskJourneyHtml(focus) : "") +
15916
16558
  `<div class="task-live-summary">` +
15917
16559
  (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
16560
  checkProgressHtml(focus.checkProgress) +
@@ -15936,6 +16578,16 @@ function taskChatLiveRegion(focus, csrf, fragment = false, inert = false, approv
15936
16578
  (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
16579
  `</section>`);
15938
16580
  }
16581
+ /** The first task's way to Ready, filled in as it moves (onboarding): the same stage every surface reads. */
16582
+ function firstTaskJourneyHtml(focus) {
16583
+ const planning = focus.plan === "requested" && focus.approval === null;
16584
+ const read = focus.assignment !== null ? assignmentStageOf(focus.assignment, { token: focus.status.token }, planning) : stageOfCode(focus.status.token, { planning });
16585
+ const steps = firstTaskJourney(read, focus.scope === "approved", focus.state === "done");
16586
+ const current = steps.find(one => one.state === "current" || one.state === "stuck");
16587
+ return `<ol class="first-task-journey" aria-label="Where this task is${current === undefined ? ": Ready" : `: ${escape(current.label)}`}" data-first-task-journey>` +
16588
+ 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("") +
16589
+ `</ol>`;
16590
+ }
15939
16591
  function taskChatHeading(focus) {
15940
16592
  return (`<div class="chat-head task-chat-head"><div>` +
15941
16593
  `<p class="meta chat-task-back"><a href="${taskHref(focus.id)}">← task overview</a></p>` +
@@ -16195,55 +16847,83 @@ function chatLimitsHtml(facts) {
16195
16847
  rows.push(`<span>this week ${chatMoney(facts.weekly.spent)} of ${chatMoney(facts.weekly.ceiling)}</span>`);
16196
16848
  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
16849
  }
16850
+ /** Settings → Lead: one line for what runs the lead and one action; the full form, turning it off and stored keys under Advanced. */
16851
+ function leadSettingsHtml(data) {
16852
+ const { config, facts } = data;
16853
+ const hidden = `<input type="hidden" name="csrf" value="${escape(facts.csrf)}"><input type="hidden" name="return" value="/settings/lead">`;
16854
+ const summary = config === null
16855
+ ? data.signedIn !== null
16856
+ ? `<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>`
16857
+ : `<p>The lead is off. Sign in an agent on this computer to turn it on:</p><p><code>${escape(data.command)}</code></p>`
16858
+ : data.words !== null
16859
+ ? `<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>`
16860
+ : `<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>`;
16861
+ 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)}">` +
16862
+ `<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("");
16863
+ return [
16864
+ `<p><a href="/settings">Settings</a></p><h1>Lead</h1>`,
16865
+ data.said === null ? "" : `<p class="problem" role="status">${escape(data.said)}</p>`,
16866
+ `<section class="card lead-settings" data-lead-settings>${summary}</section>`,
16867
+ `<details class="lead-advanced" data-lead-advanced><summary>Advanced</summary>`,
16868
+ leadConfigForm(config, facts),
16869
+ config === null ? "" : `<form method="post" action="/chat/config" class="inline">${hidden}<input type="hidden" name="off" value="1">` +
16870
+ `<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>`,
16871
+ forget,
16872
+ `</details>`,
16873
+ ].join("\n");
16874
+ }
16875
+ /** The lead's full settings (Settings → Lead → Advanced): provider, model, limits and a direct API key. */
16876
+ function leadConfigForm(current, data) {
16877
+ const anthropicModels = PRICED_MODELS.filter(one => !one.includes("/"));
16878
+ const openrouterModels = data.openrouterModels ?? PRICED_MODELS.filter(one => one.includes("/"));
16879
+ const currentSubscription = current !== null && isSubscriptionChatProvider(current.provider);
16880
+ const labels = new Map((data.liveModels ?? []).map(one => [one.value, one.label]));
16881
+ const models = [...new Set(["default", ...labels.keys(), ...anthropicModels, ...openrouterModels, ...(current === null ? [] : [current.model])])];
16882
+ return [
16883
+ `<form method="post" action="/chat/config" class="card">`,
16884
+ `<input type="hidden" name="csrf" value="${escape(data.csrf)}">`,
16885
+ `<input type="hidden" name="return" value="${escape(data.returnTo)}">`,
16886
+ `<label>provider<select name="provider">`,
16887
+ `<option value="codex-subscription"${current?.provider === "codex-subscription" ? " selected" : ""}>Codex membership (logged-in CLI)</option>`,
16888
+ `<option value="claude-subscription"${current?.provider === "claude-subscription" ? " selected" : ""}>Anthropic membership (logged-in CLI)</option>`,
16889
+ `<option value="anthropic-api"${current?.provider === "anthropic-api" ? " selected" : ""}>anthropic-api (direct API)</option>`,
16890
+ `<option value="openrouter-api"${current?.provider === "openrouter-api" ? " selected" : ""}>openrouter-api (direct API)</option>`,
16891
+ `</select></label>`,
16892
+ `<label>model <span class="meta">(use default for your membership's current model; direct API models need a pinned price)</span>` +
16893
+ `<input name="model" list="chat-models" value="${escape(current?.model ?? "default")}"><datalist id="chat-models">` +
16894
+ `${models.map(model => `<option value="${escape(model)}">${escape(labels.get(model) ?? "")}</option>`).join("")}</datalist></label>`,
16895
+ data.openrouterModels === null
16896
+ ? `<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>`
16897
+ : `<p class="meta">${data.openrouterModels.length} models live from OpenRouter's catalog; saving pins today's price — re-save to re-pin</p>`,
16898
+ currentSubscription
16899
+ ? `<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>`
16900
+ : `<label>weekly ceiling <span class="meta">(direct API only; leave blank when choosing a membership)</span>` +
16901
+ `<input type="text" name="weekly-usd" inputmode="decimal" style="width:8rem" value="${current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2)}"></label>`,
16902
+ `<label>daily turns <span class="meta">(default 50)</span>` +
16903
+ `<input type="text" name="daily-turns" inputmode="numeric" style="width:8rem" value="${current === null ? "" : String(current.dailyTurns)}"></label>`,
16904
+ currentSubscription
16905
+ ? `<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>`
16906
+ : `<label>API key <span class="meta">(${data.keyFacts
16907
+ .map(one => one.state === "none"
16908
+ ? `${escape(one.provider)}: none yet`
16909
+ : one.state === "environment"
16910
+ ? `${escape(one.provider)}: from the environment`
16911
+ : `${escape(one.provider)}: stored ${escape(one.tail ?? "")}`)
16912
+ .join(" · ")})</span>` +
16913
+ `<input type="password" name="key" placeholder="direct API only — leave empty to keep" autocomplete="off"></label>`,
16914
+ `<label>your password <span class="meta">(typed again to change the provider)</span>` +
16915
+ `<input type="password" name="token" autocomplete="current-password"></label>`,
16916
+ `<button type="submit">${current === null ? "turn chat on" : "save"}</button>`,
16917
+ `</form>`,
16918
+ currentSubscription
16919
+ ? `<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>`
16920
+ : `<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 (` +
16921
+ `<span class="mono">ANTHROPIC_API_KEY</span> / <span class="mono">OPENROUTER_API_KEY</span>) always wins when set</p>`,
16922
+ ].join("\n");
16923
+ }
16198
16924
  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
- };
16925
+ const formFacts = { keyFacts: data.keyFacts, openrouterModels: data.openrouterModels, ...(data.liveModels === undefined ? {} : { liveModels: data.liveModels }), csrf: data.csrf,
16926
+ returnTo: data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id) };
16247
16927
  const parts = [
16248
16928
  data.focusTask === null
16249
16929
  ? chatHeading("", data.projects.length, data.enabled.ok)
@@ -16256,14 +16936,17 @@ function chatPage(chrome, data) {
16256
16936
  const code = data.enabled.code;
16257
16937
  // The sandbox shows no conversation at all: chat evidence is a real
16258
16938
  // 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
- }
16939
+ // With no lead yet, the first run says what happens next (it turns on with the signed-in agent, or shows the one
16940
+ // command to run); otherwise one line, and its settings live in Settings → Lead (onboarding).
16941
+ const settingsLink = data.canManage ? ` <a href="/settings/lead">Settings → Lead</a>` : "";
16942
+ if (!(code === "unconfigured" && data.firstRunShown === true))
16943
+ parts.push(code === "demo"
16944
+ ? `<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>`
16945
+ : code === "unconfigured"
16946
+ ? `<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>`
16947
+ : code === "unpriced" || code === "no-key"
16948
+ ? `<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>`
16949
+ : `<div class="card" id="latest"><p><strong>chat is off.</strong></p><p class="meta">${escape(data.enabled.why)}</p></div>`);
16267
16950
  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
16951
  }
16269
16952
  const config = data.enabled.config;
@@ -16319,15 +17002,7 @@ function chatPage(chrome, data) {
16319
17002
  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
17003
  }
16321
17004
  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>`);
17005
+ parts.push(`<p class="meta" id="chat-settings"><a href="/settings/lead">Lead settings</a> · provider, model and limits</p>`);
16331
17006
  }
16332
17007
  if (data.recent.length > 0) {
16333
17008
  parts.push(`<details class="chat-turn-history"><summary>Conversation activity <span class="meta">${data.recent.length} recent turns</span></summary>`);
@@ -16444,7 +17119,7 @@ function proposalCardParts(view, csrf, inert, decision, returnTo = null) {
16444
17119
  : "inspect first when the work needs repository context";
16445
17120
  what =
16446
17121
  `<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>") : ""]);
17122
+ 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
17123
  }
16449
17124
  else if (view.kind === "action") {
16450
17125
  const action = sharedActionPayload(payload);
@@ -16807,7 +17482,7 @@ function matePage(chrome, data) {
16807
17482
  : `<p class="meta">recent turns: ${data.recent
16808
17483
  .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
17484
  .join(" · ")}</p>`,
16810
- `<p class="meta"><a href="/chat?settings=1#chat-settings">Chat settings</a> · provider, model and limits</p>`,
17485
+ `<p class="meta"><a href="/settings/lead">Lead settings</a> · provider, model and limits</p>`,
16811
17486
  `</details>`,
16812
17487
  ].join("\n");
16813
17488
  const conversation = [
@@ -17303,7 +17978,7 @@ function workPage(chrome, data) {
17303
17978
  };
17304
17979
  return screen('work', `<div class="work-head"><h1>Tasks</h1>${tools}</div>${limitsHtml(data.limits ?? null)}${tabs}${list}${pages}`, { chrome, workspace: { view } });
17305
17980
  }
17306
- function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = null, permissionDefault = "auto", qualityDefault = "default") {
17981
+ function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = null, permissionDefault = "auto", qualityDefault = "default", replaced = new Map()) {
17307
17982
  const filters = TASK_STATES.map(one => (one === state ? `<strong>${one}</strong>` : `<a href="/tasks?state=${one}">${one}</a>`)).join(" · ");
17308
17983
  const rows = tasks.length === 0
17309
17984
  ? `<p class="meta">${state === null
@@ -17311,7 +17986,7 @@ function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = n
17311
17986
  : `Nothing is ${escape(state)}.`}</p>`
17312
17987
  : tasks
17313
17988
  .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>`)
17989
+ `${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
17990
  .join("\n");
17316
17991
  return screen("tasks", [
17317
17992
  "<h1>Tasks</h1>",
@@ -17982,7 +18657,7 @@ function githubReposPage(chrome, data) {
17982
18657
  `<p class="row" style="margin-top:.6rem"><a class="badge" href="/projects">← back to projects</a></p>`,
17983
18658
  ].join("\n"), { chrome });
17984
18659
  }
17985
- function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn) {
18660
+ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn, checkLevelOf) {
17986
18661
  // The onboarding card (repo onboarding, findings 1-39): preview first,
17987
18662
  // then a password-confirmed clone into a configured root. Disabled
17988
18663
  // states explain themselves in words (finding 28/39).
@@ -18073,6 +18748,7 @@ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscoped
18073
18748
  name, path, shortPath: path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path, open: open !== null && open === path, openedAt,
18074
18749
  knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(path)}`,
18075
18750
  ...(pullRequestsOn === undefined ? {} : { pullRequests: { href: `/settings/pull-requests?repo=${encodeURIComponent(path)}`, on: pullRequestsOn(path) } }),
18751
+ ...(checkLevelOf === undefined ? {} : { checks: { href: `/settings/checks?repo=${encodeURIComponent(path)}`, level: CHECK_LEVEL_WORDS[checkLevelOf(path)] } }),
18076
18752
  peek: peek === null ? null : [
18077
18753
  ...(peek.waiting > 0 ? [{ label: `${peek.waiting} waiting on you`, href: "/", tone: "attention" }] : []),
18078
18754
  ...(peek.running > 0 ? [{ label: `${peek.running} running`, href: "/runs", tone: "neutral" }] : []),
@@ -18223,6 +18899,10 @@ function loginReturn(raw) {
18223
18899
  const query = parsed.searchParams.toString();
18224
18900
  return `${parsed.pathname}${query === "" ? "" : `?${query}`}`;
18225
18901
  }
18902
+ /** A request path fit for a log: the one-time sign-in link's code is left out. */
18903
+ export function redactedPath(path) {
18904
+ return path.startsWith(SIGN_IN_LINK_PATH) ? `${SIGN_IN_LINK_PATH}…` : path;
18905
+ }
18226
18906
  /** The sign-in page that comes back to `path` afterwards, or plain /login when the path is not one to come back to. */
18227
18907
  function loginHref(path) {
18228
18908
  const back = loginReturn(path);
@@ -19506,7 +20186,7 @@ function taskBodyParts(data) {
19506
20186
  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
20187
  }
19508
20188
  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)}`] }
20189
+ : 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
20190
  : { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
19511
20191
  facts.push({ label: "Publishes as", parts: [publishesAs] });
19512
20192
  // v102: who asked for it, and what the project's approval rules need.
@@ -19824,6 +20504,135 @@ function taskBodyParts(data) {
19824
20504
  cancelAct,
19825
20505
  `</div><aside class="task-rail">${rail}</aside></div>`,
19826
20506
  ].join("\n");
20507
+ // Console v2: one thread in time order (the plan, the agent's notes and
20508
+ // results, its questions, the person's replies) and the metadata grouped
20509
+ // for a Details panel. Every form below keeps its own route and fields.
20510
+ const familyRuns = data.family?.runs ?? data.runs.map(run => ({ ...run, taskId: task.id }));
20511
+ const versionLabelOf = (taskId) => {
20512
+ const index = data.family?.versions.findIndex(one => one.id === taskId) ?? -1;
20513
+ return index <= 0 ? "" : ` · revision ${index}`;
20514
+ };
20515
+ const thread = [];
20516
+ const filedBy = data.filer?.name ?? data.coordinator?.label ?? null;
20517
+ const rootFiled = data.family?.root.createdAt ?? task.createdAt;
20518
+ thread.push({ key: "filed", at: rootFiled, kind: "filed", who: "person", author: filedBy ?? "", title: "Filed the task",
20519
+ text: (() => { const goal = data.family?.root.goal ?? scope?.goal ?? null; return goal === null ? null : oneLineOf(goal, 280); })(), link: null, html: "", more: null });
20520
+ const newestBuilt = familyRuns.filter(run => run.outcome === "built" && run.role !== "planner" && run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
20521
+ for (const run of familyRuns) {
20522
+ const live = run.id === liveHistoryRunId || (run.outcome === null && run.id === liveRunId);
20523
+ const noun = runNoun(run);
20524
+ const href = { label: `${noun[0].toUpperCase()}${noun.slice(1)} #${run.id}`, href: `/r/${run.id}` };
20525
+ const revision = versionLabelOf(run.taskId);
20526
+ if (run.role === "planner") {
20527
+ thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "plan", who: "agent", author: run.runner,
20528
+ 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)}`,
20529
+ text: null, link: href, html: "", more: null });
20530
+ continue;
20531
+ }
20532
+ if (live) {
20533
+ thread.push({ key: `run-${run.id}`, at: run.startedAt, kind: "progress", who: "agent", author: run.runner,
20534
+ title: `${run.role === "reviewer" ? "Reviewing" : run.role === "scout" ? "Investigating" : "Building"}${revision}`,
20535
+ text: `${homePhaseWords(run)}.`, link: href, html: "", more: null });
20536
+ continue;
20537
+ }
20538
+ const title = run.role === "reviewer" ? "Reviewed the result"
20539
+ : run.outcome === "built" ? (run.role === "scout" ? "Report ready" : "Result ready")
20540
+ : run.outcome === "no-change" ? "Finished with no change"
20541
+ : run.outcome === "parked" ? "Stopped to ask"
20542
+ : run.outcome === null ? "Attempt never finished"
20543
+ : `${noun[0].toUpperCase()}${noun.slice(1)} ${run.outcome}`;
20544
+ thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "result", who: "agent", author: run.runner,
20545
+ title: `${title}${revision}`, text: run.handoff === null ? (run.reason === null || run.outcome === "built" ? null : reasonWords(run.reason)) : oneLineOf(run.handoff, 600),
20546
+ link: href, html: "",
20547
+ more: previousResult !== null && run.id === newestBuilt?.id && run.taskId === task.id ? { summary: "Result details", html: previousResult.html } : null });
20548
+ }
20549
+ // What the agent is working through now, and how its plan changed.
20550
+ const liveAt = liveRun?.startedAt ?? task.updatedAt;
20551
+ if (planCard !== "")
20552
+ thread.push({ key: "plan", at: familyRuns.filter(run => run.role === "planner").map(run => run.finishedAt ?? run.startedAt).sort().at(-1) ?? task.updatedAt,
20553
+ kind: "plan", who: "agent", author: "", title: "The plan", text: null, link: null, html: planCard, more: null });
20554
+ if (progressCard !== "")
20555
+ thread.push({ key: "progress", at: liveAt, kind: "progress", who: "agent", author: "", title: "Progress", text: null, link: null, html: progressCard, more: null });
20556
+ if (revisionLedgerCard !== "")
20557
+ thread.push({ key: "plan-revisions", at: liveAt, kind: "progress", who: "agent", author: "", title: "Plan changes", text: null, link: null, html: revisionLedgerCard, more: null });
20558
+ // The change a person asked for: the revision's own brief.
20559
+ if (data.revision != null && !("problem" in data.revision)) {
20560
+ const feedback = data.revision;
20561
+ thread.push({ key: "revision", at: task.createdAt, kind: "reply", who: "person", author: feedback.comments[0]?.author ?? "You",
20562
+ title: feedback.kind === "ci-repair" ? "Asked for a CI repair" : feedback.kind === "criterion-repair" ? "Asked for a repair" : "Asked for changes",
20563
+ text: feedback.comments.map(one => `${one.path === null ? "" : `${one.path}${one.line === null ? "" : `:${one.line}`} — `}${one.note}`).join("\n"),
20564
+ link: { label: `From build #${feedback.sourceRun}`, href: `/r/${feedback.sourceRun}` }, html: "", more: null });
20565
+ }
20566
+ for (const decision of data.decisions) {
20567
+ if (decision.state === "open" || decision.state === "expired")
20568
+ continue;
20569
+ thread.push({ key: `question-${decision.id}`, at: decision.createdAt, kind: "question", who: "agent", author: "", title: "Asked",
20570
+ text: decision.question, link: { label: "Question", href: `/d/${decision.id}` }, html: "", more: null });
20571
+ if (decision.answeredAt !== null) {
20572
+ const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice;
20573
+ thread.push({ key: `answer-${decision.id}`, at: decision.answeredAt, kind: "reply", who: "person", author: decision.answeredBy ?? "You", title: "Answered",
20574
+ text: [chosen, decision.note].filter((one) => one !== null && one !== "").join(" — ") || null, link: null, html: "", more: null });
20575
+ }
20576
+ }
20577
+ // Open questions keep their answer card (and its #task-questions anchor).
20578
+ if (decisionRail !== "")
20579
+ thread.push({ key: "questions", at: openDecisions.map(one => one.createdAt).sort()[0] ?? task.updatedAt, kind: "question", who: "agent", author: "",
20580
+ title: openDecisions.length === 1 ? "Asked you a question" : `Asked you ${openDecisions.length} questions`, text: null, link: null, html: decisionRail, more: null });
20581
+ for (const note of data.steering ?? []) {
20582
+ 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 });
20583
+ }
20584
+ thread.sort((a, b) => a.at === b.at ? 0 : a.at < b.at ? -1 : 1);
20585
+ // The Details panel: the same facts, grouped and said once.
20586
+ const lastRunForAgent = liveRun ?? familyRuns.filter(run => run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
20587
+ const work = [];
20588
+ work.push({ label: "Status", parts: [data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions).status.headline : status.label] });
20589
+ if (data.repo !== null)
20590
+ work.push({ label: "Project", parts: [projectName(data.repo)] });
20591
+ work.push({ label: "Agent", parts: [lastRunForAgent !== undefined ? `${lastRunForAgent.provider}${lastRunForAgent.model === null ? "" : ` · ${lastRunForAgent.model}`} on ${lastRunForAgent.runner}`
20592
+ : data.route?.legacy != null ? `${data.route.legacy.provider} · ${data.route.legacy.model}` : "The project's default"] });
20593
+ if (scope !== null)
20594
+ work.push({ label: "Checks level", parts: [qualityModeTitle(scope.qualityMode ?? "default")] });
20595
+ for (const label of ["Worker", "Last attempt", "Queue", "Budget", "Policy", "Strikes"]) {
20596
+ const fact = facts.find(one => one.label === label);
20597
+ if (fact !== undefined)
20598
+ work.push(fact);
20599
+ }
20600
+ const links = [];
20601
+ if (data.family != null && data.family.root.id !== task.id)
20602
+ links.push({ label: "Parent", parts: [{ label: data.family.root.title, href: `${taskHref(data.family.root.id)}?version=${encodeURIComponent(data.family.root.id)}` }] });
20603
+ const blockers = data.waitsFor ?? [];
20604
+ if (blockers.length > 0)
20605
+ 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]) });
20606
+ 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);
20607
+ if (laterVersions.length > 0)
20608
+ 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)}` }]) });
20609
+ const published = facts.find(one => one.label === "Published");
20610
+ if (published !== undefined)
20611
+ links.push({ ...published, label: "Pull request" });
20612
+ else if (data.pullRequest?.view != null) {
20613
+ const pr = data.pullRequest.view;
20614
+ const prHref = safePrUrl(pr.prUrl);
20615
+ links.push({ label: "Pull request", parts: [prHref === null ? `#${pr.prNumber ?? "?"}` : { label: `#${pr.prNumber ?? "?"}`, href: prHref }, ` · ${pr.label}`] });
20616
+ }
20617
+ const review = [];
20618
+ const approvedScope = facts.find(one => one.label === "Approved scope" || one.label === "Scope");
20619
+ if (approvedScope !== undefined)
20620
+ review.push({ ...approvedScope, label: approvedScope.label === "Approved scope" ? "Approvals" : "Scope" });
20621
+ review.push({ label: "Who reviews", parts: [facts.find(one => one.label === "Approval rules")?.parts[0] ?? "Any approver in this project"] });
20622
+ review.push({ label: "Publishes as", parts: [publishesAs] });
20623
+ const about = [];
20624
+ const filedFact = facts.find(one => one.label === "Filed by");
20625
+ about.push(filedFact ?? { label: "Filed by", parts: [filedBy ?? (data.filedVia == null ? "Not recorded" : `via ${data.filedVia}`)] });
20626
+ about.push({ label: "Filed", parts: [{ at: rootFiled }] });
20627
+ about.push({ label: "Updated", parts: [{ at: task.updatedAt }] });
20628
+ for (const label of ["Audit", "This attempt", "Task total"]) {
20629
+ const fact = facts.find(one => one.label === label);
20630
+ if (fact !== undefined)
20631
+ about.push({ ...fact, label: label === "Audit" ? "Audit" : label === "This attempt" ? "Usage, this attempt" : "Usage, all attempts" });
20632
+ }
20633
+ const details = [
20634
+ { title: "Work", facts: work }, { title: "Links", facts: links }, { title: "Review", facts: review }, { title: "About", facts: about },
20635
+ ];
19827
20636
  // The rebuilt page (shadcn/ui): the same parts in a calmer order — what
19828
20637
  // needs a person, then facts, then folds; the mechanics under Manage.
19829
20638
  const MANAGE = new Set(["steering", "waits-for", "holds"]);
@@ -19842,25 +20651,35 @@ function taskBodyParts(data) {
19842
20651
  status: data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions) : null,
19843
20652
  statusHtml,
19844
20653
  approval: approvalHtml,
20654
+ // The plan, progress and plan changes are thread entries now; the rest still needs a person here.
19845
20655
  lead: [
19846
20656
  { key: "history", html: data.history ?? "" }, { key: "control", html: controlHtml }, { key: "problem", html: problemHtml },
19847
20657
  { 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 },
20658
+ { key: "needs-scope", html: needsScopeCard },
20659
+ { key: "mirror", html: mirrorCard }, { key: "contest", html: contestCard }, { key: "attempt", html: attemptPanel },
19850
20660
  ].filter(one => one.html !== ""),
19851
- questions: decisionRail,
20661
+ // Open questions are answered in the thread.
20662
+ questions: "",
19852
20663
  facts,
19853
20664
  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),
20665
+ ...(previousResult === null || thread.some(one => one.more !== null) ? [] : [{ id: "result", title: previousResult.title, html: previousResult.html, open: false, count: null }]),
20666
+ // Questions are in the thread; the scope and the ledgers fold here until asked.
20667
+ ...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
20668
  ],
19858
20669
  manage: [
19859
20670
  ...sectionParts.filter(one => MANAGE.has(one.id)),
19860
20671
  { id: "task-diagnostics", title: "Task options", html: optionsHtml, open: optionsOpen, count: null },
19861
20672
  ],
19862
20673
  cancel: cancelForm === "" ? null : { html: cancelForm, open: data.cancelDraft !== undefined },
20674
+ journey: data.guide !== true || task.state === "cancelled" || data.status === undefined ? null : (() => {
20675
+ const planning = data.plan === "requested" && approveForm === "";
20676
+ const read = data.assignment != null ? assignmentStageOf(data.assignment, { token: data.status.token }, planning) : stageOfCode(data.status.token, { planning });
20677
+ return firstTaskJourney(read, scope !== null && approvalOf(scope).approved, task.state === "done");
20678
+ })(),
19863
20679
  // "Do this every time…": the starter flow that does this kind of work on its own, one yes away.
20680
+ thread,
20681
+ details,
20682
+ chatHref: data.csrf === "" ? null : taskChatHref(data.rootId ?? task.id),
19864
20683
  everyTime: data.repo === null || data.csrf === "" ? null : (() => {
19865
20684
  const starter = starterForWork(`${task.title}\n${scope?.goal ?? ""}`);
19866
20685
  return { href: `/settings/flows?repo=${encodeURIComponent(data.repo)}&starter=${starter.id}#starter-${starter.id}`, starter: starter.name };
@@ -20403,7 +21222,7 @@ function pullRequestCardHtml(pr, csrf) {
20403
21222
  `<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="run" value="${view.runId}">` +
20404
21223
  `<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
21224
  `<label>Password<input type="password" name="token" autocomplete="current-password" required></label>` +
20406
- `<button type="submit">Merge</button></form>`;
21225
+ (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
21226
  return `<section class="card pull-request pull-request--${tone}" id="merge" data-pull-request="${escape(view.state)}">` +
20408
21227
  `<div class="pull-request-head"><strong>${escape(view.label)}</strong>${link}</div>` +
20409
21228
  `<p class="meta">${escape(view.detail)}${revision}</p>${merged}${act}</section>`;
@@ -20446,6 +21265,18 @@ const PHASE_WORDS = {
20446
21265
  "capturing-evidence": "capturing evidence",
20447
21266
  committing: "committing",
20448
21267
  };
21268
+ /** An agent's phase on the home card, from the machine's own vocabulary. */
21269
+ function homePhaseWords(run) {
21270
+ if (run.role === "planner")
21271
+ return "Planning";
21272
+ if (run.role === "reviewer")
21273
+ return "Reviewing";
21274
+ if (run.role === "scout")
21275
+ return "Investigating";
21276
+ const words = { "agent-running": "Writing the change", "validating-handoff": "Checking its handoff", "capturing-evidence": "Saving its evidence",
21277
+ committing: "Committing", "verifying-proof": "Running the checks", "correcting-proof": "Correcting its evidence" };
21278
+ return run.phase === null ? "Starting" : words[run.phase] ?? "Working";
21279
+ }
20449
21280
  function phaseWords(phase) {
20450
21281
  return PHASE_WORDS[phase] ?? "the agent is working";
20451
21282
  }
@@ -21102,6 +21933,34 @@ function evidenceBundleCard(view, run, publication = null, review = null) {
21102
21933
  * the operator's acceptance alone — shared with the review cockpit's
21103
21934
  * header chip so the two surfaces never name one state differently. */
21104
21935
  /** The receipt's facts as the shared projection reads them. */
21936
+ /** A result's follow-ups: checks run on its commit since it finished, and its tests task. */
21937
+ function followUpsFor(store, evidenceRoot, run, now) {
21938
+ if (run.role !== "builder" || run.finishedAt === null || run.headRevision === null)
21939
+ return null;
21940
+ const repo = store.refById(run.taskRef)?.repo ?? null;
21941
+ 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);
21942
+ return { repo, checks: followUpChecksOf(store, run.id, now, evidenceRoot), testsTask: tests === undefined ? null : String(tests["outcome"]),
21943
+ quick: repo !== null && liveQuickCommand(store, repo) !== null, full: repo !== null && store.liveVerifyCommand(repo) !== null };
21944
+ }
21945
+ const FOLLOW_UP_WORDS = { waiting: "waiting for a worker", running: "running", passed: "passed", failed: "failed", "not-run": "didn't run" };
21946
+ /** Run checks and Add tests, under the result's Checks: what ran since, then the two acts. */
21947
+ function followUpsHtml(followUps, runId, o) {
21948
+ 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])}` +
21949
+ `${one.exitCode === null || one.state === "passed" ? "" : ` (exit ${one.exitCode})`} on <span class="mono">${escape(one.head.slice(0, 7))}</span>` +
21950
+ `<span class="meta"> · ${one.why === "pull-request" ? "for the pull request" : escape(one.actor)}${one.note === null ? "" : ` · ${escape(one.note)}`}</span>` +
21951
+ `${one.logArtifactId === null ? "" : ` <a href="/r/${runId}/evidence/${one.logArtifactId}">Log</a>`}</li>`).join("");
21952
+ const hidden = `<input type="hidden" name="csrf" value="${escape(o.csrf)}"><input type="hidden" name="return" value="${escape(o.returnTo)}">`;
21953
+ const canCheck = followUps.quick || followUps.full;
21954
+ const checkActs = !canCheck || o.csrf === "" ? "" : `<form method="post" action="/r/${runId}/checks" class="follow-up-act">${hidden}` +
21955
+ (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>`
21956
+ : `<button type="submit" name="level" value="${followUps.quick ? "quick" : "full"}">Run checks</button>`) + `</form>`;
21957
+ const tests = followUps.testsTask !== null ? `<p class="meta">Tests task: <a href="${taskHref(followUps.testsTask)}">${escape(followUps.testsTask)}</a></p>`
21958
+ : o.csrf === "" ? "" : `<form method="post" action="/r/${runId}/add-tests" class="follow-up-act">${hidden}<button type="submit" class="secondary">Add tests</button></form>`;
21959
+ return `<section class="result-section follow-ups" id="follow-ups" data-follow-ups><strong>Follow-ups</strong>` +
21960
+ (rows === "" ? "" : `<ul class="follow-up-list">${rows}</ul>`) +
21961
+ (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>`) +
21962
+ (checkActs === "" && tests === "" ? "" : `<div class="follow-up-acts">${checkActs}${tests}</div>`) + `</section>`;
21963
+ }
21105
21964
  function receiptStatusOf(view, review = view.review) {
21106
21965
  // The evidence health rides the status (repair 2026-09-14): a result
21107
21966
  // whose stored records are damaged is never called ready on the strength
@@ -21222,7 +22081,9 @@ function resultPanelParts(detail, o) {
21222
22081
  const { run, receipt, proof, terminal, handoff } = detail;
21223
22082
  const facts = receipt.facts;
21224
22083
  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` };
22084
+ const followUps = detail.followUps ?? null;
22085
+ const resultLinks = { result: o.hrefFor("summary"), checks: o.hrefFor("checks"), pullRequest: `${taskHref(detail.rootId ?? detail.taskId)}#merge`,
22086
+ ...(followUps !== null && (followUps.quick || followUps.full) && o.csrf !== "" ? { runChecks: `${o.hrefFor("checks")}#follow-ups` } : {}) };
21226
22087
  const presentation = current == null ? null : assignmentPresentationOf(current, { additionalAttention: [...facts.evidenceProblems, ...receipt.caveats],
21227
22088
  ...(detail.publication === null ? {} : { pullRequest: pullRequestFactOf({ ...detail.publication, lastCheckState: detail.ciFailing ? "failing" : detail.publication.lastCheckState }) }),
21228
22089
  evidence: { damaged: facts.evidenceHealth.damaged, missing: facts.evidenceHealth.missing, shortened: facts.evidenceHealth.shortened }, links: resultLinks });
@@ -21265,6 +22126,16 @@ function resultPanelParts(detail, o) {
21265
22126
  if (current == null)
21266
22127
  attention.push(...receipt.caveats.filter(caveat => !historicalAssessmentReason(caveat) && !attention.some(problem => problem.includes(caveat))));
21267
22128
  attention.push(...(handoff?.followUps ?? []).map(one => `Follow-up: ${one}`));
22129
+ // The automatic review: a HIGH that did not send the work back, or a review
22130
+ // that could not finish, is the person's to see. (Said once when the
22131
+ // current assignment already carries it.)
22132
+ const automatic = detail.automaticReview ?? null;
22133
+ if (automatic !== null && automatic.sentBackAs === null) {
22134
+ const reviewed = [...(automatic.state === "not-reviewed" ? [`Not reviewed: ${automatic.reason ?? "the automatic review did not finish"}.`] : []), ...automatic.high.map(one => `Review: ${findingWords(one)}`)];
22135
+ for (const line of reviewed)
22136
+ if (!attention.includes(line))
22137
+ attention.push(line);
22138
+ }
21268
22139
  // Publication risks stay in the open (repair 2026-09-14); the routine
21269
22140
  // publication fact lives with the build details below.
21270
22141
  // With the shared status, the pull request is its own quiet row (its reason under Details).
@@ -21339,6 +22210,10 @@ function resultPanelParts(detail, o) {
21339
22210
  ];
21340
22211
  if (agentAccount.length > 0)
21341
22212
  summaryParts.push(`<details class="result-notes" data-result-notes-agent><summary>What the agent reported</summary>${agentAccount.join("")}</details>`);
22213
+ // MEDIUM and LOW never block: suggested follow-ups, on request.
22214
+ if (automatic !== null && automatic.followUps.length > 0) {
22215
+ summaryParts.push(`<details class="result-notes" data-review-followups="${automatic.followUps.length}"><summary>Suggested follow-ups · ${automatic.followUps.length}</summary><ul class="result-changes">${automatic.followUps.map(one => `<li><span class="badge">${one.severity === "MEDIUM" ? "Medium" : "Low"}</span> <span class="mono">${escape(`${one.file}:${one.line}`)}</span> ${escape(one.scenario)}</li>`).join("")}</ul></details>`);
22216
+ }
21342
22217
  const evidenceSources = [
21343
22218
  ...(report !== null ? [report.ok ? `verified report${report.truncated ? " (shortened)" : ""}` : "report (unverifiable)"] : []),
21344
22219
  ...(patch !== null ? [patchOk ? `sealed diff${patch.truncated ? " (shortened)" : ""}` : "diff (unverifiable)"] : []),
@@ -21463,7 +22338,7 @@ function resultPanelParts(detail, o) {
21463
22338
  }
21464
22339
  if (proof.proofProblem !== null)
21465
22340
  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"'));
22341
+ 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
22342
  checkParts.push(proof.proof === null || proof.proof.checks.length === 0
21468
22343
  ? directAssessment ? "" : `<p class="meta" data-cockpit-source="agent">The agent reported no checks.</p>`
21469
22344
  : `<details class="cockpit-proof-group" data-cockpit-source="agent"><summary>Agent checks · ${proof.proof.checks.length}</summary><div class="result-section"><ul>` +
@@ -21481,6 +22356,8 @@ function resultPanelParts(detail, o) {
21481
22356
  ? directAssessment ? "" : `<p class="meta" data-cockpit-source="caveats">No caveats were reported.</p>`
21482
22357
  : `<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
22358
  }
22359
+ if (followUps !== null && run.role === "builder")
22360
+ checkParts.push(followUpsHtml(followUps, runId, o));
21484
22361
  // The handoff's own account of its checks — the agent's words, labeled
21485
22362
  // as such, whether or not a proof exists.
21486
22363
  if (!directAssessment && handoff !== null && handoff.verification.length > 0)
@@ -22001,6 +22878,13 @@ function telegramDeliveryWords(store, bot) {
22001
22878
  return `Telegram pushes new messages to ${state.url}${state.problem === null ? "." : `, but ${state.problem.charAt(0).toLowerCase()}${state.problem.slice(1)}.`}`;
22002
22879
  return `Toolroll asks Telegram for new messages every few seconds${state?.problem ? ` (${state.problem})` : ""}.`;
22003
22880
  }
22881
+ /** The projects this person can see, by name, and whether they muted each one's pings. */
22882
+ function notificationProjects(store, account) {
22883
+ const muted = new Set(store.mutedProjects(account));
22884
+ return store.listProjects().filter(one => store.accountCanAccess(account, one.path))
22885
+ .map(one => ({ repo: one.path, name: one.name, muted: muted.has(one.path) }))
22886
+ .sort((a, b) => a.name.localeCompare(b.name));
22887
+ }
22004
22888
  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
22889
  const permissionCard = permissionDefault === null
22006
22890
  ? ""
@@ -22045,6 +22929,14 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
22045
22929
  `<p class="meta">One message: what finished, what waits, what failed.</p>`,
22046
22930
  `<button type="submit">Save</button>`,
22047
22931
  `</form>`,
22932
+ ...((chatNotices.projects ?? []).length === 0 ? [] : [
22933
+ "<h3>Projects</h3>",
22934
+ `<ul class="card">${chatNotices.projects.map(one => `<li><form method="post" action="/settings/notifications/mute">` +
22935
+ `<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="repo" value="${escape(one.repo)}">` +
22936
+ (one.muted ? "" : `<input type="hidden" name="pings" value="on">`) +
22937
+ `${escape(one.name)} <span class="meta">· ${one.muted ? "muted" : "pings on"}</span> <button type="submit">${one.muted ? "Unmute" : "Mute"}</button></form></li>`).join("")}</ul>`,
22938
+ `<p class="meta">Muted projects still show in Tasks and the evening digest.</p>`,
22939
+ ]),
22048
22940
  ].join("\n");
22049
22941
  const digestCard = digest === null || csrf === "" || chatNotices?.mode === "quiet"
22050
22942
  ? ""
@@ -22326,6 +23218,7 @@ function settingsTiles() {
22326
23218
  const SETTINGS_TILE_ICONS = [
22327
23219
  ["/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
23220
  ["/settings/integrations", "Integrations", `<path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/>`],
23221
+ ["/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
23222
  ["/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
23223
  ["/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
23224
  ["/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"/>`],