toolroll 0.8.2 → 0.9.1

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 (68) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agent-onboard.d.ts +24 -1
  3. package/dist/agent-onboard.js +53 -1
  4. package/dist/backup-ui.js +4 -2
  5. package/dist/browser/workspace.css +2 -2
  6. package/dist/browser/workspace.js +33 -32
  7. package/dist/browser-workspace.d.ts +22 -2
  8. package/dist/chat-actions.d.ts +5 -0
  9. package/dist/chat-actions.js +43 -3
  10. package/dist/chat-delivery.js +51 -3
  11. package/dist/chat-quiet.d.ts +28 -0
  12. package/dist/chat-quiet.js +161 -0
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +4 -1
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/digest-times.d.ts +3 -0
  17. package/dist/digest-times.js +7 -0
  18. package/dist/dispose.js +2 -0
  19. package/dist/export.js +1 -1
  20. package/dist/flow-engine.js +13 -1
  21. package/dist/flow-pull-request.d.ts +11 -0
  22. package/dist/flow-pull-request.js +123 -0
  23. package/dist/flow-starters-ui.d.ts +20 -0
  24. package/dist/flow-starters-ui.js +35 -0
  25. package/dist/flow-starters.d.ts +55 -0
  26. package/dist/flow-starters.js +104 -0
  27. package/dist/flow-steps.js +14 -0
  28. package/dist/flows-ui.d.ts +1 -1
  29. package/dist/flows-ui.js +4 -3
  30. package/dist/flows.d.ts +30 -6
  31. package/dist/flows.js +110 -6
  32. package/dist/integrations-ui.d.ts +13 -0
  33. package/dist/integrations-ui.js +70 -0
  34. package/dist/integrations.d.ts +113 -0
  35. package/dist/integrations.js +526 -0
  36. package/dist/lead-context.d.ts +1 -1
  37. package/dist/lead-context.js +1 -1
  38. package/dist/mate-contract.js +1 -1
  39. package/dist/mate-tools.js +18 -5
  40. package/dist/models-ui.js +3 -2
  41. package/dist/monitoring-ui.js +2 -1
  42. package/dist/operate.d.ts +5 -2
  43. package/dist/operate.js +169 -4
  44. package/dist/policy-ui.js +2 -1
  45. package/dist/project-delete.js +1 -0
  46. package/dist/publish.d.ts +11 -0
  47. package/dist/publish.js +9 -2
  48. package/dist/pull-request-flow.d.ts +172 -0
  49. package/dist/pull-request-flow.js +475 -0
  50. package/dist/pull-request-schema.d.ts +5 -0
  51. package/dist/pull-request-schema.js +21 -0
  52. package/dist/pull-request-ui.d.ts +23 -0
  53. package/dist/pull-request-ui.js +55 -0
  54. package/dist/pulls.d.ts +5 -0
  55. package/dist/pulls.js +11 -0
  56. package/dist/serve.d.ts +6 -0
  57. package/dist/serve.js +371 -58
  58. package/dist/setup-guide.d.ts +2 -2
  59. package/dist/skills-ui.js +2 -1
  60. package/dist/store.d.ts +61 -0
  61. package/dist/store.js +144 -3
  62. package/dist/surface.js +3 -1
  63. package/dist/telegram-progress.d.ts +1 -0
  64. package/dist/telegram-progress.js +1 -1
  65. package/dist/telegram.js +95 -15
  66. package/dist/when-html.d.ts +8 -0
  67. package/dist/when-html.js +36 -0
  68. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -25,6 +25,8 @@ import { grantTool, revokeTool, rulesFromForm, setToolRules } from "./teammate-t
25
25
  import { addKitGithubTrigger, addKitSample, kitInstalled, kitOf, setUpKit } from "./kits.js";
26
26
  import { CONNECT_CALLBACK, connectionsOf, finishConnect, oneClickOf, startConnect } from "./mcp-connect.js";
27
27
  import { KITS_CSS, kitPageHtml, kitsGalleryHtml } from "./kits-ui.js";
28
+ import { STARTERS_CSS, startersHtml } from "./flow-starters-ui.js";
29
+ import { starterForWork, starterOf, startersFor, switchOnStarter } from "./flow-starters.js";
28
30
  import { createTeammateFrom, labelOf, nameOf, saveSoul, setTeammateState, teammateSettings, sendTeammateSummaries } from "./teammate-admin.js";
29
31
  import { editMemory, forgetMemory, tellTeammate } from "./teammate-memory.js";
30
32
  import { addRoutine, removeRoutine, runRoutine } from "./teammate-desk.js";
@@ -56,6 +58,8 @@ import { prepareWorkspaceRevision, WorkspaceValidatorCache } from "./workspace-r
56
58
  import { workIndexPage, workCountsByProject, WorkIndexCursorError } from "./work-index.js";
57
59
  import { openWorkDecisionOf } from "./work-summary.js";
58
60
  import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
61
+ import { pullRequestSettingsHtml, PULL_REQUEST_SETTINGS_CSS } from './pull-request-ui.js';
62
+ import { checkPublishing, completeAndOpenPullRequest, mergeAsPerson, pullRequestBlocker, pullRequestViewOf, publishingOf, saveMergeSettings, savePublishing, MERGE_METHODS } from './pull-request-flow.js';
59
63
  import { leadBriefHtml, LEAD_CONTEXT_CSS } from './lead-context.js';
60
64
  import { assignmentCatchUp } from './assignment-brief.js';
61
65
  import { assignmentCardOf, assignmentStatusOf, assignmentSummaryHtml, assignmentWithEvidence, ASSIGNMENT_CSS } from './assignment-ui.js';
@@ -167,7 +171,8 @@ import { dirname } from "node:path";
167
171
  import { AsyncLocalStorage } from "node:async_hooks";
168
172
  import { loadOrCreateVapidKeys, validatePushEndpoint } from "./push.js";
169
173
  import { parseGithubRepo, previewGithubRepo, cloneGithubRepo, listGithubRepos, isLargeRepo } from "./onboard.js";
170
- import { verifiedAuthor, LEAD_THREAD } from "./store.js";
174
+ import { verifiedAuthor, LEAD_THREAD, isDigestTime } from "./store.js";
175
+ import { digestTimes } from "./digest-times.js";
171
176
  import { updateRepos, addRepos, removeRepos } from "./repos.js";
172
177
  import { run as execRun } from "./exec.js";
173
178
  import { findFirstTasks, firstResultWords, firstRunSteps, firstTaskSuggestions, SANDBOX_COMMAND } from "./first-run.js";
@@ -201,6 +206,8 @@ import { accessFromGroups, accountNameFor, discoverOidc, exchangeOidcCode, newOi
201
206
  import { readSsoSettings, removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "./sso-settings.js";
202
207
  import { monitoringChange, readMonitoring, saveMonitoring } from "./monitoring-settings.js";
203
208
  import { MONITORING_CSS, monitoringHtml, signingSecretHtml } from "./monitoring-ui.js";
209
+ import { INTEGRATIONS_CSS, integrationsHtml } from "./integrations-ui.js";
210
+ import { createIntegrationMonitor } from "./integrations.js";
204
211
  import { BACKUP_CSS, backupHtml } from "./backup-ui.js";
205
212
  import { BACKUP_EVERY_HOURS, MAX_KEEP, backupFolderOf, backupNow, checkBackupFolder, defaultBackupFolder } from "./backup.js";
206
213
  import { prometheusMetrics } from "./metrics.js";
@@ -241,6 +248,7 @@ import { cachedRelease, isNewer, runnerVersions, setUpdateChecks, updateChecksOf
241
248
  import { installMethod } from "./install-method.js";
242
249
  import { PACKAGE_VERSION } from "./version.js";
243
250
  import { updateNoticeWords } from "./update-notice.js";
251
+ import { whenHtml } from "./when-html.js";
244
252
  const SESSION_COOKIE = "standing-orders_session";
245
253
  const BODY_CAP = 16 * 1024;
246
254
  // URL encoding can triple UTF-8 bytes. Admit the existing bounded task
@@ -438,6 +446,19 @@ export function createDecisionServer(options) {
438
446
  catch { /* the pause keeps its own state */ }
439
447
  return value;
440
448
  };
449
+ // Settings → Integrations: checked in the background with a short cache, as the first-run suggestions are.
450
+ const integrations = createIntegrationMonitor(() => ({
451
+ store,
452
+ dir: options.configDir ?? null,
453
+ telegramTokenFile: options.telegramTokenFile ?? null,
454
+ env: process.env,
455
+ repos: managedRepos(),
456
+ gh: options.firstTaskRunner ?? execRun,
457
+ checkConnection: connectionCheck,
458
+ toolHome,
459
+ clock,
460
+ ...options.integrationIo,
461
+ }));
441
462
  const modelCatalog = openRouterModelsCache(options.modelCatalogFetcher);
442
463
  const modelSeams = {
443
464
  home: providerHome,
@@ -1564,9 +1585,9 @@ export function createDecisionServer(options) {
1564
1585
  refuse(response, who, 404, 'No such action in your projects.', '/projects');
1565
1586
  return false;
1566
1587
  }
1567
- const read = new Set(["/settings/skills", "/settings/knowledge", "/settings", "/settings/learning", "/recipes", "/recipes/run", "/recipes/start", "/recipes/new", "/recipes/edit", "/recipes/from-task", "/recipes/preview", "/recipes/export", "/", "/inbox", "/work", "/projects", "/people", "/ledger", "/ledger/export", "/next", "/board", "/tasks", "/tasks/new", "/runs", "/review", "/done", "/routines", "/menu"]);
1568
- const write = new Set(["/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"]);
1569
- 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|stop|resume-arm|resume)$");
1588
+ const read = new Set(["/settings/flows", "/settings/skills", "/settings/knowledge", "/settings", "/settings/learning", "/recipes", "/recipes/run", "/recipes/start", "/recipes/new", "/recipes/edit", "/recipes/from-task", "/recipes/preview", "/recipes/export", "/", "/inbox", "/work", "/projects", "/people", "/ledger", "/ledger/export", "/next", "/board", "/tasks", "/tasks/new", "/runs", "/review", "/done", "/routines", "/menu"]);
1589
+ 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"]);
1590
+ 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)$");
1570
1591
  const resource = request.method === "GET"
1571
1592
  ? /^\/(?: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)
1572
1593
  : /^\/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);
@@ -3305,7 +3326,7 @@ export function createDecisionServer(options) {
3305
3326
  back: { href: roomId ? "/chat?conversation=" + encodeURIComponent(roomId) : taskChatHref(focusTask?.id ?? ""), label: "Back to chat" },
3306
3327
  }) + (who.role === 'approver' && focusTask?.assignment?.state === 'ready-to-check'
3307
3328
  && focusTask.assignment.receipt?.runId === resultRun.id
3308
- ? completionForm(focusTask.assignment.receipt.taskId, resultRun.id, focusTask.assignment.receipt.digest, who.session.csrf) : '');
3329
+ ? completionForm(focusTask.assignment.receipt.taskId, resultRun.id, focusTask.assignment.receipt.digest, who.session.csrf, pullRequestTargetOf(resultRun.id)) : '');
3309
3330
  const focusProblem = requestedTask !== null && focusTask === null
3310
3331
  ? "That task is not available in this workspace."
3311
3332
  : requestedResult !== null && focusTask !== null && resultRun === null
@@ -3621,6 +3642,27 @@ export function createDecisionServer(options) {
3621
3642
  sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name) };
3622
3643
  return sendScreen(response, 200, screen("Sessions & tokens", `<p><a href="/settings">Settings</a></p><h1>Sessions &amp; tokens</h1>${credentialsHtml(view, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3623
3644
  }
3645
+ // Settings → Flows: the starter flows, each switched on with one yes. ?starter= marks the one a task's
3646
+ // "Do this every time…" asked about.
3647
+ if (url.pathname === "/settings/flows") {
3648
+ const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3649
+ const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
3650
+ if (chosen && !projects.includes(chosen))
3651
+ return refuse(response, who, 403, "That project is outside your access.", "/settings");
3652
+ const content = chosen === "" ? "<p>Add a project to switch on starter flows.</p>" : startersHtml({
3653
+ repo: chosen, projects: projects.map(path => ({ path, name: projectName(path) })), starters: startersFor(store, chosen), csrf: who.via === "cookie" ? who.session.csrf : "",
3654
+ canSwitch: who.via === "cookie" && who.role === "approver" && !store.isDemo(), suggested: starterOf(url.searchParams.get("starter") ?? "")?.id ?? null,
3655
+ said: url.searchParams.get("said"), problem: url.searchParams.get("problem")
3656
+ });
3657
+ return sendScreen(response, 200, screen("Flows", `<p><a href="/settings">Settings</a></p><h1>Flows</h1>${content}`, { chrome: chromeFor(chosen || project, "settings") }));
3658
+ }
3659
+ // Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
3660
+ if (url.pathname === "/settings/integrations") {
3661
+ if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
3662
+ return refuse(response, who, 403, "An installation approver sees integrations.", "/settings");
3663
+ const list = integrations.list();
3664
+ return sendScreen(response, 200, screen("Integrations", `<p><a href="/settings">Settings</a></p><h1>Integrations</h1>${integrationsHtml(list, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem"), checking: integrations.checking })}`, { chrome: chromeFor(project, "settings") }));
3665
+ }
3624
3666
  // v104: Settings → Monitoring, where the audit stream and traces go. An instance operator's page.
3625
3667
  if (url.pathname === "/settings/monitoring") {
3626
3668
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
@@ -3659,6 +3701,20 @@ export function createDecisionServer(options) {
3659
3701
  const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
3660
3702
  return sendScreen(response, 200, screen("Storage", `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${storageHtml({ plan, csrf: who.session.csrf }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3661
3703
  }
3704
+ // Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
3705
+ // exactly what turning on would do, or what to fix first.
3706
+ if (url.pathname === "/settings/pull-requests") {
3707
+ const repo = url.searchParams.get("repo") ?? "";
3708
+ const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
3709
+ if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
3710
+ return refuse(response, who, 404, "No such project.", "/projects");
3711
+ const publishing = publishingOf(store, repo);
3712
+ const canChange = who.role === "approver" && !store.isDemo();
3713
+ const check = publishing.on || !canChange ? null : await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
3714
+ const html = pullRequestSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange, publishing, check,
3715
+ said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
3716
+ return sendScreen(response, 200, screen("Pull requests", `<p><a href="/projects">Projects</a></p><h1>Pull requests</h1>${html}`, { chrome: chromeFor(project, "projects") }));
3717
+ }
3662
3718
  // Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
3663
3719
  if (url.pathname === "/settings/backups") {
3664
3720
  const databaseFile = store.databaseFile();
@@ -3833,6 +3889,9 @@ export function createDecisionServer(options) {
3833
3889
  // The installation fact: when the first Ready result arrived, measured from the first account.
3834
3890
  const at = store.firstSuccessAt(now), since = store.installationStartedAt();
3835
3891
  return at === null || since === null ? null : firstResultWords(since, at);
3892
+ })(), (() => {
3893
+ const chosen = store.notificationPreference(who.name);
3894
+ return { mode: chosen.mode, digestAt: chosen.digestAt };
3836
3895
  })()));
3837
3896
  }
3838
3897
  if (url.pathname === "/push/key") {
@@ -4848,7 +4907,7 @@ export function createDecisionServer(options) {
4848
4907
  .map(run => `<a class="item${run.id === currentId ? " current" : ""}" href="/r/${run.id}">` +
4849
4908
  `<span class="t">#${run.id} \u00b7 ${escape(run.taskId)}</span>` +
4850
4909
  `<span class="m">${runOutcomeBadge(run, live.has(run.id))}` +
4851
- `<span class="mono">${escape(when(run.startedAt))}</span></span></a>`)
4910
+ `<span class="mono">${whenTime(run.startedAt)}</span></span></a>`)
4852
4911
  .join("\n");
4853
4912
  return `<h2>builds</h2>\n${items === "" ? `<p class="meta">none yet</p>` : items}`;
4854
4913
  }
@@ -4903,7 +4962,7 @@ export function createDecisionServer(options) {
4903
4962
  peeks[one.path] = peekOf(one.path);
4904
4963
  for (const path of candidates)
4905
4964
  peeks[path] = peekOf(path);
4906
- return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, csrf, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo));
4965
+ 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));
4907
4966
  }
4908
4967
  /** The task screen, shared by the GET and by every refusal that re-renders it. */
4909
4968
  /**
@@ -5361,6 +5420,41 @@ export function createDecisionServer(options) {
5361
5420
  : completionReceiptView(store, latest, store.artifactsFor(latest.id), evidenceRoot, reviewFactsFor(latest.id)),
5362
5421
  };
5363
5422
  }
5423
+ /** The newest pull request any version of this task opened through Complete; else, for a result completed
5424
+ * without one, the offer to open it (an approver's browser session only). */
5425
+ /** Where "Complete and open a pull request" would open one for this result, or null when it isn't offered. */
5426
+ function pullRequestTargetOf(runId) {
5427
+ const run = store.getRun(runId);
5428
+ const publishing = publishingOf(store, run === null ? null : store.refById(run.taskRef)?.repo ?? null);
5429
+ if (!publishing.on || publishing.legacy || store.publicationForRun(runId) !== null || pullRequestBlocker(store, runId) !== null)
5430
+ return null;
5431
+ return `${publishing.githubRepo} into ${publishing.base}`;
5432
+ }
5433
+ function taskPullRequestOf(taskId, who) {
5434
+ const family = familyOf(taskId);
5435
+ const rootId = family?.root.id ?? taskId;
5436
+ const ids = family === null ? [taskId] : [...family.versions.map(one => one.id)].reverse();
5437
+ const repo = store.lookupRef(taskId)?.repo ?? null;
5438
+ const publishing = publishingOf(store, repo);
5439
+ const target = publishing.on ? publishing.base : null;
5440
+ for (const id of ids) {
5441
+ const ref = store.lookupRef(id);
5442
+ if (ref === null)
5443
+ continue;
5444
+ for (const run of store.runsFor(ref.id)) {
5445
+ const view = pullRequestViewOf(store, run.id);
5446
+ if (view !== null)
5447
+ return { taskId: rootId, view: { ...view, canMerge: view.canMerge && who.role === "approver" && who.via === "cookie" }, offer: null, target };
5448
+ }
5449
+ }
5450
+ if (!publishing.on || who.via !== "cookie" || who.role !== "approver")
5451
+ return null;
5452
+ const assignment = assignmentOf(store, taskId, clock(), { principal: "operator", repos: admissionList(), includeUnplaced: visible(null) }, evidenceRoot);
5453
+ const receipt = assignment?.receipt ?? null;
5454
+ if (assignment?.state !== "complete" || receipt === null || store.publicationForRun(receipt.runId) !== null || pullRequestBlocker(store, receipt.runId) !== null)
5455
+ return null;
5456
+ return { taskId: rootId, view: null, offer: { taskId: receipt.taskId, runId: receipt.runId, digest: receipt.digest }, target };
5457
+ }
5364
5458
  function taskScreen(response, who, taskId, problem, status, scopeDraft, cancelDraft) {
5365
5459
  const admittedFamily = familyOf(taskId);
5366
5460
  if (admittedFamily?.problem != null)
@@ -5376,6 +5470,7 @@ export function createDecisionServer(options) {
5376
5470
  presentedData.scopeDraft = scopeDraft;
5377
5471
  if (cancelDraft !== undefined)
5378
5472
  presentedData.cancelDraft = cancelDraft;
5473
+ presentedData.pullRequest = taskPullRequestOf(taskId, who);
5379
5474
  const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
5380
5475
  const page = taskPage(paneProject === null && !unscopedMode
5381
5476
  ? chromeFor(paneProject, "tasks")
@@ -6196,7 +6291,12 @@ export function createDecisionServer(options) {
6196
6291
  return redirect(response, `/flows?problem=${encodeURIComponent("Choose one of your projects.")}`);
6197
6292
  const template = FLOW_TEMPLATES.find(one => one.id === body.get("template")) ?? FLOW_TEMPLATES[0];
6198
6293
  const name = (body.get("name") ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80) || template.label;
6199
- return redirect(response, `/flows/${store.createFlow({ repo, name, definitionJson: JSON.stringify(template.definition), by: who.name }, now)}`);
6294
+ const made = store.createFlow({ repo, name, definitionJson: JSON.stringify(template.definition), by: who.name }, now);
6295
+ // A template that starts from something (Issues to PRs: labelled issues) comes with its trigger.
6296
+ const trigger = template.trigger === undefined ? null : addFlowTriggerTo(store, store.getFlow(made), template.trigger, who.name, now, options.configDir ?? null);
6297
+ if (trigger !== null && !trigger.ok)
6298
+ return redirect(response, `/flows?problem=${encodeURIComponent(`${name} was made, but its trigger wasn't: ${trigger.message} Add one on its Triggers panel.`)}`);
6299
+ return redirect(response, `/flows/${made}`);
6200
6300
  }
6201
6301
  const flow = store.getFlow(Number((flowPost ?? triggerPost)[1]));
6202
6302
  if (flow === null || flow.state !== "active" || !visible(flow.repo))
@@ -6668,6 +6768,43 @@ export function createDecisionServer(options) {
6668
6768
  const done = await discardCheckout(store, pool, body.get("path") ?? "", now, who.name);
6669
6769
  return done.ok ? back("said", `Discarded, about ${bytesWords(done.bytes)}. Its branch stays.`) : back("problem", done.message);
6670
6770
  }
6771
+ // Pull requests for one project: turn on (the checks run again and must match what the page showed), merge
6772
+ // settings, or turn off. Each takes the password and lands in the ledger.
6773
+ if (url.pathname === "/settings/pull-requests") {
6774
+ const repo = body.get("repo") ?? "";
6775
+ const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
6776
+ if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
6777
+ return refuse(response, who, 404, "No such project.", "/projects");
6778
+ if (who.role !== "approver" || store.isDemo())
6779
+ return refuse(response, who, 403, "An approver sets up pull requests.", "/projects");
6780
+ const back = (key, words) => redirect(response, `/settings/pull-requests?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
6781
+ const act = body.get("act");
6782
+ if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
6783
+ return back("problem", "Enter your Toolroll password to change pull requests.");
6784
+ if (act === "on") {
6785
+ const checked = await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
6786
+ if (!checked.ok)
6787
+ return back("problem", checked.message);
6788
+ if (checked.plan.githubRepo !== body.get("github") || checked.plan.base !== body.get("base"))
6789
+ return back("problem", "GitHub answered differently from what you saw. Check the details again.");
6790
+ savePublishing(store, checked.plan, who.name, {}, now);
6791
+ return back("said", "Pull requests are on.");
6792
+ }
6793
+ if (act === "settings") {
6794
+ const method = body.get("method") ?? "";
6795
+ if (!MERGE_METHODS.includes(method))
6796
+ return back("problem", "Choose a merge method.");
6797
+ const saved = saveMergeSettings(store, repo, { mergeMethod: method, mergeWhenGreen: body.get("when-green") === "1" }, who.name, now);
6798
+ return saved.ok ? back("said", "Saved.") : back("problem", saved.message);
6799
+ }
6800
+ if (act === "off") {
6801
+ const revoked = store.revokePublicationGrant(repo, who.name, now);
6802
+ if (revoked)
6803
+ store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "pull requests turned off", outcome: "off", source: "policy" });
6804
+ return back("said", revoked ? "Pull requests are off." : "Pull requests were already off.");
6805
+ }
6806
+ return back("problem", "That change isn't available.");
6807
+ }
6671
6808
  // Sprint 8: back up now. An instance operator; the outcome is kept like a scheduled one's.
6672
6809
  if (url.pathname === "/settings/backups/now") {
6673
6810
  const databaseFile = store.databaseFile();
@@ -6716,6 +6853,33 @@ export function createDecisionServer(options) {
6716
6853
  "cache-control": "no-store", "x-content-type-options": "nosniff" });
6717
6854
  return void response.end(zip);
6718
6855
  }
6856
+ // Settings → Flows → Switch on: a starter flow's zones and trigger, made together.
6857
+ if (url.pathname === "/settings/flows/on") {
6858
+ const repo = body.get("repo") ?? "";
6859
+ const back = (key, words) => redirect(response, `/settings/flows?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
6860
+ const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])];
6861
+ if (who.via !== "cookie" || who.role !== "approver" || store.isDemo())
6862
+ return refuse(response, who, 403, "An approver switches starter flows on.", "/settings/flows");
6863
+ if (!known.includes(repo) || !visible(repo))
6864
+ return refuse(response, who, 404, "No such project.", "/settings/flows");
6865
+ const starter = starterOf(body.get("starter") ?? "");
6866
+ if (starter === null)
6867
+ return back("problem", "Choose a starter flow.");
6868
+ const switched = switchOnStarter(store, starter, repo, who.name, clock(), options.configDir ?? null);
6869
+ return switched.ok ? back("said", switched.said) : back("problem", switched.said);
6870
+ }
6871
+ // Settings → Integrations → Send test: one harmless read-only check of one integration, now.
6872
+ if (url.pathname === "/settings/integrations/test") {
6873
+ if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
6874
+ return refuse(response, who, 403, "An installation approver tests integrations.", "/settings");
6875
+ const key = body.get("key") ?? "";
6876
+ const back = (name, words) => redirect(response, `/settings/integrations?${name}=${encodeURIComponent(words)}`);
6877
+ const checked = (await integrations.check([key])).find(one => one.key === key);
6878
+ if (checked === undefined)
6879
+ return back("problem", "That integration isn't here any more.");
6880
+ return checked.state === "connected" ? back("said", `${checked.name} works${checked.account === null ? "" : ` (${checked.account})`}.`)
6881
+ : back("problem", `${checked.name}: ${checked.action.kind === "fix" ? checked.action.words : "not set up."}`);
6882
+ }
6719
6883
  // v104: change where monitoring goes. A step-up; the ledger keeps before → after (addresses, never secrets).
6720
6884
  if (url.pathname === "/settings/monitoring") {
6721
6885
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
@@ -7275,6 +7439,20 @@ export function createDecisionServer(options) {
7275
7439
  ? "New tasks now use Strict / release. Approved tasks keep their setting."
7276
7440
  : "New tasks now use Default quality. Approved tasks keep their setting.")}`);
7277
7441
  }
7442
+ if (url.pathname === "/settings/notifications") {
7443
+ // Each person's own choice: a closed list of modes and a 24-hour HH:MM, or off.
7444
+ const mode = body.get("mode"), time = (body.get("digest") ?? "").trim();
7445
+ if (mode !== null && mode !== "quiet" && mode !== "all")
7446
+ return refuse(response, who, 400, "choose Only when I'm needed or Every step", "/settings");
7447
+ if (time !== "" && time !== "off" && !isDigestTime(time))
7448
+ return refuse(response, who, 400, "the evening digest time is HH:MM, or off", "/settings");
7449
+ const before = store.notificationPreference(who.name);
7450
+ const after = store.setNotificationPreference(who.name, { ...(mode === null ? {} : { mode }), ...(time === "" ? {} : { digestAt: time === "off" ? null : time }) }, who.name, now);
7451
+ const said = after.mode !== before.mode
7452
+ ? after.mode === "quiet" ? "Chats now message you only when you're needed." : "Chats now message you at every step."
7453
+ : after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
7454
+ return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
7455
+ }
7278
7456
  if (url.pathname === "/settings/telegram-digest" && options.telegramTokenFile !== undefined) {
7279
7457
  // The cadence is a closed list of minutes — never a free number from
7280
7458
  // a form; "off" clears it. Any approver session may set it.
@@ -8006,7 +8184,28 @@ export function createDecisionServer(options) {
8006
8184
  if (attendAct !== null) {
8007
8185
  return attendMutation(response, who, attendAct.taskId, attendAct.verb, body, now);
8008
8186
  }
8009
- const act = matchTaskPath(url.pathname, "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|block|unblock|repair-dependency|next|reopen|steer|follow-up|accept-proof|accept-revision|reject-revision|route|retry-review|complete|stop|resume-arm|resume)$");
8187
+ const act = matchTaskPath(url.pathname, "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|block|unblock|repair-dependency|next|reopen|steer|follow-up|accept-proof|accept-revision|reject-revision|route|retry-review|complete|merge|stop|resume-arm|resume)$");
8188
+ if (act !== null && act.verb === "merge") {
8189
+ // Merging is a person's act behind their password, typed again: a browser session, an approver, the exact
8190
+ // result's pull request. The flow re-reads GitHub before merging and records who merged in the ledger.
8191
+ if (who.via !== "cookie" || who.role !== "approver")
8192
+ return refuse(response, who, 403, "Only an approver can merge.");
8193
+ if (store.isDemo())
8194
+ return taskScreen(response, who, act.taskId, "The demo merges nothing.", 403);
8195
+ const ref = store.lookupRef(act.taskId);
8196
+ if (ref === null || !visible(ref.repo))
8197
+ return refuse(response, who, 404, "no such task", "/tasks");
8198
+ const named = (body.get("run") ?? "").trim();
8199
+ const run = /^[0-9]{1,15}$/.test(named) ? store.getRun(Number(named)) : null;
8200
+ const runTask = run === null ? null : store.externalIdFor(run.taskRef);
8201
+ if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
8202
+ return taskScreen(response, who, act.taskId, "That pull request isn't part of this task.", 409);
8203
+ }
8204
+ const merged = await mergeAsPerson(store, { runId: run.id, name: who.name, password: body.get("token") ?? "", ...(options.publishExec === undefined ? {} : { exec: options.publishExec }), clock });
8205
+ if (!merged.ok)
8206
+ return taskScreen(response, who, act.taskId, merged.message, merged.reason === "password" ? 403 : 409);
8207
+ return redirect(response, `${taskHref(act.taskId)}#merge`);
8208
+ }
8010
8209
  if (act !== null) {
8011
8210
  return taskMutation(response, who, act.taskId, act.verb, body, now);
8012
8211
  }
@@ -9232,7 +9431,7 @@ export function createDecisionServer(options) {
9232
9431
  `<p class="meta">worker</p><p class="recap" style="margin-top:0">${escape(terms.runner)} (this machine)</p>` +
9233
9432
  `<p class="meta">spending</p><p class="recap" style="margin-top:0">up to about $${(terms.budgetMicrousd / 1_000_000).toFixed(2)} — the agent stops as soon as its total crosses this; the final step may run a little past it</p>` +
9234
9433
  `<p class="meta">conversation</p><p class="recap" style="margin-top:0">at most ${terms.maxSessionTurns} messages to the agent, this whole session; each may work up to ${Math.round(terms.turnTimeoutSeconds / 60)} minutes — one that runs past that ends the whole session. If it needs a repair, the repair uses the same session, model, and clock.</p>` +
9235
- `<p class="meta">while you watch — a signed term</p><p class="recap" style="margin-top:0">your console being open is what keeps it running — any page of it, this one included; close the console and the session winds down within a minute. Everything ends by ${escape(when(terms.absoluteExpiry))} regardless. One attempt; it never converts into unattended work.</p>` +
9434
+ `<p class="meta">while you watch — a signed term</p><p class="recap" style="margin-top:0">your console being open is what keeps it running — any page of it, this one included; close the console and the session winds down within a minute. Everything ends by ${whenTime(terms.absoluteExpiry)} regardless. One attempt; it never converts into unattended work.</p>` +
9236
9435
  (quick
9237
9436
  ? `<p class="meta">your signed mode covers this mint — no password; the mode is re-proved as you confirm, and the session is stamped with its signature</p>`
9238
9437
  : `<label>your password, typed again — a signed-in session alone cannot authorize work<input type="password" name="token" autocomplete="current-password"></label>`) +
@@ -10043,6 +10242,14 @@ export function createDecisionServer(options) {
10043
10242
  if (current === null || current.receipt?.taskId !== taskId || !/^[0-9]{1,15}$/.test(namedRun) || current.receipt.runId !== Number(namedRun)) {
10044
10243
  return taskScreen(response, who, taskId, "This result changed. Open the current result before marking it complete.", 409);
10045
10244
  }
10245
+ if (body.get("publish") === "1") {
10246
+ // "Complete and open a pull request": the same exact-receipt completion, plus the owed PR for this
10247
+ // exact commit in one transaction. The watch process pushes and opens it; the task shows its progress.
10248
+ const opened = completeAndOpenPullRequest(store, { taskId, digest, runId: Number(namedRun), who: principal, root: evidenceRoot }, now);
10249
+ if (!opened.ok)
10250
+ return taskScreen(response, who, taskId, opened.message, 409);
10251
+ return redirect(response, `${taskHref(familyOf(taskId)?.root.id ?? taskId)}#merge`);
10252
+ }
10046
10253
  const completed = checkAssignmentAsOperator(store, taskId, digest, principal, now, evidenceRoot);
10047
10254
  if (!completed.ok)
10048
10255
  return taskScreen(response, who, taskId, completed.message, 409);
@@ -10567,6 +10774,7 @@ export function createDecisionServer(options) {
10567
10774
  proof,
10568
10775
  terminal,
10569
10776
  publication,
10777
+ pullRequestTo: pullRequestTargetOf(run.id),
10570
10778
  ciFailing: publication !== null && publication.prNumber !== null && store.hasOpenCiEpisode(publication.githubRepo, publication.prNumber),
10571
10779
  files,
10572
10780
  outsideTouches: files.filter(one => one.outsideTouches).map(one => one.path),
@@ -10806,6 +11014,10 @@ function consoleSpend(summary) {
10806
11014
  function when(iso) {
10807
11015
  return iso === null ? "" : iso.slice(0, 16).replace("T", " ");
10808
11016
  }
11017
+ /** The same minute as a `<time>`: the full stamp on a desk, "16:39" / "Yesterday 16:39" / "Sep 28" on a phone. */
11018
+ function whenTime(iso) {
11019
+ return iso === null ? "" : whenHtml(iso, when(iso));
11020
+ }
10809
11021
  /** Overdue is derived at render — display never writes. */
10810
11022
  function isOverdue(decision, now) {
10811
11023
  if (decision.state === "expired")
@@ -11293,7 +11505,7 @@ function agentsAvailabilityHtml(projection) {
11293
11505
  const items = [...seen.values()].map(leg => {
11294
11506
  const state = leg.readiness === "ready" ? "ready" : leg.readiness === "unavailable" ? "unavailable" : "not yet checked";
11295
11507
  const detail = leg.readiness === "unavailable" && leg.readinessReason !== null ? ` (${escape(leg.readinessReason)})` : "";
11296
- return `<li class="agents-availability-${escape(leg.readiness)}"><span class="mono">${escape(leg.provider)}</span> ${state}${detail}${leg.readinessRunner === null ? "" : ` <span class="meta">— ${escape(leg.readinessRunner)}${leg.observedAt === null ? "" : `, ${escape(when(leg.observedAt))}`}</span>`}</li>`;
11508
+ return `<li class="agents-availability-${escape(leg.readiness)}"><span class="mono">${escape(leg.provider)}</span> ${state}${detail}${leg.readinessRunner === null ? "" : ` <span class="meta">— ${escape(leg.readinessRunner)}${leg.observedAt === null ? "" : `, ${whenTime(leg.observedAt)}`}</span>`}</li>`;
11297
11509
  });
11298
11510
  return `<p class="meta agents-availability-label">Availability right now</p><ul class="agents-availability">${items.join("")}</ul>`;
11299
11511
  }
@@ -11383,7 +11595,7 @@ function agentsCardHtml(taskId, view, csrf, canEdit) {
11383
11595
  const overrides = view.overrides.length === 0
11384
11596
  ? ""
11385
11597
  : `<ul class="agents-overrides">${view.overrides
11386
- .map(one => `<li><span>${escape(ROLE_NOUN[one.phase])} → <span class="mono">${escape(one.provider)} · ${escape(one.model)}</span> <span class="meta">by ${escape(one.by)} ${escape(when(one.at))}</span></span>` +
11598
+ .map(one => `<li><span>${escape(ROLE_NOUN[one.phase])} → <span class="mono">${escape(one.provider)} · ${escape(one.model)}</span> <span class="meta">by ${escape(one.by)} ${whenTime(one.at)}</span></span>` +
11387
11599
  (canEdit && view.editable ? `<form method="post" action="${taskHref(taskId)}/route" class="agents-clear">${hidden}<input type="hidden" name="clear-phase" value="${escape(one.phase)}"><button type="submit" class="secondary" aria-label="clear the ${escape(ROLE_NOUN[one.phase].toLowerCase())} choice">clear</button></form>` : "") +
11388
11600
  `</li>`)
11389
11601
  .join("")}</ul>`;
@@ -11556,6 +11768,9 @@ ${THEME_MAPPING}
11556
11768
  --so-ease-out: cubic-bezier(.23, 1, .32, 1);
11557
11769
  --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
11558
11770
  --font-mono: "Geist Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
11771
+ /* Phone spacing steps (760px and narrower): side gutter, gap under the
11772
+ header, gap between page blocks, between rows, and inside a card. */
11773
+ --so-phone-gutter: 14px; --so-phone-gap: 12px; --so-phone-block: 14px; --so-phone-row: 8px; --so-phone-card: 12px;
11559
11774
  }
11560
11775
  @media (prefers-color-scheme: dark) {
11561
11776
  :root:not([data-theme="light"]) {
@@ -12946,6 +13161,23 @@ ${THEME_DARK}
12946
13161
  .status-line[data-tone="ready"] .status-dot, .status-line[data-tone="done"] .status-dot { background: var(--success); }
12947
13162
  .status-line[data-tone="muted"] { color: var(--muted-foreground); font-weight: 500; }
12948
13163
  .status-line[data-tone="muted"] .status-dot { background: var(--border); }
13164
+ .pull-request { display: grid; gap: .35rem; }
13165
+ .pull-request p { margin: 0; overflow-wrap: anywhere; }
13166
+ .pull-request-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .75rem; }
13167
+ .pull-request-head a, .pull-request .meta a { display: inline-flex; align-items: center; min-height: 44px; }
13168
+ .pull-request-head strong { font-size: 1.0625rem; font-weight: 600; color: var(--foreground); display: inline-flex; align-items: center; gap: .5rem; }
13169
+ .pull-request-head strong::before { content: ""; width: .5rem; height: .5rem; border-radius: 999px; background: var(--muted-foreground); }
13170
+ .pull-request--ok .pull-request-head strong::before { background: var(--success, #15803d); }
13171
+ .pull-request--problem .pull-request-head strong::before { background: var(--destructive, #b91c1c); }
13172
+ .pull-request-act { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .5rem .75rem; margin: .25rem 0 0; }
13173
+ .pull-request-act .meta { flex: 1 1 100%; }
13174
+ .pull-request-act label { display: grid; gap: .25rem; flex: 1 1 12rem; margin: 0; }
13175
+ .pull-request-act label input { margin: 0; min-height: 44px; box-sizing: border-box; width: 100%; }
13176
+ .pull-request-act button { min-height: 44px; white-space: nowrap; }
13177
+ .result-complete-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
13178
+ .result-complete-actions button { white-space: nowrap; }
13179
+ .result-complete-actions button[name=publish], .pull-request-act button { background: var(--primary); color: var(--primary-foreground); border-color: var(--primary); font-weight: 600; }
13180
+ @media (max-width: 480px) { .pull-request-act button, .result-complete-actions button { flex: 1 1 100%; } }
12949
13181
  .receipt-publication { position: relative; z-index: 1; margin: .35rem 0 0; font-size: .8125rem; color: var(--muted-foreground); }
12950
13182
  /* A raw repository path in ordinary copy wraps at any point rather than
12951
13183
  widening the page (the phone task-list overflow, package 1). */
@@ -14109,12 +14341,27 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
14109
14341
  }
14110
14342
  .sticky-actions button { margin: 0; }
14111
14343
  }
14344
+
14345
+ /* A time a person reads (when-html.ts): the full stamp on a desk, the short
14346
+ one on a phone; the full stamp stays in the title. */
14347
+ .so-when-short { display: none; }
14348
+ @media (max-width: 760px) {
14349
+ .so-when-full { display: none; }
14350
+ .so-when-short { display: inline; }
14351
+ /* Server pages outside the workspace keep the same phone rhythm. */
14352
+ main { padding: var(--so-phone-gap) var(--so-phone-gutter) 3rem; }
14353
+ h2 { margin: 1.25rem 0 .375rem; }
14354
+ .card { padding: var(--so-phone-card); margin: var(--so-phone-row) 0; }
14355
+ .problem { padding: .5rem .625rem; margin: var(--so-phone-row) 0; }
14356
+ .meta, .hint { line-height: 1.35; }
14357
+ details { margin: var(--so-phone-row) 0; }
14358
+ }
14112
14359
  `;
14113
14360
  /** Appearance: a three-way segmented switch, one tap per choice. */
14114
14361
  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}`;
14115
14362
  /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
14116
14363
  export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
14117
- const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_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 + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + LEAD_CONTEXT_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}');
14364
+ 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 + 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}');
14118
14365
  /**
14119
14366
  * The board's liveness: fetch this page's own fragment on a timer and swap
14120
14367
  * it in place — no flicker, no scroll reset, no long-lived stream to manage,
@@ -14784,7 +15031,7 @@ function inboxPage(chrome, data) {
14784
15031
  : `<div class="card builder-notice" data-builder-status="${data.worker.registered === 0 ? "not-connected" : "disconnected"}">` +
14785
15032
  (data.worker.registered === 0
14786
15033
  ? `<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.`
14787
- : `<strong>Builder disconnected.</strong> ${data.worker.registered} builder${data.worker.registered === 1 ? " is" : "s are"} configured, last checked in ${data.worker.lastHeard === null ? "never" : escape(when(data.worker.lastHeard))}. Reopen Toolroll on that machine. Queued work starts automatically when a builder reconnects.`) +
15034
+ : `<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.`) +
14788
15035
  `</div>`;
14789
15036
  return screen("inbox", [
14790
15037
  `<h1>Inbox</h1>`,
@@ -14852,8 +15099,8 @@ function systemPage(chrome, data) {
14852
15099
  ? ""
14853
15100
  : `<div class="stat-card"><span class="k"><span class="dot ${data.episode.endedAt === null ? "dot-ok pulse" : "dot-off"}"></span>Toolroll</span>` +
14854
15101
  `<span class="v">${data.episode.endedAt === null
14855
- ? `running since ${escape(when(data.episode.startedAt))}`
14856
- : `last run: ${data.episode.built} built, ${data.episode.broke} broke \u00b7 ended ${escape(when(data.episode.endedAt))}`}</span></div>`;
15102
+ ? `running since ${whenTime(data.episode.startedAt)}`
15103
+ : `last run: ${data.episode.built} built, ${data.episode.broke} broke \u00b7 ended ${whenTime(data.episode.endedAt)}`}</span></div>`;
14857
15104
  const cards = [...runnerCards, watchCard, ...worktreeCards].filter(one => one !== "");
14858
15105
  const PHASE_SAID = {
14859
15106
  plan: "planning sessions ask questions and draft the plan you approve",
@@ -15123,7 +15370,7 @@ function donePage(chrome, rows, ciRed) {
15123
15370
  return (`<div class="card"><p><a href="${taskHref(row.taskId)}"><strong>${escape(row.title)}</strong></a>` +
15124
15371
  `${row.outcome === "no-change" ? ` <span class="badge">no change needed</span>` : ""}${pr}${needsVerification}${criterionMatrixSummary(row.proofMatrix)}</p>` +
15125
15372
  `${row.handoff === null ? "" : `<p class="meta">${escape(row.handoff.length > 200 ? row.handoff.slice(0, 200) + "\u2026" : row.handoff)}</p>`}` +
15126
- `<p class="meta mono">${escape(row.taskId)} \u00b7 ${escape(when(row.completedAt))}${row.ranMinutes === null ? "" : ` \u00b7 ran ${row.ranMinutes}m`}${row.provider === null ? "" : ` \u00b7 ${escape(runCostWords({ authMode: row.authMode, costUsd: row.costUsd, tokensIn: null, tokensOut: null }))}`}` +
15373
+ `<p class="meta mono">${escape(row.taskId)} \u00b7 ${whenTime(row.completedAt)}${row.ranMinutes === null ? "" : ` \u00b7 ran ${row.ranMinutes}m`}${row.provider === null ? "" : ` \u00b7 ${escape(runCostWords({ authMode: row.authMode, costUsd: row.costUsd, tokensIn: null, tokensOut: null }))}`}` +
15127
15374
  ` \u00b7 <a href="${reviewHref(row.taskId)}">review \u2192</a></p></div>`);
15128
15375
  })
15129
15376
  .join("\n");
@@ -16560,7 +16807,7 @@ function routineScreenPage(chrome, data) {
16560
16807
  `<p class="meta">${routine.paused ? "resuming fires again at the next due slot" : "pausing stops firing instantly; a running instance finishes"}${runNowForm === "" ? "" : " · run now spawns an extra instance without touching the schedule — spend outside the schedule takes your password again"}</p>` +
16561
16808
  `</div>`;
16562
16809
  const ledger = fires.length === 0
16563
- ? `<p class="meta">No firings yet${approved && routine.nextFireAt !== null ? ` — first at ${escape(when(routine.nextFireAt))}` : ""}.</p>`
16810
+ ? `<p class="meta">No firings yet${approved && routine.nextFireAt !== null ? ` — first at ${whenTime(routine.nextFireAt)}` : ""}.</p>`
16564
16811
  : fires
16565
16812
  .map(fire => {
16566
16813
  const said = fire.outcome === "fired"
@@ -16569,7 +16816,7 @@ function routineScreenPage(chrome, data) {
16569
16816
  : `<a href="${taskHref(fire.instanceTaskId)}" class="mono">${escape(fire.instanceTaskId)}</a>${fire.instanceState === null ? "" : ` <span class="badge badge-${escape(fire.instanceState)}">${escape(fire.instanceState)}</span>`}`
16570
16817
  : `<span class="meta">skipped — ${escape(fire.reason ?? "")}</span>`;
16571
16818
  const slot = fire.scheduledFor.replace(/^manual:/, "");
16572
- return `<p class="row">${said}<span class="right meta mono">${fire.reason === "manual" ? "run now · " : ""}${escape(when(slot))}</span></p>`;
16819
+ return `<p class="row">${said}<span class="right meta mono">${fire.reason === "manual" ? "run now · " : ""}${whenTime(slot)}</span></p>`;
16573
16820
  })
16574
16821
  .join("\n");
16575
16822
  return screen(`routine · ${routine.name}`, [
@@ -16581,7 +16828,7 @@ function routineScreenPage(chrome, data) {
16581
16828
  ? `<div class="problem">stopped behind <a href="${taskHref(data.blocker.taskId)}" class="mono">${escape(data.blocker.taskId)}</a> (${escape(data.blocker.state)}) — the track resumes when it finishes or is cancelled</div>`
16582
16829
  : "",
16583
16830
  approved && !routine.paused && routine.nextFireAt !== null
16584
- ? `<p class="meta">next fire ${escape(when(routine.nextFireAt))}</p>`
16831
+ ? `<p class="meta">next fire ${whenTime(routine.nextFireAt)}</p>`
16585
16832
  : "",
16586
16833
  "<h2>the standing order</h2>",
16587
16834
  terms,
@@ -16675,8 +16922,8 @@ function homePage(chrome, data) {
16675
16922
  ? ""
16676
16923
  : `<div class="stat-card"><span class="k"><span class="dot ${data.episode.endedAt === null ? "dot-ok pulse" : "dot-off"}"></span>Toolroll</span>` +
16677
16924
  `<span class="v">${data.episode.endedAt === null
16678
- ? `running since ${escape(when(data.episode.startedAt))}`
16679
- : `last window: ${data.episode.built} built, ${data.episode.broke} broke \u00b7 ended ${escape(when(data.episode.endedAt))}`}</span></div>`;
16925
+ ? `running since ${whenTime(data.episode.startedAt)}`
16926
+ : `last window: ${data.episode.built} built, ${data.episode.broke} broke \u00b7 ended ${whenTime(data.episode.endedAt)}`}</span></div>`;
16680
16927
  const fleetCards = [...runnerCards, watchCard, ...worktreeCards].filter(one => one !== "");
16681
16928
  const fleet = fleetCards.length === 0
16682
16929
  ? `<h2>system status</h2><p class="hint">No builder is connected yet. On the machine where the project lives, open that folder and run <code>toolroll up</code>.</p>`
@@ -17357,7 +17604,7 @@ function contestPage(chrome, data) {
17357
17604
  `only one result will be kept as the task's outcome; the rest stay archived with their evidence</p>`,
17358
17605
  data.problem === null ? "" : `<div class="problem">${escape(data.problem)}</div>`,
17359
17606
  `<p class="row"><strong>${escape((CONTEST_STATE_WORDS[contest.state] ?? "the tournament is in an unexpected state — the records have the detail").replace(/tournament/g, contestNoun(contest.kind)))}</strong></p>`,
17360
- contest.pickedBy === null ? "" : `<p class="meta">picked by ${escape(contest.pickedBy)} at ${escape(when(contest.pickedAt ?? ""))}</p>`,
17607
+ contest.pickedBy === null ? "" : `<p class="meta">picked by ${escape(contest.pickedBy)} at ${whenTime(contest.pickedAt ?? "")}</p>`,
17361
17608
  contest.kind === "comparison"
17362
17609
  ? `<p class="row"><strong>spend</strong> ${escape(contestDollars(data.totalMicrousd))} measured on the lanes that report dollars` +
17363
17610
  `${data.anyUnknown ? ` <span class="meta">— the rest report tokens only</span>` : ""}</p>`
@@ -17554,7 +17801,7 @@ function githubReposPage(chrome, data) {
17554
17801
  `<div class="row"><strong>${escape(repo.nameWithOwner)}</strong>${repo.isPrivate ? ` <span class="badge">private</span>` : ""}`,
17555
17802
  `<span class="right">${action}</span></div>`,
17556
17803
  localPath === null
17557
- ? `<p class="meta">not on this machine yet${/^\d{4}-\d{2}-\d{2}T/.test(repo.updatedAt) ? ` · pushed ${escape(when(repo.updatedAt))}` : ""}</p>`
17804
+ ? `<p class="meta">not on this machine yet${/^\d{4}-\d{2}-\d{2}T/.test(repo.updatedAt) ? ` · pushed ${whenTime(repo.updatedAt)}` : ""}</p>`
17558
17805
  : `<p class="meta mono" style="overflow-wrap:anywhere;margin:.2rem 0">${escape(localPath)}</p>`,
17559
17806
  repo.description === "" ? "" : `<p class="meta">${escape(repo.description)}</p>`,
17560
17807
  `</div>`,
@@ -17568,7 +17815,7 @@ function githubReposPage(chrome, data) {
17568
17815
  `<p class="row" style="margin-top:.6rem"><a class="badge" href="/projects">← back to projects</a></p>`,
17569
17816
  ].join("\n"), { chrome });
17570
17817
  }
17571
- function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/") {
17818
+ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn) {
17572
17819
  // The onboarding card (repo onboarding, findings 1-39): preview first,
17573
17820
  // then a password-confirmed clone into a configured root. Disabled
17574
17821
  // states explain themselves in words (finding 28/39).
@@ -17658,6 +17905,7 @@ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscoped
17658
17905
  return {
17659
17906
  name, path, shortPath: path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path, open: open !== null && open === path, openedAt,
17660
17907
  knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(path)}`,
17908
+ ...(pullRequestsOn === undefined ? {} : { pullRequests: { href: `/settings/pull-requests?repo=${encodeURIComponent(path)}`, on: pullRequestsOn(path) } }),
17661
17909
  peek: peek === null ? null : [
17662
17910
  ...(peek.waiting > 0 ? [{ label: `${peek.waiting} waiting on you`, href: "/", tone: "attention" }] : []),
17663
17911
  ...(peek.running > 0 ? [{ label: `${peek.running} running`, href: "/runs", tone: "neutral" }] : []),
@@ -18254,7 +18502,7 @@ function planRevisionLedgerHtml(ledger, taskId, csrf) {
18254
18502
  : `<details class="plan-revision-history"><summary>revision history (${ledger.history.length})</summary>` +
18255
18503
  ledger.history
18256
18504
  .map(one => `<p class="row"><span class="mono">rev ${one.revision}</span> <span class="badge">${escape(one.status)}</span> ` +
18257
- `<span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span> — ${escape(one.reason)}</p>`)
18505
+ `<span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span> — ${escape(one.reason)}</p>`)
18258
18506
  .join("\n") +
18259
18507
  `</details>`;
18260
18508
  return (`<div class="card plan-revision"><span class="eyebrow">plan updated</span><h2>Using plan revision ${current.revision}</h2>` +
@@ -18591,7 +18839,7 @@ function taskBodyParts(data) {
18591
18839
  ? `attached to build #${one.attachedRun} — delivery not yet proven`
18592
18840
  : "waiting for the next attempt";
18593
18841
  const steerRows = steering
18594
- .map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))} · ${escape(steerState(one))}</span> ` +
18842
+ .map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)} · ${escape(steerState(one))}</span> ` +
18595
18843
  `${escape(one.note)}</p>`)
18596
18844
  .join("\n");
18597
18845
  const steerForm = data.csrf === "" || task.state === "done" || task.state === "cancelled" || (data.contest ?? null) !== null
@@ -18607,7 +18855,7 @@ function taskBodyParts(data) {
18607
18855
  : `<h2>holds</h2>` +
18608
18856
  data.holds
18609
18857
  .map(hold => `<p class="row">${escape(holdOwnerWords(hold.ownerKind))} — ${escape(hold.reason)}` +
18610
- `${hold.until === null ? "" : ` <span class="meta">until ${escape(when(hold.until))}</span>`}</p>`)
18858
+ `${hold.until === null ? "" : ` <span class="meta">until ${whenTime(hold.until)}</span>`}</p>`)
18611
18859
  .join("\n") +
18612
18860
  `<p class="meta">only your hold can be lifted here — waits caused by questions, incidents, or retry delays clear on their own</p>`;
18613
18861
  const approval = approvalOf(scope);
@@ -18633,7 +18881,7 @@ function taskBodyParts(data) {
18633
18881
  })(),
18634
18882
  acceptanceCeremonyHtml(scope.acceptance),
18635
18883
  approval.approved
18636
- ? `<p class="meta scope-seal">Approved by ${escape(approval.by)} · ${escape(when(approval.at))} · <span class="seal">signs ${shortDigest(scope.digest)}</span><span class="so-sr-only"> — approval binds to this exact wording</span></p>`
18884
+ ? `<p class="meta scope-seal">Approved by ${escape(approval.by)} · ${whenTime(approval.at)} · <span class="seal">signs ${shortDigest(scope.digest)}</span><span class="so-sr-only"> — approval binds to this exact wording</span></p>`
18637
18885
  : `<p class="meta"><span class="seal">signs ${shortDigest(scope.digest)}</span> — approval binds to this exact wording</p>` +
18638
18886
  `<p class="meta">Not approved${approval.reason === "changed" ? " — approved once, then rewritten" : ""}</p>`,
18639
18887
  `</div>`,
@@ -18839,7 +19087,7 @@ function taskBodyParts(data) {
18839
19087
  ? [
18840
19088
  `<div class="card" data-attended="${escape(attended.open.id)}">`,
18841
19089
  `<p><strong>attended session</strong> <span class="meta">${escape(attended.open.state)}</span></p>`,
18842
- `<p class="meta">${attended.open.running ? "the agent is running — your open console keeps it live" : "waiting to dispatch to this machine"} · ${attended.open.turnsUsed}/${attended.open.cap} messages · $${(attended.open.spentMicrousd / 1_000_000).toFixed(2)} of $${(attended.open.budgetMicrousd / 1_000_000).toFixed(2)} · everything ends by ${escape(when(attended.open.expiresAt))}</p>`,
19090
+ `<p class="meta">${attended.open.running ? "the agent is running — your open console keeps it live" : "waiting to dispatch to this machine"} · ${attended.open.turnsUsed}/${attended.open.cap} messages · $${(attended.open.spentMicrousd / 1_000_000).toFixed(2)} of $${(attended.open.budgetMicrousd / 1_000_000).toFixed(2)} · everything ends by ${whenTime(attended.open.expiresAt)}</p>`,
18843
19091
  `<p class="meta">this page being open keeps it alive — close it and the session winds down within a minute</p>`,
18844
19092
  `<form method="post" action="${taskHref(task.id)}/attend-revoke" class="inline">`,
18845
19093
  `<input type="hidden" name="csrf" value="${escape(data.csrf)}">`,
@@ -18992,7 +19240,7 @@ function taskBodyParts(data) {
18992
19240
  runOutcomeBadge(run, run.id === liveHistoryRunId) +
18993
19241
  `${run.reason === null ? "" : ` <span class="meta">${escape(reasonWords(run.reason))}</span>`}` +
18994
19242
  ` <span class="meta mono">${escape(bits.join(" · "))}</span>` +
18995
- `<span class="right meta mono">${escape(when(run.startedAt))}</span></p>`);
19243
+ `<span class="right meta mono">${whenTime(run.startedAt)}</span></p>`);
18996
19244
  })
18997
19245
  .join("\n");
18998
19246
  // ---- the right rail (slice 1c) ------------------------------------------
@@ -19047,6 +19295,9 @@ function taskBodyParts(data) {
19047
19295
  return "no project — no publication grant can apply";
19048
19296
  if (grant === null)
19049
19297
  return "branch only — publishing is not set up";
19298
+ if (grant.publishOn === "complete") {
19299
+ return `pull requests to ${grant.githubRepo} into ${grant.base} when completed · ${grant.mergeWhenGreen === true ? "merges when checks pass" : "a person merges"} (${grant.mergeMethod ?? "squash"})`;
19300
+ }
19050
19301
  return [
19051
19302
  grant.capabilities.includes("push-branch") ? `may push ${grant.headPrefix}* to ${grant.githubRepo}` : "cannot push",
19052
19303
  grant.capabilities.includes("open-pr") ? `may open a PR against ${grant.base}${grant.draft ? " (draft)" : ""}` : null,
@@ -19068,7 +19319,7 @@ function taskBodyParts(data) {
19068
19319
  const scopeRow = scope === null
19069
19320
  ? prop("scope", "none yet")
19070
19321
  : approval.approved
19071
- ? prop("approved scope", `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${escape(qualityModeTitle(scope.qualityMode ?? "default"))} · approved by ${escape(approval.by)} · ${escape(when(approval.at))}`)
19322
+ ? prop("approved scope", `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${escape(qualityModeTitle(scope.qualityMode ?? "default"))} · approved by ${escape(approval.by)} · ${whenTime(approval.at)}`)
19072
19323
  : prop("scope", approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved");
19073
19324
  const strikesRow = data.strikes > 0 ? prop("strikes", `${data.strikes} failed attempt(s)`) : "";
19074
19325
  const propsCard = `<div class="card props">${workerRow}${queueRow}${scopeRow}${publishesRow}${economics}${strikesRow}</div>`;
@@ -19102,7 +19353,8 @@ function taskBodyParts(data) {
19102
19353
  facts.push({ label: "Budget", parts: [`${data.budgetHold} · `, { label: "Spend", href: "/spend" }] });
19103
19354
  if (data.policyHold != null)
19104
19355
  facts.push({ label: "Policy", parts: [`${data.policyHold} · `, { label: "Policy", href: "/settings/policy" }] });
19105
- if (data.publication !== null && data.publication !== undefined) {
19356
+ // The pull-request card already says this once, with its action.
19357
+ if (data.publication !== null && data.publication !== undefined && data.pullRequest?.view == null) {
19106
19358
  const prHref = safePrUrl(data.publication.prUrl), pr = `PR #${data.publication.prNumber ?? "?"}`;
19107
19359
  facts.push({ label: "Published", parts: [prHref === null ? pr : { label: pr, href: prHref },
19108
19360
  ` · ${data.publication.state}${data.publication.remoteState !== null ? ` · ${data.publication.remoteState.toLowerCase()} on GitHub` : ""}${data.publication.lastCheckState !== null ? ` · CI ${data.publication.lastCheckState} at last observation` : " · no checks observed"}`] });
@@ -19283,7 +19535,9 @@ function taskBodyParts(data) {
19283
19535
  : data.filedVia === null || data.filedVia === undefined
19284
19536
  ? ""
19285
19537
  : ` · filed via ${escape(data.filedVia)}`}${data.deliverable === "report" ? ` · <span class="badge">scout</span>` : ""}</p>`;
19286
- const assignmentOptions = data.assignment == null ? null : { workStatus: status, hideAction: approveForm !== "" && data.dispatch?.action === "approve-scope", problem: status.tone === "problem", diagnostics: [...(status.diagnostics ?? []), ...(status.tone === "problem" && status.detail !== data.assignment.detail ? [status] : [])] };
19538
+ // A pull request that can be merged (or opened) owns the one primary action; the result stays in its section.
19539
+ const pullRequestActs = data.csrf !== "" && (data.pullRequest?.view?.canMerge === true || (data.pullRequest?.view == null && data.pullRequest?.offer != null));
19540
+ const assignmentOptions = data.assignment == null ? null : { workStatus: status, hideAction: (approveForm !== "" && data.dispatch?.action === "approve-scope") || pullRequestActs, problem: status.tone === "problem", diagnostics: [...(status.diagnostics ?? []), ...(status.tone === "problem" && status.detail !== data.assignment.detail ? [status] : [])] };
19287
19541
  // The result takes over from the task status as soon as it is ready.
19288
19542
  const statusHtml = (data.assignment != null && assignmentOptions !== null ? assignmentSummaryHtml(data.assignment, assignmentOptions) : receiptLeads ? completionReceiptCard(data.completion.receipt, task.id, "task", status) : taskStatusCard(status, task.id, data.dispatch ?? null, liveRunId, approveForm !== "" && data.dispatch?.action === "approve-scope")) + checkProgressHtml(data.checkProgress ?? null);
19289
19543
  // The exact-run control (v52), directly under the scheduler's answer:
@@ -19413,6 +19667,7 @@ function taskBodyParts(data) {
19413
19667
  approval: approvalHtml,
19414
19668
  lead: [
19415
19669
  { key: "history", html: data.history ?? "" }, { key: "control", html: controlHtml }, { key: "problem", html: problemHtml },
19670
+ { key: "pull-request", html: data.pullRequest == null ? "" : pullRequestCardHtml(data.pullRequest, data.csrf) },
19416
19671
  { key: "needs-scope", html: needsScopeCard }, { key: "progress", html: progressCard }, { key: "revisions", html: revisionLedgerCard },
19417
19672
  { key: "plan", html: planCard }, { key: "mirror", html: mirrorCard }, { key: "contest", html: contestCard }, { key: "attempt", html: attemptPanel },
19418
19673
  ].filter(one => one.html !== ""),
@@ -19428,6 +19683,11 @@ function taskBodyParts(data) {
19428
19683
  { id: "task-diagnostics", title: "Task options", html: optionsHtml, open: optionsOpen, count: null },
19429
19684
  ],
19430
19685
  cancel: cancelForm === "" ? null : { html: cancelForm, open: data.cancelDraft !== undefined },
19686
+ // "Do this every time…": the starter flow that does this kind of work on its own, one yes away.
19687
+ everyTime: data.repo === null || data.csrf === "" ? null : (() => {
19688
+ const starter = starterForWork(`${task.title}\n${scope?.goal ?? ""}`);
19689
+ return { href: `/settings/flows?repo=${encodeURIComponent(data.repo)}&starter=${starter.id}#starter-${starter.id}`, starter: starter.name };
19690
+ })(),
19431
19691
  };
19432
19692
  return { html, view };
19433
19693
  }
@@ -19770,7 +20030,7 @@ function reviewCockpitPage(chrome, data) {
19770
20030
  : `<span class="cockpit-why">${statusLineHtml(status)}</span>`;
19771
20031
  return (`<li data-review-priority="${row.priority.band}"><a class="cockpit-row${current ? " current" : ""}" href="${escape(reviewHref(row.taskId, row.runId, row.repo))}"${current ? ` aria-current="page"` : ""}>` +
19772
20032
  `<span class="cockpit-row-head"><strong>${escape(row.title)}</strong></span>` +
19773
- `<span class="cockpit-row-meta">${escape(when(row.completedAt))}${row.runId === null ? " · no build" : row.outcome === "no-change" ? " · no change" : ""}${row.prNumber === null ? "" : ` · PR #${row.prNumber}`}</span>` +
20033
+ `<span class="cockpit-row-meta">${whenTime(row.completedAt)}${row.runId === null ? " · no build" : row.outcome === "no-change" ? " · no change" : ""}${row.prNumber === null ? "" : ` · PR #${row.prNumber}`}</span>` +
19774
20034
  (row.historyProblem ? `<span class="cockpit-why" data-history-problem>History unavailable</span>` : why) +
19775
20035
  (status !== null && row.ciFailing ? `<span class="cockpit-why">CI is failing</span>` : "") +
19776
20036
  `</a></li>`);
@@ -19858,7 +20118,7 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
19858
20118
  ? "the scope changed after approval — the words below are the current text, not the signed one"
19859
20119
  : "never approved — the result was built without a signed scope";
19860
20120
  const approval = intent.approval.approved
19861
- ? `approved by ${escape(intent.approvedBy ?? "an operator")} · ${escape(when(intent.approval.at))}`
20121
+ ? `approved by ${escape(intent.approvedBy ?? "an operator")} · ${whenTime(intent.approval.at)}`
19862
20122
  : approvalWords;
19863
20123
  const body = `<p class="recap" style="margin-top:.25rem"><strong>goal</strong> ${escape(intent.goal)}</p>` +
19864
20124
  (intent.outOfScope === null ? `<p class="meta">no boundary was stated</p>` : `<p class="recap"><strong>not this</strong> ${escape(intent.outOfScope)}</p>`) +
@@ -19917,10 +20177,10 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
19917
20177
  const complete = assignment?.state === "ready-to-check" && assignment.receipt !== null && canRetryReview && csrf !== ""
19918
20178
  ? { action: `${taskHref(view.taskId)}/complete`, receipt: assignment.receipt.digest, run: run.id } : null;
19919
20179
  if (complete !== null)
19920
- parts.push(completionForm(view.taskId, run.id, complete.receipt, csrf));
20180
+ parts.push(completionForm(view.taskId, run.id, complete.receipt, csrf, view.detail?.pullRequestTo ?? null));
19921
20181
  if (view.notes.length > 0) {
19922
20182
  parts.push(`<section class="card cockpit-section" data-cockpit-section="notes"><h3>operator notes</h3>` +
19923
- view.notes.map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span> ${escape(one.note)}</p>`).join("\n") +
20183
+ view.notes.map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span> ${escape(one.note)}</p>`).join("\n") +
19924
20184
  `</section>`);
19925
20185
  }
19926
20186
  return {
@@ -19931,8 +20191,41 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
19931
20191
  },
19932
20192
  };
19933
20193
  }
19934
- function completionForm(taskId, runId, digest, csrf) {
19935
- return `<form method="post" action="${taskHref(taskId)}/complete" class="card result-complete"><input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="receipt" value="${escape(digest)}"><input type="hidden" name="run" value="${runId}"><p class="meta">After inspecting the work, mark this result complete. Checks stay unchanged; nothing is published or deployed.</p><button type="submit" style="min-height:44px">Mark complete</button></form>`;
20194
+ function completionForm(taskId, runId, digest, csrf, pullRequestTo = null) {
20195
+ const hidden = `<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="receipt" value="${escape(digest)}"><input type="hidden" name="run" value="${runId}">`;
20196
+ if (pullRequestTo !== null) {
20197
+ // With pull requests set up: the PR is the primary road, a bare completion the quiet one beside it.
20198
+ return `<form method="post" action="${taskHref(taskId)}/complete" class="card result-complete">${hidden}<p class="meta">After inspecting the work, mark this result complete. A pull request opens on ${escape(pullRequestTo)} from this exact commit.</p><div class="result-complete-actions"><button type="submit" name="publish" value="1" style="min-height:44px">Complete and open a pull request</button><button type="submit" class="secondary" style="min-height:44px">Complete only</button></div></form>`;
20199
+ }
20200
+ return `<form method="post" action="${taskHref(taskId)}/complete" class="card result-complete">${hidden}<p class="meta">After inspecting the work, mark this result complete. Checks stay unchanged; nothing is published or deployed.</p><button type="submit" style="min-height:44px">Mark complete</button></form>`;
20201
+ }
20202
+ /** The pull request, in one card: its state and link, one line of detail, and the one action it needs — Merge
20203
+ * behind the password when checks passed, or Open a pull request for a result completed without one. */
20204
+ function pullRequestCardHtml(pr, csrf) {
20205
+ if (pr.view === null) {
20206
+ if (pr.offer === null || csrf === "")
20207
+ return "";
20208
+ return `<section class="card pull-request" id="merge" data-pull-request="none"><div class="pull-request-head"><strong>No pull request</strong></div>` +
20209
+ `<p class="meta">This result was completed without one.</p>` +
20210
+ `<form method="post" action="${taskHref(pr.offer.taskId)}/complete" class="pull-request-act"><input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="receipt" value="${escape(pr.offer.digest)}"><input type="hidden" name="run" value="${pr.offer.runId}"><input type="hidden" name="publish" value="1">` +
20211
+ `<button type="submit" class="secondary">Open a pull request</button></form></section>`;
20212
+ }
20213
+ const view = pr.view;
20214
+ const url = safePrUrl(view.prUrl);
20215
+ const link = view.prNumber === null ? "" : url === null ? `<span class="mono">PR #${view.prNumber}</span>` : `<a href="${escape(url)}" class="mono" rel="noreferrer" target="_blank">PR #${view.prNumber}</a>`;
20216
+ const tone = view.state === "ready" || view.state === "merged" ? "ok" : view.state === "failing" || view.state === "failed" || view.state === "closed" ? "problem" : "waiting";
20217
+ const revision = view.revisionTask === null || view.state !== "failing" ? "" : ` <a href="${taskHref(view.revisionTask)}">Open revision</a>`;
20218
+ const merged = view.mergeCommit === null ? "" : `<p class="meta">Merge commit <span class="mono">${escape(view.mergeCommit.slice(0, 12))}</span></p>`;
20219
+ const target = pr.target === null ? "the base branch" : pr.target;
20220
+ const act = !view.canMerge || csrf === "" || view.prNumber === null ? "" :
20221
+ `<form method="post" action="${taskHref(pr.taskId)}/merge" class="pull-request-act" data-merge-form>` +
20222
+ `<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="run" value="${view.runId}">` +
20223
+ `<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>` +
20224
+ `<label>Password<input type="password" name="token" autocomplete="current-password" required></label>` +
20225
+ `<button type="submit">Merge</button></form>`;
20226
+ return `<section class="card pull-request pull-request--${tone}" id="merge" data-pull-request="${escape(view.state)}">` +
20227
+ `<div class="pull-request-head"><strong>${escape(view.label)}</strong>${link}</div>` +
20228
+ `<p class="meta">${escape(view.detail)}${revision}</p>${merged}${act}</section>`;
19936
20229
  }
19937
20230
  /** Exactly one primary road per result, chosen from its state; the
19938
20231
  * others stay reachable from their own sections. Every form posts to the
@@ -20083,7 +20376,7 @@ function runsPage(chrome, rows, liveIds, nextCursor, verdicts = new Map(), accep
20083
20376
  needsVerification +
20084
20377
  criterionMatrixSummary(verdicts.get(run.id)?.matrix ?? []) +
20085
20378
  `${run.provider === "claude" ? "" : ` <span class="meta mono">${escape(run.provider)}</span>`}` +
20086
- `<span class="right meta mono">${escape(when(run.startedAt))}` +
20379
+ `<span class="right meta mono">${whenTime(run.startedAt)}` +
20087
20380
  `${run.providerStartedAt === null && run.tokensIn === null && run.tokensOut === null && run.costUsd === null ? "" : ` \u00b7 ${escape(runCostWords(run, liveIds.has(run.id)))}`}</span></p>`);
20088
20381
  })
20089
20382
  .join("\n");
@@ -20580,7 +20873,7 @@ function evidenceBundleCard(view, run, publication = null, review = null) {
20580
20873
  parts.push(`<p class="row" data-proof-verdict="${escape(dispatchStatusToken(view.verdict))}">${statusLineHtml(status)} <span class="meta">${escape(verificationExplanation(view.verdict, view.reasons))}</span></p>`);
20581
20874
  }
20582
20875
  if (view.accepted !== null) {
20583
- parts.push(`<p class="meta">Accepted with an exception by <span class="mono">${escape(view.accepted.by)}</span> · ${escape(when(view.accepted.at))}${view.accepted.note === null ? "" : ` — ${escape(view.accepted.note)}`}</p>`);
20876
+ parts.push(`<p class="meta">Accepted with an exception by <span class="mono">${escape(view.accepted.by)}</span> · ${whenTime(view.accepted.at)}${view.accepted.note === null ? "" : ` — ${escape(view.accepted.note)}`}</p>`);
20584
20877
  }
20585
20878
  parts.push(criterionMatrixHtml(view.matrix, { runId, links: view.matrixLinks }));
20586
20879
  parts.push(semanticCoverageHtml(view.matrix, view.qualityMode));
@@ -20856,7 +21149,7 @@ function resultPanelParts(detail, o) {
20856
21149
  (publication === null || publication.prNumber === null
20857
21150
  ? ""
20858
21151
  : ` ${prUrl === null ? `<span class="mono">PR #${publication.prNumber}</span>` : `<a href="${escape(prUrl)}">PR #${publication.prNumber}</a>`}` +
20859
- `<span class="meta" data-ci-observed="${escape(detail.ciFailing ? "failing" : (publication.lastCheckState ?? "none"))}"> · ${detail.ciFailing ? "CI failing at the last check" : publication.lastCheckState === "passing" ? `CI passing, observed ${escape(when(publication.lastCheckAt))}` : publication.lastCheckState === "running" ? "CI still running at the last check" : "no CI checks found — verify on GitHub"}</span>`) +
21152
+ `<span class="meta" data-ci-observed="${escape(detail.ciFailing ? "failing" : (publication.lastCheckState ?? "none"))}"> · ${detail.ciFailing ? "CI failing at the last check" : publication.lastCheckState === "passing" ? `CI passing, observed ${whenTime(publication.lastCheckAt)}` : publication.lastCheckState === "running" ? "CI still running at the last check" : "no CI checks found — verify on GitHub"}</span>`) +
20860
21153
  `</p>`;
20861
21154
  const agent = runAgentWords(detail.route);
20862
21155
  const links = [];
@@ -20872,7 +21165,7 @@ function resultPanelParts(detail, o) {
20872
21165
  summaryParts.push(`<details class="result-details" data-result-details><summary>Build details</summary>` +
20873
21166
  (unavailableShots.length === 0 ? "" : `<ul class="result-unavailable meta" data-result-unavailable="${unavailableShots.length}">${unavailableShots.map(one => `<li>Screenshot unavailable — <span class="mono">${escape(one.path)}</span>: ${escape(one.problem ?? "")}</li>`).join("")}</ul>`) +
20874
21167
  `<dl class="result-facts">` +
20875
- `<div><dt>Build</dt><dd>#${runId} · ${escape(run.runner)}${agent === null ? ` · ${escape(run.provider)}` : ""}${run.finishedAt === null ? "" : ` · ${escape(when(run.finishedAt))}`}</dd></div>` +
21168
+ `<div><dt>Build</dt><dd>#${runId} · ${escape(run.runner)}${agent === null ? ` · ${escape(run.provider)}` : ""}${run.finishedAt === null ? "" : ` · ${whenTime(run.finishedAt)}`}</dd></div>` +
20876
21169
  (agent === null ? "" : `<div><dt>Agent</dt><dd>${escape(agent)}</dd></div>`) +
20877
21170
  `<div><dt>Commits</dt><dd>${facts.head === null
20878
21171
  ? facts.base === null ? "no commit recorded" : `<span class="mono">${escape(facts.base.slice(0, 12))}</span> <span class="meta">base · no head recorded</span>`
@@ -20951,7 +21244,7 @@ function resultPanelParts(detail, o) {
20951
21244
  checkParts.push(`<p class="meta" data-proof-verdict="none">No verification result is available for this build.</p>`);
20952
21245
  }
20953
21246
  if (proof.accepted !== null)
20954
- checkParts.push(`<p class="meta">${humanReview ? "Accepted after human review" : "Accepted with an exception"} by <span class="mono">${escape(proof.accepted.by)}</span> · ${escape(when(proof.accepted.at))}${proof.accepted.note === null ? "" : ` — ${escape(proof.accepted.note)}`}</p>`);
21247
+ checkParts.push(`<p class="meta">${humanReview ? "Accepted after human review" : "Accepted with an exception"} by <span class="mono">${escape(proof.accepted.by)}</span> · ${whenTime(proof.accepted.at)}${proof.accepted.note === null ? "" : ` — ${escape(proof.accepted.note)}`}</p>`);
20955
21248
  if (proof.matrix.length === 0) {
20956
21249
  checkParts.push(detail.signedCriteria > 0 ? `<p class="meta">The approved scope has ${detail.signedCriteria} requirement${detail.signedCriteria === 1 ? "" : "s"}, but this build has no requirement-by-requirement verification.</p>` : `<p class="meta">This scope signed no acceptance checks.</p>`);
20957
21250
  }
@@ -21002,7 +21295,7 @@ function resultPanelParts(detail, o) {
21002
21295
  };
21003
21296
  if (detail.comments.length > 0) {
21004
21297
  requestParts.push(`<p class="meta">Saved for later · ${detail.comments.length}</p><div class="diff-comments" data-result-notes="${detail.comments.length}">` +
21005
- detail.comments.map(one => `<div class="diff-comment"><span class="diff-comment-pin" aria-hidden="true"></span><p>${one.path === null ? "" : pathWords(one.path, one.line)}${escape(one.note)}</p><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span></div>`).join("") +
21298
+ detail.comments.map(one => `<div class="diff-comment"><span class="diff-comment-pin" aria-hidden="true"></span><p>${one.path === null ? "" : pathWords(one.path, one.line)}${escape(one.note)}</p><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span></div>`).join("") +
21006
21299
  `</div>`);
21007
21300
  if (o.csrf !== "" && !detail.canAnnotate) {
21008
21301
  requestParts.push(`<form method="post" action="/r/${runId}/revise" class="card revision-from-comments"><input type="hidden" name="csrf" value="${escape(o.csrf)}"><input type="hidden" name="return" value="${escape(o.returnTo)}">${revisionSealFields(detail.comments, detail.sourceDigest)}` +
@@ -21011,7 +21304,7 @@ function resultPanelParts(detail, o) {
21011
21304
  }
21012
21305
  }
21013
21306
  if ((detail.pastComments?.length ?? 0) > 0) {
21014
- requestParts.push(`<details class="result-feedback-history"><summary>Earlier feedback</summary><div class="diff-comments" data-past-feedback>${detail.pastComments.map(one => `<div class="diff-comment"><p>${one.path === null ? "" : pathWords(one.path, one.line)}${escape(one.note)}</p><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span></div>`).join("")}</div></details>`);
21307
+ requestParts.push(`<details class="result-feedback-history"><summary>Earlier feedback</summary><div class="diff-comments" data-past-feedback>${detail.pastComments.map(one => `<div class="diff-comment"><p>${one.path === null ? "" : pathWords(one.path, one.line)}${escape(one.note)}</p><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span></div>`).join("")}</div></details>`);
21015
21308
  }
21016
21309
  for (const revision of detail.revisions) {
21017
21310
  // The child's standing is the shared status projection's own words
@@ -21230,7 +21523,7 @@ function runPage(chrome, run, taskId, artifacts, terminal = null, notes = [], cs
21230
21523
  ? "the brief"
21231
21524
  : turn.sourceKind === "answer"
21232
21525
  ? "your answer"
21233
- : "repair (machine)"} · ${escape(when(turn.recordedAt))}${turnWords(turn)}</span> ${escape(oneLineOf(turn.text, 240))}</p>`)
21526
+ : "repair (machine)"} · ${whenTime(turn.recordedAt)}${turnWords(turn)}</span> ${escape(oneLineOf(turn.text, 240))}</p>`)
21234
21527
  .join("\n")) +
21235
21528
  (heldTurns.open && csrf !== ""
21236
21529
  ? `<form method="post" action="/r/${run.id}/turn" class="row">` +
@@ -21305,7 +21598,7 @@ function runPage(chrome, run, taskId, artifacts, terminal = null, notes = [], cs
21305
21598
  const commentRows = comments
21306
21599
  .map(one => `<div class="diff-comment"><span class="diff-comment-pin" aria-hidden="true"></span><p>` +
21307
21600
  `${one.path === null ? "" : commentPathWords(one.path, one.line)}` +
21308
- `${escape(one.note)}</p><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span></div>`)
21601
+ `${escape(one.note)}</p><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span></div>`)
21309
21602
  .join("\n");
21310
21603
  const commentForm = csrf === "" || !hasTerminalDiff
21311
21604
  ? ""
@@ -21349,7 +21642,7 @@ function runPage(chrome, run, taskId, artifacts, terminal = null, notes = [], cs
21349
21642
  const noteRows = notes.length === 0
21350
21643
  ? ""
21351
21644
  : notes
21352
- .map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span> ` +
21645
+ .map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span> ` +
21353
21646
  `${escape(one.note)}</p>`)
21354
21647
  .join("\n");
21355
21648
  const noteForm = csrf === ""
@@ -21537,7 +21830,7 @@ function telegramDeliveryWords(store, bot) {
21537
21830
  return `Telegram pushes new messages to ${state.url}${state.problem === null ? "." : `, but ${state.problem.charAt(0).toLowerCase()}${state.problem.slice(1)}.`}`;
21538
21831
  return `Toolroll asks Telegram for new messages every few seconds${state?.problem ? ` (${state.problem})` : ""}.`;
21539
21832
  }
21540
- 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) {
21833
+ 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) {
21541
21834
  const permissionCard = permissionDefault === null
21542
21835
  ? ""
21543
21836
  : [
@@ -21550,7 +21843,7 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21550
21843
  : `<div class="card"><p><strong>${permissionDefault.mode === "bypassPermissions" ? "Full access" : "Auto"}</strong></p><p class="meta">an approver can change this default</p></div>`,
21551
21844
  permissionDefault.updatedAt === null
21552
21845
  ? ""
21553
- : `<p class="meta settings-changed">Changed ${escape(when(permissionDefault.updatedAt))}${permissionDefault.updatedBy === null ? "" : ` by ${escape(permissionDefault.updatedBy)}`}</p>`,
21846
+ : `<p class="meta settings-changed">Changed ${whenTime(permissionDefault.updatedAt)}${permissionDefault.updatedBy === null ? "" : ` by ${escape(permissionDefault.updatedBy)}`}</p>`,
21554
21847
  ].join("\n");
21555
21848
  const qualityCard = qualityDefault === null
21556
21849
  ? ""
@@ -21564,9 +21857,25 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21564
21857
  : `<div class="card"><p><strong>${escape(qualityModeTitle(qualityDefault.mode))}</strong></p><p class="meta">an approver can change this default</p></div>`,
21565
21858
  qualityDefault.updatedAt === null
21566
21859
  ? ""
21567
- : `<p class="meta settings-changed">Changed ${escape(when(qualityDefault.updatedAt))}${qualityDefault.updatedBy === null ? "" : ` by ${escape(qualityDefault.updatedBy)}`}</p>`,
21860
+ : `<p class="meta settings-changed">Changed ${whenTime(qualityDefault.updatedAt)}${qualityDefault.updatedBy === null ? "" : ` by ${escape(qualityDefault.updatedBy)}`}</p>`,
21861
+ ].join("\n");
21862
+ // Quiet chat: this person's own choice. The installation's Telegram cadence only bundles Every step updates.
21863
+ const chatCard = chatNotices === null || csrf === ""
21864
+ ? ""
21865
+ : [
21866
+ "<h3>Chat messages</h3>",
21867
+ `<form method="post" action="/settings/notifications" class="card" data-autosave>`,
21868
+ `<input type="hidden" name="csrf" value="${escape(csrf)}">`,
21869
+ `<label><input type="radio" name="mode" value="quiet"${chatNotices.mode === "quiet" ? " checked" : ""}> Only when I'm needed <span class="meta">· one message per task, updated as it moves</span></label>`,
21870
+ `<label><input type="radio" name="mode" value="all"${chatNotices.mode === "all" ? " checked" : ""}> Every step <span class="meta">· a new message for each update</span></label>`,
21871
+ `<label>Evening digest<select name="digest">` +
21872
+ digestTimes(chatNotices.digestAt).map(([value, label]) => `<option value="${value}"${value === (chatNotices.digestAt ?? "off") ? " selected" : ""}>${label}</option>`).join("") +
21873
+ `</select></label>`,
21874
+ `<p class="meta">One message: what finished, what waits, what failed.</p>`,
21875
+ `<button type="submit">Save</button>`,
21876
+ `</form>`,
21568
21877
  ].join("\n");
21569
- const digestCard = digest === null || csrf === ""
21878
+ const digestCard = digest === null || csrf === "" || chatNotices?.mode === "quiet"
21570
21879
  ? ""
21571
21880
  : [
21572
21881
  "<h3>Telegram digest</h3>",
@@ -21651,7 +21960,7 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21651
21960
  : `<p class="meta">Alerts need a secure (https) address for this app.</p>`,
21652
21961
  ...push.devices
21653
21962
  .filter(one => one.retiredAt === null || one.retiredReason === "gone")
21654
- .map(one => `<p class="row">${escape(one.uaWords)} · since ${escape(when(one.createdAt))}` +
21963
+ .map(one => `<p class="row">${escape(one.uaWords)} · since ${whenTime(one.createdAt)}` +
21655
21964
  `${one.retiredAt !== null ? ` · <span class="meta">expired</span>` : one.consecutiveFailures >= 20 ? ` · <span class="meta">failing</span>` : ""}` +
21656
21965
  (one.retiredAt === null
21657
21966
  ? ` <form method="post" action="/push/remove" class="inline"><input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="id" value="${one.id}"><button type="submit">Remove</button></form>`
@@ -21729,7 +22038,8 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21729
22038
  }),
21730
22039
  services: messaging === null || messaging.configured.length === 0 ? null : { configured: messaging.configured, channel: messaging.channel, implicit: messaging.implicit },
21731
22040
  push: push === null || csrf === "" ? null : { available: push.available, devices: push.devices.filter(one => one.retiredAt === null || one.retiredReason === "gone").map(one => ({ id: one.id, words: `${one.uaWords} · since ${when(one.createdAt)}`, state: one.retiredAt !== null ? "expired" : one.consecutiveFailures >= 20 ? "failing" : "ok", removable: one.retiredAt === null })) },
21732
- digest: digest === null || csrf === "" ? null : { every: digest.everyMs === null ? "off" : String(Math.round(digest.everyMs / 60_000)), held: digest.everyMs === null ? null : `${digest.held} routine fact(s) held` },
22041
+ chat: chatNotices === null || csrf === "" ? null : chatNotices,
22042
+ digest: digest === null || csrf === "" || chatNotices?.mode === "quiet" ? null : { every: digest.everyMs === null ? "off" : String(Math.round(digest.everyMs / 60_000)), held: digest.everyMs === null ? null : `${digest.held} routine fact(s) held` },
21733
22043
  telegram: { state: hasEnv ? "from the environment" : existing === null ? "not set" : "saved", current, delivery: telegramDelivery },
21734
22044
  email,
21735
22045
  workers,
@@ -21746,7 +22056,8 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21746
22056
  workersCard(workers),
21747
22057
  updatesCard(updates, csrf),
21748
22058
  firstResult === null ? "" : `<p class="meta" data-first-result>${escape(firstResult)}</p>`,
21749
- pushCard === "" && messagingCard === "" && digestCard === "" ? "" : `<h2>Notifications</h2>`,
22059
+ pushCard === "" && messagingCard === "" && digestCard === "" && chatCard === "" ? "" : `<h2>Notifications</h2>`,
22060
+ chatCard,
21750
22061
  messagingCard,
21751
22062
  pushCard,
21752
22063
  digestCard,
@@ -21842,6 +22153,8 @@ function settingsTiles() {
21842
22153
  return `<nav class="settings-tiles" aria-label="Settings sections">${tiles.map(([href, label, icon]) => `<a href="${href}">${strokeIcon(icon)}<span>${label}</span></a>`).join("")}</nav>`;
21843
22154
  }
21844
22155
  const SETTINGS_TILE_ICONS = [
22156
+ ["/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"/>`],
22157
+ ["/settings/integrations", "Integrations", `<path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/>`],
21845
22158
  ["/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"/>`],
21846
22159
  ["/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"/>`],
21847
22160
  ["/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"/>`],
@@ -21902,7 +22215,7 @@ function nextPage(chrome, data) {
21902
22215
  if (item.kind === "decision") {
21903
22216
  const { decision } = item;
21904
22217
  card =
21905
- `<h1>${escape(decision.taskId)} <span class="meta">asked ${escape(when(decision.createdAt))}</span></h1>` +
22218
+ `<h1>${escape(decision.taskId)} <span class="meta">asked ${whenTime(decision.createdAt)}</span></h1>` +
21906
22219
  `<div class="recap">${escape(decision.recap)}</div>` +
21907
22220
  `<div class="question">${escape(decision.question)}</div>` +
21908
22221
  decisionOptionForms(decision, data.csrf, "next");