toolroll 0.9.73 → 0.9.75

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 (145) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/dist/agent-turns.js +5 -0
  4. package/dist/agents-live.d.ts +31 -0
  5. package/dist/agents-live.js +43 -0
  6. package/dist/assignment.d.ts +3 -0
  7. package/dist/assignment.js +27 -8
  8. package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +31 -30
  11. package/dist/browser-workspace.d.ts +7 -1
  12. package/dist/builder.js +1 -1
  13. package/dist/chat-acceptance.d.ts +4 -1
  14. package/dist/chat-acceptance.js +13 -3
  15. package/dist/chat-continuity.js +10 -0
  16. package/dist/chat-quiet.js +3 -3
  17. package/dist/cli.js +1 -1
  18. package/dist/coding-handoff.js +4 -3
  19. package/dist/coding-ui.js +2 -2
  20. package/dist/coding-workspace.js +16 -4
  21. package/dist/contracts/console-api.d.ts +5 -1
  22. package/dist/contracts/console-api.js +10 -0
  23. package/dist/control-ui.d.ts +3 -2
  24. package/dist/control-ui.js +7 -2
  25. package/dist/demo.js +5 -3
  26. package/dist/deploy-gate.js +5 -2
  27. package/dist/desktop-update.d.ts +4 -1
  28. package/dist/desktop-update.js +43 -9
  29. package/dist/dispose.js +35 -2
  30. package/dist/exec.js +13 -16
  31. package/dist/first-run.d.ts +20 -1
  32. package/dist/first-run.js +71 -0
  33. package/dist/flow-card-task.js +3 -2
  34. package/dist/flow-unattended.d.ts +2 -0
  35. package/dist/flow-unattended.js +12 -1
  36. package/dist/integrations.d.ts +4 -3
  37. package/dist/integrations.js +5 -6
  38. package/dist/invoke.d.ts +13 -0
  39. package/dist/invoke.js +21 -12
  40. package/dist/knowledge-ui.d.ts +4 -1
  41. package/dist/knowledge-ui.js +10 -5
  42. package/dist/lead-contract.d.ts +1 -1
  43. package/dist/lead-contract.js +2 -2
  44. package/dist/lead-follow.js +3 -1
  45. package/dist/lead-progress.d.ts +4 -1
  46. package/dist/lead-progress.js +3 -1
  47. package/dist/lead.js +4 -0
  48. package/dist/live-bus.d.ts +2 -0
  49. package/dist/live-bus.js +1 -0
  50. package/dist/live-children-read.d.ts +52 -0
  51. package/dist/live-children-read.js +333 -0
  52. package/dist/live-children.d.ts +73 -0
  53. package/dist/live-children.js +318 -0
  54. package/dist/live-detail.d.ts +4 -3
  55. package/dist/live-detail.js +9 -5
  56. package/dist/live-input.js +7 -8
  57. package/dist/live-main-read.d.ts +19 -0
  58. package/dist/live-main-read.js +91 -0
  59. package/dist/live-open.d.ts +28 -0
  60. package/dist/live-open.js +125 -0
  61. package/dist/live-records.d.ts +10 -1
  62. package/dist/live-records.js +16 -5
  63. package/dist/live.d.ts +54 -22
  64. package/dist/live.js +218 -69
  65. package/dist/openrouter-models.d.ts +5 -2
  66. package/dist/openrouter-models.js +7 -4
  67. package/dist/operate.js +5 -2
  68. package/dist/peek-cli.d.ts +10 -3
  69. package/dist/peek-cli.js +17 -8
  70. package/dist/planner.js +1 -1
  71. package/dist/project-learning.js +44 -6
  72. package/dist/project-tools.js +19 -1
  73. package/dist/provider-limits.d.ts +3 -1
  74. package/dist/provider-limits.js +5 -2
  75. package/dist/provider.js +6 -12
  76. package/dist/pull-request-ui.d.ts +1 -1
  77. package/dist/pull-request-ui.js +5 -1
  78. package/dist/push.d.ts +2 -0
  79. package/dist/push.js +24 -19
  80. package/dist/read-only-request.d.ts +4 -0
  81. package/dist/read-only-request.js +15 -0
  82. package/dist/release-adapters.js +4 -0
  83. package/dist/release.d.ts +3 -0
  84. package/dist/release.js +3 -0
  85. package/dist/repository-context-ui.js +2 -1
  86. package/dist/repository-context.js +6 -1
  87. package/dist/result-follow-ups.js +20 -1
  88. package/dist/result-review.js +5 -1
  89. package/dist/run-efficiency.d.ts +3 -3
  90. package/dist/run-efficiency.js +20 -8
  91. package/dist/scout.d.ts +3 -0
  92. package/dist/scout.js +5 -3
  93. package/dist/serve.js +196 -29
  94. package/dist/server/agents-live-filed.d.ts +17 -0
  95. package/dist/server/agents-live-filed.js +97 -0
  96. package/dist/server/agents-live-lead.d.ts +47 -0
  97. package/dist/server/agents-live-lead.js +116 -0
  98. package/dist/server/agents-live-room.d.ts +23 -0
  99. package/dist/server/agents-live-room.js +91 -0
  100. package/dist/server/chat.js +115 -53
  101. package/dist/server/chrome.js +2 -2
  102. package/dist/server/console.css +68 -40
  103. package/dist/server/http.d.ts +5 -0
  104. package/dist/server/last-checks.d.ts +17 -0
  105. package/dist/server/last-checks.js +22 -0
  106. package/dist/server/live.d.ts +4 -0
  107. package/dist/server/live.js +42 -2
  108. package/dist/server/pages.js +98 -4
  109. package/dist/server/people-tokens.js +7 -4
  110. package/dist/server/render-chat.d.ts +15 -1
  111. package/dist/server/render-chat.js +14 -7
  112. package/dist/server/render-pages.d.ts +10 -5
  113. package/dist/server/render-pages.js +83 -74
  114. package/dist/server/render-results.d.ts +11 -2
  115. package/dist/server/render-results.js +54 -12
  116. package/dist/server/render-settings.js +3 -3
  117. package/dist/server/render-tasks.js +13 -3
  118. package/dist/server/route-table.js +19 -2
  119. package/dist/server/runtime.d.ts +17 -2
  120. package/dist/server/settings.js +85 -32
  121. package/dist/server/task-views.js +1 -2
  122. package/dist/server/tasks.js +70 -43
  123. package/dist/storage-ui.d.ts +2 -0
  124. package/dist/storage-ui.js +1 -1
  125. package/dist/storage.js +2 -1
  126. package/dist/store.d.ts +34 -1
  127. package/dist/store.js +108 -11
  128. package/dist/stream-scope.d.ts +18 -0
  129. package/dist/stream-scope.js +28 -0
  130. package/dist/subagent-work.js +3 -0
  131. package/dist/subscription-chat.js +5 -3
  132. package/dist/task-status.d.ts +28 -1
  133. package/dist/task-status.js +93 -11
  134. package/dist/teardown-git.d.ts +27 -1
  135. package/dist/teardown-git.js +185 -39
  136. package/dist/telegram-progress.js +4 -4
  137. package/dist/telegram-status.js +8 -3
  138. package/dist/tool-secret-store.js +8 -0
  139. package/dist/toolroll-update-ui.d.ts +2 -0
  140. package/dist/toolroll-update-ui.js +4 -3
  141. package/dist/toolroll-update.js +10 -9
  142. package/dist/work-index.js +29 -12
  143. package/dist/workspace-ui.d.ts +5 -0
  144. package/dist/workspace-ui.js +38 -10
  145. package/package.json +1 -1
@@ -20,7 +20,7 @@ import { CHAT_TASK_ACTIONS } from "../chat-task-actions.js";
20
20
  import {} from "../contracts/console-api.js";
21
21
  import {} from "../dispatch.js";
22
22
  import {} from "../evidence.js";
23
- import { firstTaskJourney } from "../first-run.js";
23
+ import { taskRoad } from "../first-run.js";
24
24
  import { starterForWork } from "../flow-starters.js";
25
25
  import {} from "../gaps.js";
26
26
  import { readAuthModeStrict } from "../keys.js";
@@ -1437,6 +1437,11 @@ export function taskBodyParts(data) {
1437
1437
  if (resultStatus.token === "evidence-damaged") {
1438
1438
  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
1439
  }
1440
+ // A recorded pass the check read now doesn't stand behind (unreadable, failed since, or not read here) never
1441
+ // reads "passed": the box says what holds now (task-status.ts approvedCheckOf).
1442
+ if (proof.proofVerdict === "verified" && !accepted && !(proof.receipt?.checks !== undefined && ["ready-to-review", "pr-opened", "merge-observed", "pr-closed"].includes(resultStatus.token))) {
1443
+ 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);
1444
+ }
1440
1445
  if (proof.proofVerdict === "verified" && !accepted) {
1441
1446
  const recovered = verificationRecovered(proof.proofReasons);
1442
1447
  return withReview(box("ok", resultStatus.label, recovered
@@ -2359,10 +2364,15 @@ export function taskBodyParts(data) {
2359
2364
  { id: "task-diagnostics", title: "Task options", html: htmlString(optionsHtml), open: optionsOpen, count: null },
2360
2365
  ],
2361
2366
  cancel: cancelForm === "" ? null : { html: htmlString(cancelForm), open: data.cancelDraft !== undefined },
2362
- journey: data.guide !== true || task.state === "cancelled" || data.status === undefined ? null : (() => {
2367
+ // Every task's road (the first task's guide used to be the only one): facts only, so the checks step says when
2368
+ // checks didn't run, and a research report has none.
2369
+ journey: task.state === "cancelled" || data.status === undefined ? null : (() => {
2363
2370
  const planning = data.plan === "requested" && approveForm === "";
2364
2371
  const read = data.assignment != null ? assignmentStageOf(data.assignment, { token: data.status.token }, planning) : stageOfCode(data.status.token, { planning });
2365
- return firstTaskJourney(read, scope !== null && approvalOf(scope).approved, task.state === "done");
2372
+ // The one answer the status card's headline and Project checks row read (task-status.ts approvedCheckOf).
2373
+ const checks = statusCard?.status.check ?? null;
2374
+ const report = data.deliverable === "report" || data.report != null;
2375
+ return taskRoad(read, scope !== null && approvalOf(scope).approved, task.state === "done", { checks, report });
2366
2376
  })(),
2367
2377
  // "Do this every time…": the starter flow that does this kind of work on its own, one yes away.
2368
2378
  thread,
@@ -139,10 +139,14 @@ get("tasks", "done", "/done", { limited: "collection" });
139
139
  get("settings", "mode", "/mode");
140
140
  get("people", "people.page", "/people", { limited: "unscoped", ...free });
141
141
  get("pages", "system", "/system");
142
- get("tasks", "tasks", "/tasks", { limited: "collection" });
142
+ get("tasks", "tasks", "/tasks", { limited: "collection", ...free }); // a redirect to /work now: never sent to the project opener first
143
143
  get("tasks", "queue", "/queue");
144
144
  get("pages", "peek", "/peek", free);
145
145
  get("pages", "agents", "/agents", { limited: "unscoped", ...free });
146
+ // The Agents view's Live grid (docs/plans/agents-view.md §3): read-only, every request filtered to what the viewer may see.
147
+ // Browser sessions only, like the transcript and the peek: no token (a lead's, a runner's, a remote one) reads live output.
148
+ get("pages", "agents.live", "/agents/live", { limited: "unscoped", callers: ["cookie"], ...free });
149
+ get("pages", "agents.live.read", `^\\/agents\\/live\\/${ID}$`, { limited: "unscoped", callers: ["cookie"], ...free, sample: "/agents/live/1" });
146
150
  get("pages", "fleet", "/fleet", free);
147
151
  get("tasks", "tasks.new", "/tasks/new", { limited: "collection", ...free });
148
152
  get("tasks", "task.page", `^\\/t\\/${TASK}$`, { project: "task", limited: "resource", ...free, sample: "/t/one" });
@@ -233,6 +237,10 @@ for (const one of ["close", "remove"])
233
237
  post("tasks", `term.${one}`, `^\\/term\\/${TERMINAL}\\/${one}$`, { ...TERM, sample: `/term/${"a".repeat(32)}/${one}` });
234
238
  for (const one of ["send", "stop", "resume", "recover", "continue", "ship", "answer"])
235
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` });
236
244
  // Any other coding action answers "not found" after the same access check.
237
245
  post("tasks", "code.act-other", "^\\/code(\\/.*)?$", { ...CODING_ACT, sample: "/code/unknown" });
238
246
  post("settings", "settings.skills-revise", "/settings/skills/revise", { project: "form", limited: "unscoped", ...approver("Sign in to revise a test.", "/settings/skills") });
@@ -304,10 +312,12 @@ const SETTINGS_ACT_ROLES = {
304
312
  updates: operator("An instance operator updates Toolroll.", "/settings"),
305
313
  "updates/seen": operator("An instance operator updates Toolroll.", "/settings"),
306
314
  "updates/cancel": operator("An instance operator updates Toolroll.", "/settings"),
315
+ "updates/check": operator("An instance operator updates Toolroll.", "/settings"),
307
316
  retention: operator("An instance operator sets retention.", "/settings"),
308
317
  storage: operator("An instance operator looks after storage.", "/settings"),
309
318
  "storage/clean": operator("An instance operator looks after storage.", "/settings"),
310
319
  "storage/discard": operator("An instance operator looks after storage.", "/settings"),
320
+ "storage/check": operator("An instance operator looks after storage.", "/settings"),
311
321
  "backups/now": operator("An instance operator looks after backups.", "/settings"),
312
322
  backups: operator("An instance operator looks after backups.", "/settings"),
313
323
  data: operator("An instance operator exports data.", "/settings"),
@@ -316,7 +326,7 @@ const SETTINGS_ACT_ROLES = {
316
326
  "tools/connect": approver("Sign in as an approver to connect tools.", "/settings/tools"),
317
327
  "tools/change": approver("Sign in as an approver to manage tools.", "/settings/tools"),
318
328
  };
319
- 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"]) {
329
+ 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"]) {
320
330
  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] });
321
331
  }
322
332
  post("settings", "settings.flows-on", "/settings/flows/on", { limited: "collection", ...approver("An approver switches starter flows on.", "/settings/flows") });
@@ -330,6 +340,9 @@ post("settings", "settings.knowledge-proposal", "/settings/knowledge/proposal",
330
340
  post("settings", "settings.knowledge-decision", "/settings/knowledge/decision", { project: "form", ...approver("Sign in as an approver to change project decisions.", "/settings/knowledge") });
331
341
  post("settings", "settings.knowledge-change", "/settings/knowledge/change", { project: "form", limited: "unscoped", ...approver("Sign in as an approver to change project knowledge.", "/settings/knowledge") });
332
342
  post("settings", "settings.learning-change", "/settings/learning/change", { project: "form", limited: "unscoped" });
343
+ // A page shows the last check; these ask again (docs/plans/get-side-effects.md).
344
+ post("settings", "control.models-refresh", "/control/models-refresh", anyApprover("Project setup requires an approver."));
345
+ post("settings", "settings.connection-check", "/settings/connection/check", anyApprover("An approver checks a sign-in.", "/settings"));
333
346
  for (const one of ["setup-preview", "setup-approve", "instructions-preview", "instructions-approve"])
334
347
  post("settings", `control.${one}`, `/control/${one}`, { ...(one.endsWith("approve") ? { scope: "step-up" } : {}) });
335
348
  for (const [service, name] of [["slack", "Slack"], ["teams", "Teams"], ["discord", "Discord"]]) {
@@ -344,6 +357,7 @@ post("settings", "settings.telegram-unpair", "/settings/telegram/unpair", { limi
344
357
  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"]) {
345
358
  post("settings", `settings.${one.replaceAll("/", "-")}-send`, `/settings/${one}`);
346
359
  }
360
+ post("pages", "projects.github-list", "/projects/github", { callers: ["cookie"], ...anyApprover("your login can watch — adding projects is an approver's act") });
347
361
  post("pages", "projects.select", "/projects/select", { project: "form-path", limited: "unscoped", viewer: true });
348
362
  post("pages", "projects.remove", "/projects/remove", { project: "form", ...operator("An instance operator removes projects.", "/settings/project") });
349
363
  post("pages", "projects.open", "/projects/open", { project: "form-path" });
@@ -393,6 +407,9 @@ post("pages", "projects.onboard-preview", "/projects/onboard-preview", { project
393
407
  post("pages", "projects.onboard-confirm", "/projects/onboard-confirm", { scope: "step-up", project: "none" });
394
408
  post("chat", "push.subscribe", "/push/subscribe", { scope: "step-up" });
395
409
  post("chat", "push.remove", "/push/remove");
410
+ // Ask this computer's agent CLIs again (docs/plans/get-side-effects.md): Chat's first run and Settings → Lead's Check
411
+ // now. GET /lead/status only reads the last answer.
412
+ post("chat", "lead.check", "/lead/check", { callers: ["cookie"] });
396
413
  post("chat", "onboarding.phone-dismiss", "/onboarding/phone/dismiss", approver("An approver puts this away.", "/chat"));
397
414
  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." };
398
415
  for (const one of ["identity", "about", "promise/cancel", "on"])
@@ -146,7 +146,7 @@ export interface ServerRuntime {
146
146
  via: "cookie";
147
147
  }, principal: VerifiedApprover, enabled: Extract<ChatEnablement, {
148
148
  ok: true;
149
- }>, ceilingUsd: number, follow: boolean, now: Date) => number;
149
+ }>, ceilingUsd: number, follow: boolean, now: Date, scope?: LeadThreadScope, request?: string) => number;
150
150
  projectFamilyPeek: (repo: string, now: Date) => ProjectPeek;
151
151
  needsYouBadge: (project: string | null) => {
152
152
  count: number;
@@ -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;
@@ -63,7 +63,7 @@ import { starterOf, startersFor, switchOnStarter } from "../flow-starters.js";
63
63
  import { disconnectGoogle, googleConnected, googleConsent, readGoogleMail, saveGoogleClient } from "../google-mail.js";
64
64
  import { integrationsHtml } from "../integrations-ui.js";
65
65
  import { clearProviderKey, keyStatus, plausibleKey, PROVIDER_KEY_ENV, readAuthMode, readProviderKey, saveProviderKey, setAuthMode, SUBSCRIPTION_CAPABLE, verdictWords, verifyProviderKey } from "../keys.js";
66
- import { decisionsHtml, knowledgeHtml, MEMORY_INTRO, memorySearchHtml, proposalsHtml } from "../knowledge-ui.js";
66
+ import { decisionsHtml, framedSection, knowledgeHtml, MEMORY_INTRO, memorySearchHtml, proposalsHtml } from "../knowledge-ui.js";
67
67
  import { aboutYouOf, checkAboutYou, saveAboutYou } from "../lead-about.js";
68
68
  import { cancelCommitment, conditionWords, openCommitments } from '../lead-commitments.js';
69
69
  import { checkLeadIdentity, leadIdentityOf } from "../lead-identity.js";
@@ -122,14 +122,14 @@ import { teamsSettingsHtml } from "../teams-settings.js";
122
122
  import { telegramSettingsHtml } from "../telegram-settings.js";
123
123
  import { hashPairingCode, loadBotToken, mintPairingCode, PAIRING_TTL_MS, redactToken, saveBotToken } from "../telegram.js";
124
124
  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";
125
+ import { abandonRuntimeUpdate, currentRuntime, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal } from "../toolroll-update.js";
126
126
  import { TOOLS_PROJECT_SCRIPT, toolsHtml, toolsProjectPicker } from "../tools-ui.js";
127
127
  import { effectivePrimary, isMessagingChannel, savePrimary } from "../webhooks.js";
128
128
  import { personChip, ROLE_TITLES } from "./chrome.js";
129
129
  import { chatReturnWithSaid, goOutside } from "./http.js";
130
- import { latestReleaseFor, leadSettingsHtml, notificationProjects, SETTINGS_AUTOSAVE_SCRIPT, settingsPage, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./render-settings.js";
130
+ import { leadSettingsHtml, notificationProjects, SETTINGS_AUTOSAVE_SCRIPT, settingsPage, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./render-settings.js";
131
131
  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;
132
+ 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
133
  // v105: what agent work cost, by month, and the monthly budgets. An instance operator's page.
134
134
  async function spendPage(ctx) {
135
135
  const { url, who, response, now } = ctx;
@@ -225,10 +225,11 @@ ${hasGrant ? "" : html `<span class="meta">self-merging needs a merge-capable pu
225
225
  if (!isProviderId(asked))
226
226
  return refuse(response, who, 400, "Choose a known provider.", "/control");
227
227
  const task = url.searchParams.get("task") ?? "";
228
- const connection = await connectionCheck(asked, url.searchParams.get("check-connection") === "1");
228
+ // The last sign-in check: Check again posts (docs/plans/get-side-effects.md).
229
+ const connection = connectionKnown(asked);
229
230
  if (view === "connection") {
230
231
  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>\
232
+ return sendScreen(response, 200, screen(`${ASSISTANTS[asked].name} connection`, html `<h1>${ASSISTANTS[asked].name} ${connection?.state === "connected" ? "is connected" : "connection"}</h1>\
232
233
  ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On the computer running Toolroll, sign in with:</p><pre class="recap">${command}</pre>`}\
233
234
  <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
235
  }
@@ -241,20 +242,61 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
241
242
  const models = live.length === 0 ? modelChoices(asked, configured, providerHome)
242
243
  : configured && !live.some(one => one.value === configured) ? [{ value: configured, label: `${configured} — current choice` }, ...live] : live;
243
244
  const instructions = previewProjectInstructions(project);
244
- const catalog = asked === "openrouter" ? await modelCatalog(readProviderKey("openrouter", providerHome), url.searchParams.get("refresh-models") === "1") : null;
245
+ // The model list as last loaded; Refresh models posts, and the background checks keep it current.
246
+ const catalog = asked === "openrouter" ? modelCatalog.known(readProviderKey("openrouter", providerHome)) ?? { ok: false, problem: "The model list has not loaded yet." } : null;
245
247
  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
248
  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
249
  instructions: instructions.ok ? { installed: instructions.installed } : { installed: false, message: instructions.message } }), { chrome: chromeFor(project, "settings"), ...(catalog === null ? {} : { functional: { script: openRouterPickerScript() } }) }));
248
250
  };
251
+ // Settings → Projects → Pull requests → Check setup: git and GitHub, for one project, by an approver who may turn it on.
252
+ async function pullRequestsCheck(ctx) {
253
+ const { who, response, posted } = ctx;
254
+ const repo = readForm(posted, CONSOLE_FORMS.pullRequestsCheck).get("repo") ?? "";
255
+ const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
256
+ if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
257
+ return refuse(response, who, 404, "No such project.", "/projects");
258
+ if (who.role !== "approver" || store.isDemo())
259
+ return refuse(response, who, 403, "An approver can turn on pull requests.", "/projects");
260
+ await lastChecks.run(`publishing:${repo}`, () => checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec }));
261
+ return redirect(response, `/settings/pull-requests?repo=${encodeURIComponent(repo)}`);
262
+ }
263
+ // Settings → Storage → Check now: measure the checkouts again.
264
+ async function storageCheck(ctx) {
265
+ const databaseFile = store.databaseFile();
266
+ if (databaseFile !== null)
267
+ await measureStorage(databaseFile);
268
+ return redirect(ctx.response, "/settings/storage");
269
+ }
270
+ const measureStorage = (databaseFile) => lastChecks.run("storage", () => checkoutPlan(store, storagePool(databaseFile), clock(), { manual: true }));
271
+ // Settings → Updates → Check now: ask npm once, whether or not automatic checks are on.
272
+ async function updatesCheck(ctx) {
273
+ await checkRelease();
274
+ return redirect(ctx.response, "/settings/updates");
275
+ }
276
+ // Check again (Settings → providers, Project setup): ask this computer's CLI, or test the saved key, now.
277
+ async function connectionCheckNow(ctx) {
278
+ const { response, posted } = ctx;
279
+ const body = readForm(posted, CONSOLE_FORMS.connectionCheck);
280
+ const provider = body.get("provider") ?? "";
281
+ const back = safeReturn(body.get("return") ?? "/settings#providers");
282
+ if (!isProviderId(provider))
283
+ return redirect(response, back);
284
+ await connectionCheck(provider, true).catch(() => undefined);
285
+ return redirect(response, back);
286
+ }
287
+ // Refresh models (Project setup, OpenRouter): load the list again, then show the setup page with it.
288
+ async function modelsRefresh(ctx) {
289
+ const { response, posted } = ctx;
290
+ const body = readForm(posted, CONSOLE_FORMS.modelsRefresh);
291
+ await modelCatalog(readProviderKey("openrouter", providerHome), true);
292
+ const query = new URLSearchParams({ provider: "openrouter", ...(body.get("task") ? { task: body.get("task") } : {}) });
293
+ return redirect(response, `/control?${query.toString()}`);
294
+ }
249
295
  async function modelsPage(ctx) {
250
296
  const { url, who, response, project } = ctx;
251
297
  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
- }
298
+ // The page shows the last check and when it ran (docs/plans/get-side-effects.md): Check now, or the watch loop's
299
+ // automatic checks, fetch the lists and may queue a notice; opening the page never does.
258
300
  const view = modelsView(who.role === "approver", who.via === "cookie" ? who.session.csrf : "", now, url.searchParams.get("said"), url.searchParams.get("problem"));
259
301
  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
302
  }
@@ -290,7 +332,8 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
290
332
  if (chosen) {
291
333
  try {
292
334
  const approver = who.role === "approver" && who.via === "cookie";
293
- const view = toolsViewOf(chosen, approver ? await codexServers(chosen) : null, kit, wanted);
335
+ // What Codex had configured at the last background check (docs/plans/get-side-effects.md): the page starts no codex.
336
+ const view = toolsViewOf(chosen, approver ? lastChecks.known(`codex-tools:${chosen}`)?.value ?? null : null, kit, wanted);
294
337
  if (approver)
295
338
  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
339
  content = (toolsHtml(view, who.via === "cookie" ? who.session.csrf : "", approver, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }));
@@ -376,19 +419,16 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
376
419
  const databaseFile = store.databaseFile();
377
420
  if (databaseFile === null)
378
421
  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 */ }
422
+ // A stalled update is released by the background pass, never by opening this page (docs/plans/get-side-effects.md).
384
423
  const status = runtimeUpdateStatus(dirname(databaseFile));
385
424
  if (url.searchParams.get("fragment") === "steps")
386
425
  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
426
  const active = status.journal !== null && !runtimeUpdateTerminal(status.journal.phase);
388
427
  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 };
428
+ // The last answer npm gave (the background checks ask while update checks are on; Check now asks once).
429
+ const checksOff = updateChecksOff(process.env, options.configDir ?? dirname(databaseFile)).off;
430
+ const known = lastChecks.known("release")?.value ?? null;
431
+ 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
432
  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
433
  }
394
434
  // v105: Settings → Retention, how long each kind of data is kept. An instance operator's page.
@@ -406,9 +446,16 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
406
446
  const databaseFile = store.databaseFile();
407
447
  if (databaseFile === null)
408
448
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
409
- const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
449
+ // The last measurement (docs/plans/get-side-effects.md): the background checks measure the checkouts, and so do
450
+ // Check now and every clean-up; opening the page runs no du or git.
451
+ const measured = lastChecks.known("storage");
452
+ const check = postForm("/settings/storage/check", html `<button type="submit" class="secondary">Check now</button>`, { attrs: { class: "inline" } });
453
+ if (measured === null) {
454
+ 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") }));
455
+ }
456
+ const plan = { ...measured.value, cleanup: store.checkoutCleanup() };
410
457
  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") }));
458
+ 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
459
  }
413
460
  // Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
414
461
  // exactly what turning on would do, or what to fix first.
@@ -420,7 +467,8 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
420
467
  return refuse(response, who, 404, "No such project.", "/projects");
421
468
  const publishing = publishingOf(store, repo);
422
469
  const canChange = who.role === "approver" && !store.isDemo();
423
- const check = publishing.on || !canChange ? null : await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
470
+ // The last setup check for this project (Check setup posts); opening the page asks git and GitHub nothing.
471
+ const check = publishing.on || !canChange ? null : lastChecks.known(`publishing:${repo}`)?.value ?? "unchecked";
424
472
  const body = (pullRequestSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange, publishing, check,
425
473
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }));
426
474
  return sendScreen(response, 200, screen("Pull requests", html `<p><a href="/projects">Projects</a></p><h1>Pull requests</h1>${body}`, { chrome: chromeFor(project, "projects") }));
@@ -508,7 +556,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
508
556
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
509
557
  if (chosen && (!projects.includes(chosen) || !visible(chosen)))
510
558
  return refuse(response, who, 403, "That project is outside your access.", "/settings/knowledge");
511
- const selector = projects.length > 1 ? html `<form class="knowledge" method="get" action="/settings/knowledge"><label>Project<select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? ' selected' : ''}>${p.split('/').at(-1) ?? p}</option>`)}</select></label><button>Show project</button></form>` : '';
559
+ const selector = projects.length > 1 ? framedSection('Project', html `<form class="field-row" method="get" action="/settings/knowledge"><label><span class="visually-hidden">Project</span><select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? ' selected' : ''}>${p.split('/').at(-1) ?? p}</option>`)}</select></label><button>Show project</button></form>`) : '';
512
560
  let content = html `<p>Add a project to keep its knowledge here.</p>`;
513
561
  if (chosen) {
514
562
  try {
@@ -533,7 +581,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
533
581
  }
534
582
  content = html `<p class="meta">${MEMORY_INTRO}</p>${memorySearchHtml(chosen, memoryQuery, hits)}${proposals}${(decisionsHtml(chosen, listDecisions(store, chosen, who.name, { limit: 50 }), csrf, who.role === 'approver'))}\
535
583
  ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf : '')}${knowledgeHtml(view, csrf, who.role === 'approver')}\
536
- <details class="knowledge"><summary>Learned lessons</summary>${(learningHtml(learningView(store, evidenceRoot, chosen, who.name), csrf, who.role === 'approver'))}</details>`;
584
+ <details class="knowledge frame"><summary class="frame-head">Learned lessons</summary><div class="frame-panel">${(learningHtml(learningView(store, evidenceRoot, chosen, who.name), csrf, who.role === 'approver'))}</div></details>`;
537
585
  }
538
586
  catch {
539
587
  content = html `<p class="problem" role="alert">Project knowledge is unavailable. Reload to retry.</p>`;
@@ -563,15 +611,13 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
563
611
  async function leadPage(ctx) {
564
612
  const { url, response, now, project } = ctx;
565
613
  const who = browserCaller(ctx.who);
566
- if (runtime.localSignIn === null)
567
- await Promise.race([checkLocalAgents(), new Promise(done => setTimeout(done, 6_000).unref?.())]);
568
614
  const config = store.getChatConfig();
569
615
  const facts = {
570
616
  keyFacts: ["anthropic-api", "openrouter-api"].map(one => {
571
617
  const found = chatKeyFor(one);
572
618
  return { provider: one, state: found === null ? "none" : found.source, tail: found === null || found.source === "environment" ? null : redactToken(found.key) };
573
619
  }),
574
- openrouterModels: (await chatCatalog())?.map(one => one.id) ?? null,
620
+ openrouterModels: chatCatalogKnown()?.map(one => one.id) ?? null,
575
621
  liveModels: [...modelOptions(store, "claude", now, providerHome), ...modelOptions(store, "codex", now, providerHome)],
576
622
  csrf: who.session.csrf,
577
623
  returnTo: "/settings/lead",
@@ -646,7 +692,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
646
692
  provider,
647
693
  envName: PROVIDER_KEY_ENV[provider],
648
694
  ...keyStatus(provider, providerHome),
649
- connection: await connectionCheck(provider, url.searchParams.get("check-connection") === provider),
695
+ ...(connectionKnown(provider) === null ? {} : { connection: connectionKnown(provider) }),
650
696
  ambient: (process.env[PROVIDER_KEY_ENV[provider]] ?? "") !== "",
651
697
  mode: readAuthMode(provider, providerHome) === "api-key" ? "api-key" : "subscription",
652
698
  subscriptionCapable: SUBSCRIPTION_CAPABLE[provider],
@@ -1114,7 +1160,9 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1114
1160
  const databaseFile = store.databaseFile();
1115
1161
  if (databaseFile === null)
1116
1162
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
1117
- const back = sayTo(response, "/settings/storage");
1163
+ // What the page shows next is measured again after the change.
1164
+ const said = sayTo(response, "/settings/storage");
1165
+ const back = async (kind, words) => { await measureStorage(databaseFile).catch(() => undefined); return said(kind, words); };
1118
1166
  const what = op === "clean" ? "clean up" : op === "discard" ? "discard a checkout" : "change checkout cleanup";
1119
1167
  if (!authenticateApprover(who, body.get("password") ?? "").ok)
1120
1168
  return back("problem", `Enter your Toolroll password to ${what}.`);
@@ -2468,6 +2516,11 @@ ${modeWords(terms)
2468
2516
  "mode": modePage,
2469
2517
  "control": controlPage("setup"),
2470
2518
  "control.connection": controlPage("connection"),
2519
+ "settings.updates-check-send": updatesCheck,
2520
+ "settings.storage-check-send": storageCheck,
2521
+ "settings.pull-requests-check-send": pullRequestsCheck,
2522
+ "settings.connection-check": connectionCheckNow,
2523
+ "control.models-refresh": modelsRefresh,
2471
2524
  "settings.models": modelsPage,
2472
2525
  "settings.tools": toolsPage,
2473
2526
  "settings.project": projectPage,
@@ -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
  })(),
@@ -779,7 +778,7 @@ export function createTaskViews(runtime) {
779
778
  return runtime.sendScreen(response, status, screen(admittedFamily.root.title, html `<h1>${admittedFamily.root.title}</h1><p>${admittedFamily.current.state}</p><p class="problem" data-history-problem>${admittedFamily.problem}</p>`, { chrome: runtime.chromeFor(admittedFamily.root.repo, "tasks") }));
780
779
  const data = taskViewData(taskId, who, problem);
781
780
  if (data === null)
782
- return refuse(response, who, 404, "no such task", "/tasks");
781
+ return refuse(response, who, 404, "no such task", "/work");
783
782
  const family = runtime.familyOf(taskId);
784
783
  const presentedData = { ...data, rootId: family?.root.id ?? taskId, rootTitle: family?.root.title ?? data.task.title,
785
784
  ...(family === null || data.assignment != null ? {} : { history: familyHistory(family) }),