toolroll 0.9.86 → 0.9.88

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 (173) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/acp-check.d.ts +9 -0
  3. package/dist/acp-check.js +40 -28
  4. package/dist/agents-board.d.ts +12 -0
  5. package/dist/agents-board.js +26 -0
  6. package/dist/approval-rules-ui.d.ts +4 -1
  7. package/dist/approval-rules-ui.js +31 -15
  8. package/dist/assignment-ui.js +1 -1
  9. package/dist/backup-ui.d.ts +5 -4
  10. package/dist/backup-ui.js +65 -32
  11. package/dist/browser/terminal.css +1 -1
  12. package/dist/browser/terminal.js +57 -54
  13. package/dist/browser/workspace.css +2 -2
  14. package/dist/browser/workspace.js +35 -70
  15. package/dist/browser-crew.js +1 -1
  16. package/dist/browser-workspace.d.ts +329 -183
  17. package/dist/browser-workspace.js +10 -9
  18. package/dist/chat-controls.d.ts +1 -1
  19. package/dist/chat-controls.js +1 -1
  20. package/dist/chat-settings-page.d.ts +29 -9
  21. package/dist/chat-settings-page.js +59 -28
  22. package/dist/check-levels-ui.d.ts +2 -1
  23. package/dist/check-levels-ui.js +67 -58
  24. package/dist/contracts/cli.d.ts +43 -0
  25. package/dist/contracts/cli.js +4 -0
  26. package/dist/contracts/console-api.d.ts +1 -1
  27. package/dist/contracts/console-api.js +1 -1
  28. package/dist/credentials-ui.d.ts +5 -3
  29. package/dist/credentials-ui.js +58 -31
  30. package/dist/design/kit.css +722 -0
  31. package/dist/design/kit.d.ts +208 -0
  32. package/dist/design/kit.js +177 -0
  33. package/dist/design/tokens.css +31 -1
  34. package/dist/discord-settings.js +8 -4
  35. package/dist/export-ui.d.ts +6 -4
  36. package/dist/export-ui.js +18 -8
  37. package/dist/flow-changed.d.ts +14 -0
  38. package/dist/flow-changed.js +53 -0
  39. package/dist/flow-gallery-ui.d.ts +41 -4
  40. package/dist/flow-gallery-ui.js +95 -63
  41. package/dist/flow-starters-ui.d.ts +3 -9
  42. package/dist/flow-starters-ui.js +12 -23
  43. package/dist/flow-unattended-words.d.ts +20 -0
  44. package/dist/flow-unattended-words.js +24 -0
  45. package/dist/flow-unattended.d.ts +9 -5
  46. package/dist/flow-unattended.js +45 -23
  47. package/dist/flows-cli.js +7 -3
  48. package/dist/flows-ui.d.ts +12 -6
  49. package/dist/flows-ui.js +141 -30
  50. package/dist/integrations-ui.d.ts +6 -4
  51. package/dist/integrations-ui.js +34 -40
  52. package/dist/integrations.d.ts +2 -2
  53. package/dist/integrations.js +4 -4
  54. package/dist/kits-ui.d.ts +8 -10
  55. package/dist/kits-ui.js +29 -33
  56. package/dist/kits.js +1 -1
  57. package/dist/knowledge-ui.js +76 -19
  58. package/dist/ledger-view.d.ts +2 -0
  59. package/dist/ledger-view.js +41 -38
  60. package/dist/models-ui.d.ts +8 -3
  61. package/dist/models-ui.js +82 -59
  62. package/dist/monitoring-ui.d.ts +4 -2
  63. package/dist/monitoring-ui.js +44 -13
  64. package/dist/needs-you.d.ts +1 -1
  65. package/dist/needs-you.js +8 -0
  66. package/dist/operate-remote-arguments.js +1 -0
  67. package/dist/operate-remote.js +1 -1
  68. package/dist/operate.d.ts +1 -1
  69. package/dist/operate.js +38 -4
  70. package/dist/passwordless.d.ts +1 -1
  71. package/dist/passwordless.js +1 -1
  72. package/dist/people-audit-ui.d.ts +2 -1
  73. package/dist/people-audit-ui.js +25 -23
  74. package/dist/policy-ui.d.ts +3 -2
  75. package/dist/policy-ui.js +27 -21
  76. package/dist/policy.d.ts +3 -0
  77. package/dist/policy.js +14 -1
  78. package/dist/project-delete-ui.d.ts +4 -2
  79. package/dist/project-delete-ui.js +135 -126
  80. package/dist/project-knowledge.d.ts +10 -10
  81. package/dist/publish.d.ts +11 -0
  82. package/dist/publish.js +16 -3
  83. package/dist/pull-request-ui.d.ts +2 -1
  84. package/dist/pull-request-ui.js +39 -28
  85. package/dist/recipe-ui.d.ts +4 -2
  86. package/dist/recipe-ui.js +14 -4
  87. package/dist/release-adapters.d.ts +3 -0
  88. package/dist/release-adapters.js +22 -2
  89. package/dist/release-cli.js +81 -9
  90. package/dist/release.d.ts +23 -0
  91. package/dist/release.js +46 -4
  92. package/dist/releases.js +1 -1
  93. package/dist/request-budget-ui.d.ts +4 -2
  94. package/dist/request-budget-ui.js +30 -19
  95. package/dist/retention-ui.d.ts +4 -3
  96. package/dist/retention-ui.js +15 -12
  97. package/dist/scope.d.ts +3 -0
  98. package/dist/scope.js +6 -0
  99. package/dist/serve.d.ts +0 -1
  100. package/dist/serve.js +51 -7
  101. package/dist/server/agents-computers.d.ts +9 -0
  102. package/dist/server/agents-computers.js +67 -0
  103. package/dist/server/chat.js +16 -16
  104. package/dist/server/chrome.d.ts +1 -1
  105. package/dist/server/chrome.js +19 -9
  106. package/dist/server/console.css +24 -12
  107. package/dist/server/flows.js +64 -36
  108. package/dist/server/home.d.ts +35 -0
  109. package/dist/server/home.js +104 -0
  110. package/dist/server/http.d.ts +3 -0
  111. package/dist/server/http.js +5 -0
  112. package/dist/server/kit-standalone.d.ts +22 -0
  113. package/dist/server/kit-standalone.js +70 -0
  114. package/dist/server/kit.d.ts +419 -0
  115. package/dist/server/kit.js +369 -0
  116. package/dist/server/pages.d.ts +2 -1
  117. package/dist/server/pages.js +213 -174
  118. package/dist/server/people-tokens.js +26 -85
  119. package/dist/server/render-history.d.ts +51 -0
  120. package/dist/server/render-history.js +228 -0
  121. package/dist/server/render-new-task.d.ts +29 -0
  122. package/dist/server/render-new-task.js +96 -0
  123. package/dist/server/render-next.d.ts +63 -0
  124. package/dist/server/render-next.js +109 -0
  125. package/dist/server/render-pages.d.ts +22 -190
  126. package/dist/server/render-pages.js +123 -575
  127. package/dist/server/render-people.d.ts +24 -3
  128. package/dist/server/render-people.js +93 -72
  129. package/dist/server/render-projects.d.ts +103 -0
  130. package/dist/server/render-projects.js +247 -0
  131. package/dist/server/render-results.d.ts +5 -24
  132. package/dist/server/render-results.js +48 -185
  133. package/dist/server/render-settings.d.ts +5 -74
  134. package/dist/server/render-settings.js +102 -493
  135. package/dist/server/render-tasks.d.ts +35 -5
  136. package/dist/server/render-tasks.js +114 -85
  137. package/dist/server/route-table.js +20 -5
  138. package/dist/server/settings.js +276 -255
  139. package/dist/server/task-views.d.ts +8 -0
  140. package/dist/server/task-views.js +85 -2
  141. package/dist/server/tasks.d.ts +8 -0
  142. package/dist/server/tasks.js +95 -40
  143. package/dist/server/terminals.js +1 -1
  144. package/dist/server/welcome.d.ts +22 -0
  145. package/dist/server/welcome.js +69 -0
  146. package/dist/settings-groups-ui.d.ts +279 -0
  147. package/dist/settings-groups-ui.js +615 -0
  148. package/dist/settings-kit.d.ts +42 -6
  149. package/dist/settings-kit.js +69 -7
  150. package/dist/setup-guide.d.ts +2 -2
  151. package/dist/skills-ui.js +59 -7
  152. package/dist/slack-settings.js +12 -5
  153. package/dist/spend-ui.d.ts +45 -26
  154. package/dist/spend-ui.js +124 -56
  155. package/dist/sso-ui.d.ts +5 -2
  156. package/dist/sso-ui.js +63 -31
  157. package/dist/storage-ui.d.ts +3 -2
  158. package/dist/storage-ui.js +56 -50
  159. package/dist/store.d.ts +112 -3
  160. package/dist/store.js +292 -20
  161. package/dist/surface.js +5 -1
  162. package/dist/teams-settings.d.ts +2 -1
  163. package/dist/teams-settings.js +24 -41
  164. package/dist/telegram-settings.d.ts +0 -4
  165. package/dist/telegram-settings.js +52 -25
  166. package/dist/toolroll-update-ui.d.ts +8 -4
  167. package/dist/toolroll-update-ui.js +55 -45
  168. package/dist/tools-ui.d.ts +5 -3
  169. package/dist/tools-ui.js +107 -42
  170. package/dist/work-index.js +11 -7
  171. package/dist/workspace-ui.d.ts +1 -1
  172. package/dist/workspace-ui.js +7 -4
  173. package/package.json +1 -1
package/dist/release.js CHANGED
@@ -81,6 +81,19 @@ export const REQUIRED_PR_CHECKS_WORKFLOW = {
81
81
  export const PROTECTION_REPORT_ONLY_CHECKS = [];
82
82
  /** The checks main's protection must require at minimum: every required check that isn't report-only. */
83
83
  export const PROTECTION_MINIMUM_CHECKS = REQUIRED_PR_CHECKS.filter(name => !PROTECTION_REPORT_ONLY_CHECKS.includes(name));
84
+ /**
85
+ * Pull-request checks a release never waits for or fails on, by exact name and the workflow file that defines them:
86
+ * youiyoux's UI drift check (src/design/KIT.md), which fails a pull request that adds design drift but says nothing
87
+ * about whether a build is safe to release, and the scripted journeys on a pull request (journeys.yml,
88
+ * docs/plans/ci-speed.md item 4), advisory while they settle in CI: the release check runs every journey itself. Only a
89
+ * GitHub Actions run of that name from that workflow is left out; the same name from anywhere else is judged like any
90
+ * other check. Never a required check.
91
+ */
92
+ export const ADVISORY_PR_CHECKS = {
93
+ "youiyoux · UI drift": ".github/workflows/youiyoux.yml",
94
+ "journeys · app (scripted)": ".github/workflows/journeys.yml",
95
+ "journeys · flows (scripted)": ".github/workflows/journeys.yml",
96
+ };
84
97
  /** The checks a release's publication waits for on its tag (.github/workflows/publish.yml, docs/plans/test-matrix.md):
85
98
  * the release proof (the tagged tree passed every REQUIRED_PR_CHECKS check on its pull request, read from GitHub) and
86
99
  * the publish job that needs it. The tag runs no tests again. Nothing else reported on the merge commit (main-push.yml)
@@ -130,7 +143,7 @@ export function landedWords(landed) {
130
143
  }
131
144
  const branchFile = (branch) => branch.replace(/[^A-Za-z0-9._-]+/g, "_");
132
145
  export const journalFile = (stateDir, branch) => join(stateDir, "releases", `${branchFile(branch)}.json`);
133
- export const resumeCommand = (branch, checkout) => `toolroll release ${branch} --repo ${checkout}`;
146
+ export const resumeCommand = (branch, checkout) => `toolroll release ${releaseBranchArg(branch)} --repo ${checkout}`;
134
147
  /** Whole or not at all: a private temp file, synced, renamed into place, its folder synced. */
135
148
  export function writeJournal(file, journal) {
136
149
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
@@ -232,7 +245,18 @@ export async function runRelease(options, adapters) {
232
245
  let freshApproval = false;
233
246
  let supersededTask = null;
234
247
  const save = () => { journal.updatedAt = adapters.now().toISOString(); writeJournal(file, journal); };
235
- const advance = (next) => { journal.step = next; journal.stopped = null; save(); };
248
+ /** Tell the progress record about a step (v146). Never throws and never waits: the release goes on whatever it says. */
249
+ const mark = (at, state, detail = null) => {
250
+ const j = journal;
251
+ if (j === null || adapters.progress === undefined || at === "done")
252
+ return;
253
+ try {
254
+ adapters.progress({ taskId: j.task.id, tag: j.tag, version: j.packageVersion, pr: j.pr, step: at, state, at: adapters.now(), detail });
255
+ }
256
+ catch { /* best effort */ }
257
+ };
258
+ const advance = (next) => { const was = journal.step; journal.step = next; journal.stopped = null; save(); if (was !== next)
259
+ mark(was, "done"); };
236
260
  const intend = (at) => { journal.intents[at] = adapters.now().toISOString(); save(); };
237
261
  const recoverDeploy = async () => {
238
262
  const j = journal;
@@ -266,13 +290,18 @@ export async function runRelease(options, adapters) {
266
290
  unreachable = networkWords(error);
267
291
  answer = null;
268
292
  }
269
- if (answer !== null)
293
+ if (answer !== null) {
294
+ if (said && journal !== null)
295
+ mark(journal.step, "running");
270
296
  return answer;
297
+ }
271
298
  if (adapters.now().getTime() >= deadline)
272
299
  throw new Stop("timeout", `Still waiting for ${what} after ${limits[at]} minutes${unreachable === null ? "" : ` (GitHub last didn't answer: ${unreachable})`}.`);
273
300
  if (!said) {
274
301
  say(` waiting for ${what} (up to ${limits[at]} min)`);
275
302
  said = true;
303
+ if (journal !== null)
304
+ mark(journal.step, "waiting", `waiting for ${what}`);
276
305
  }
277
306
  await adapters.sleep(pollMs);
278
307
  }
@@ -584,6 +613,8 @@ export async function runRelease(options, adapters) {
584
613
  const watch = async (action) => { if (rel !== undefined)
585
614
  await identity(action, true); };
586
615
  const restart = async (head) => {
616
+ // The step this commit was at ends here: the release goes on with the new commit (and, filed again, a new task).
617
+ mark(j.step, "failed", `${j.branch} moved to ${head.slice(0, 7)}`);
587
618
  const tree = await adapters.repo.tree(head);
588
619
  if (rel !== undefined && j.authority && epoch !== null) {
589
620
  say(` ${j.branch} moved to ${head.slice(0, 7)}: checking whether the approval carries`);
@@ -663,7 +694,7 @@ export async function runRelease(options, adapters) {
663
694
  }
664
695
  const verdict = prChecksVerdict(runs, j.candidate.sha, REQUIRED_PR_CHECKS, { workflow: REQUIRED_PR_CHECKS_WORKFLOW });
665
696
  if (verdict.state === "failed") {
666
- throw new Stop("ci-failed", `GitHub's checks failed on ${j.candidate.sha.slice(0, 7)}: ${verdict.failed.join(", ")}. ${nothing}. ${j.approval === null ? "" : `The approval covers ${j.candidate.sha.slice(0, 7)} only. `}Rerun the failed job on GitHub and run \`${resume}\` again, or fix the branch and release the new commit with \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`);
697
+ throw new Stop("ci-failed", `GitHub's checks failed on ${j.candidate.sha.slice(0, 7)}: ${verdict.failed.join(", ")}. ${nothing}. ${j.approval === null ? "" : `The approval covers ${j.candidate.sha.slice(0, 7)} only. `}Rerun the failed job on GitHub and run \`${resume}\` again, or fix the branch and release the new commit with \`toolroll release ${releaseBranchArg(j.branch)} --repo ${j.checkout} --new\`.`);
667
698
  }
668
699
  if (verdict.state !== "passed")
669
700
  return false;
@@ -739,6 +770,7 @@ export async function runRelease(options, adapters) {
739
770
  while (j.step !== "done" && homebrewLeft === null) {
740
771
  step = j.step;
741
772
  say(`• ${j.step}`);
773
+ mark(j.step, "running");
742
774
  try {
743
775
  switch (j.step) {
744
776
  case "gate": {
@@ -1217,6 +1249,7 @@ export async function runRelease(options, adapters) {
1217
1249
  save();
1218
1250
  if (progress.state === "pending" || progress.state === "failed") {
1219
1251
  homebrewLeft = { state: progress.state, why: progress.why };
1252
+ mark("homebrew", progress.state === "failed" ? "failed" : "waiting", progress.why);
1220
1253
  break;
1221
1254
  }
1222
1255
  advance("done");
@@ -1254,6 +1287,7 @@ export async function runRelease(options, adapters) {
1254
1287
  save();
1255
1288
  }
1256
1289
  catch { /* the stop is still reported */ }
1290
+ mark(journal.step, "failed", stop.message);
1257
1291
  }
1258
1292
  return { ok: false, reason: stop.reason, message: stop.message, step: journal?.step ?? step, journal, file, resume };
1259
1293
  }
@@ -1441,6 +1475,10 @@ export function prChecksVerdict(runs, head, required, options = {}) {
1441
1475
  const failed = [], waiting = [], failures = [];
1442
1476
  const valid = [];
1443
1477
  for (const run of runs) {
1478
+ // youiyoux's drift check from its own workflow neither holds nor fails a release (ADVISORY_PR_CHECKS).
1479
+ const advisory = Object.hasOwn(ADVISORY_PR_CHECKS, run.name) ? ADVISORY_PR_CHECKS[run.name] : undefined;
1480
+ if (advisory !== undefined && !required.includes(run.name) && run.app === "github-actions" && workflowIs(run.workflowPath, advisory))
1481
+ continue;
1444
1482
  if (run.head !== head) {
1445
1483
  failed.push(`${run.name} (reported for ${run.head == null ? "no commit" : run.head.slice(0, 7)}, not ${head.slice(0, 7)})`);
1446
1484
  continue;
@@ -1577,3 +1615,7 @@ export function workflowIs(path, file) {
1577
1615
  const ref = path.startsWith(`${file}@`) ? path.slice(file.length + 1) : "";
1578
1616
  return /^[A-Za-z0-9._+/-]+$/.test(ref) && !/\.ya?ml$/i.test(ref);
1579
1617
  }
1618
+ /** The words `toolroll release` reads as its own (release-cli.ts), never as a branch: such a branch is named with --branch. */
1619
+ const RELEASE_WORDS = new Set(["status", "nightly", "carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"]);
1620
+ /** How a person names this branch to `toolroll release` again. */
1621
+ export function releaseBranchArg(branch) { return RELEASE_WORDS.has(branch) ? `--branch ${branch}` : branch; }
package/dist/releases.js CHANGED
@@ -129,7 +129,7 @@ export function notifySecurityRelease(store, release, current, updateCommand, no
129
129
  dedupeKey: `release:security:${newer.version}:${recipient}`,
130
130
  kind: "security-release",
131
131
  pushClass: "attention",
132
- link: "/settings#updates",
132
+ link: "/settings/system#updates",
133
133
  subject: `Toolroll ${newer.version} is a security release`,
134
134
  body: `This computer runs ${current}. Update with \`${updateCommand}\`. Notes: ${newer.url}`,
135
135
  recipient,
@@ -5,8 +5,10 @@
5
5
  */
6
6
  import type { ApiTokenRow, Database } from "./store.js";
7
7
  import { type Html } from "./html.js";
8
- export declare const REQUEST_LIMITS_CSS: string;
8
+ /** The old card look; the section is built from the kit now. Kept for chrome.ts, which still concatenates it. */
9
+ export declare const REQUEST_LIMITS_CSS = "";
9
10
  /** "120 requests a minute · 10,000 a day" for one token's line. */
10
11
  export declare function tokenLimitWords(db: Database, token: ApiTokenRow): string;
11
- /** The operator's disclosure: everyone's tokens, or one token. Blank fields use the default shown. */
12
+ /** The operator's section: everyone's tokens, or one token. Blank fields use the default shown. Saving takes the
13
+ * operator's password (or Passwordless, or a fresh identity-provider sign-in). */
12
14
  export declare function requestLimitsHtml(db: Database, tokens: readonly ApiTokenRow[], now: number): Html;
@@ -1,31 +1,42 @@
1
- import { html, postForm, reauth } from "./html.js";
1
+ import { html, joinHtml } from "./html.js";
2
+ import { btn, disclose, formRow, input, note, select, settingsSection } from "./server/kit.js";
3
+ import { kitSectionForm } from "./settings-kit.js";
2
4
  import { effectiveLimits, limitOverrides, PER_DAY_MAX, PER_MINUTE_MAX, REQUEST_BUDGET_DEFAULTS } from "./request-budget.js";
3
5
  const n = (value) => value.toLocaleString("en-US");
4
- export const REQUEST_LIMITS_CSS = `.credentials .limits{display:grid;gap:14px}.credentials .limits form{display:grid;gap:10px}.credentials .limits label{display:grid;gap:6px}` +
5
- `.credentials .limits input,.credentials .limits select{box-sizing:border-box;width:100%;min-width:0}.credentials .limits .pair{display:grid;grid-template-columns:1fr 1fr;gap:10px}.credentials .limits .actions{display:flex;flex-wrap:wrap;gap:8px}` +
6
- `.credentials .limits h3{font-size:.95rem;margin:4px 0 0}@media(max-width:600px){.credentials .limits .pair{grid-template-columns:1fr}.credentials .limits input,.credentials .limits select{font-size:16px}}`;
6
+ /** The old card look; the section is built from the kit now. Kept for chrome.ts, which still concatenates it. */
7
+ export const REQUEST_LIMITS_CSS = "";
7
8
  /** "120 requests a minute · 10,000 a day" for one token's line. */
8
9
  export function tokenLimitWords(db, token) {
9
10
  const limits = effectiveLimits(db, token.id, token.access);
10
11
  return `${n(limits.perMinute)} requests a minute · ${n(limits.perDay)} a day`;
11
12
  }
12
- /** The operator's disclosure: everyone's tokens, or one token. Blank fields use the default shown. */
13
+ /** The operator's section: everyone's tokens, or one token. Blank fields use the default shown. Saving takes the
14
+ * operator's password (or Passwordless, or a fresh identity-provider sign-in). */
13
15
  export function requestLimitsHtml(db, tokens, now) {
14
16
  const overrides = limitOverrides(db);
15
17
  const everyone = overrides.get("*") ?? { readPerMinute: null, actPerMinute: null, perDay: null };
16
- const field = (name, label, value, placeholder, max) => html `<label>${label}<input type="number" name="${name}" min="1" max="${max}" step="1" inputmode="numeric" value="${value === null ? "" : value}" placeholder="${placeholder}"></label>`;
17
- const password = html `${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`)}`;
18
- const buttons = html `<div class="actions"><button name="action" value="save">Save limits</button><button class="secondary" name="action" value="clear">Use defaults</button></div>`;
18
+ const number = (id, name, label, value, placeholder, max) => input({ id, name, type: "number", value: value === null ? "" : String(value), attrs: { min: 1, max, step: 1, inputmode: "numeric", placeholder, "aria-label": label } });
19
+ const installation = kitSectionForm({ proof: "request-limits", action: "/settings/request-limits", id: "limits-everyone", gate: true, hidden: { target: "*", action: "save" },
20
+ save: { label: "Save limits" }, confirm: "Change request limits for everyone's tokens",
21
+ body: joinHtml([
22
+ formRow("Read tokens, a minute", `Default ${n(REQUEST_BUDGET_DEFAULTS.read)}.`, number("limits-read", "read-per-minute", "Read tokens, a minute", everyone.readPerMinute, String(REQUEST_BUDGET_DEFAULTS.read), PER_MINUTE_MAX)),
23
+ formRow("Act tokens, a minute", `Default ${n(REQUEST_BUDGET_DEFAULTS.act)}.`, number("limits-act", "act-per-minute", "Act tokens, a minute", everyone.actPerMinute, String(REQUEST_BUDGET_DEFAULTS.act), PER_MINUTE_MAX)),
24
+ formRow("Each token, a day", `Default ${n(REQUEST_BUDGET_DEFAULTS.day)}.`, number("limits-day", "per-day", "Each token, a day", everyone.perDay, String(REQUEST_BUDGET_DEFAULTS.day), PER_DAY_MAX)),
25
+ ]) });
26
+ const reset = kitSectionForm({ proof: "request-limits", action: "/settings/request-limits", id: "limits-everyone-clear", hidden: { target: "*", action: "clear" },
27
+ save: { label: "Use defaults for everyone", variant: "outline" }, confirm: "Put everyone's tokens back on the default limits", body: html `` });
19
28
  const live = tokens.filter(one => one.revokedAt === null && Date.parse(one.expiresAt) > now);
20
- const installation = postForm("/settings/request-limits", html `\
21
- <h3>Everyone's tokens</h3><div class="pair">${field("read-per-minute", "Read tokens, a minute", everyone.readPerMinute, String(REQUEST_BUDGET_DEFAULTS.read), PER_MINUTE_MAX)}\
22
- ${field("act-per-minute", "Act tokens, a minute", everyone.actPerMinute, String(REQUEST_BUDGET_DEFAULTS.act), PER_MINUTE_MAX)}</div>\
23
- ${field("per-day", "Each token, a day", everyone.perDay, String(REQUEST_BUDGET_DEFAULTS.day), PER_DAY_MAX)}${password}${buttons}`, { hidden: { target: "*" } });
24
- const one = live.length === 0 ? "" : postForm("/settings/request-limits", html `\
25
- <h3>One token</h3><label>Token<select name="target">${live.map(row => {
26
- const set = overrides.get(row.id);
27
- return html `<option value="${row.id}">${row.name} (${row.account}, ${row.access === "act" ? "act" : "read"})${set === undefined ? "" : " · own limits"}</option>`;
28
- })}</select></label>\
29
- <div class="pair">${field("per-minute", "A minute", null, "Everyone's", PER_MINUTE_MAX)}${field("per-day", "A day", null, "Everyone's", PER_DAY_MAX)}</div>${password}${buttons}`);
30
- return html `<details class="card"><summary>Request limits</summary><div class="limits">${installation}${one}</div></details>`;
29
+ const one = live.length === 0 ? "" : disclose("Limits for one token", kitSectionForm({ proof: "request-limits", action: "/settings/request-limits", id: "limits-one",
30
+ save: { label: "Save limits", name: "action", value: "save" }, confirm: "Change one token's request limits",
31
+ body: joinHtml([
32
+ formRow("Token", null, select({ id: "limits-target", name: "target", label: "Token", wide: true, items: live.map(row => {
33
+ const set = overrides.get(row.id);
34
+ return [row.id, `${row.name} (${row.account}, ${row.access === "act" ? "act" : "read"})${set === undefined ? "" : " · own limits"}`];
35
+ }) })),
36
+ formRow("A minute", "Blank uses everyone's.", number("limits-one-minute", "per-minute", "A minute", null, "Everyone's", PER_MINUTE_MAX)),
37
+ formRow("A day", "Blank uses everyone's.", number("limits-one-day", "per-day", "A day", null, "Everyone's", PER_DAY_MAX)),
38
+ html `<div class="k-acts">${btn("Use everyone's limits", { sm: true, type: "submit", attrs: { name: "action", value: "clear" } })}</div>`,
39
+ ]) }));
40
+ return settingsSection({ id: "request-limits", title: "Request limits", lead: "How many requests each API token may make. A token over its limit is refused until the minute or day resets, and nothing it asked for runs.",
41
+ body: joinHtml([installation, reset, one, note("Only an instance operator sees and changes these.")]) });
31
42
  }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Settings → Retention (v105): how long each kind of data is kept, and what
3
3
  * the next daily sweep would remove. An instance operator's page; changes
4
- * take a step-up.
4
+ * take a step-up. Built from the revamp kit; the Settings frame adds the title.
5
5
  */
6
6
  import { type Html } from "./html.js";
7
7
  import { type RetentionCount, type RetentionKind, type RetentionPeriods } from "./retention.js";
8
- export declare const RETENTION_CSS: string;
8
+ /** The page is kit pieces now; nothing of the old look is left. Kept as an export for the shared stylesheet. */
9
+ export declare const RETENTION_CSS = "";
9
10
  /** `chosen`: the kinds someone chose a period for; the rest keep their default. */
10
11
  export type RetentionView = {
11
12
  periods: RetentionPeriods;
@@ -14,7 +15,7 @@ export type RetentionView = {
14
15
  lastSweep: string | null;
15
16
  csrf: string;
16
17
  };
17
- export declare function retentionHtml(view: RetentionView, notice: {
18
+ export declare function retentionHtml(view: RetentionView, said: {
18
19
  said?: string | null;
19
20
  problem?: string | null;
20
21
  }): Html;
@@ -1,16 +1,15 @@
1
1
  /**
2
2
  * Settings → Retention (v105): how long each kind of data is kept, and what
3
3
  * the next daily sweep would remove. An instance operator's page; changes
4
- * take a step-up.
4
+ * take a step-up. Built from the revamp kit; the Settings frame adds the title.
5
5
  */
6
- import { html, postForm, reauth } from "./html.js";
6
+ import { html, joinHtml } from "./html.js";
7
7
  import { bytesWords } from "./storage.js";
8
8
  import { RETENTION_KINDS, countWords, periodChoices, periodLabel } from "./retention.js";
9
- export const RETENTION_CSS = `.retention{max-width:720px;min-width:0}.retention-next{margin:0 0 16px}` +
10
- `.retention form{display:grid;gap:0}.retention .kind{display:grid;grid-template-columns:minmax(0,1fr) 11rem;gap:4px 16px;align-items:center;padding:12px 0;border-bottom:1px solid var(--border)}` +
11
- `.retention .kind .name{font-weight:500;font-size:.875rem}.retention .kind .meta{grid-column:1;margin:0}.retention .kind select{grid-column:2;grid-row:1/span 2;width:100%;min-height:44px}` +
12
- `.retention .step-up{display:grid;gap:4px;font-size:.8125rem;margin:16px 0 8px;max-width:20rem}.retention .step-up input{min-height:44px}.retention button{justify-self:start;min-height:44px}` +
13
- `@media (max-width:560px){.retention .kind{grid-template-columns:minmax(0,1fr)}.retention .kind select{grid-column:1;grid-row:auto}}`;
9
+ import { formRow, infoLine, note, notice, select, settingsSection } from "./server/kit.js";
10
+ import { kitSectionForm } from "./settings-kit.js";
11
+ /** The page is kit pieces now; nothing of the old look is left. Kept as an export for the shared stylesheet. */
12
+ export const RETENTION_CSS = "";
14
13
  const listWords = (parts) => parts.length < 2 ? parts.join("") : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
15
14
  /** The next sweep in a line: what goes and about how much space, or that nothing is due. */
16
15
  function nextWords(view) {
@@ -22,14 +21,18 @@ function nextWords(view) {
22
21
  const bytes = due.reduce((sum, one) => sum + one.bytes, 0);
23
22
  return `The next daily sweep removes ${listWords(due.map(countWords))} (about ${bytesWords(bytes)}).`;
24
23
  }
25
- export function retentionHtml(view, notice) {
26
- const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
24
+ export function retentionHtml(view, said) {
27
25
  const rows = RETENTION_KINDS.map(({ kind, label, detail }) => {
28
26
  const current = view.periods[kind];
29
27
  const chosen = view.chosen.includes(kind);
30
- const options = periodChoices(kind, current).map(days => html `<option value="${days === null ? "forever" : days}"${days === current ? html ` selected` : ""}>${periodLabel(kind, days, chosen || days !== current)}</option>`);
31
- return html `<div class="kind" data-kind="${kind}"><label class="name" for="keep-${kind}">${label}</label><p class="meta">${detail}</p><select id="keep-${kind}" name="${kind}">${options}</select></div>`;
28
+ const items = periodChoices(kind, current).map(days => [days === null ? "forever" : String(days), periodLabel(kind, days, chosen || days !== current)]);
29
+ return formRow(label, detail, select({ id: `keep-${kind}`, name: kind, label, current: current === null ? "forever" : String(current), items }), "inline");
32
30
  });
33
31
  const last = view.lastSweep === null ? "" : ` Last sweep ${view.lastSweep.slice(0, 10)}.`;
34
- return html `<article class="retention">${note}<p class="retention-next" data-retention-next>${nextWords(view)}${last}</p>${postForm("/settings/retention", html `${rows}${reauth(html `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}<button type="submit">Save</button>`)}<p class="meta">Never removed: the action ledger, and anything a task still needs (unfinished tasks, results Ready for review, anything on hold).</p></article>`;
32
+ return settingsSection({ id: "retention", title: "How long data is kept", lead: html `<span data-retention-next>${nextWords(view)}${last}</span>`,
33
+ body: joinHtml([
34
+ said.problem ? notice(said.problem) : said.said ? infoLine(said.said) : "",
35
+ kitSectionForm({ proof: "retention", action: "/settings/retention", id: "retention-form", gate: true, body: joinHtml(rows) }),
36
+ note("Never removed: the action ledger, and anything a task still needs (unfinished tasks, results Ready for review, anything on hold)."),
37
+ ]) });
35
38
  }
package/dist/scope.d.ts CHANGED
@@ -1004,3 +1004,6 @@ export declare function releaseApprovalOf(store: Store, taskId: string, _now?: D
1004
1004
  ok: false;
1005
1005
  why: string;
1006
1006
  };
1007
+ /** When a password typed at `at` (ISO) stops standing in, for a page's words (ISO); null when `at` isn't a time. Words
1008
+ * only: freshnessFor decides whether it stands in, at the moment of use. */
1009
+ export declare function freshUntil(at: string): string | null;
package/dist/scope.js CHANGED
@@ -1610,3 +1610,9 @@ export function releaseApprovalOf(store, taskId, _now = new Date()) {
1610
1610
  const auto = autoReleaseApprovalOf(store, taskId, "auto-green");
1611
1611
  return auto.ok ? { ok: true, by: auto.by, at: auto.at, method: "auto-green" } : auto;
1612
1612
  }
1613
+ /** When a password typed at `at` (ISO) stops standing in, for a page's words (ISO); null when `at` isn't a time. Words
1614
+ * only: freshnessFor decides whether it stands in, at the moment of use. */
1615
+ export function freshUntil(at) {
1616
+ const ms = Date.parse(at);
1617
+ return Number.isFinite(ms) ? new Date(ms + FRESH_WINDOW_MS).toISOString() : null;
1618
+ }
package/dist/serve.d.ts CHANGED
@@ -7,6 +7,5 @@ export { TASK_COMPOSER_MODES, type TaskComposerMode } from "./server/render-chat
7
7
  export { inboxFingerprints, parseInboxTab, queueScript, type InboxTab } from "./server/render-pages.js";
8
8
  export { ssoStepUps, wrongHostPage } from "./server/render-people.js";
9
9
  export { diffFileAnchor, editorFileHref, needActionOf, orderChangedFiles, plainConclusionOf, rankReviewQueue, reviewFilePriority, reviewPriorityOf, runFactsFragment, withinSignedTouches, type ReviewFileRow, type ReviewPriority, type ReviewQueueFacts } from "./server/render-results.js";
10
- export { settingsGroups, settingsTiles } from "./server/render-settings.js";
11
10
  export { decisionAnswerScript, earlierVersionsWords, revisionLineageWords, type RouteView } from "./server/render-tasks.js";
12
11
  export { SIGN_IN_LINK_MS, SIGN_IN_LINK_PATH, type DecisionServer } from "./server/session.js";
package/dist/serve.js CHANGED
@@ -157,9 +157,11 @@ import { chromeScript, DEMO_BANNER, DEMO_BANNER_SHORT, focusDocument, KBD_HELP,
157
157
  import { BODY_CAP, form, loginHref, matchTaskPath, page, projectChatHref, redactedPath, redirect, respond, safeReturn, SHUTDOWN_WAIT_MS, taskChatHref } from "./server/http.js";
158
158
  import { decisionsFor, LEAD_BY_DEFAULT_FACT, leadBrowserMessages, leadChatVersion, NOT_STARTED_SESSION, TASK_COMPOSER_MODES, teamProposalCardParts } from "./server/render-chat.js";
159
159
  import {} from "./server/render-pages.js";
160
- import { ssoStepUps, wrongHostPage } from "./server/render-people.js";
160
+ import { serverErrorPage, ssoStepUps, wrongHostPage } from "./server/render-people.js";
161
161
  import { NO_PROJECT, NO_TOUCH_FRAGMENTS, NONCE_CAP, NONCE_TTL_MS, PersistentSessions, SESSION_ABSOLUTE_MS, SESSION_IDLE_MS, sessionWithinLifetime, SIGN_IN_LINK_MS, SIGN_IN_LINK_PATH } from "./server/session.js";
162
162
  import { createTasksHandlers } from './server/tasks.js';
163
+ import { KIT_BEHAVIOR_MARKUP, KIT_STANDALONE_SCRIPT } from './server/kit-standalone.js';
164
+ import { createWelcomeHandlers } from './server/welcome.js';
163
165
  import { DEFAULT_GUARD_POLICY, passwordGuardOf, provenPasswordAccount, SourceBudget, withPasswordSource } from "./sign-in-guard.js";
164
166
  import { sourceKey } from "./source-key.js";
165
167
  import { readSsoSettings } from "./sso-settings.js";
@@ -670,14 +672,26 @@ export function createDecisionServer(options) {
670
672
  // A GET or HEAD only reads (docs/plans/get-side-effects.md): store reads that would tidy up as they go don't.
671
673
  const answer = () => handle(request, response);
672
674
  void withPasswordSource(joinSourceOf(request), () => request.method === "GET" || request.method === "HEAD" ? asReadOnlyRequest(answer) : answer()).catch(error => {
673
- // Every unhandled error is logged (v99): the path and the message, never the request's body or query.
674
- logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error) });
675
+ // Every unhandled error is logged (v99): the path and the message, never the request's body or query. The reference
676
+ // is what a person's error page shows, so the log line can be found from it.
677
+ const reference = `err_${randomBytes(4).toString("hex")}`;
678
+ logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error), reference });
675
679
  if (envValue(process.env, "SERVE_DEBUG") === "1")
676
680
  console.error("SERVE ERROR:", error);
677
681
  if (!response.headersSent) {
678
682
  const updating = error instanceof Error && error.message.includes(UPDATE_PAUSED);
679
683
  if (updating)
680
684
  response.setHeader("Retry-After", "5");
685
+ // A browser asking for a page gets a page (Wave E): what happened, whether anything changed, the reference. It
686
+ // holds no error text. Everything else keeps the plain answer.
687
+ if (/\btext\/html\b/.test(String(request.headers.accept ?? "")) && (request.method === "GET" || request.method === "POST")) {
688
+ const read = request.method === "GET";
689
+ const at = new URL(request.url ?? "/", "http://placeholder");
690
+ try {
691
+ return page(response, updating ? 503 : 500, serverErrorPage({ updating, read, reference, at: new Date(), retry: read ? at.pathname + at.search : null }));
692
+ }
693
+ catch { /* the plain answer below */ }
694
+ }
681
695
  respond(response, updating ? 503 : 500, "text/plain; charset=utf-8", updating ? UPDATE_PAUSED : "something broke");
682
696
  }
683
697
  else {
@@ -1058,6 +1072,19 @@ export function createDecisionServer(options) {
1058
1072
  }
1059
1073
  project = wanted;
1060
1074
  }
1075
+ // Home (UI revamp wave B) shows every project unless its address names one (proved the same way), and the rail's
1076
+ // and the dock's Needs-you count follow that same one scope.
1077
+ if (route.id === 'home') {
1078
+ const wanted = url.searchParams.get('project');
1079
+ if (wanted !== null && (url.searchParams.getAll('project').length !== 1 || !visible(wanted) ||
1080
+ ![...managedRepos(), ...store.listProjects().map(one => one.path)].includes(wanted))) {
1081
+ return refuse(response, who, 404, 'That project is not available in this workspace.', '/');
1082
+ }
1083
+ project = wanted;
1084
+ }
1085
+ // All projects, asked for by name on Tasks (its project filter): the rolled-up list, admission-bound as ever.
1086
+ if (route.id === 'work' && url.searchParams.get('all') === '1' && !url.searchParams.has('project') && url.searchParams.getAll('all').length === 1)
1087
+ project = null;
1061
1088
  // Every page's Tasks count covers this one project view, whatever project the page itself shows.
1062
1089
  const facts = requestContext.getStore();
1063
1090
  if (facts !== undefined)
@@ -1618,8 +1645,22 @@ export function createDecisionServer(options) {
1618
1645
  catchUpHtml: extras.catchUpHtml === undefined ? '' : htmlString(extras.catchUpHtml), controlsHtml: extras.controlsHtml === undefined ? '' : htmlString(extras.controlsHtml), notices, view: extras.view ?? null,
1619
1646
  ...(s.chrome.demo ? { demo: { text: DEMO_BANNER, short: DEMO_BANNER_SHORT } } : {}),
1620
1647
  pageHtml: extras.pageHtml === undefined ? (conversation === null ? pageHtml : null) : extras.pageHtml === null ? null : htmlString(extras.pageHtml),
1621
- navigation: [...browserNavigationOf(currentPath, s.chrome.project, needsYou, tasksProject, s.chrome.inboxLabel), { label: 'Workspace tools', href: '/menu', active: path.pathname === '/menu' }],
1648
+ navigation: browserNavigationOf(currentPath, s.chrome.project, needsYou, tasksProject, s.chrome.inboxLabel),
1649
+ // The one Needs-you count (tasks only), with the scope it covers: the Tasks list's own count for this project
1650
+ // view (or every project), so the rail, the phone header and Home all read the same number.
1651
+ needsYou: { count: needsYou, saturated: s.chrome.inboxSaturated === true, scope: tasksProject ?? null,
1652
+ label: s.chrome.inboxLabel ?? `${needsYou} ${needsYou === 1 ? 'needs' : 'need'} you`,
1653
+ // Every project, said by name (all=1), so a session's own open project never narrows the list the count opens.
1654
+ href: tasksProject === null ? '/work?all=1&view=needs-you' : `/work?project=${encodeURIComponent(tasksProject)}&view=needs-you` },
1622
1655
  chats,
1656
+ // The same count per project (the rail's project rows), read from the same work index totals.
1657
+ ...(() => { if (sensitive)
1658
+ return {}; try {
1659
+ return { needsYouByProject: projectCounts(clock()).map(one => ({ path: one.repo, count: one.totals['needs-you'] })) };
1660
+ }
1661
+ catch {
1662
+ return {};
1663
+ } })(),
1623
1664
  ...(s.refreshSeconds === undefined ? {} : { refreshSeconds: Math.max(5, Math.floor(s.refreshSeconds)) }),
1624
1665
  ...(s.chrome.signIn === undefined ? {} : { signIn: s.chrome.signIn }),
1625
1666
  ...(s.chrome.update === undefined ? {} : { update: s.chrome.update }),
@@ -1662,7 +1703,10 @@ export function createDecisionServer(options) {
1662
1703
  const functional = s.functional?.script ?? "";
1663
1704
  // Sensitive pages strip the palette and keys but keep the sidebar toggle.
1664
1705
  const sensitiveChrome = sensitive && s.chrome !== undefined ? sidebarScript() : "";
1665
- const script = functional + (chromeLayer ? chromeScript() + WORKSPACE_MOTION_SCRIPT : sensitiveChrome);
1706
+ // Kit markup the workspace bundle would have wired (a composer, an overlay, a switch, an idea) gets the kit's own small
1707
+ // behaviour here (kit-standalone.ts); never on a sensitive page, which carries no chrome additions.
1708
+ const kitBehavior = chromeLayer && KIT_BEHAVIOR_MARKUP.test(htmlString(s.body)) ? KIT_STANDALONE_SCRIPT : "";
1709
+ const script = functional + (chromeLayer ? chromeScript() + WORKSPACE_MOTION_SCRIPT + kitBehavior : sensitiveChrome);
1666
1710
  const nonce = script === "" ? undefined : randomBytes(16).toString("base64");
1667
1711
  const body = chromeLayer
1668
1712
  ? html `${s.body}\n${paletteTagCached(s.chrome?.project ?? null)}\n${KBD_HELP}`
@@ -1774,7 +1818,7 @@ export function createDecisionServer(options) {
1774
1818
  const update = updateFacts();
1775
1819
  if (update === null || !update.newer || update.release === null || facts.updateSeen === update.release.version)
1776
1820
  return {};
1777
- return { update: { version: update.release.version, security: update.release.security, href: "/settings#updates", dismissHref: "/settings/updates/dismiss" } };
1821
+ return { update: { version: update.release.version, security: update.release.security, href: "/settings/system#updates", dismissHref: "/settings/updates/dismiss" } };
1778
1822
  })(),
1779
1823
  ...(() => {
1780
1824
  const databaseFile = store.databaseFile();
@@ -2301,6 +2345,7 @@ export function createDecisionServer(options) {
2301
2345
  ...tasksHandlers.registrations, ...flowsHandlers.registrations, ...chatHandlers.registrations,
2302
2346
  ...settingsHandlers.registrations, ...peopleHandlers.registrations, ...pagesHandlers.registrations,
2303
2347
  ...remoteHandlers.registrations, ...liveHandlers.registrations, ...terminalHandlers.registrations, ...createSweepHandlers(handlerRuntime).registrations,
2348
+ ...createWelcomeHandlers(handlerRuntime).registrations,
2304
2349
  ]);
2305
2350
  /** The row's role, after admission and before its one handler. */
2306
2351
  function dispatchConsole(route, ctx) {
@@ -2333,7 +2378,6 @@ export { TASK_COMPOSER_MODES } from "./server/render-chat.js";
2333
2378
  export { inboxFingerprints, parseInboxTab, queueScript } from "./server/render-pages.js";
2334
2379
  export { ssoStepUps, wrongHostPage } from "./server/render-people.js";
2335
2380
  export { diffFileAnchor, editorFileHref, needActionOf, orderChangedFiles, plainConclusionOf, rankReviewQueue, reviewFilePriority, reviewPriorityOf, runFactsFragment, withinSignedTouches } from "./server/render-results.js";
2336
- export { settingsGroups, settingsTiles } from "./server/render-settings.js";
2337
2381
  export { decisionAnswerScript, earlierVersionsWords, revisionLineageWords } from "./server/render-tasks.js";
2338
2382
  export { SIGN_IN_LINK_MS, SIGN_IN_LINK_PATH } from "./server/session.js";
2339
2383
  /**
@@ -0,0 +1,9 @@
1
+ import type { BrowserAgentPhase, BrowserAgentsSystem, BrowserComputers } from "../browser-workspace.js";
2
+ import type { Store, WorktreeRow } from "../store.js";
3
+ /** Every registered computer that isn't retired, plus a retired one still holding reserved work, as Agents lists it. */
4
+ export declare function computersOf(store: Store, now: Date, visible: (repo: string | null) => boolean, manage: boolean): BrowserComputers;
5
+ /** Which provider and model runs each phase for the open project (or the installation), and where that choice comes from. */
6
+ export declare function phasesOf(store: Store, project: string | null): BrowserAgentPhase[];
7
+ /** The rest of what /system showed: its workspaces, the background service on the open project, notifications waiting
8
+ * to go out and external work. */
9
+ export declare function systemOf(store: Store, project: string | null, worktreeShown: (tree: WorktreeRow) => boolean, visible: (repo: string | null) => boolean): BrowserAgentsSystem;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The Agents page's installation parts (UI revamp wave B): the computers that build (what /fleet showed), which model
3
+ * runs each phase and the rest of what /system showed. Saved facts only, filtered through the caller's `visible`, for
4
+ * a person with access to the whole installation; nothing here grants an act (register, retire, move and note keep
5
+ * their own handlers and checks).
6
+ */
7
+ import { INSTALLATION_SCOPE, resolvePhaseAgent } from "../agentconfig.js";
8
+ import { agentModelName } from "../model-names.js";
9
+ import { projectName } from "../project.js";
10
+ import { isAlive } from "../runner.js";
11
+ import { taskHref } from "./http.js";
12
+ /** Every registered computer that isn't retired, plus a retired one still holding reserved work, as Agents lists it. */
13
+ export function computersOf(store, now, visible, manage) {
14
+ const queued = store.fleetQueue(now);
15
+ const claims = store.liveClaims(null, now);
16
+ const owned = new Set(queued.map(one => one.assignedRunner).filter((one) => one !== null));
17
+ const runners = store.listRunners().filter(one => one.retiredAt === null || owned.has(one.name));
18
+ const list = runners.map(runner => {
19
+ const live = claims.filter(claim => claim.runner === runner.name);
20
+ const shown = live.filter(claim => visible(claim.repo ?? null));
21
+ const alive = isAlive(runner, now);
22
+ return {
23
+ name: runner.name, host: runner.host, capacity: runner.capacity, retired: runner.retiredAt !== null,
24
+ state: !alive ? "offline" : live.length > 0 ? "building" : "idle",
25
+ building: shown.map(claim => ({ taskId: claim.taskId, title: store.getTask(claim.taskId)?.title ?? claim.taskId, href: taskHref(claim.taskId),
26
+ project: claim.repo == null ? null : projectName(claim.repo) })),
27
+ hidden: live.length - shown.length,
28
+ heardAt: runner.heartbeatAt, registeredAt: runner.registeredAt,
29
+ projects: runner.repos.filter(repo => visible(repo)).map(repo => projectName(repo)),
30
+ note: runner.queueNote ?? null,
31
+ reserved: queued.filter(one => one.assignedRunner === runner.name && visible(one.repo))
32
+ .map(one => ({ id: one.id, title: one.title, href: taskHref(one.id), project: one.repo === null ? null : projectName(one.repo), taken: one.taken })),
33
+ };
34
+ });
35
+ return { list, manage, queueRevision: store.queueRevision(), shared: queued.filter(one => one.assignedRunner === null && visible(one.repo)).length };
36
+ }
37
+ const PHASES = ["plan", "build", "repair", "review"];
38
+ /** Which provider and model runs each phase for the open project (or the installation), and where that choice comes from. */
39
+ export function phasesOf(store, project) {
40
+ return PHASES.map(phase => {
41
+ const answer = resolvePhaseAgent(store, phase, project, {});
42
+ const row = (project === null ? null : store.phaseConfig(project, phase)) ?? store.phaseConfig(INSTALLATION_SCOPE, phase);
43
+ const by = row?.updatedBy == null ? "" : ` by ${row.updatedBy}`;
44
+ if (!answer.ok)
45
+ return { phase, model: null, problem: answer.problem, source: "", noDollars: false };
46
+ const source = answer.source === "project" ? `Chosen for this project${by}`
47
+ : answer.source === "installation" ? `Set for the whole installation${by}` : "The default: nothing chosen";
48
+ return { phase, model: agentModelName(answer.spec.provider, answer.spec.model ?? null), problem: null, source, noDollars: answer.spec.provider !== "claude" };
49
+ });
50
+ }
51
+ /** The rest of what /system showed: its workspaces, the background service on the open project, notifications waiting
52
+ * to go out and external work. */
53
+ export function systemOf(store, project, worktreeShown, visible) {
54
+ const workspaces = store.listWorktrees().filter(worktreeShown).map(tree => {
55
+ const leased = tree.leasedAt !== null && tree.releasedAt === null;
56
+ return { name: tree.path.split("/").pop() ?? tree.path, branch: tree.branch, state: leased ? "building" : tree.verified ? "free" : "needs review" };
57
+ });
58
+ const episode = project === null ? null : store.latestWatchEpisode(project);
59
+ const service = episode === null ? null : { running: episode.endedAt === null, at: episode.endedAt ?? episode.startedAt, built: episode.built, broke: episode.broke };
60
+ const external = store.listGrants()
61
+ .filter(one => one.dispatch === true && one.remoteRepo != null && visible(one.repo))
62
+ .map(one => {
63
+ const open = store.listNotifications("all").find(n => n.kind === "sync-failed" && n.resolvedAt === null && n.dedupeKey.startsWith(`sync:${one.remoteRepo}:`))?.subject ?? null;
64
+ return { repo: String(one.remoteRepo), state: one.dispatchBlocked != null ? "Dispatch blocked: the tracker connection needs repair (toolroll sync says why)" : open !== null ? `Sync failing: ${open}` : "Syncing normally" };
65
+ });
66
+ return { workspaces, service, outbox: store.pendingForAttention().length, external };
67
+ }