toolroll 0.9.85 → 0.9.87

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 (220) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/agent-fence.d.ts +6 -2
  3. package/dist/agent-fence.js +3 -2
  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/assignment.js +4 -3
  10. package/dist/backup-ui.d.ts +5 -4
  11. package/dist/backup-ui.js +65 -32
  12. package/dist/browser/terminal.css +1 -1
  13. package/dist/browser/terminal.js +57 -54
  14. package/dist/browser/workspace.css +2 -2
  15. package/dist/browser/workspace.js +35 -68
  16. package/dist/browser-crew.js +1 -1
  17. package/dist/browser-workspace.d.ts +363 -177
  18. package/dist/browser-workspace.js +10 -9
  19. package/dist/chat-actions.d.ts +11 -1
  20. package/dist/chat-actions.js +53 -1
  21. package/dist/chat-channel.js +1 -0
  22. package/dist/chat-controls.d.ts +1 -1
  23. package/dist/chat-controls.js +1 -1
  24. package/dist/chat-quiet.js +3 -2
  25. package/dist/chat-settings-page.d.ts +29 -9
  26. package/dist/chat-settings-page.js +59 -28
  27. package/dist/check-levels-ui.d.ts +2 -1
  28. package/dist/check-levels-ui.js +67 -58
  29. package/dist/claim.d.ts +3 -0
  30. package/dist/claim.js +4 -1
  31. package/dist/contracts/chat-actions.d.ts +9 -0
  32. package/dist/contracts/chat-actions.js +3 -0
  33. package/dist/contracts/cli.d.ts +43 -0
  34. package/dist/contracts/cli.js +4 -0
  35. package/dist/contracts/console-api.d.ts +4 -1
  36. package/dist/contracts/console-api.js +4 -1
  37. package/dist/contracts/flow-propose.d.ts +4 -1
  38. package/dist/contracts/flow-propose.js +1 -1
  39. package/dist/contracts/flow.d.ts +41 -0
  40. package/dist/contracts/flow.js +4 -2
  41. package/dist/contracts/lead-tools.d.ts +21 -0
  42. package/dist/contracts/lead-tools.js +2 -0
  43. package/dist/contracts/store-json.d.ts +2 -0
  44. package/dist/contracts/trigger-payloads.d.ts +20 -0
  45. package/dist/contracts/trigger-payloads.js +4 -0
  46. package/dist/coordinator.js +1 -0
  47. package/dist/credentials-ui.d.ts +5 -3
  48. package/dist/credentials-ui.js +58 -31
  49. package/dist/deploy-gate.js +4 -0
  50. package/dist/design/kit.css +685 -0
  51. package/dist/design/kit.d.ts +208 -0
  52. package/dist/design/kit.js +177 -0
  53. package/dist/design/tokens.css +19 -1
  54. package/dist/discord-settings.js +8 -4
  55. package/dist/dispatch.d.ts +3 -0
  56. package/dist/dispatch.js +4 -1
  57. package/dist/export-ui.d.ts +6 -4
  58. package/dist/export-ui.js +18 -8
  59. package/dist/flow-browser.d.ts +103 -0
  60. package/dist/flow-browser.js +204 -0
  61. package/dist/flow-changed.d.ts +14 -0
  62. package/dist/flow-changed.js +53 -0
  63. package/dist/flow-code.d.ts +5 -1
  64. package/dist/flow-code.js +4 -1
  65. package/dist/flow-engine.js +16 -0
  66. package/dist/flow-gallery-ui.d.ts +41 -4
  67. package/dist/flow-gallery-ui.js +95 -63
  68. package/dist/flow-live.js +9 -1
  69. package/dist/flow-pull-request.js +15 -7
  70. package/dist/flow-share.js +3 -1
  71. package/dist/flow-starters-ui.d.ts +3 -9
  72. package/dist/flow-starters-ui.js +12 -23
  73. package/dist/flow-steps.d.ts +9 -1
  74. package/dist/flow-steps.js +55 -5
  75. package/dist/flow-triggers.js +55 -0
  76. package/dist/flow-unattended-words.d.ts +20 -0
  77. package/dist/flow-unattended-words.js +24 -0
  78. package/dist/flow-unattended.d.ts +9 -5
  79. package/dist/flow-unattended.js +45 -23
  80. package/dist/flows-cli.d.ts +19 -0
  81. package/dist/flows-cli.js +54 -5
  82. package/dist/flows-ui.d.ts +12 -6
  83. package/dist/flows-ui.js +152 -32
  84. package/dist/flows.js +5 -2
  85. package/dist/integrations-ui.d.ts +6 -4
  86. package/dist/integrations-ui.js +34 -40
  87. package/dist/integrations.d.ts +2 -2
  88. package/dist/integrations.js +4 -4
  89. package/dist/kits-ui.d.ts +8 -10
  90. package/dist/kits-ui.js +29 -33
  91. package/dist/kits.js +1 -1
  92. package/dist/knowledge-ui.js +76 -19
  93. package/dist/lead-tools.js +35 -6
  94. package/dist/lead-voice.js +16 -5
  95. package/dist/ledger-view.d.ts +2 -0
  96. package/dist/ledger-view.js +41 -38
  97. package/dist/models-ui.d.ts +8 -3
  98. package/dist/models-ui.js +82 -59
  99. package/dist/monitoring-ui.d.ts +4 -2
  100. package/dist/monitoring-ui.js +44 -13
  101. package/dist/needs-you.d.ts +1 -1
  102. package/dist/needs-you.js +8 -0
  103. package/dist/operate-remote-arguments.js +1 -0
  104. package/dist/operate-remote.js +1 -1
  105. package/dist/operate.d.ts +1 -1
  106. package/dist/operate.js +249 -6
  107. package/dist/passwordless.d.ts +1 -1
  108. package/dist/passwordless.js +1 -1
  109. package/dist/people-audit-ui.d.ts +2 -1
  110. package/dist/people-audit-ui.js +25 -23
  111. package/dist/policy-ui.d.ts +3 -2
  112. package/dist/policy-ui.js +27 -21
  113. package/dist/policy.d.ts +3 -0
  114. package/dist/policy.js +14 -1
  115. package/dist/project-delete-ui.d.ts +4 -2
  116. package/dist/project-delete-ui.js +135 -126
  117. package/dist/project-delete.js +7 -3
  118. package/dist/project-knowledge.d.ts +10 -10
  119. package/dist/publish.d.ts +11 -0
  120. package/dist/publish.js +16 -3
  121. package/dist/pull-request-flow.js +35 -4
  122. package/dist/pull-request-ui.d.ts +2 -1
  123. package/dist/pull-request-ui.js +39 -28
  124. package/dist/recipe-ui.d.ts +4 -2
  125. package/dist/recipe-ui.js +14 -4
  126. package/dist/release-adapters.d.ts +3 -0
  127. package/dist/release-adapters.js +22 -2
  128. package/dist/release-cli.js +81 -9
  129. package/dist/release.d.ts +21 -0
  130. package/dist/release.js +40 -4
  131. package/dist/releases.js +1 -1
  132. package/dist/request-budget-ui.d.ts +4 -2
  133. package/dist/request-budget-ui.js +30 -19
  134. package/dist/result-follow-ups.js +4 -0
  135. package/dist/retention-ui.d.ts +4 -3
  136. package/dist/retention-ui.js +15 -12
  137. package/dist/scope.d.ts +8 -0
  138. package/dist/scope.js +13 -0
  139. package/dist/serve.d.ts +0 -1
  140. package/dist/serve.js +51 -7
  141. package/dist/server/agents-computers.d.ts +9 -0
  142. package/dist/server/agents-computers.js +67 -0
  143. package/dist/server/chat.js +20 -18
  144. package/dist/server/chrome.d.ts +1 -1
  145. package/dist/server/chrome.js +19 -9
  146. package/dist/server/console.css +31 -2
  147. package/dist/server/flows.js +79 -36
  148. package/dist/server/home.d.ts +35 -0
  149. package/dist/server/home.js +104 -0
  150. package/dist/server/http.d.ts +3 -0
  151. package/dist/server/http.js +5 -0
  152. package/dist/server/kit-standalone.d.ts +22 -0
  153. package/dist/server/kit-standalone.js +69 -0
  154. package/dist/server/kit.d.ts +405 -0
  155. package/dist/server/kit.js +361 -0
  156. package/dist/server/pages.d.ts +2 -1
  157. package/dist/server/pages.js +213 -174
  158. package/dist/server/people-tokens.js +26 -85
  159. package/dist/server/render-history.d.ts +51 -0
  160. package/dist/server/render-history.js +228 -0
  161. package/dist/server/render-new-task.d.ts +29 -0
  162. package/dist/server/render-new-task.js +96 -0
  163. package/dist/server/render-next.d.ts +63 -0
  164. package/dist/server/render-next.js +109 -0
  165. package/dist/server/render-pages.d.ts +35 -190
  166. package/dist/server/render-pages.js +154 -563
  167. package/dist/server/render-people.d.ts +24 -3
  168. package/dist/server/render-people.js +93 -72
  169. package/dist/server/render-projects.d.ts +103 -0
  170. package/dist/server/render-projects.js +247 -0
  171. package/dist/server/render-results.d.ts +5 -24
  172. package/dist/server/render-results.js +48 -185
  173. package/dist/server/render-settings.d.ts +5 -74
  174. package/dist/server/render-settings.js +102 -493
  175. package/dist/server/render-tasks.d.ts +43 -5
  176. package/dist/server/render-tasks.js +131 -91
  177. package/dist/server/route-table.js +31 -5
  178. package/dist/server/settings.js +276 -255
  179. package/dist/server/task-views.d.ts +8 -0
  180. package/dist/server/task-views.js +90 -2
  181. package/dist/server/tasks.d.ts +18 -0
  182. package/dist/server/tasks.js +148 -41
  183. package/dist/server/terminals.js +1 -1
  184. package/dist/server/welcome.d.ts +22 -0
  185. package/dist/server/welcome.js +69 -0
  186. package/dist/settings-groups-ui.d.ts +279 -0
  187. package/dist/settings-groups-ui.js +615 -0
  188. package/dist/settings-kit.d.ts +42 -6
  189. package/dist/settings-kit.js +69 -7
  190. package/dist/settings-proofs.js +1 -0
  191. package/dist/setup-guide.d.ts +2 -2
  192. package/dist/skills-ui.js +59 -7
  193. package/dist/slack-settings.js +12 -5
  194. package/dist/spend-ui.d.ts +45 -26
  195. package/dist/spend-ui.js +124 -56
  196. package/dist/sso-ui.d.ts +5 -2
  197. package/dist/sso-ui.js +63 -31
  198. package/dist/storage-ui.d.ts +3 -2
  199. package/dist/storage-ui.js +56 -50
  200. package/dist/store.d.ts +182 -5
  201. package/dist/store.js +512 -38
  202. package/dist/surface.js +23 -3
  203. package/dist/task-archive.d.ts +10 -0
  204. package/dist/task-archive.js +16 -0
  205. package/dist/task-control.d.ts +1 -1
  206. package/dist/teams-settings.d.ts +2 -1
  207. package/dist/teams-settings.js +24 -41
  208. package/dist/telegram-settings.d.ts +0 -4
  209. package/dist/telegram-settings.js +52 -25
  210. package/dist/tidy.d.ts +130 -0
  211. package/dist/tidy.js +503 -0
  212. package/dist/toolroll-update-ui.d.ts +8 -4
  213. package/dist/toolroll-update-ui.js +55 -45
  214. package/dist/tools-ui.d.ts +5 -3
  215. package/dist/tools-ui.js +107 -42
  216. package/dist/work-index.d.ts +24 -0
  217. package/dist/work-index.js +56 -30
  218. package/dist/workspace-ui.d.ts +1 -1
  219. package/dist/workspace-ui.js +8 -4
  220. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { canReveal, cdCommandOf, localCheckoutOf } from "../checkout-proof.js";
4
4
  import { instanceHttpAnswer } from "../console-instance.js";
5
5
  import { handlersOf } from './handler-registry.js';
6
6
  import { html, joinHtml, postForm, reauth } from "../html.js";
7
+ import * as kit from "./kit.js";
7
8
  /** pages handlers, moved without changing their route bodies. */
8
9
  import { createHmac, randomBytes } from "node:crypto";
9
10
  import { realpathSync } from "node:fs";
@@ -15,7 +16,6 @@ import { listFolder, localOrigins } from "../folder-look.js";
15
16
  import { Readable } from "node:stream";
16
17
  import { pipeline } from "node:stream/promises";
17
18
  import { setImmediate as yieldEventLoop } from "node:timers/promises";
18
- import { INSTALLATION_SCOPE, resolvePhaseAgent } from "../agentconfig.js";
19
19
  import { parseApiToken, tokenLive } from "../api-tokens.js";
20
20
  import { classify } from "../board.js";
21
21
  import { serveBrowserAsset } from '../browser-shell.js';
@@ -27,7 +27,8 @@ import { run as execRun } from "../exec.js";
27
27
  import { computeGaps } from "../gaps.js";
28
28
  import { LEDGER_CSV_HEADER, ledgerCsvRows } from "../ledger-csv.js";
29
29
  import { ledgerBody } from "../ledger-view.js";
30
- import { agentLanesOf, agentRowOf, agentsBoardOf, agentFactsFor, laneWindowOf, sealedDiffOf } from "../agents-board.js";
30
+ import { agentRowOf, agentsListOf, agentFactsFor, sealedDiffOf } from "../agents-board.js";
31
+ import { computersOf, phasesOf, systemOf } from "./agents-computers.js";
31
32
  import { markAgentSeen } from "../agent-board.js";
32
33
  import { agentsLiveOf } from "../agents-live.js";
33
34
  import { readChildWindow } from "../live-children-read.js";
@@ -48,17 +49,24 @@ import { serveInstallAsset } from "./pages-assets.js";
48
49
  import { healthz } from "./remote-hooks.js";
49
50
  import { slowRequestLines, SLOW_REQUEST_MS } from "../latency-timeline.js";
50
51
  import { leadsStillAdmitted, leadTilesFor } from './agents-live-lead.js';
51
- import { refuse, regionScript, screen, settingsRows, WORKSPACE_STYLE, workToolRows } from "./chrome.js";
52
+ import { refuse, regionScript, relativeAge as relativeAgeOf, screen, settingsRows, WORKSPACE_STYLE, workToolRows } from "./chrome.js";
52
53
  import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
53
54
  import { assignmentOf } from "../assignment.js";
54
55
  import { assignmentTaskStatusOf } from "../assignment-presentation.js";
55
- import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage, homePage, portfolioOverview, projectsPage, startBuilderHtml, systemPage, workbenchRail } from "./render-pages.js";
56
+ import { githubIdentityOf, homePage, portfolioOverview, startBuilderHtml, workbenchRail } from "./render-pages.js";
57
+ import { browsePage, githubReposPage, projectHref, projectPage, projectsPage, unknownProject } from "./render-projects.js";
58
+ import { notFound as kitNotFound } from "./kit.js";
59
+ import {} from "./render-pages.js";
60
+ import {} from "../project-outcomes.js";
61
+ import { CHECK_LEVEL_WORDS } from "../check-levels.js";
56
62
  import { capsPage } from "./render-settings.js";
57
63
  import { decisionAnswerScript, taskBody } from "./render-tasks.js";
58
64
  import {} from "./session.js";
59
65
  import { rereadBrowser } from "./live.js";
66
+ import { homeView } from "./home.js";
67
+ import { workIndexPage } from "../work-index.js";
60
68
  export function createPagesHandlers(runtime) {
61
- const { identities, clock, lastChecks, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession } = runtime;
69
+ const { identities, clock, lastChecks, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession, firstRunStepsNow } = runtime;
62
70
  // The filesystem browser (operator request): pick a project folder by
63
71
  // looking, not typing. CONFINED to exactly what opening allows —
64
72
  // explicit --repo mode has an enumerable list and gets no browser at
@@ -98,14 +106,14 @@ export function createPagesHandlers(runtime) {
98
106
  .slice(0, 200);
99
107
  const root = browseRoots.find(one => canonical === one || canonical.startsWith(`${one}/`));
100
108
  const parent = canonical === root ? null : canonical.slice(0, canonical.lastIndexOf("/")) || root;
101
- const csrf = who.session.csrf;
109
+ const registered = new Set(store.listProjects().filter(one => visible(one.path)).map(one => one.path));
102
110
  return sendScreen(response, 200, browsePage(chromeFor(who.session.project, "projects"), {
103
111
  at: canonical,
104
112
  root,
105
113
  roots: browseRoots,
106
114
  parent,
107
115
  entries,
108
- csrf,
116
+ registered,
109
117
  }));
110
118
  }
111
119
  // The signed-in gh account's repositories, OFFERED instead of typed
@@ -155,10 +163,10 @@ export function createPagesHandlers(runtime) {
155
163
  listed,
156
164
  local: localByIdentity,
157
165
  registered,
158
- csrf: who.via === "cookie" ? who.session.csrf : "",
159
166
  cloneReady: ceiling.roots.length > 0,
160
167
  openProject: who.via === "cookie" ? who.session.project ?? null : null,
161
168
  localUnanswered,
169
+ now: clock(),
162
170
  }));
163
171
  }
164
172
  // List repositories / Refresh list: ask the GitHub CLI signed in on this machine, then show the page.
@@ -171,17 +179,50 @@ export function createPagesHandlers(runtime) {
171
179
  }
172
180
  async function projectsIndex(ctx) {
173
181
  const { url, who, response } = ctx;
174
- return void projectsScreen(response, who, url.searchParams.get("said"), 200, safeReturn(url.searchParams.get("return")));
182
+ return void projectsScreen(response, who, url.searchParams.get("said"), 200, safeReturn(url.searchParams.get("return")), "said");
175
183
  }
184
+ // Home (UI revamp wave B): what needs this person, four counts that agree with the Tasks filters, and the lead's box.
185
+ // A token or a restricted session keeps landing on Tasks; a first run with nothing filed yet keeps its checklist in the chat.
176
186
  async function homeRedirect(ctx) {
177
- const { who, response } = ctx;
178
- return redirect(response, who.via === "cookie" && !restricted() ? "/chat" : "/work");
187
+ const { who, response, now, project } = ctx;
188
+ if (who.via !== "cookie" || restricted())
189
+ return redirect(response, "/work");
190
+ if (firstRunStepsNow(now) !== null && !store.hasAnyWork())
191
+ return redirect(response, "/chat");
192
+ // One scope for the page and the chrome around it: the project its address names (proved by the router), else all.
193
+ const chrome = chromeFor(project, "none");
194
+ const projects = chrome.projects ?? [];
195
+ const view = homeView({ store, now, access: workAccess(), evidenceRoot, project, projects, chat: chrome.chat === true, canAnswer: who.role === "approver" });
196
+ return sendScreen(response, 200, screen("Home", html `<div data-kit-page><h1>Home</h1><p>${view.say}</p><p><a href="/work">Tasks</a></p></div>`, { chrome, workspace: { view } }));
197
+ }
198
+ // Search (UI revamp wave B): the ⌘K menu's "See all results". The places come from the page; the tasks here are the
199
+ // most recent ones in view (live and archived), matched on their title and id. Reads only.
200
+ async function searchPage(ctx) {
201
+ const { url, response, now } = ctx;
202
+ const q = (url.searchParams.get("q") ?? "").replace(/[\p{Cc}\p{Cf}]/gu, " ").trim().slice(0, 200);
203
+ const words = q.toLowerCase().split(/\s+/).filter(Boolean);
204
+ const access = workAccess();
205
+ const live = workIndexPage(store, now, access, { view: "all", limit: 100, root: evidenceRoot });
206
+ const archived = workIndexPage(store, now, access, { view: "archived", limit: 40, root: evidenceRoot });
207
+ const multi = new Set(live.items.map(one => one.repo ?? "")).size > 1;
208
+ const tasks = words.length === 0 ? [] : [...live.items, ...archived.items]
209
+ .filter(one => words.every(word => `${one.title} ${one.rootId}`.toLowerCase().includes(word)))
210
+ .slice(0, 60)
211
+ .map(one => {
212
+ const waits = one.status.views.includes("needs-you");
213
+ const tone = one.archived !== undefined ? "" : waits ? "needs"
214
+ : { attention: "needs", ready: "needs", live: "live", done: "done", problem: "failed", muted: "", neutral: "" }[one.status.tone];
215
+ return { id: one.rootId, title: one.title, href: taskHref(one.rootId), project: multi || one.repo === null ? (one.repo === null ? "Unplaced" : projectName(one.repo)) : null,
216
+ age: relativeAgeOf(one.archived?.at ?? one.updatedAt, now), chip: { word: one.archived !== undefined ? "Archived" : waits ? "Needs you" : one.status.label, tone }, archived: one.archived !== undefined };
217
+ });
218
+ const view = { kind: "search", q, tasks, truncated: live.nextCursor !== null || archived.nextCursor !== null };
219
+ return sendScreen(response, 200, screen("Search", html `<div data-kit-page><h1>Search</h1><form action="/search"><input type="search" name="q" value="${q}" aria-label="Search"></form></div>`, { chrome: chromeFor(null, "none"), workspace: { view } }));
179
220
  }
180
221
  // The board's old name; bookmarks keep working. A GET-only alias, so a
181
222
  // plain 302 — never the shared 303 helper, which belongs to POST landings.
182
223
  async function morningAlias(ctx) {
183
224
  const { response } = ctx;
184
- response.writeHead(302, { ...SAFETY, Location: "/activity" });
225
+ response.writeHead(302, { ...SAFETY, Location: "/" });
185
226
  response.end();
186
227
  return;
187
228
  }
@@ -309,70 +350,24 @@ export function createPagesHandlers(runtime) {
309
350
  const rows = store.actionLedger({ ...query, ...(before === null ? {} : { before: Number(before) }), limit: 51 });
310
351
  if (url.searchParams.get("format") === "json")
311
352
  return respond(response, 200, "application/json; charset=utf-8", JSON.stringify(checkResponse("ledgerPage", { chain: { ok: report.ok, entries: report.entries, through: report.through, head: report.head, problem: report.problem }, entries: rows.slice(0, 50), nextBefore: rows.length > 50 ? rows[49].id : null })));
312
- return sendScreen(response, 200, screen("Action ledger", ledgerBody(rows, projects, url.searchParams, chainView), { chrome: chromeFor(chosen === "" ? null : chosen, "ledger", undefined, chosen === "" ? "all" : "project") }));
353
+ return sendScreen(response, 200, screen("Audit log", ledgerBody(rows, projects, url.searchParams, chainView), { chrome: chromeFor(chosen === "" ? null : chosen, "ledger", undefined, chosen === "" ? "all" : "project") }));
313
354
  }
355
+ // The activity summary lives on Home now (UI revamp wave B); the old address keeps answering, as a GET alias.
314
356
  async function activityPage(ctx) {
315
- const { who, response, now, project } = ctx;
316
- const since = new Date(now.getTime() - 24 * 60 * 60_000).toISOString();
317
- return sendScreen(response, 200, homePage(chromeFor(project, "runs"), {
318
- csrf: who.via === "cookie" ? who.session.csrf : "",
319
- taskCount: store.listTasksScoped(project, undefined, 1, null).length,
320
- repo: project,
321
- summary: tally(store.runsSinceScoped(since, project)),
322
- decisions: store.listDecisionsScoped(project),
323
- incidents: store.openIncidents(project),
324
- stranded: store.strandedTasks(project),
325
- gaps: project === null ? null : computeGaps(store, project, now),
326
- // Rows that want a person, plus task updates the live pairing failed to send (never quiet progress).
327
- outboxPending: store.pendingForAttention().length,
328
- settings: options.telegramTokenFile !== undefined,
329
- building: store.liveClaims(project, now),
330
- runners: store.listRunners(),
331
- worktrees: store
332
- .listWorktrees()
333
- .filter(one => project === null || one.repo === project || identities.hot(one.repo) === identities.hot(project)),
334
- episode: project === null ? null : store.latestWatchEpisode(project),
335
- now,
336
- }));
337
- }
338
- async function systemScreen(ctx) {
339
- const { response, now, project } = ctx;
340
- const since = new Date(now.getTime() - 24 * 60 * 60_000).toISOString();
341
- void since;
342
- return sendScreen(response, 200, systemPage(chromeFor(project, "system"), {
343
- agents: ["plan", "build", "repair", "review"].map(phase => {
344
- const answer = resolvePhaseAgent(store, phase, project, {});
345
- const row = (project === null ? null : store.phaseConfig(project, phase)) ??
346
- store.phaseConfig(INSTALLATION_SCOPE, phase);
347
- return {
348
- phase,
349
- ...(answer.ok
350
- ? { provider: answer.spec.provider, model: answer.spec.model, source: answer.source }
351
- : { problem: answer.problem }),
352
- setBy: row?.updatedBy ?? null,
353
- };
354
- }),
355
- building: store.liveClaims(project, now),
356
- runners: store.listRunners(),
357
- worktrees: store.listWorktrees().filter(one => project === null || one.repo === project || identities.hot(one.repo) === identities.hot(project)),
358
- episode: project === null ? null : store.latestWatchEpisode(project),
359
- outboxPending: store.pendingForAttention().length,
360
- // External work at a glance (arc 3 finding 24): every dispatch
361
- // grant with its blocked state, plus any open sync episode — the
362
- // page a sync-failed push deep-links to now shows the fact.
363
- externalWork: store
364
- .listGrants()
365
- .filter(one => one.dispatch === true && one.remoteRepo != null && visible(one.repo))
366
- .map(one => ({
367
- remoteRepo: String(one.remoteRepo),
368
- blocked: one.dispatchBlocked ?? null,
369
- openEpisode: store
370
- .listNotifications("all")
371
- .find(n => n.kind === "sync-failed" && n.resolvedAt === null && n.dedupeKey.startsWith(`sync:${one.remoteRepo}:`))?.subject ?? null,
372
- })),
373
- now,
374
- }));
357
+ const { response } = ctx;
358
+ response.writeHead(302, { ...SAFETY, Location: "/" });
359
+ response.end();
360
+ return;
375
361
  }
362
+ // A GET-only alias, a plain 302 (like morningAlias): an old address lands on the part of Agents that took it over.
363
+ const aliasTo = (location) => async (ctx) => {
364
+ ctx.response.writeHead(302, { ...SAFETY, Location: location });
365
+ ctx.response.end();
366
+ };
367
+ // System became part of Agents (UI revamp wave B): the model per phase and the computers that build.
368
+ const systemScreen = aliasTo("/agents#models");
369
+ // Terminals live on their own page (/term, its own bundle); the Agents area's address for them leads there.
370
+ const agentsTerminals = aliasTo("/term");
376
371
  // Peek became the Agents board: one place to check on every running agent (agents-board.ts).
377
372
  async function peekPage(ctx) {
378
373
  return redirect(ctx.response, "/agents");
@@ -431,6 +426,13 @@ export function createPagesHandlers(runtime) {
431
426
  }
432
427
  return { task, open };
433
428
  }
429
+ /** After a retire: fixed words naming a computer the store knows as retired (never text from the address). */
430
+ function retiredSaid(name) {
431
+ if (name === null)
432
+ return null;
433
+ const known = store.listRunners().find(one => one.name === name && one.retiredAt !== null);
434
+ return known === undefined ? null : { tone: "info", text: `${known.name} is retired. It takes no more work; tasks reserved for it wait until you move them.` };
435
+ }
434
436
  // The Agents board (one place to check on running agents): rows by state, and the opened agent's live view.
435
437
  async function agentsPage(ctx) {
436
438
  const { url, who, response, project } = ctx;
@@ -489,11 +491,21 @@ export function createPagesHandlers(runtime) {
489
491
  const view = { kind: "agents", groups: [], selected: null, lanes: null, empty: null, live: true };
490
492
  return sendScreen(response, 200, screen("Agents", html `<h1>Agents</h1><p class="meta">Live needs JavaScript.</p>`, { chrome: chromeFor(project, "runs"), workspace: { view } }));
491
493
  }
492
- const groups = agentsBoardOf(store, now, access, who.name, evidenceRoot);
493
- const lanes = selected === null ? agentLanesOf(store, now, access, who.name, laneWindowOf(url.searchParams.get("window"))) : null;
494
- const view = { kind: "agents", groups, selected, lanes,
495
- // Empty only when nothing ran at all: a planner or review on the timeline is activity too.
496
- empty: groups.length === 0 && (lanes === null || lanes === undefined) ? "No agents in the last day. They show here the moment one starts." : null };
494
+ const groups = agentsListOf(store, now, access, who.name, evidenceRoot);
495
+ // The installation's parts (what /fleet and /system showed) are for a person with access to all of it, as those
496
+ // pages were; a project-limited person sees their own agents only.
497
+ const installation = !restricted();
498
+ const windowAsked = url.searchParams.get("window");
499
+ const view = { kind: "agents", groups, selected, lanes: null,
500
+ empty: groups.length === 0 ? "No agents in the last day. They show here the moment one starts." : null,
501
+ window: windowAsked === "30m" || windowAsked === "2h" ? windowAsked : "24h",
502
+ ...(selected !== null || !installation ? {} : {
503
+ computers: computersOf(store, now, visible, who.via === "cookie" && who.role === "approver" && !store.isDemo()),
504
+ phases: phasesOf(store, project),
505
+ system: systemOf(store, project, one => project === null || one.repo === project || identities.hot(one.repo) === identities.hot(project), visible),
506
+ computerSaid: retiredSaid(url.searchParams.get("retired")),
507
+ }),
508
+ terminals: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.isInstanceOperator(who.name) };
497
509
  const fallback = joinHtml([
498
510
  html `<h1>Agents</h1>`,
499
511
  groups.length === 0 ? html `<p class="meta">${view.empty ?? ""}</p>` : joinHtml(groups.map(group => joinHtml([
@@ -501,39 +513,13 @@ export function createPagesHandlers(runtime) {
501
513
  ...group.rows.map(row => html `<p class="row"><a href="${row.href}"><strong>${row.title}</strong></a> <span class="meta">${[row.label, row.line].filter(Boolean).join(" \u00b7 ")}</span></p>`),
502
514
  ], "\n")), "\n"),
503
515
  ], "\n");
504
- return sendScreen(response, 200, screen("Agents", fallback, { chrome: chromeFor(project, "runs"), workspace: { view } }));
516
+ // The list reads itself while something builds, as /system did (forms being edited are kept); an opened agent
517
+ // follows its own room instead.
518
+ const refreshSeconds = selected !== null ? undefined : store.liveClaims(null, now).length > 0 ? 10 : installation ? 60 : undefined;
519
+ return sendScreen(response, 200, screen("Agents", fallback, { chrome: chromeFor(project, "runs"), workspace: { view }, ...(refreshSeconds === undefined ? {} : { refreshSeconds }) }));
505
520
  }
506
- async function fleetPage(ctx) {
507
- const { url, response, project } = ctx;
508
- // Cross-project by design: which agent is on which project is the
509
- // question, so this screen is a survey — reads are still filtered
510
- // through the ceiling before a card renders. No project needed.
511
- const queued = store.fleetQueue(clock());
512
- const building = store.liveClaims(null, clock());
513
- const owned = new Set(queued.map(one => one.assignedRunner).filter((one) => one !== null));
514
- const runners = store.listRunners().filter(one => one.retiredAt === null || owned.has(one.name));
515
- const body = fleetBody(queued, building, runners, store.queueRevision(), visible);
516
- if (url.searchParams.get("fragment") === "1") {
517
- return respond(response, 200, "text/html; charset=utf-8", body);
518
- }
519
- const said = url.searchParams.get("said");
520
- const fleetScreen = screen("fleet", joinHtml([
521
- html `<h1>Fleet</h1>`,
522
- html `<p class="hint">one lane per worker — drag a queued card onto another worker to re-reserve it</p>`,
523
- ...(said === null ? [] : [html `<p class="meta">${said}</p>`]),
524
- html `<div id="fleet-region">${body}</div>`,
525
- html `<p class="meta" id="fleet-region-stamp"></p>`,
526
- html `<h2>Register a worker</h2>`,
527
- postForm("/fleet/runner/register", html `<label>Name<input type="text" name="name" placeholder="builder-2" maxlength="60" required></label><label>Capacity<input type="text" name="capacity" inputmode="numeric" value="1" aria-label="capacity"></label><label>Your password, typed again<input type="password" name="token" autocomplete="current-password" required></label><button type="submit">Register — its token is shown once</button>`, { attrs: { class: "card" } }),
528
- html `<details class="arm-danger"><summary>Retire a worker</summary>${postForm("/fleet/runner/retire", html `<label>Which worker<input type="text" name="name" placeholder="builder-1" required></label>${reauth(html `<label>Your password, typed again<input type="password" name="token" autocomplete="current-password" required></label>`)}<button type="submit" class="danger">Retire it</button>`, { attrs: { class: "card" } })}</details>`,
529
- ], "\n"),
530
- // The password fields make this screen sensitive: sendScreen strips
531
- // the chrome additions and keeps the reorder poller — the named
532
- // functional exception (it never reads the fields).
533
- { chrome: chromeFor(project, "fleet", undefined, "all"), functional: { script: fleetScript(), fetches: true } });
534
- return sendScreen(response, 200, fleetScreen);
535
- }
536
- // v103: every ledger entry in a date range, sealed, with an evidence pack for each task it names.
521
+ // Fleet became the computers on Agents (UI revamp wave B): register, retire, reserved work and notes are there.
522
+ const fleetPage = aliasTo("/agents#computers");
537
523
  async function ledgerExport(ctx) {
538
524
  const { url, who, request, response, now } = ctx;
539
525
  const from = exportDay(url.searchParams.get("from")), last = exportDay(url.searchParams.get("to"));
@@ -689,29 +675,29 @@ export function createPagesHandlers(runtime) {
689
675
  const decidedOn = identities.generation();
690
676
  const resolved = asked === "" ? null : await identities.resolveNow(asked);
691
677
  if (resolved === "no-answer") {
692
- return void projectsScreen(response, who, "that folder didn't answer — it may be on a drive that's disconnected, or waiting on macOS; try again in a moment", 503, safeReturn(body.get("return")));
678
+ return void projectsScreen(response, who, "that folder didn't answer — it may be on a drive that's disconnected, or waiting on macOS; try again in a moment", 503, safeReturn(body.get("return")), "open");
693
679
  }
694
680
  const canonical = resolved;
695
681
  if (canonical === null) {
696
- return void projectsScreen(response, who, "that path does not exist on this server", 400, safeReturn(body.get("return")));
682
+ return void projectsScreen(response, who, "that path does not exist on this server", 400, safeReturn(body.get("return")), "open");
697
683
  }
698
684
  // Authorization is the ceiling, then the path must actually be a git
699
685
  // repository — validation with direct argv and a bound, never a shell.
700
686
  // Both checks apply even to configured repos: naming a directory in
701
687
  // config authorizes it; only being a repository makes it openable.
702
688
  if (!(await authorizedProject(liveCeiling(), canonical))) {
703
- return void projectsScreen(response, who, "that path is outside what this server was configured to serve", 403, safeReturn(body.get("return")));
689
+ return void projectsScreen(response, who, "that path is outside what this server was configured to serve", 403, safeReturn(body.get("return")), "open");
704
690
  }
705
691
  if (!(await isGitRepo(canonical))) {
706
- return void projectsScreen(response, who, "that path is not a git repository", 400, safeReturn(body.get("return")));
692
+ return void projectsScreen(response, who, "that path is not a git repository", 400, safeReturn(body.get("return")), "open");
707
693
  }
708
694
  // An account limited to listed projects never widens its own list.
709
695
  if (!visible(canonical)) {
710
- return void projectsScreen(response, who, "That project is outside your access.", 403, safeReturn(body.get("return")));
696
+ return void projectsScreen(response, who, "That project is outside your access.", 403, safeReturn(body.get("return")), "open");
711
697
  }
712
698
  // An identity that changed during the awaits above undoes what they proved.
713
699
  if (identities.generation() !== decidedOn)
714
- return void projectsScreen(response, who, "a project folder changed while this was being checked — try again", 409, safeReturn(body.get("return")));
700
+ return void projectsScreen(response, who, "a project folder changed while this was being checked — try again", 409, safeReturn(body.get("return")), "open");
715
701
  // Opening an allowed repository admits it the same way `repos add` does
716
702
  // (project-admission.ts). A co-located `up` notices the durable list and
717
703
  // connects its builder; there is no separate restart or binding step.
@@ -719,11 +705,11 @@ export function createPagesHandlers(runtime) {
719
705
  const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
720
706
  const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now, stillAllowed });
721
707
  if (!admitted.ok) {
722
- return void projectsScreen(response, who, admitted.reason === "not-git" ? "that path is not a git repository" : `that project is valid, but it could not be added — ${admitted.message}`, admitted.reason === "moved" ? 409 : 400, safeReturn(body.get("return")));
708
+ return void projectsScreen(response, who, admitted.reason === "not-git" ? "that path is not a git repository" : `that project is valid, but it could not be added — ${admitted.message}`, admitted.reason === "moved" ? 409 : 400, safeReturn(body.get("return")), "open");
723
709
  }
724
710
  // And once more before the session follows it.
725
711
  if (!stillAllowed(admitted.repo))
726
- return void projectsScreen(response, who, "a project folder changed while this was being checked — try again", 409, safeReturn(body.get("return")));
712
+ return void projectsScreen(response, who, "a project folder changed while this was being checked — try again", 409, safeReturn(body.get("return")), "open");
727
713
  deletedRepos.delete(admitted.repo);
728
714
  who.session.project = admitted.repo;
729
715
  who.session.projectRevision++;
@@ -744,26 +730,25 @@ export function createPagesHandlers(runtime) {
744
730
  const token = body.get("token") ?? "";
745
731
  // A worker credential is minted here: a credential act, a password typed now even with Passwordless on.
746
732
  if (!authenticateCredential(who, token).ok) {
747
- return refuse(response, who, 403, "registering a worker takes your password, typed again", "/fleet");
733
+ return refuse(response, who, 403, "registering a worker takes your password, typed again", "/agents#computers");
748
734
  }
749
735
  const name = (body.get("name") ?? "").trim();
750
736
  if (name === "" || name.length > 60 || /[\r\n\t]/.test(name) || hasForbiddenControls(name)) {
751
- return refuse(response, who, 400, "a worker's name is one short line, no control characters", "/fleet");
737
+ return refuse(response, who, 400, "a worker's name is one short line, no control characters", "/agents#computers");
752
738
  }
753
739
  const capacity = Number((body.get("capacity") ?? "1").trim() || "1");
754
740
  if (!Number.isInteger(capacity) || capacity < 1 || capacity > 64) {
755
- return refuse(response, who, 400, "capacity is a whole number of tasks, 1 to 64", "/fleet");
741
+ return refuse(response, who, 400, "capacity is a whole number of tasks, 1 to 64", "/agents#computers");
756
742
  }
757
743
  const { token: minted } = registerRunner(store, { name, host: hostname(), capacity, now });
758
- const tokenScreen = screen("fleet", joinHtml([
759
- html `<h1>${name} is registered</h1>`,
760
- html `<div class="card">`,
761
- html `<p>Its token, shown once. Only a hash is kept, so copy it now.</p>`,
762
- html `<p class="mono secret-value">${minted}</p>`,
763
- html `<p class="meta">Keep it beside the worker, in a file only you can read (0600) or a secrets manager. If it's lost, register the name again; its old claims are taken back.</p>`,
764
- html `</div>`,
765
- html `<p class="meta"><a href="/fleet">Back to Fleet</a></p>`,
766
- ], "\n"),
744
+ // A kit reading page (UI revamp wave B), back to the computers on Agents. The token is text to select: a Copy
745
+ // button would need a script, and this page carries none.
746
+ const tokenScreen = screen("fleet", html `<div data-kit-page>${kit.phoneHeader("Computer registered", { back: "/agents#computers" })}${kit.doc(kit.docHeader({ title: `${name} is registered`, back: ["/agents#computers", "Agents"], same: true, meta: html `Takes up to ${capacity} ${capacity === 1 ? "task" : "tasks"} at a time` }), kit.slots({
747
+ status: html `<h2>Its token, shown once</h2>`,
748
+ text: "Only a hash is kept, so copy it now.",
749
+ acts: html `<code class="k-code" data-runner-token>${minted}</code>`,
750
+ terms: "Keep it beside the computer, in a file only you can read (0600) or a secrets manager. If it's lost, register the name again; its old claims are taken back.",
751
+ }), kit.acts(kit.btn("Back to Agents", { href: "/agents#computers" })))}</div>`,
767
752
  // A one-time secret on screen: no script of any kind rides along.
768
753
  { chrome: chromeFor(projectOf(who, request) ?? null, "fleet"), forceSensitive: true });
769
754
  return sendScreen(response, 200, tokenScreen);
@@ -775,13 +760,14 @@ export function createPagesHandlers(runtime) {
775
760
  return refuse(response, who, 403, "runner retirement is a browser surface");
776
761
  const token = body.get("token") ?? "";
777
762
  if (!authenticateApprover(who, token).ok) {
778
- return refuse(response, who, 403, "retiring a worker takes your password, typed again", "/fleet");
763
+ return refuse(response, who, 403, "retiring a worker takes your password, typed again", "/agents#computers");
779
764
  }
780
765
  const name = (body.get("name") ?? "").trim();
781
766
  const retired = store.retireRunner(name, now);
782
767
  if (!retired)
783
- return refuse(response, who, 404, `no worker \`${name}\``, "/fleet");
784
- return redirect(response, `/fleet?said=${encodeURIComponent(`${name} is retired — its queued work is still here, drag it elsewhere`)}`);
768
+ return refuse(response, who, 404, `no worker \`${name}\``, "/agents#computers");
769
+ // Agents says it in fixed words, naming the computer only once the store knows it as retired (retiredSaid).
770
+ return redirect(response, `/agents?retired=${encodeURIComponent(name)}#computers`);
785
771
  }
786
772
  async function onboardPreview(ctx) {
787
773
  const { who, response, posted } = ctx;
@@ -795,24 +781,24 @@ export function createPagesHandlers(runtime) {
795
781
  if (process.platform === "win32")
796
782
  return refuse(response, who, 403, "adding from GitHub is not supported on Windows yet");
797
783
  if (ceiling.roots.length === 0)
798
- return projectsScreen(response, who, "naming where repositories live takes --project-root", 400);
784
+ return projectsScreen(response, who, "naming where repositories live takes --project-root", 400, "/", "link");
799
785
  const shape = parseGithubRepo(body.get("repo") ?? "");
800
786
  if (!shape.ok)
801
- return projectsScreen(response, who, shape.problem, 400);
787
+ return projectsScreen(response, who, shape.problem, 400, "/", "link");
802
788
  // Roots by INDEX (finding 13) — a posted path is never accepted.
803
789
  const rootIndex = Number(body.get("root") ?? "0");
804
790
  const rootRaw = Number.isInteger(rootIndex) ? ceiling.roots[rootIndex] : undefined;
805
791
  if (rootRaw === undefined)
806
- return projectsScreen(response, who, "pick one of the configured roots", 400);
792
+ return projectsScreen(response, who, "pick one of the configured roots", 400, "/", "link");
807
793
  const root = await identities.resolveNow(rootRaw);
808
794
  if (root === null || root === "no-answer")
809
- return projectsScreen(response, who, "that projects root does not resolve right now", 400);
795
+ return projectsScreen(response, who, "that projects root does not resolve right now", 400, "/", "link");
810
796
  const previewed = await (options.ghPreview ?? previewGithubRepo)(shape.owner, shape.name);
811
797
  if (!previewed.ok)
812
- return projectsScreen(response, who, previewed.message, 400);
798
+ return projectsScreen(response, who, previewed.message, 400, "/", "link");
813
799
  const reparsed = parseGithubRepo(previewed.preview.nameWithOwner);
814
800
  if (!reparsed.ok)
815
- return projectsScreen(response, who, "GitHub named a repository shape this console refuses", 400);
801
+ return projectsScreen(response, who, "GitHub named a repository shape this console refuses", 400, "/", "link");
816
802
  // The hook boundary is NORMALIZED like the real preview would be
817
803
  // (verification finding 2): a size that is not a nonnegative finite
818
804
  // number is unknown, and unknown is LARGE.
@@ -821,7 +807,7 @@ export function createPagesHandlers(runtime) {
821
807
  : null;
822
808
  const target = join(root, reparsed.name);
823
809
  if (dirname(target) !== root)
824
- return projectsScreen(response, who, "the target escaped its root — refused", 400);
810
+ return projectsScreen(response, who, "the target escaped its root — refused", 400, "/", "link");
825
811
  // The session-held record (finding 14): swept at mint, capped 3.
826
812
  const session = who.session;
827
813
  session.onboard ??= new Map();
@@ -830,7 +816,7 @@ export function createPagesHandlers(runtime) {
830
816
  if (record.mintedAt < cutoff)
831
817
  session.onboard.delete(key);
832
818
  if (session.onboard.size >= 3)
833
- return projectsScreen(response, who, "three previews are already waiting — confirm or let one expire", 400);
819
+ return projectsScreen(response, who, "three previews are already waiting — confirm or let one expire", 400, "/", "link");
834
820
  const nonce = randomBytes(16).toString("hex");
835
821
  session.onboard.set(nonce, {
836
822
  nameWithOwner: previewed.preview.nameWithOwner,
@@ -840,7 +826,7 @@ export function createPagesHandlers(runtime) {
840
826
  large: isLargeRepo({ ...previewed.preview, diskUsageKib }),
841
827
  mintedAt: Date.now(),
842
828
  });
843
- return projectsScreen(response, who, null, 200);
829
+ return projectsScreen(response, who, null, 200, "/", "link");
844
830
  }
845
831
  async function onboardConfirm(ctx) {
846
832
  const { who, request, response, posted, now } = ctx;
@@ -854,13 +840,13 @@ export function createPagesHandlers(runtime) {
854
840
  if (process.platform === "win32")
855
841
  return refuse(response, who, 403, "adding from GitHub is not supported on Windows yet");
856
842
  if (ceiling.roots.length === 0)
857
- return projectsScreen(response, who, "naming where repositories live takes --project-root", 400);
843
+ return projectsScreen(response, who, "naming where repositories live takes --project-root", 400, "/", "clone");
858
844
  // CONFIRM: password, then session liveness AGAIN via lookupSession
859
845
  // (finding 22/33) — the record is consumed from the RETURNED live
860
846
  // session, synchronously, before the first await.
861
847
  const password = body.get("token") ?? "";
862
848
  if (!authenticateApprover(who, password).ok) {
863
- return projectsScreen(response, who, "cloning takes your password, typed again", 403);
849
+ return projectsScreen(response, who, "cloning takes your password, typed again", 403, "/", "clone");
864
850
  }
865
851
  const cookieId = /(?:^|;\s*)standing-orders_session=([0-9a-f]{64})/.exec(request.headers.cookie ?? "")?.[1];
866
852
  const live = cookieId === undefined ? null : lookupSession(cookieId, false);
@@ -870,53 +856,53 @@ export function createPagesHandlers(runtime) {
870
856
  const record = live.onboard?.get(nonce);
871
857
  if (record === undefined || record.mintedAt < Date.now() - 10 * 60_000) {
872
858
  live.onboard?.delete(nonce);
873
- return projectsScreen(response, who, "that preview expired or was already used — preview again", 400);
859
+ return projectsScreen(response, who, "that preview expired or was already used — preview again", 400, "/", "clone");
874
860
  }
875
861
  live.onboard?.delete(nonce); // consumed BEFORE the first await
876
862
  // Identities as this confirmation found them: the admission below re-checks against them (project-identity.ts).
877
863
  const decidedOn = identities.generation();
878
864
  if (record.large && body.get("big-ok") !== "1") {
879
- return projectsScreen(response, who, "that repository is large (or its size is unknown) — tick the box to clone it anyway", 400);
865
+ return projectsScreen(response, who, "that repository is large (or its size is unknown) — tick the box to clone it anyway", 400, "/", "clone");
880
866
  }
881
867
  const rootRaw = ceiling.roots[record.rootIndex];
882
868
  if (rootRaw === undefined)
883
- return projectsScreen(response, who, "the configured roots changed — preview again", 400);
869
+ return projectsScreen(response, who, "the configured roots changed — preview again", 400, "/", "clone");
884
870
  const root = await identities.resolveNow(rootRaw);
885
871
  if (root === null || root === "no-answer")
886
- return projectsScreen(response, who, "that projects root does not resolve right now", 400);
872
+ return projectsScreen(response, who, "that projects root does not resolve right now", 400, "/", "clone");
887
873
  if (dirname(record.target) !== root)
888
- return projectsScreen(response, who, "the configured roots changed — preview again", 400);
874
+ return projectsScreen(response, who, "the configured roots changed — preview again", 400, "/", "clone");
889
875
  const cloned = await (options.ghClone ?? cloneGithubRepo)(record.nameWithOwner, root, async (cwd) => {
890
876
  const answer = await execRun("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 15_000 });
891
877
  return { code: answer.code, stdout: answer.stdout };
892
878
  });
893
879
  if (!cloned.ok)
894
- return projectsScreen(response, who, cloned.message, 400);
880
+ return projectsScreen(response, who, cloned.message, 400, "/", "clone");
895
881
  // The clone answer is BOUND to the record's exact target (verification
896
882
  // finding 2): whatever produced it — the real primitive or a test
897
883
  // hook — an answer naming any other path never enrolls or opens.
898
884
  if (cloned.target !== record.target) {
899
- return projectsScreen(response, who, "the clone answered with a different path than the preview promised — refused; nothing was enrolled", 400);
885
+ return projectsScreen(response, who, "the clone answered with a different path than the preview promised — refused; nothing was enrolled", 400, "/", "clone");
900
886
  }
901
887
  // authorizedProject AFTER the repository exists (finding 20).
902
888
  const clonedAt = await identities.resolveNow(cloned.target);
903
889
  const admitted = typeof clonedAt === "string" && clonedAt !== "no-answer" && (await authorizedProject(liveCeiling(), clonedAt)) ? clonedAt : null;
904
890
  if (admitted === null) {
905
- return projectsScreen(response, who, `the clone landed at ${cloned.target} but did not prove under the ceiling — it was left in place; enroll it by hand`, 400);
891
+ return projectsScreen(response, who, `the clone landed at ${cloned.target} but did not prove under the ceiling — it was left in place; enroll it by hand`, 400, "/", "clone");
906
892
  }
907
893
  const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
908
894
  const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now, stillAllowed });
909
895
  if (!joined.ok) {
910
- return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, joined.reason === "moved" ? 409 : 400);
896
+ return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, joined.reason === "moved" ? 409 : 400, "/", "clone");
911
897
  }
912
898
  if (!stillAllowed(joined.repo))
913
- return projectsScreen(response, who, `${cloned.target} is cloned but wasn't opened — a project folder changed while it was being checked; try again`, 409);
899
+ return projectsScreen(response, who, `${cloned.target} is cloned but wasn't opened — a project folder changed while it was being checked; try again`, 409, "/", "clone");
914
900
  deletedRepos.delete(admitted);
915
901
  who.session.project = admitted;
916
902
  who.session.projectRevision += 1;
917
903
  return projectsScreen(response, who, options.upConsole === true
918
904
  ? `${projectName(admitted)} is ready — the builder is connecting automatically`
919
- : `${admitted} is ready and open`, 200);
905
+ : `${admitted} is ready and open`, 200, "/", "said");
920
906
  }
921
907
  /**
922
908
  * The opener: every project inside the ceiling, most recently opened
@@ -925,8 +911,9 @@ export function createPagesHandlers(runtime) {
925
911
  * registry row never confers access; this page only offers what the
926
912
  * server was configured to allow.
927
913
  */
928
- async function projectsScreen(response, who, problem, status, returnTo = "/") {
929
- const now = clock();
914
+ /** The projects this person may open: the registered ones they can see, most recently opened first, then the
915
+ * configured ones the queue has seen. A registry row never confers access. */
916
+ function visibleProjects() {
930
917
  const recent = store.listProjects().filter(one => visible(one.path));
931
918
  const recentPaths = new Set(recent.map(one => one.path));
932
919
  const candidates = new Set();
@@ -935,6 +922,20 @@ export function createPagesHandlers(runtime) {
935
922
  if (!recentPaths.has(canonical) && visible(canonical))
936
923
  candidates.add(canonical);
937
924
  }
925
+ return { recent, candidates: [...candidates] };
926
+ }
927
+ /** A cheap peek per project: one small set of COUNTs, scoped to the exact repo. */
928
+ function peekOf(path, now) {
929
+ try {
930
+ return projectFamilyPeek(path, now);
931
+ }
932
+ catch {
933
+ return null;
934
+ }
935
+ }
936
+ async function projectsScreen(response, who, problem, status, returnTo = "/", problemAt = null) {
937
+ const now = clock();
938
+ const { recent, candidates } = visibleProjects();
938
939
  const open = who.via === "cookie" ? who.session.project : null;
939
940
  // Onboarding availability (findings 2/28/32/39): a cookie session on a
940
941
  // root-configured, non-demo, POSIX serve gets the live card; everybody
@@ -953,31 +954,66 @@ export function createPagesHandlers(runtime) {
953
954
  : "choose which folder this server may use with --project-root <dir>",
954
955
  }
955
956
  : { enabled: true, roots: ceiling.roots, record: [...(who.session.onboard?.entries() ?? [])][0] ?? null };
956
- // A cheap peek per project for the switcher cards — one small set of
957
- // COUNTs each, scoped to the exact repo.
958
- const peekOf = (path) => {
959
- try {
960
- return projectFamilyPeek(path, now);
961
- }
962
- catch {
963
- return null;
964
- }
965
- };
966
957
  const peeks = {};
967
958
  for (const one of recent)
968
- peeks[one.path] = peekOf(one.path);
959
+ peeks[one.path] = peekOf(one.path, now);
969
960
  for (const path of candidates)
970
- peeks[path] = peekOf(path);
971
- let activity;
972
- if (open !== null && !returnTo.startsWith("/tasks/new") && visible(open) && store.accountCanAccess(who.name, open)) {
961
+ peeks[path] = peekOf(path, now);
962
+ return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), {
963
+ recent, candidates, open, problem, problemAt, onboard: onboardState, peeks, returnTo,
964
+ browsable: !restricted() && (ceiling.roots.length > 0 || unscopedMode),
965
+ }));
966
+ }
967
+ /** One project's reading page (/projects/<name>). A GET only reads (docs/plans/get-side-effects.md): it never
968
+ * switches the session's project; its Open project button posts to /projects/select, which proves it again. The
969
+ * name is found among the projects this person may open (projectsSelect's rule: visible here and inside the
970
+ * ceiling); a name it can't see answers exactly like one that doesn't exist. */
971
+ async function projectPageHandler(ctx) {
972
+ const { url, who, response, now } = ctx;
973
+ let name = "";
974
+ try {
975
+ name = decodeURIComponent(url.pathname.slice("/projects/".length));
976
+ }
977
+ catch {
978
+ name = "";
979
+ }
980
+ const { recent, candidates } = visibleProjects();
981
+ const known = [...recent.map(one => ({ path: one.path, name: one.name, openedAt: one.lastOpenedAt })), ...candidates.map(path => ({ path, name: projectName(path), openedAt: null }))];
982
+ const named = known.filter(one => one.name === name);
983
+ const askedPath = url.searchParams.get("path");
984
+ const found = askedPath !== null ? named.find(one => one.path === askedPath) ?? null : named.length === 1 ? named[0] : null;
985
+ const open = who.via === "cookie" ? who.session.project : null;
986
+ if (found === null || !(await authorizedProject(liveCeiling(), found.path))) {
987
+ const words = unknownProject(name === "" ? "by that name" : name);
988
+ return sendScreen(response, 404, screen("Not found", kitNotFound(url.pathname, words), { chrome: chromeFor(open, "projects") }));
989
+ }
990
+ const path = found.path;
991
+ // The tasks that wait on this person or run, from the board's own classification, scoped to this one project.
992
+ const snapshot = store.boardScoped(path, now, 200, null);
993
+ const cards = snapshot.tasks
994
+ .filter(facts => facts.repo === path)
995
+ .map(facts => classify(facts.blockerRepo !== null && !visible(facts.blockerRepo) ? { ...facts, blockerState: null } : facts, now))
996
+ .filter(card => card.lane === "attention" || card.lane === "building");
997
+ const tasks = cards.slice(0, 5).map(card => ({ href: card.href, title: card.title, reason: card.reason, needs: card.lane === "attention", since: card.stalledSince }));
998
+ const cookie = who.via === "cookie";
999
+ let activity = null;
1000
+ if (store.accountCanAccess(who.name, path)) {
973
1001
  try {
974
- activity = { project: projectName(open), report: projectOutcomes(store, open, now, { principal: 'operator', repos: [open] }) };
1002
+ activity = { report: projectOutcomes(store, path, now, { principal: 'operator', repos: [path] }) };
975
1003
  }
976
1004
  catch {
977
- activity = { project: projectName(open), report: null };
1005
+ activity = { report: null };
978
1006
  }
979
1007
  }
980
- return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined, who.via === "cookie" ? (path) => projectCheckLevel(store, path).level : undefined, activity));
1008
+ return sendScreen(response, 200, projectPage(chromeFor(open, "projects"), {
1009
+ name: found.name, path, selfHref: projectHref(found.name, path, named.length > 1), open: open === path, openedAt: found.openedAt,
1010
+ peek: peekOf(path, now), tasks, moreTasks: Math.max(0, cards.length - tasks.length),
1011
+ checks: cookie ? { href: `/settings/checks?repo=${encodeURIComponent(path)}`, level: CHECK_LEVEL_WORDS[projectCheckLevel(store, path).level] } : null,
1012
+ pullRequests: cookie ? { href: `/settings/pull-requests?repo=${encodeURIComponent(path)}`, on: publishingOf(store, path).on } : null,
1013
+ knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(path)}`,
1014
+ settingsHref: restricted() ? null : `/settings/project?repo=${encodeURIComponent(path)}`,
1015
+ activity, now,
1016
+ }));
981
1017
  }
982
1018
  async function instanceEdge(ctx) {
983
1019
  const { request, response, url } = ctx;
@@ -1108,7 +1144,9 @@ export function createPagesHandlers(runtime) {
1108
1144
  "projects.github": projectsGithub,
1109
1145
  "projects.github-list": projectsGithubList,
1110
1146
  "projects.page": projectsIndex,
1147
+ "projects.project": projectPageHandler,
1111
1148
  "home": homeRedirect,
1149
+ "search": searchPage,
1112
1150
  "morning": morningAlias,
1113
1151
  "workbench": workbench,
1114
1152
  "ledger": ledgerPage,
@@ -1119,6 +1157,7 @@ export function createPagesHandlers(runtime) {
1119
1157
  "agents": agentsPage,
1120
1158
  "agents.live": agentsLive,
1121
1159
  "agents.live.read": agentsLiveRead,
1160
+ "agents.terminals": agentsTerminals,
1122
1161
  "fleet": fleetPage,
1123
1162
  "ledger.export": ledgerExport,
1124
1163
  "menu": menuPage,