toolroll 0.9.74 → 0.9.76

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 (119) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/agent-fence.js +8 -0
  3. package/dist/approval-authority.js +6 -2
  4. package/dist/approval-rules-ui.d.ts +11 -0
  5. package/dist/approval-rules-ui.js +13 -1
  6. package/dist/assignment.d.ts +3 -0
  7. package/dist/assignment.js +27 -8
  8. package/dist/batch-checks.js +3 -1
  9. package/dist/browser/workspace.js +27 -27
  10. package/dist/browser-workspace.d.ts +1 -1
  11. package/dist/builder.js +1 -1
  12. package/dist/chat-acceptance.d.ts +4 -1
  13. package/dist/chat-acceptance.js +13 -3
  14. package/dist/cli.js +1 -1
  15. package/dist/coding-handoff.js +4 -3
  16. package/dist/coding-ui.js +2 -2
  17. package/dist/coding-workspace.js +23 -9
  18. package/dist/contracts/console-api.d.ts +6 -1
  19. package/dist/contracts/console-api.js +11 -0
  20. package/dist/control-ui.d.ts +3 -2
  21. package/dist/control-ui.js +7 -2
  22. package/dist/demo.js +5 -3
  23. package/dist/deploy-gate.d.ts +3 -0
  24. package/dist/deploy-gate.js +3 -2
  25. package/dist/desktop-update.d.ts +4 -1
  26. package/dist/desktop-update.js +43 -9
  27. package/dist/discover.js +6 -1
  28. package/dist/first-run.d.ts +8 -5
  29. package/dist/first-run.js +42 -7
  30. package/dist/flow-card-task.js +3 -2
  31. package/dist/flow-unattended.d.ts +2 -0
  32. package/dist/flow-unattended.js +12 -1
  33. package/dist/git-incident.d.ts +49 -0
  34. package/dist/git-incident.js +69 -0
  35. package/dist/git-views.d.ts +20 -0
  36. package/dist/git-views.js +120 -0
  37. package/dist/grant.js +3 -1
  38. package/dist/integrations.d.ts +4 -3
  39. package/dist/integrations.js +5 -6
  40. package/dist/lead-contract.d.ts +1 -1
  41. package/dist/lead-contract.js +2 -2
  42. package/dist/lead-follow.js +3 -1
  43. package/dist/openrouter-models.d.ts +5 -2
  44. package/dist/openrouter-models.js +7 -4
  45. package/dist/operate.js +9 -1
  46. package/dist/planner.js +5 -1
  47. package/dist/project-knowledge.js +1 -1
  48. package/dist/project-learning.js +65 -7
  49. package/dist/project-tools.js +19 -1
  50. package/dist/pull-request-flow.js +4 -0
  51. package/dist/pull-request-ui.d.ts +1 -1
  52. package/dist/pull-request-ui.js +5 -1
  53. package/dist/push.d.ts +2 -0
  54. package/dist/push.js +24 -19
  55. package/dist/read-only-request.d.ts +4 -0
  56. package/dist/read-only-request.js +15 -0
  57. package/dist/release-adapters.d.ts +24 -2
  58. package/dist/release-adapters.js +136 -10
  59. package/dist/release-authority.d.ts +46 -0
  60. package/dist/release-authority.js +64 -0
  61. package/dist/release-auto.d.ts +153 -0
  62. package/dist/release-auto.js +496 -0
  63. package/dist/release-cli.js +56 -2
  64. package/dist/release-coverage.js +1 -1
  65. package/dist/release.d.ts +105 -1
  66. package/dist/release.js +229 -9
  67. package/dist/repository-context.js +5 -2
  68. package/dist/result-follow-ups.js +23 -2
  69. package/dist/result-review.js +5 -1
  70. package/dist/scope.d.ts +31 -2
  71. package/dist/scope.js +44 -3
  72. package/dist/scout.js +5 -1
  73. package/dist/serve.js +162 -6
  74. package/dist/server/agents-live-filed.d.ts +17 -0
  75. package/dist/server/agents-live-filed.js +97 -0
  76. package/dist/server/agents-live-room.js +4 -1
  77. package/dist/server/chat.js +31 -23
  78. package/dist/server/http.d.ts +5 -0
  79. package/dist/server/last-checks.d.ts +17 -0
  80. package/dist/server/last-checks.js +22 -0
  81. package/dist/server/pages.js +18 -4
  82. package/dist/server/people-tokens.js +7 -4
  83. package/dist/server/render-pages.d.ts +2 -1
  84. package/dist/server/render-pages.js +28 -24
  85. package/dist/server/render-results.d.ts +11 -2
  86. package/dist/server/render-results.js +54 -12
  87. package/dist/server/render-settings.js +3 -3
  88. package/dist/server/render-tasks.d.ts +10 -0
  89. package/dist/server/render-tasks.js +30 -17
  90. package/dist/server/route-table.js +17 -1
  91. package/dist/server/runtime.d.ts +16 -1
  92. package/dist/server/settings.js +111 -31
  93. package/dist/server/task-views.js +8 -1
  94. package/dist/server/tasks.js +11 -2
  95. package/dist/storage-ui.d.ts +2 -0
  96. package/dist/storage-ui.js +1 -1
  97. package/dist/storage.js +2 -1
  98. package/dist/store.d.ts +4 -1
  99. package/dist/store.js +52 -13
  100. package/dist/subagent-work.js +3 -0
  101. package/dist/task-base.js +1 -1
  102. package/dist/task-status.d.ts +28 -1
  103. package/dist/task-status.js +93 -11
  104. package/dist/teardown-git.d.ts +59 -5
  105. package/dist/teardown-git.js +1022 -90
  106. package/dist/telegram-progress.js +4 -4
  107. package/dist/telegram-status.js +8 -3
  108. package/dist/test-infra.d.ts +26 -0
  109. package/dist/test-infra.js +321 -0
  110. package/dist/tool-secret-store.js +8 -0
  111. package/dist/toolroll-update-ui.d.ts +2 -0
  112. package/dist/toolroll-update-ui.js +4 -3
  113. package/dist/toolroll-update.js +10 -9
  114. package/dist/work-index.js +29 -12
  115. package/dist/workspace-ui.d.ts +5 -0
  116. package/dist/workspace-ui.js +38 -10
  117. package/dist/worktree.d.ts +4 -0
  118. package/dist/worktree.js +13 -7
  119. package/package.json +1 -1
@@ -232,6 +232,11 @@ export declare function agentsStripHtml(view: RouteView | null, taskId: string):
232
232
  /** A ceremony nonce as stored: the sha256 hex of the value the form carried. */
233
233
  export declare function nonceHashOf(value: string): string;
234
234
  /** The approve POST's refusal, in words a person can act on. */
235
+ /** A release task's automatic approval, in words (docs/plans/auto-approve-green.md). */
236
+ export declare function autoReleaseWords(auto: {
237
+ kind: "checking" | "approved";
238
+ candidate: string;
239
+ }): string;
235
240
  /** " (password remembered from 10:42)" when the approval stood on a remembered password or sign-in; nothing otherwise. */
236
241
  export declare function approvalProofWords(proof: {
237
242
  method: string;
@@ -523,6 +528,11 @@ export declare function taskBodyParts(data: {
523
528
  method: string;
524
529
  at: string;
525
530
  } | null;
531
+ /** A release task the plane approved (or is checking) itself (docs/plans/auto-approve-green.md): its candidate. */
532
+ autoRelease?: {
533
+ kind: "checking" | "approved";
534
+ candidate: string;
535
+ } | null;
526
536
  /** One approval per release (release-once.md): the release this task belongs to, and whether this reader may stop it. */
527
537
  release?: ReleaseView | null;
528
538
  canStopRelease?: boolean;
@@ -746,6 +746,11 @@ export function nonceHashOf(value) {
746
746
  return createHash("sha256").update(value).digest("hex");
747
747
  }
748
748
  /** The approve POST's refusal, in words a person can act on. */
749
+ /** A release task's automatic approval, in words (docs/plans/auto-approve-green.md). */
750
+ export function autoReleaseWords(auto) {
751
+ return auto.kind === "approved" ? `Approved automatically: CI and release check passed on ${auto.candidate.slice(0, 7)}`
752
+ : `Checking automatically: approves itself once CI and the release check pass on ${auto.candidate.slice(0, 7)}`;
753
+ }
749
754
  /** " (password remembered from 10:42)" when the approval stood on a remembered password or sign-in; nothing otherwise. */
750
755
  export function approvalProofWords(proof) {
751
756
  if (proof == null)
@@ -1437,6 +1442,11 @@ export function taskBodyParts(data) {
1437
1442
  if (resultStatus.token === "evidence-damaged") {
1438
1443
  return withReview(box("problem", resultStatus.label, html `<a href="/r/${proof.runId}">Build #${proof.runId}</a> finished. ${resultStatus.detail}${publicationWords}`, "evidence-damaged"), html `<div class="proof-review-actions"><a class="button-link" href="/r/${proof.runId}">Review the evidence problems</a></div>`, proofDetails);
1439
1444
  }
1445
+ // A recorded pass the check read now doesn't stand behind (unreadable, failed since, or not read here) never
1446
+ // reads "passed": the box says what holds now (task-status.ts approvedCheckOf).
1447
+ if (proof.proofVerdict === "verified" && !accepted && !(proof.receipt?.checks !== undefined && ["ready-to-review", "pr-opened", "merge-observed", "pr-closed"].includes(resultStatus.token))) {
1448
+ return withReview(box(resultStatus.tone === "problem" ? "problem" : "ok", resultStatus.label, html `<a href="/r/${proof.runId}">Build #${proof.runId}</a> finished. ${resultStatus.detail}${publicationWords}`, resultStatus.token), proofDetails);
1449
+ }
1440
1450
  if (proof.proofVerdict === "verified" && !accepted) {
1441
1451
  const recovered = verificationRecovered(proof.proofReasons);
1442
1452
  return withReview(box("ok", resultStatus.label, recovered
@@ -1492,11 +1502,13 @@ export function taskBodyParts(data) {
1492
1502
  html `<p><strong>Quality</strong> ${qualityModeTitle(scope.qualityMode ?? "default")}</p>`,
1493
1503
  acceptanceCeremonyHtml(scope.acceptance),
1494
1504
  scope.releaseTerms == null ? "" : html `<p><strong>Release</strong></p><p class="recap">${releaseTermsSentence(scope.releaseTerms)}</p>${scope.releaseTerms.policy.carry ? html `<details><summary>What asks again</summary><ul>${joinHtml(releaseAsksAgainWords(scope.releaseTerms).map(line => html `<li>${line}</li>`), "")}</ul></details>` : ""}`,
1495
- approval.approved && data.release?.kind === "carried"
1496
- ? html `<p class="meta scope-seal">Carried from ${data.release.approver ?? "a person"}'s release approval · <a href="${taskHref(data.release.anchorTask)}">Release ${data.release.version}</a> · <span class="seal">signs ${shortDigest(scope.digest)}</span></p>`
1497
- : approval.approved
1498
- ? html `<p class="meta scope-seal">Approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)} · <span class="seal">signs ${shortDigest(scope.digest)}</span><span class="so-sr-only"> — approval binds to this exact wording</span></p>`
1499
- : html `<p class="meta"><span class="seal">signs ${shortDigest(scope.digest)}</span> — approval binds to this exact wording</p><p class="meta">Not approved${approval.reason === "changed" ? " — approved once, then rewritten" : ""}</p>`,
1505
+ approval.approved && data.autoRelease != null
1506
+ ? html `<p class="meta scope-seal">${autoReleaseWords(data.autoRelease)} · <span class="seal">signs ${shortDigest(scope.digest)}</span></p>`
1507
+ : approval.approved && data.release?.kind === "carried"
1508
+ ? html `<p class="meta scope-seal">Carried from ${data.release.approver ?? "a person"}'s release approval · <a href="${taskHref(data.release.anchorTask)}">Release ${data.release.version}</a> · <span class="seal">signs ${shortDigest(scope.digest)}</span></p>`
1509
+ : approval.approved
1510
+ ? html `<p class="meta scope-seal">Approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)} · <span class="seal">signs ${shortDigest(scope.digest)}</span><span class="so-sr-only"> — approval binds to this exact wording</span></p>`
1511
+ : html `<p class="meta"><span class="seal">signs ${shortDigest(scope.digest)}</span> — approval binds to this exact wording</p><p class="meta">Not approved${approval.reason === "changed" ? " — approved once, then rewritten" : ""}</p>`,
1500
1512
  html `</div>`,
1501
1513
  ], "\n");
1502
1514
  // The scout's report (mate arc §10): title, summary, the document inert,
@@ -1787,11 +1799,13 @@ export function taskBodyParts(data) {
1787
1799
  : "";
1788
1800
  const scopeRow = scope === null
1789
1801
  ? prop("scope", "none yet")
1790
- : approval.approved && data.release?.kind === "carried"
1791
- ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · carried from ${data.release.approver ?? "a person"}'s release approval · <a href="${taskHref(data.release.anchorTask)}">Release ${data.release.version}</a>`)
1792
- : approval.approved
1793
- ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)}`)
1794
- : prop("scope", approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved");
1802
+ : approval.approved && data.autoRelease != null
1803
+ ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${autoReleaseWords(data.autoRelease)}`)
1804
+ : approval.approved && data.release?.kind === "carried"
1805
+ ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · carried from ${data.release.approver ?? "a person"}'s release approval · <a href="${taskHref(data.release.anchorTask)}">Release ${data.release.version}</a>`)
1806
+ : approval.approved
1807
+ ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)}`)
1808
+ : prop("scope", approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved");
1795
1809
  const strikesRow = data.strikes > 0 ? prop("strikes", `${data.strikes} failed attempt(s)`) : "";
1796
1810
  const propsCard = html `<div class="card props">${workerRow}${queueRow}${scopeRow}${publishesRow}${economics}${strikesRow}</div>`;
1797
1811
  // The same facts for the rebuilt page, row for row.
@@ -1806,9 +1820,10 @@ export function taskBodyParts(data) {
1806
1820
  facts.push({ label: "Queue", parts: [`${data.position.position} of ${data.position.total}${data.position.column === null ? " in the shared queue" : ` in ${data.position.column}'s queue`} · `, { label: "Reorder", href: "/board?view=order" }] });
1807
1821
  }
1808
1822
  facts.push(scope === null ? { label: "Scope", parts: ["none yet"] }
1809
- : approval.approved && data.release?.kind === "carried" ? { label: "Approved scope", parts: [`Carried from ${data.release.approver ?? "a person"}'s release approval · `, { label: `Release ${data.release.version}`, href: taskHref(data.release.anchorTask) }] }
1810
- : approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)}`, ...(approval.at === null ? [] : [" · ", { at: approval.at }])] }
1811
- : { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
1823
+ : approval.approved && data.autoRelease != null ? { label: "Approved scope", parts: [autoReleaseWords(data.autoRelease)] }
1824
+ : approval.approved && data.release?.kind === "carried" ? { label: "Approved scope", parts: [`Carried from ${data.release.approver ?? "a person"}'s release approval · `, { label: `Release ${data.release.version}`, href: taskHref(data.release.anchorTask) }] }
1825
+ : approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)}`, ...(approval.at === null ? [] : [" · ", { at: approval.at }])] }
1826
+ : { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
1812
1827
  if (data.release?.kind === "anchor")
1813
1828
  facts.push({ label: "Release", parts: [`Approved for release ${data.release.version}`, ...(data.release.landed.length === 0 ? [] : [` · later commits: ${data.release.landed.map(one => one.sha.slice(0, 7)).join(", ")}`]), ...(data.release.carrying ? [] : [` · later commits ask again`])] });
1814
1829
  facts.push({ label: "Publishes as", parts: [publishesAs] });
@@ -2364,10 +2379,8 @@ export function taskBodyParts(data) {
2364
2379
  journey: task.state === "cancelled" || data.status === undefined ? null : (() => {
2365
2380
  const planning = data.plan === "requested" && approveForm === "";
2366
2381
  const read = data.assignment != null ? assignmentStageOf(data.assignment, { token: data.status.token }, planning) : stageOfCode(data.status.token, { planning });
2367
- // The checks fact the status card shows, read as what happened: a "note" is a failure when its words say so.
2368
- const shown = statusCard?.status.details.find(one => one.key === "checks") ?? null;
2369
- const checks = shown === null ? null : shown.mark === "ok" ? "passed" : shown.mark === "running" ? "running"
2370
- : shown.mark === "failed" || /\bfailed\b/i.test(shown.text) ? "failed" : shown.mark === "none" ? "not-run" : "unread";
2382
+ // The one answer the status card's headline and Project checks row read (task-status.ts approvedCheckOf).
2383
+ const checks = statusCard?.status.check ?? null;
2371
2384
  const report = data.deliverable === "report" || data.report != null;
2372
2385
  return taskRoad(read, scope !== null && approvalOf(scope).approved, task.state === "done", { checks, report });
2373
2386
  })(),
@@ -237,6 +237,10 @@ for (const one of ["close", "remove"])
237
237
  post("tasks", `term.${one}`, `^\\/term\\/${TERMINAL}\\/${one}$`, { ...TERM, sample: `/term/${"a".repeat(32)}/${one}` });
238
238
  for (const one of ["send", "stop", "resume", "recover", "continue", "ship", "answer"])
239
239
  post("tasks", `code.${one}-send`, `^\\/code\\/${SESSION}\\/${one}$`, { ...CODING_ACT, sample: `/code/${"a".repeat(32)}/${one}` });
240
+ // Reading a coding checkout runs git in a folder an agent writes, so it is a POST with the form token, never a page
241
+ // visit (docs/plans/get-side-effects.md): the session's changes, and the review-for-shipping preview.
242
+ post("tasks", "code.changes-read", `^\\/code\\/${SESSION}\\/changes$`, { project: "coding", limited: "self", callers: ["cookie"], sample: `/code/${"a".repeat(32)}/changes` });
243
+ post("tasks", "code.ship-preview", `^\\/code\\/${SESSION}\\/ship\\/preview$`, { project: "coding", limited: "self", callers: ["cookie"], sample: `/code/${"a".repeat(32)}/ship/preview` });
240
244
  // Any other coding action answers "not found" after the same access check.
241
245
  post("tasks", "code.act-other", "^\\/code(\\/.*)?$", { ...CODING_ACT, sample: "/code/unknown" });
242
246
  post("settings", "settings.skills-revise", "/settings/skills/revise", { project: "form", limited: "unscoped", ...approver("Sign in to revise a test.", "/settings/skills") });
@@ -299,6 +303,9 @@ post("tasks", "sweep.cancel-rest", "^\\/sweeps\\/[1-9][0-9]{0,9}\\/cancel-rest$"
299
303
  // (the handler proves it as a credential act); turning it off is one click, so the row itself is not a step-up.
300
304
  get("settings", "settings.passwordless", "/settings/passwordless", { ...free, ...operator("An instance operator sets Passwordless.", "/settings") });
301
305
  post("settings", "settings.passwordless-send", "/settings/passwordless", { callers: ["cookie"], ...operator("An instance operator sets Passwordless.", "/settings") });
306
+ // Release approval (docs/plans/auto-approve-green.md): an approver of the project, from a browser. Approving releases
307
+ // automatically takes a typed password (the handler proves it as a credential act); asking again is one click.
308
+ post("settings", "settings.release-approval-send", "/settings/release-approval", { callers: ["cookie"], ...approver("An approver sets how releases are approved.", "/settings/approval") });
302
309
  post("pages", "ledger.checkpoint", "/ledger/checkpoint", operator("An instance operator makes ledger checkpoints.", "/ledger"));
303
310
  const SETTINGS_ACT_ROLES = {
304
311
  policy: operator("An instance operator sets the policy.", "/settings/policy"),
@@ -308,10 +315,12 @@ const SETTINGS_ACT_ROLES = {
308
315
  updates: operator("An instance operator updates Toolroll.", "/settings"),
309
316
  "updates/seen": operator("An instance operator updates Toolroll.", "/settings"),
310
317
  "updates/cancel": operator("An instance operator updates Toolroll.", "/settings"),
318
+ "updates/check": operator("An instance operator updates Toolroll.", "/settings"),
311
319
  retention: operator("An instance operator sets retention.", "/settings"),
312
320
  storage: operator("An instance operator looks after storage.", "/settings"),
313
321
  "storage/clean": operator("An instance operator looks after storage.", "/settings"),
314
322
  "storage/discard": operator("An instance operator looks after storage.", "/settings"),
323
+ "storage/check": operator("An instance operator looks after storage.", "/settings"),
315
324
  "backups/now": operator("An instance operator looks after backups.", "/settings"),
316
325
  backups: operator("An instance operator looks after backups.", "/settings"),
317
326
  data: operator("An instance operator exports data.", "/settings"),
@@ -320,7 +329,7 @@ const SETTINGS_ACT_ROLES = {
320
329
  "tools/connect": approver("Sign in as an approver to connect tools.", "/settings/tools"),
321
330
  "tools/change": approver("Sign in as an approver to manage tools.", "/settings/tools"),
322
331
  };
323
- for (const one of ["policy", "approval", "request-limits", "sessions", "sign-in", "updates", "updates/seen", "updates/cancel", "retention", "storage", "storage/clean", "storage/discard", "pull-requests", "checks", "backups/now", "backups", "data", "integrations/test", "monitoring", "tools/connect", "tools/change", "appearance"]) {
332
+ for (const one of ["policy", "approval", "request-limits", "sessions", "sign-in", "updates", "updates/seen", "updates/cancel", "updates/check", "retention", "storage", "storage/clean", "storage/discard", "storage/check", "pull-requests", "pull-requests/check", "checks", "backups/now", "backups", "data", "integrations/test", "monitoring", "tools/connect", "tools/change", "appearance"]) {
324
333
  post(one === "sessions" ? "people" : "settings", `settings.${one.replaceAll("/", "-")}-send`, `/settings/${one}`, { ...(["policy", "approval", "request-limits", "sessions", "sign-in", "updates", "updates/seen", "updates/cancel", "retention", "storage", "storage/clean", "storage/discard", "pull-requests", "checks", "backups", "data", "monitoring", "tools/connect", "tools/change"].includes(one) ? { scope: "step-up" } : {}), ...SETTINGS_ACT_ROLES[one] });
325
334
  }
326
335
  post("settings", "settings.flows-on", "/settings/flows/on", { limited: "collection", ...approver("An approver switches starter flows on.", "/settings/flows") });
@@ -334,6 +343,9 @@ post("settings", "settings.knowledge-proposal", "/settings/knowledge/proposal",
334
343
  post("settings", "settings.knowledge-decision", "/settings/knowledge/decision", { project: "form", ...approver("Sign in as an approver to change project decisions.", "/settings/knowledge") });
335
344
  post("settings", "settings.knowledge-change", "/settings/knowledge/change", { project: "form", limited: "unscoped", ...approver("Sign in as an approver to change project knowledge.", "/settings/knowledge") });
336
345
  post("settings", "settings.learning-change", "/settings/learning/change", { project: "form", limited: "unscoped" });
346
+ // A page shows the last check; these ask again (docs/plans/get-side-effects.md).
347
+ post("settings", "control.models-refresh", "/control/models-refresh", anyApprover("Project setup requires an approver."));
348
+ post("settings", "settings.connection-check", "/settings/connection/check", anyApprover("An approver checks a sign-in.", "/settings"));
337
349
  for (const one of ["setup-preview", "setup-approve", "instructions-preview", "instructions-approve"])
338
350
  post("settings", `control.${one}`, `/control/${one}`, { ...(one.endsWith("approve") ? { scope: "step-up" } : {}) });
339
351
  for (const [service, name] of [["slack", "Slack"], ["teams", "Teams"], ["discord", "Discord"]]) {
@@ -348,6 +360,7 @@ post("settings", "settings.telegram-unpair", "/settings/telegram/unpair", { limi
348
360
  for (const one of ["messaging", "permission-default", "quality-default", "notifications", "notifications/mute", "telegram-digest", "provider-key", "provider-key-clear", "telegram-token", "email", "email-test", "email-read-test", "google", "google/disconnect"]) {
349
361
  post("settings", `settings.${one.replaceAll("/", "-")}-send`, `/settings/${one}`);
350
362
  }
363
+ post("pages", "projects.github-list", "/projects/github", { callers: ["cookie"], ...anyApprover("your login can watch — adding projects is an approver's act") });
351
364
  post("pages", "projects.select", "/projects/select", { project: "form-path", limited: "unscoped", viewer: true });
352
365
  post("pages", "projects.remove", "/projects/remove", { project: "form", ...operator("An instance operator removes projects.", "/settings/project") });
353
366
  post("pages", "projects.open", "/projects/open", { project: "form-path" });
@@ -397,6 +410,9 @@ post("pages", "projects.onboard-preview", "/projects/onboard-preview", { project
397
410
  post("pages", "projects.onboard-confirm", "/projects/onboard-confirm", { scope: "step-up", project: "none" });
398
411
  post("chat", "push.subscribe", "/push/subscribe", { scope: "step-up" });
399
412
  post("chat", "push.remove", "/push/remove");
413
+ // Ask this computer's agent CLIs again (docs/plans/get-side-effects.md): Chat's first run and Settings → Lead's Check
414
+ // now. GET /lead/status only reads the last answer.
415
+ post("chat", "lead.check", "/lead/check", { callers: ["cookie"] });
400
416
  post("chat", "onboarding.phone-dismiss", "/onboarding/phone/dismiss", approver("An approver puts this away.", "/chat"));
401
417
  const LEAD_WORDS = { identity: "An approver names their lead.", about: "An approver edits what their lead knows about them.", "promise/cancel": "An approver manages the lead's promises.", on: "An approver turns the lead on." };
402
418
  for (const one of ["identity", "about", "promise/cancel", "on"])
@@ -158,6 +158,8 @@ export interface ServerRuntime {
158
158
  source: "environment" | "stored";
159
159
  } | null;
160
160
  chatCatalog: () => Promise<import("../converse.js").CatalogModel[] | null>;
161
+ /** The catalog as last fetched: what a page shows. A page never fetches. */
162
+ chatCatalogKnown: () => import("../converse.js").CatalogModel[] | null;
161
163
  leadRecheckedAt: number;
162
164
  checkLocalAgents: (fresh?: boolean) => Promise<void>;
163
165
  localSignIn: {
@@ -167,7 +169,18 @@ export interface ServerRuntime {
167
169
  } | null;
168
170
  agentSignInCommand: () => string;
169
171
  connectionCheck: (provider: ProviderId, fresh?: boolean) => Promise<ProviderConnection>;
170
- modelCatalog: (key: string | null, refresh?: boolean) => Promise<OpenRouterModels>;
172
+ /** What the last sign-in check said: what a page shows. A page never checks (docs/plans/get-side-effects.md). */
173
+ connectionKnown: (provider: ProviderId) => ProviderConnection | null;
174
+ /** The last answer of each other check a page shows; a page only reads it. */
175
+ lastChecks: import("./last-checks.js").LastChecks;
176
+ checkRelease: () => Promise<{
177
+ version: string;
178
+ } | {
179
+ problem: string;
180
+ }>;
181
+ modelCatalog: ((key: string | null, refresh?: boolean) => Promise<OpenRouterModels>) & {
182
+ known: (key: string | null) => OpenRouterModels | null;
183
+ };
171
184
  modelSeams: CatalogSeams;
172
185
  toolsViewOf: (repo: string, codex: unknown[] | null, kit?: string | null, wanted?: string | null) => ToolsView;
173
186
  codexServers: (repo: string) => Promise<unknown[] | null>;
@@ -209,6 +222,8 @@ export interface ServerRuntime {
209
222
  provider: OidcProvider;
210
223
  at: number;
211
224
  } | null;
225
+ /** The identity provider as last discovered; a page never discovers it. */
226
+ providerKnown: (issuer: string) => import("../oidc.js").OidcProvider | null;
212
227
  providerFor: (issuer: string) => Promise<{
213
228
  ok: true;
214
229
  provider: OidcProvider;
@@ -39,7 +39,8 @@ import { browserCaller } from "./session.js";
39
39
  import { DEFAULT_ACCENT, normalHex } from "../accent-colors.js";
40
40
  import { resolvePhaseAgent } from "../agentconfig.js";
41
41
  import { parseProtectedPaths } from "../approval-policy.js";
42
- import { approvalRulesHtml } from "../approval-rules-ui.js";
42
+ import { approvalRulesHtml, releaseApprovalHtml } from "../approval-rules-ui.js";
43
+ import { releaseApprovalSetting, setReleaseApproval } from "../release-auto.js";
43
44
  import { backupHtml } from "../backup-ui.js";
44
45
  import { BACKUP_EVERY_HOURS, backupFolderOf, backupNow, checkBackupFolder, defaultBackupFolder, MAX_KEEP } from "../backup.js";
45
46
  import { projectBatchChecks, setProjectBatchChecks } from '../batch-policy.js';
@@ -122,14 +123,14 @@ import { teamsSettingsHtml } from "../teams-settings.js";
122
123
  import { telegramSettingsHtml } from "../telegram-settings.js";
123
124
  import { hashPairingCode, loadBotToken, mintPairingCode, PAIRING_TTL_MS, redactToken, saveBotToken } from "../telegram.js";
124
125
  import { newerThan, updatesHtml, updatesScript, updateStepsHtml } from "../toolroll-update-ui.js";
125
- import { abandonRuntimeUpdate, currentRuntime, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, releaseStalledUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal } from "../toolroll-update.js";
126
+ import { abandonRuntimeUpdate, currentRuntime, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal } from "../toolroll-update.js";
126
127
  import { TOOLS_PROJECT_SCRIPT, toolsHtml, toolsProjectPicker } from "../tools-ui.js";
127
128
  import { effectivePrimary, isMessagingChannel, savePrimary } from "../webhooks.js";
128
129
  import { personChip, ROLE_TITLES } from "./chrome.js";
129
130
  import { chatReturnWithSaid, goOutside } from "./http.js";
130
- import { latestReleaseFor, leadSettingsHtml, notificationProjects, SETTINGS_AUTOSAVE_SCRIPT, settingsPage, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./render-settings.js";
131
+ import { leadSettingsHtml, notificationProjects, SETTINGS_AUTOSAVE_SCRIPT, settingsPage, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./render-settings.js";
131
132
  export function createSettingsHandlers(runtime) {
132
- const { store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, authenticateCredential, deletedRepos, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce, defaultProject, googleVisits } = runtime;
133
+ const { store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, connectionKnown, lastChecks, checkRelease, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, chatCatalogKnown, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, authenticateCredential, deletedRepos, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce, defaultProject, googleVisits } = runtime;
133
134
  // v105: what agent work cost, by month, and the monthly budgets. An instance operator's page.
134
135
  async function spendPage(ctx) {
135
136
  const { url, who, response, now } = ctx;
@@ -225,10 +226,11 @@ ${hasGrant ? "" : html `<span class="meta">self-merging needs a merge-capable pu
225
226
  if (!isProviderId(asked))
226
227
  return refuse(response, who, 400, "Choose a known provider.", "/control");
227
228
  const task = url.searchParams.get("task") ?? "";
228
- const connection = await connectionCheck(asked, url.searchParams.get("check-connection") === "1");
229
+ // The last sign-in check: Check again posts (docs/plans/get-side-effects.md).
230
+ const connection = connectionKnown(asked);
229
231
  if (view === "connection") {
230
232
  const command = asked === "claude" ? "claude auth login" : asked === "codex" ? "codex login" : asked === "gemini" ? "gemini" : null;
231
- return sendScreen(response, 200, screen(`${ASSISTANTS[asked].name} connection`, html `<h1>${ASSISTANTS[asked].name} ${connection.state === "connected" ? "is connected" : "connection"}</h1>\
233
+ return sendScreen(response, 200, screen(`${ASSISTANTS[asked].name} connection`, html `<h1>${ASSISTANTS[asked].name} ${connection?.state === "connected" ? "is connected" : "connection"}</h1>\
232
234
  ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On the computer running Toolroll, sign in with:</p><pre class="recap">${command}</pre>`}\
233
235
  <p><a href="/settings#providers">Manage API keys and authentication mode</a></p><input type="hidden" name="resume-task" value="${task}">`, { chrome: chromeFor(project, "settings") }));
234
236
  }
@@ -241,20 +243,61 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
241
243
  const models = live.length === 0 ? modelChoices(asked, configured, providerHome)
242
244
  : configured && !live.some(one => one.value === configured) ? [{ value: configured, label: `${configured} — current choice` }, ...live] : live;
243
245
  const instructions = previewProjectInstructions(project);
244
- const catalog = asked === "openrouter" ? await modelCatalog(readProviderKey("openrouter", providerHome), url.searchParams.get("refresh-models") === "1") : null;
246
+ // The model list as last loaded; Refresh models posts, and the background checks keep it current.
247
+ const catalog = asked === "openrouter" ? modelCatalog.known(readProviderKey("openrouter", providerHome)) ?? { ok: false, problem: "The model list has not loaded yet." } : null;
245
248
  const inputs = { provider: asked, model: saved?.provider === asked ? saved.model ?? "" : models[0]?.value ?? "", command: setup?.command ?? (saved === null ? preparation?.command ?? "" : ""), seconds: String((setup?.timeoutMs ?? 300_000) / 1000) };
246
249
  return sendScreen(response, 200, screen("Project setup", controlSetupHtml({ repo: project, csrf: who.via === "cookie" ? who.session.csrf : "", provider: asked, inputs, models, connection, catalog, task, preparation,
247
250
  instructions: instructions.ok ? { installed: instructions.installed } : { installed: false, message: instructions.message } }), { chrome: chromeFor(project, "settings"), ...(catalog === null ? {} : { functional: { script: openRouterPickerScript() } }) }));
248
251
  };
252
+ // Settings → Projects → Pull requests → Check setup: git and GitHub, for one project, by an approver who may turn it on.
253
+ async function pullRequestsCheck(ctx) {
254
+ const { who, response, posted } = ctx;
255
+ const repo = readForm(posted, CONSOLE_FORMS.pullRequestsCheck).get("repo") ?? "";
256
+ const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
257
+ if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
258
+ return refuse(response, who, 404, "No such project.", "/projects");
259
+ if (who.role !== "approver" || store.isDemo())
260
+ return refuse(response, who, 403, "An approver can turn on pull requests.", "/projects");
261
+ await lastChecks.run(`publishing:${repo}`, () => checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec }));
262
+ return redirect(response, `/settings/pull-requests?repo=${encodeURIComponent(repo)}`);
263
+ }
264
+ // Settings → Storage → Check now: measure the checkouts again.
265
+ async function storageCheck(ctx) {
266
+ const databaseFile = store.databaseFile();
267
+ if (databaseFile !== null)
268
+ await measureStorage(databaseFile);
269
+ return redirect(ctx.response, "/settings/storage");
270
+ }
271
+ const measureStorage = (databaseFile) => lastChecks.run("storage", () => checkoutPlan(store, storagePool(databaseFile), clock(), { manual: true }));
272
+ // Settings → Updates → Check now: ask npm once, whether or not automatic checks are on.
273
+ async function updatesCheck(ctx) {
274
+ await checkRelease();
275
+ return redirect(ctx.response, "/settings/updates");
276
+ }
277
+ // Check again (Settings → providers, Project setup): ask this computer's CLI, or test the saved key, now.
278
+ async function connectionCheckNow(ctx) {
279
+ const { response, posted } = ctx;
280
+ const body = readForm(posted, CONSOLE_FORMS.connectionCheck);
281
+ const provider = body.get("provider") ?? "";
282
+ const back = safeReturn(body.get("return") ?? "/settings#providers");
283
+ if (!isProviderId(provider))
284
+ return redirect(response, back);
285
+ await connectionCheck(provider, true).catch(() => undefined);
286
+ return redirect(response, back);
287
+ }
288
+ // Refresh models (Project setup, OpenRouter): load the list again, then show the setup page with it.
289
+ async function modelsRefresh(ctx) {
290
+ const { response, posted } = ctx;
291
+ const body = readForm(posted, CONSOLE_FORMS.modelsRefresh);
292
+ await modelCatalog(readProviderKey("openrouter", providerHome), true);
293
+ const query = new URLSearchParams({ provider: "openrouter", ...(body.get("task") ? { task: body.get("task") } : {}) });
294
+ return redirect(response, `/control?${query.toString()}`);
295
+ }
249
296
  async function modelsPage(ctx) {
250
297
  const { url, who, response, project } = ctx;
251
298
  const now = clock();
252
- const watch = watchState(store);
253
- // Opening the page refreshes a stale snapshot; the lists are public and
254
- // the check sends nothing about the person or their projects.
255
- if (who.role === "approver" && (watch.checkedAt === null || now.getTime() - Date.parse(watch.checkedAt) > 3_600_000)) {
256
- await checkModels(store, now, modelSeams).catch(() => undefined);
257
- }
299
+ // The page shows the last check and when it ran (docs/plans/get-side-effects.md): Check now, or the watch loop's
300
+ // automatic checks, fetch the lists and may queue a notice; opening the page never does.
258
301
  const view = modelsView(who.role === "approver", who.via === "cookie" ? who.session.csrf : "", now, url.searchParams.get("said"), url.searchParams.get("problem"));
259
302
  return sendScreen(response, 200, screen("Models", html `<p><a href="/settings">Settings</a></p><h1>Models</h1>${modelsHtml(view)}`, { chrome: chromeFor(project, "settings"), functional: { script: modelsScript() } }));
260
303
  }
@@ -290,7 +333,8 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
290
333
  if (chosen) {
291
334
  try {
292
335
  const approver = who.role === "approver" && who.via === "cookie";
293
- const view = toolsViewOf(chosen, approver ? await codexServers(chosen) : null, kit, wanted);
336
+ // What Codex had configured at the last background check (docs/plans/get-side-effects.md): the page starts no codex.
337
+ const view = toolsViewOf(chosen, approver ? lastChecks.known(`codex-tools:${chosen}`)?.value ?? null : null, kit, wanted);
294
338
  if (approver)
295
339
  view.others = projects.filter(one => one !== chosen).map(one => ({ repo: one, project: projectName(one), open: connectionsOf(store, one).filter(c => c.state === "open").map(c => c.id) }));
296
340
  content = (toolsHtml(view, who.via === "cookie" ? who.session.csrf : "", approver, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }));
@@ -323,7 +367,8 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
323
367
  return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
324
368
  const selector = projects.length > 1 ? html `<form class="approval-project" method="get" action="/settings/approval"><label>Project<select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? " selected" : ""}>${projectName(p)}</option>`)}</select></label><button>Show</button></form>` : "";
325
369
  const approvers = chosen === "" ? 0 : store.listApprovers().filter(one => { const account = store.accountOf(one.name); return account !== null && account.revokedAt === null && account.role === "approver" && store.accountCanAccess(one.name, chosen); }).length;
326
- const content = chosen === "" ? html `<p>Add a project to set its approval rules.</p>` : (approvalRulesHtml({ repo: chosen, name: projectName(chosen), rules: store.approvalRules(chosen), canChange: who.via === "cookie" && store.isInstanceOperator(who.name), approvers }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }));
370
+ const release = chosen === "" ? null : releaseApprovalSetting(store, chosen);
371
+ const content = chosen === "" ? html `<p>Add a project to set its approval rules.</p>` : html `${approvalRulesHtml({ repo: chosen, name: projectName(chosen), rules: store.approvalRules(chosen), canChange: who.via === "cookie" && store.isInstanceOperator(who.name), approvers }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}${releaseApprovalHtml({ repo: chosen, auto: release.auto, setBy: release.setBy, at: release.at, canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, chosen) })}`;
327
372
  return sendScreen(response, 200, screen("Approval rules", html `<p><a href="/settings">Settings</a></p><h1>Approval rules</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings") }));
328
373
  }
329
374
  // Sprint 8: Settings → Policy, the organisation policy and its history. Anyone signed in reads it; an instance operator changes it.
@@ -376,19 +421,16 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
376
421
  const databaseFile = store.databaseFile();
377
422
  if (databaseFile === null)
378
423
  return refuse(response, who, 403, "An instance operator updates Toolroll.", "/settings");
379
- // A record it cannot release is shown as it is; the page never fails over it.
380
- try {
381
- releaseStalledUpdate(dirname(databaseFile), clock());
382
- }
383
- catch { /* shown as last saved */ }
424
+ // A stalled update is released by the background pass, never by opening this page (docs/plans/get-side-effects.md).
384
425
  const status = runtimeUpdateStatus(dirname(databaseFile));
385
426
  if (url.searchParams.get("fragment") === "steps")
386
427
  return respond(response, 200, "text/html; charset=utf-8", status.journal ? updateStepsHtml(status.journal, status.running) : html `<div id="update-live" data-done="1"></div>`);
387
428
  const active = status.journal !== null && !runtimeUpdateTerminal(status.journal.phase);
388
429
  const current = options.updates?.current ?? PACKAGE_VERSION;
389
- // While update checks are off the page asks npm nothing until Check now.
390
- const checksOff = updateChecksOff(process.env, options.configDir ?? dirname(databaseFile)).off && url.searchParams.get("check") !== "now";
391
- const view = { current, latest: active ? { version: current } : checksOff ? { off: true } : await latestReleaseFor(options.updates?.latest), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, rollbackTo: status.lastUpdate?.to === current ? status.lastUpdate.from : null, csrf: who.session.csrf };
430
+ // The last answer npm gave (the background checks ask while update checks are on; Check now asks once).
431
+ const checksOff = updateChecksOff(process.env, options.configDir ?? dirname(databaseFile)).off;
432
+ const known = lastChecks.known("release")?.value ?? null;
433
+ const view = { current, latest: active ? { version: current } : known ?? (checksOff ? { off: true } : { pending: true }), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, rollbackTo: status.lastUpdate?.to === current ? status.lastUpdate.from : null, csrf: who.session.csrf };
392
434
  return sendScreen(response, 200, screen("Updates", html `<p><a href="/settings">Settings</a></p><h1>Updates</h1>${(updatesHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings"), ...(active ? { functional: { script: updatesScript(), fetches: true } } : {}) }));
393
435
  }
394
436
  // v105: Settings → Retention, how long each kind of data is kept. An instance operator's page.
@@ -406,9 +448,16 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
406
448
  const databaseFile = store.databaseFile();
407
449
  if (databaseFile === null)
408
450
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
409
- const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
451
+ // The last measurement (docs/plans/get-side-effects.md): the background checks measure the checkouts, and so do
452
+ // Check now and every clean-up; opening the page runs no du or git.
453
+ const measured = lastChecks.known("storage");
454
+ const check = postForm("/settings/storage/check", html `<button type="submit" class="secondary">Check now</button>`, { attrs: { class: "inline" } });
455
+ if (measured === null) {
456
+ return sendScreen(response, 200, screen("Storage", html `<p><a href="/settings">Settings</a></p><h1>Storage</h1><section class="storage"><div class="card storage-state"><h2>Checkouts not measured yet</h2>${check}</div></section>`, { chrome: chromeFor(project, "settings") }));
457
+ }
458
+ const plan = { ...measured.value, cleanup: store.checkoutCleanup() };
410
459
  const sweep = { last: lastSweep(store), off: storageSweepOff(), schedule: sweepSchedule(store, now) };
411
- return sendScreen(response, 200, screen("Storage", html `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${(storageHtml({ plan, csrf: who.session.csrf, sweep, landed: landedSummary(store) }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
460
+ return sendScreen(response, 200, screen("Storage", html `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${(storageHtml({ plan, csrf: who.session.csrf, sweep, landed: landedSummary(store), measured: html `<p class="meta">Measured ${new Date(measured.at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })} ${check}</p>` }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
412
461
  }
413
462
  // Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
414
463
  // exactly what turning on would do, or what to fix first.
@@ -420,7 +469,8 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
420
469
  return refuse(response, who, 404, "No such project.", "/projects");
421
470
  const publishing = publishingOf(store, repo);
422
471
  const canChange = who.role === "approver" && !store.isDemo();
423
- const check = publishing.on || !canChange ? null : await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
472
+ // The last setup check for this project (Check setup posts); opening the page asks git and GitHub nothing.
473
+ const check = publishing.on || !canChange ? null : lastChecks.known(`publishing:${repo}`)?.value ?? "unchecked";
424
474
  const body = (pullRequestSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange, publishing, check,
425
475
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }));
426
476
  return sendScreen(response, 200, screen("Pull requests", html `<p><a href="/projects">Projects</a></p><h1>Pull requests</h1>${body}`, { chrome: chromeFor(project, "projects") }));
@@ -563,15 +613,13 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
563
613
  async function leadPage(ctx) {
564
614
  const { url, response, now, project } = ctx;
565
615
  const who = browserCaller(ctx.who);
566
- if (runtime.localSignIn === null)
567
- await Promise.race([checkLocalAgents(), new Promise(done => setTimeout(done, 6_000).unref?.())]);
568
616
  const config = store.getChatConfig();
569
617
  const facts = {
570
618
  keyFacts: ["anthropic-api", "openrouter-api"].map(one => {
571
619
  const found = chatKeyFor(one);
572
620
  return { provider: one, state: found === null ? "none" : found.source, tail: found === null || found.source === "environment" ? null : redactToken(found.key) };
573
621
  }),
574
- openrouterModels: (await chatCatalog())?.map(one => one.id) ?? null,
622
+ openrouterModels: chatCatalogKnown()?.map(one => one.id) ?? null,
575
623
  liveModels: [...modelOptions(store, "claude", now, providerHome), ...modelOptions(store, "codex", now, providerHome)],
576
624
  csrf: who.session.csrf,
577
625
  returnTo: "/settings/lead",
@@ -646,7 +694,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
646
694
  provider,
647
695
  envName: PROVIDER_KEY_ENV[provider],
648
696
  ...keyStatus(provider, providerHome),
649
- connection: await connectionCheck(provider, url.searchParams.get("check-connection") === provider),
697
+ ...(connectionKnown(provider) === null ? {} : { connection: connectionKnown(provider) }),
650
698
  ambient: (process.env[PROVIDER_KEY_ENV[provider]] ?? "") !== "",
651
699
  mode: readAuthMode(provider, providerHome) === "api-key" ? "api-key" : "subscription",
652
700
  subscriptionCapable: SUBSCRIPTION_CAPABLE[provider],
@@ -1114,7 +1162,9 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1114
1162
  const databaseFile = store.databaseFile();
1115
1163
  if (databaseFile === null)
1116
1164
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
1117
- const back = sayTo(response, "/settings/storage");
1165
+ // What the page shows next is measured again after the change.
1166
+ const said = sayTo(response, "/settings/storage");
1167
+ const back = async (kind, words) => { await measureStorage(databaseFile).catch(() => undefined); return said(kind, words); };
1118
1168
  const what = op === "clean" ? "clean up" : op === "discard" ? "discard a checkout" : "change checkout cleanup";
1119
1169
  if (!authenticateApprover(who, body.get("password") ?? "").ok)
1120
1170
  return back("problem", `Enter your Toolroll password to ${what}.`);
@@ -2468,6 +2518,11 @@ ${modeWords(terms)
2468
2518
  "mode": modePage,
2469
2519
  "control": controlPage("setup"),
2470
2520
  "control.connection": controlPage("connection"),
2521
+ "settings.updates-check-send": updatesCheck,
2522
+ "settings.storage-check-send": storageCheck,
2523
+ "settings.pull-requests-check-send": pullRequestsCheck,
2524
+ "settings.connection-check": connectionCheckNow,
2525
+ "control.models-refresh": modelsRefresh,
2471
2526
  "settings.models": modelsPage,
2472
2527
  "settings.tools": toolsPage,
2473
2528
  "settings.project": projectPage,
@@ -2485,6 +2540,7 @@ ${modeWords(terms)
2485
2540
  "settings.sign-in": signInPage,
2486
2541
  "settings.passwordless": passwordlessPage,
2487
2542
  "settings.passwordless-send": passwordlessSave,
2543
+ "settings.release-approval-send": releaseApprovalSave,
2488
2544
  "settings.lead": leadPage,
2489
2545
  "settings.teams": teamsPage,
2490
2546
  "settings.discord": discordPage,
@@ -2672,5 +2728,29 @@ ${modeWords(terms)
2672
2728
  return back("problem", "An approver for this project changes its sandbox. Nothing changed.");
2673
2729
  return back("said", JSON.stringify(changed.before) === JSON.stringify(changed.after) ? "Nothing changed." : "Saved. Tasks scoped from now on use it; tasks already approved keep what they were approved with.");
2674
2730
  }
2731
+ // Release approval (docs/plans/auto-approve-green.md): an approver of the project. Approving automatically is a
2732
+ // credential act (a password typed now, never a session); asking again is one click and takes back automatic seals.
2733
+ async function releaseApprovalSave(ctx) {
2734
+ const { response, now, posted } = ctx;
2735
+ const who = browserCaller(ctx.who);
2736
+ const body = readForm(posted, CONSOLE_FORMS.releaseApproval);
2737
+ const repo = body.get("repo") ?? "";
2738
+ if (!consoleProjects().includes(repo) || !visible(repo))
2739
+ return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
2740
+ const back = sayTo(response, `/settings/approval?repo=${encodeURIComponent(repo)}`);
2741
+ if (who.role !== "approver" || store.isDemo() || !store.accountCanAccess(who.name, repo))
2742
+ return back("problem", "An approver of this project sets how its releases are approved.");
2743
+ const action = body.get("action");
2744
+ if (action === "ask") {
2745
+ const changed = setReleaseApproval(store, repo, false, who.name, now);
2746
+ return back("said", !changed.changed ? "Saved." : `Releases ask you again.${changed.withdrawn.length === 0 ? "" : ` ${changed.withdrawn.length} automatic approval${changed.withdrawn.length === 1 ? " was" : "s were"} taken back.`}`);
2747
+ }
2748
+ if (action !== "auto")
2749
+ return back("problem", "Choose Ask me or Approve automatically when checks pass.");
2750
+ if (!authenticateCredential(who, body.get("password") ?? "", repo).ok)
2751
+ return back("problem", "Enter your Toolroll password to approve releases automatically.");
2752
+ const changed = setReleaseApproval(store, repo, true, who.name, now);
2753
+ return back("said", changed.changed ? "Releases approve themselves once CI and the release check pass." : "Saved.");
2754
+ }
2675
2755
  return { registrations, modelsView };
2676
2756
  }
@@ -487,7 +487,6 @@ export function createTaskViews(runtime) {
487
487
  coordinatorProposals: (() => {
488
488
  if (ref === null || ref.repo === null || who.role !== "approver")
489
489
  return null;
490
- runtime.store.sweepCoordinatorProposals(now);
491
490
  const rows = runtime.store.listCoordinatorProposals({ repos: [ref.repo], taskId });
492
491
  return { rows, decisions: decisionsFor(runtime.store, rows), now };
493
492
  })(),
@@ -497,6 +496,7 @@ export function createTaskViews(runtime) {
497
496
  steeringRun: ref === null ? null : runtime.store.steeringLiveBuild(ref.id, now),
498
497
  release: ref === null ? null : releaseViewOf(runtime.store, ref.externalId, now, authority => releaseObligationsOf(runtime.store, runtime.evidenceRoot, authority)),
499
498
  canStopRelease: who.role === "approver" && who.via === "cookie",
499
+ autoRelease: ref === null ? null : autoReleaseOf(runtime.store.getScope(ref.externalId)),
500
500
  approvalProof: (() => {
501
501
  // The proof of the approval shown: its approver, its exact sealed digest (approvedDigest === digest when approved).
502
502
  const scope = ref === null ? null : runtime.store.getScope(ref.externalId);
@@ -1140,3 +1140,10 @@ export function createTaskViews(runtime) {
1140
1140
  }
1141
1141
  return { guided, projectFamilyPeek, familiesInView, familyTasksInView, revisionDestination, earlierLiveVersions, familyHistory, freshAssignment, workRowOf, taskListPane, runIsLive, runIsTaskResult, revisionViewOf, taskViewData, routeViewOf, taskChatFocus, pullRequestTargetOf, taskPullRequestOf, failureOf, explainAttempt, runChecksHere, taskScreen, armTaskResume, planViewOf, planContractViewOf, revisionDocOf, revisionLedgerOf, progressOf, resultDetailOf };
1142
1142
  }
1143
+ /** A release task the plane approved (or is checking) itself, for the task page (docs/plans/auto-approve-green.md). */
1144
+ function autoReleaseOf(scope) {
1145
+ if (scope === null || scope.approvedDigest !== scope.digest || (scope.candidate ?? null) === null)
1146
+ return null;
1147
+ return scope.approvalBasis === "auto-green" ? { kind: "approved", candidate: scope.candidate }
1148
+ : scope.approvalBasis === "auto-check" ? { kind: "checking", candidate: scope.candidate } : null;
1149
+ }
@@ -12,7 +12,7 @@ import { handlersOf } from './handler-registry.js';
12
12
  import { closedQuestionWords } from "../decision.js";
13
13
  import { DEPRECATED_PAGE } from "../deprecations.js";
14
14
  /** tasks handlers, moved without changing their route bodies. */
15
- import { html, htmlString, joinHtml, textHtml } from "../html.js";
15
+ import { html, htmlString, joinHtml, postForm, textHtml } from "../html.js";
16
16
  import { createHash, randomBytes, randomUUID } from "node:crypto";
17
17
  import { lstatSync, realpathSync } from "node:fs";
18
18
  import {} from "node:http";
@@ -75,7 +75,9 @@ export function createTasksHandlers(runtime) {
75
75
  function noPage(who, response) {
76
76
  return refuse(response, who, 404, "There's no page at this address.", "/chat");
77
77
  }
78
- const showCode = (part) => async (ctx) => {
78
+ // `posted`: the session page's own POST (form token). A GET of changes or the shipping preview runs no git in the
79
+ // agent's checkout: changes go back to the session page, the preview asks for one click.
80
+ const showCode = (part, posted = false) => async (ctx) => {
79
81
  const { url, who, response, project } = ctx;
80
82
  if (who.via !== 'cookie' || !codingActorAllowed({ name: who.name, generation: who.session.generation }))
81
83
  return refuse(response, who, 403, 'Coding sessions require installation-wide operator access. You can manage project tasks in Work.', '/work');
@@ -86,6 +88,11 @@ export function createTasksHandlers(runtime) {
86
88
  const selected = sessionId !== null && runtime.coding ? runtime.coding.get(sessionId, actor) : null;
87
89
  if (selected && !codingProjectAllowed(selected.repo))
88
90
  return refuse(response, who, 403, 'That project is outside your access.', '/projects');
91
+ if (!posted && part === 'changes' && selected)
92
+ return redirect(response, `/code/${selected.id}`);
93
+ if (!posted && part === 'ship' && selected) {
94
+ return sendScreen(response, 200, screen('Review for shipping', html `<section class="coding-workspace coding-handoff"><h1>Review for shipping</h1><p>${selected.title}</p>${postForm(`/code/${selected.id}/ship/preview`, html `<button type="submit">Review the committed change</button>`)}<p><a href="/code/${selected.id}">Back to coding</a></p></section>`, { chrome: chromeFor(selected.repo, 'code') }));
95
+ }
89
96
  if (part === 'ship' && selected) {
90
97
  const preview = codingHandoffPreview(store, { sessionId: selected.id, actor: who.name });
91
98
  return sendScreen(response, 200, screen('Review for shipping', codingShippingHtml(preview), { chrome: chromeFor(selected.repo, 'code') }));
@@ -2996,6 +3003,8 @@ export function createTasksHandlers(runtime) {
2996
3003
  "code.state": showCode("state"),
2997
3004
  "code.changes": showCode("changes"),
2998
3005
  "code.ship": showCode("ship"),
3006
+ "code.changes-read": showCode("changes", true),
3007
+ "code.ship-preview": showCode("ship", true),
2999
3008
  "code.other": showCode("unknown"),
3000
3009
  "inbox": showInbox,
3001
3010
  "work": showWork,