toolroll 0.9.72 → 0.9.74

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 (137) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/dist/agent-fence.d.ts +5 -0
  4. package/dist/agent-fence.js +42 -7
  5. package/dist/agent-turns.js +5 -0
  6. package/dist/agents-live.d.ts +31 -0
  7. package/dist/agents-live.js +43 -0
  8. package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +31 -30
  11. package/dist/browser-workspace.d.ts +6 -0
  12. package/dist/builder.js +1 -1
  13. package/dist/chat-actions.js +13 -8
  14. package/dist/chat-continuity.js +10 -0
  15. package/dist/chat-decide.d.ts +47 -1
  16. package/dist/chat-decide.js +170 -4
  17. package/dist/chat-quiet.js +3 -3
  18. package/dist/check-report.js +2 -1
  19. package/dist/coding-provider.js +4 -2
  20. package/dist/commit-extract.d.ts +17 -0
  21. package/dist/commit-extract.js +82 -4
  22. package/dist/contracts/console-api.d.ts +3 -3
  23. package/dist/contracts/console-api.js +4 -4
  24. package/dist/contracts/release-terms.d.ts +7 -1
  25. package/dist/contracts/release-terms.js +6 -2
  26. package/dist/deploy-gate.js +5 -2
  27. package/dist/desktop-update.d.ts +1 -1
  28. package/dist/dispose.js +35 -2
  29. package/dist/exec.d.ts +1 -1
  30. package/dist/exec.js +20 -19
  31. package/dist/first-run.d.ts +17 -1
  32. package/dist/first-run.js +38 -0
  33. package/dist/flow-gallery-ui.js +4 -2
  34. package/dist/flow-gallery.d.ts +3 -0
  35. package/dist/flow-gallery.js +6 -2
  36. package/dist/flow-triggers.d.ts +8 -0
  37. package/dist/flow-triggers.js +15 -0
  38. package/dist/flows-cli.js +13 -3
  39. package/dist/guides.js +10 -1
  40. package/dist/invoke.d.ts +13 -0
  41. package/dist/invoke.js +21 -12
  42. package/dist/kits-ui.js +4 -2
  43. package/dist/kits.d.ts +6 -4
  44. package/dist/kits.js +9 -11
  45. package/dist/knowledge-ui.d.ts +4 -1
  46. package/dist/knowledge-ui.js +10 -5
  47. package/dist/lead-progress.d.ts +4 -1
  48. package/dist/lead-progress.js +3 -1
  49. package/dist/lead.js +4 -0
  50. package/dist/live-bus.d.ts +2 -0
  51. package/dist/live-bus.js +1 -0
  52. package/dist/live-children-read.d.ts +52 -0
  53. package/dist/live-children-read.js +333 -0
  54. package/dist/live-children.d.ts +73 -0
  55. package/dist/live-children.js +318 -0
  56. package/dist/live-detail.d.ts +4 -3
  57. package/dist/live-detail.js +9 -5
  58. package/dist/live-input.js +7 -8
  59. package/dist/live-main-read.d.ts +19 -0
  60. package/dist/live-main-read.js +91 -0
  61. package/dist/live-open.d.ts +28 -0
  62. package/dist/live-open.js +125 -0
  63. package/dist/live-records.d.ts +10 -1
  64. package/dist/live-records.js +16 -5
  65. package/dist/live.d.ts +54 -22
  66. package/dist/live.js +218 -69
  67. package/dist/mcp-connect.d.ts +10 -0
  68. package/dist/mcp-connect.js +2 -0
  69. package/dist/operate.js +3 -2
  70. package/dist/peek-cli.d.ts +10 -3
  71. package/dist/peek-cli.js +17 -8
  72. package/dist/planner.js +1 -1
  73. package/dist/project-tools.js +3 -1
  74. package/dist/protected-storage.d.ts +67 -0
  75. package/dist/protected-storage.js +398 -0
  76. package/dist/provider-limits.d.ts +3 -1
  77. package/dist/provider-limits.js +5 -2
  78. package/dist/provider.js +6 -12
  79. package/dist/release-adapters.d.ts +8 -1
  80. package/dist/release-adapters.js +84 -14
  81. package/dist/release-authority.d.ts +6 -0
  82. package/dist/release-authority.js +9 -0
  83. package/dist/release-card.d.ts +76 -0
  84. package/dist/release-card.js +252 -0
  85. package/dist/release-carry.d.ts +3 -0
  86. package/dist/release-carry.js +23 -4
  87. package/dist/release-cli.js +36 -3
  88. package/dist/release-git.d.ts +43 -0
  89. package/dist/release-git.js +106 -0
  90. package/dist/release.d.ts +11 -2
  91. package/dist/release.js +82 -6
  92. package/dist/repository-context-ui.js +2 -1
  93. package/dist/run-efficiency.d.ts +3 -3
  94. package/dist/run-efficiency.js +20 -8
  95. package/dist/scope.d.ts +7 -0
  96. package/dist/scope.js +22 -5
  97. package/dist/scout.d.ts +3 -0
  98. package/dist/scout.js +5 -3
  99. package/dist/serve.js +34 -23
  100. package/dist/server/agents-live-lead.d.ts +47 -0
  101. package/dist/server/agents-live-lead.js +116 -0
  102. package/dist/server/agents-live-room.d.ts +23 -0
  103. package/dist/server/agents-live-room.js +88 -0
  104. package/dist/server/chat.js +84 -30
  105. package/dist/server/chrome.js +2 -2
  106. package/dist/server/console.css +68 -40
  107. package/dist/server/flows.js +40 -10
  108. package/dist/server/live.d.ts +4 -0
  109. package/dist/server/live.js +42 -2
  110. package/dist/server/pages.js +81 -1
  111. package/dist/server/render-chat.d.ts +15 -1
  112. package/dist/server/render-chat.js +14 -7
  113. package/dist/server/render-pages.d.ts +8 -4
  114. package/dist/server/render-pages.js +55 -50
  115. package/dist/server/render-settings.js +1 -1
  116. package/dist/server/render-tasks.js +10 -3
  117. package/dist/server/route-table.js +5 -1
  118. package/dist/server/runtime.d.ts +1 -1
  119. package/dist/server/settings.js +29 -8
  120. package/dist/server/task-views.js +1 -1
  121. package/dist/server/tasks.js +59 -41
  122. package/dist/setup-guide.d.ts +2 -2
  123. package/dist/store.d.ts +34 -1
  124. package/dist/store.js +81 -2
  125. package/dist/stream-scope.d.ts +18 -0
  126. package/dist/stream-scope.js +28 -0
  127. package/dist/subagent-memory.d.ts +1 -0
  128. package/dist/subagent-memory.js +1 -1
  129. package/dist/subagent-tools.d.ts +22 -6
  130. package/dist/subagent-tools.js +44 -8
  131. package/dist/subagent-work.d.ts +2 -0
  132. package/dist/subagent-work.js +5 -2
  133. package/dist/subagents-ui.js +6 -4
  134. package/dist/subscription-chat.js +5 -3
  135. package/dist/telegram.d.ts +6 -1
  136. package/dist/telegram.js +58 -6
  137. package/package.json +1 -1
@@ -9,9 +9,11 @@ import { handlersOf } from "./handler-registry.js";
9
9
  import { respond } from "./http.js";
10
10
  import { adapterPolicy } from "./route-policy.js";
11
11
  import { requestContext } from "./request-context.js";
12
+ import { createAgentsLiveRooms } from "./agents-live-room.js";
13
+ import { leadTilesFor } from "./agents-live-lead.js";
12
14
  /** A page asks for a few rooms at once: its workspace, what it shows, its conversation. */
13
15
  export const LIVE_ROOMS_PER_PAGE = 6;
14
- const ROOM_NAME = /^(workspace|task|flow|chat|team|agent|summary)(?::([^?]{1,400}))?(?:\?([^#]{0,600}))?$/;
16
+ const ROOM_NAME = /^(workspace|task|flow|chat|team|agent|summary|agents-live)(?::([^?]{1,400}))?(?:\?([^#]{0,600}))?$/;
15
17
  /** At most one workspace nudge a second: a burst of writes is one re-read, the first heard at once. */
16
18
  const WORKSPACE_SETTLE_MS = 1_000;
17
19
  /** A running reply's preview: at most one snapshot every 80 ms; the last one always goes out. */
@@ -34,6 +36,7 @@ export function parseLiveRoom(name) {
34
36
  switch (kind) {
35
37
  case "workspace": return key === null && query === undefined ? { kind } : null;
36
38
  case "summary": return key === null && query === undefined ? { kind } : null;
39
+ case "agents-live": return key === null && query === undefined ? { kind } : null;
37
40
  case "task": return key !== null && key !== "" && query === undefined ? { kind, task: key } : null;
38
41
  case "flow": {
39
42
  const flow = Number(key), card = params.get("card");
@@ -92,6 +95,11 @@ export function createLiveHandlers(runtime, live) {
92
95
  const chatViewers = new Set();
93
96
  // One agent's open view (Agents board): what it shows, keyed by the exact run.
94
97
  const agentRooms = createLiveRooms(run => agentRunFingerprint(store, run), null, { bus: live.bus });
98
+ // The Live grid: one room per viewer and scope, watching only (no lease, no seen mark).
99
+ // Each Live page's own lead tiles, read as its viewer's saved session now (a page's room key carries the page).
100
+ const liveLeads = new Map();
101
+ const agentsLive = createAgentsLiveRooms({ store, bus: live.bus, evidenceRoot: runtime.evidenceRoot, localRunner: runtime.options.localRunner, clock, visible,
102
+ leads: key => liveLeads.get(key)?.() ?? null, forget: key => liveLeads.delete(key) });
95
103
  const readBrowser = (request, who) => rereadBrowser(runtime, request, who);
96
104
  const sameBrowser = (request, who) => () => readBrowser(request, who) !== null;
97
105
  function admitted(viewer, thread) {
@@ -196,10 +204,11 @@ export function createLiveHandlers(runtime, live) {
196
204
  if ([...url.searchParams.keys()].some(key => key !== "room") || names.length === 0 || names.length > LIVE_ROOMS_PER_PAGE || new Set(names).size !== names.length)
197
205
  return json(response, 400, { error: "room" });
198
206
  const rooms = names.map(name => ({ name, room: parseLiveRoom(name) }));
199
- if (rooms.some(one => one.room === null) || rooms.filter(one => one.room?.kind === "agent").length > 1 || rooms.filter(one => one.room?.kind === "workspace").length > 1 || rooms.filter(one => one.room?.kind === "chat").length > 1 || rooms.filter(one => one.room?.kind === "team").length > 1 || rooms.filter(one => one.room?.kind === "summary").length > 1)
207
+ if (rooms.some(one => one.room === null) || rooms.filter(one => one.room?.kind === "agent").length > 1 || rooms.filter(one => one.room?.kind === "workspace").length > 1 || rooms.filter(one => one.room?.kind === "chat").length > 1 || rooms.filter(one => one.room?.kind === "team").length > 1 || rooms.filter(one => one.room?.kind === "summary").length > 1 || rooms.filter(one => one.room?.kind === "agents-live").length > 1)
200
208
  return json(response, 400, { error: "room" });
201
209
  // Admit every room before the stream opens, with the answers each page's own stream gave.
202
210
  const joins = [];
211
+ let refusedJoin = false;
203
212
  for (const { name, room } of rooms) {
204
213
  if (room.kind !== "team" && who.via !== "cookie")
205
214
  return json(response, 403, { error: "session" });
@@ -216,6 +225,33 @@ export function createLiveHandlers(runtime, live) {
216
225
  joins.push(connection => summaryRooms.join(summaryKey(who.name, scope), { name: who.name, room: name, connection,
217
226
  valid: () => signedIn() && live.projectScopeFor(who.name) === scope }));
218
227
  }
228
+ else if (room.kind === "agents-live") {
229
+ const browser = who;
230
+ const scope = live.projectScopeFor(who.name);
231
+ if (scope === null)
232
+ return json(response, 404, { error: "agents-live" });
233
+ const signedIn = sameBrowser(request, browser);
234
+ const valid = () => signedIn() && live.projectScopeFor(who.name) === scope;
235
+ joins.push(connection => {
236
+ // Proved before the room is made or read: an invalid viewer never causes a look at a file.
237
+ let ok = false;
238
+ try {
239
+ ok = valid();
240
+ }
241
+ catch {
242
+ ok = false;
243
+ }
244
+ if (!ok) {
245
+ connection.send(name, "gone");
246
+ refusedJoin = true;
247
+ return;
248
+ }
249
+ // Each page's own room (its key carries this connection): a newcomer is never handed a digest computed earlier.
250
+ const key = agentsLive.keyOf(who.name, scope, randomBytes(9).toString("hex"));
251
+ liveLeads.set(key, () => { const again = readBrowser(request, browser); return again === null ? null : leadTilesFor(runtime, again); });
252
+ agentsLive.rooms.join(key, { name: who.name, room: name, connection, valid });
253
+ });
254
+ }
219
255
  else if (room.kind === "task") {
220
256
  const browser = who;
221
257
  const family = familyOf(room.task);
@@ -342,6 +378,9 @@ export function createLiveHandlers(runtime, live) {
342
378
  for (const join of joins)
343
379
  if (connection.open)
344
380
  join(connection);
381
+ // A page whose only room was refused at its join ends now (`ended`), its keep-alive stopped, not left open.
382
+ if (refusedJoin && connection.rooms === 0)
383
+ connection.refuse(() => connection.close());
345
384
  request.once("close", () => connection.close());
346
385
  }
347
386
  const detachChat = live.bus.subscribe(() => {
@@ -357,6 +396,7 @@ export function createLiveHandlers(runtime, live) {
357
396
  summaryRooms.close();
358
397
  teamRooms.close();
359
398
  agentRooms.close();
399
+ agentsLive.rooms.close();
360
400
  for (const viewer of [...chatViewers])
361
401
  viewer.connection.close();
362
402
  },
@@ -26,6 +26,10 @@ import { LEDGER_CSV_HEADER, ledgerCsvRows } from "../ledger-csv.js";
26
26
  import { ledgerBody } from "../ledger-view.js";
27
27
  import { agentLanesOf, agentRowOf, agentsBoardOf, agentFactsFor, laneWindowOf, sealedDiffOf } from "../agents-board.js";
28
28
  import { markAgentSeen } from "../agent-board.js";
29
+ import { agentsLiveOf } from "../agents-live.js";
30
+ import { readChildWindow } from "../live-children-read.js";
31
+ import { readMainWindow } from "../live-main-read.js";
32
+ import { runLiveCommands } from "../store.js";
29
33
  import { prometheusMetrics } from "../metrics.js";
30
34
  import { readMonitoring } from "../monitoring-settings.js";
31
35
  import { targetOf } from "../monitoring.js";
@@ -38,6 +42,7 @@ import { healthWords } from "../server-telemetry.js";
38
42
  import { tally } from "../summary.js";
39
43
  import { serveInstallAsset } from "./pages-assets.js";
40
44
  import { healthz } from "./remote-hooks.js";
45
+ import { leadsStillAdmitted, leadTilesFor } from './agents-live-lead.js';
41
46
  import { refuse, regionScript, screen, settingsRows, WORKSPACE_STYLE, workToolRows } from "./chrome.js";
42
47
  import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
43
48
  import { assignmentOf } from "../assignment.js";
@@ -46,6 +51,7 @@ import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage,
46
51
  import { capsPage } from "./render-settings.js";
47
52
  import { decisionAnswerScript, taskBody } from "./render-tasks.js";
48
53
  import {} from "./session.js";
54
+ import { rereadBrowser } from "./live.js";
49
55
  export function createPagesHandlers(runtime) {
50
56
  const { clock, 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;
51
57
  // The filesystem browser (operator request): pick a project folder by
@@ -444,8 +450,14 @@ export function createPagesHandlers(runtime) {
444
450
  const now = clock();
445
451
  const access = workAccess();
446
452
  const asked = url.searchParams.get("run");
453
+ // The Live grid only watches (docs/plans/agents-view.md §3): it branches off before an opened agent is built, so
454
+ // viewing it never marks a run seen, takes a watch or offers a composer — a reload of ?view=live&run=… included.
455
+ const watching = url.searchParams.get("view") === "live";
456
+ // Live is a browser session's only, like its reads (no token sees the grid).
457
+ if (watching && who.via !== "cookie")
458
+ return respond(response, 403, "text/plain; charset=utf-8", "The Live view is for a signed-in browser.");
447
459
  let selected = null;
448
- if (asked !== null && /^[1-9][0-9]{0,9}$/.test(asked)) {
460
+ if (!watching && asked !== null && /^[1-9][0-9]{0,9}$/.test(asked)) {
449
461
  const run = store.getRun(Number(asked));
450
462
  const facts = run === null || !runVisible(run) ? null : agentFactsFor(store, run.id, access, who.name);
451
463
  if (facts !== null) {
@@ -485,6 +497,11 @@ export function createPagesHandlers(runtime) {
485
497
  };
486
498
  }
487
499
  }
500
+ // The Live grid is drawn in the browser from its own read-only reads (agents-live-view.tsx): no board facts here.
501
+ if (watching) {
502
+ const view = { kind: "agents", groups: [], selected: null, lanes: null, empty: null, live: true };
503
+ return sendScreen(response, 200, screen("Agents", html `<h1>Agents</h1><p class="meta">Live needs JavaScript.</p>`, { chrome: chromeFor(project, "runs"), workspace: { view } }));
504
+ }
488
505
  const groups = agentsBoardOf(store, now, access, who.name, evidenceRoot);
489
506
  const lanes = selected === null ? agentLanesOf(store, now, access, who.name, laneWindowOf(url.searchParams.get("window"))) : null;
490
507
  const view = { kind: "agents", groups, selected, lanes,
@@ -989,6 +1006,67 @@ export function createPagesHandlers(runtime) {
989
1006
  response.setHeader("cache-control", "no-store");
990
1007
  return respond(response, 200, "application/json", JSON.stringify({ proof: createHmac("sha256", Buffer.from(identity, "hex")).update(challenge).digest("hex") }));
991
1008
  }
1009
+ // The Live grid (docs/plans/agents-view.md §3), kept after every other handler.
1010
+ /** The Live grid's tiles (JSON, read-only): every role's running agents and those that just ended, that this viewer
1011
+ * may see — the console's ceiling and the account's own access, proved per run, on every request. */
1012
+ async function agentsLive(ctx) {
1013
+ const { who, request, response } = ctx;
1014
+ response.setHeader("cache-control", "no-store");
1015
+ // A browser session only (the route says so too): live output is never a token's to read.
1016
+ if (who.via !== "cookie")
1017
+ return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
1018
+ const browser = who;
1019
+ const canSee = (run) => { const repo = taskRepoOf(run.taskRef); return repo !== null && visible(repo) && store.accountCanAccess(who.name, repo); };
1020
+ const tiles = agentsLiveOf(store, clock(), { canSee, localRunner: options.localRunner });
1021
+ // The viewer's own lead turns, admitted as their chat is, from the session as it is saved now.
1022
+ const current = rereadBrowser(runtime, request, browser);
1023
+ const leads = current === null ? [] : leadTilesFor(runtime, current);
1024
+ // Re-proved right before delivery: the session still held, each tile's run still in view from its current row, and
1025
+ // each lead turn still admitted, from the session as it is saved now.
1026
+ const again = current === null ? null : rereadBrowser(runtime, request, browser);
1027
+ if (again === null)
1028
+ return respond(response, 401, "application/json", JSON.stringify({ error: "session" }));
1029
+ const shown = tiles.filter(tile => { const run = store.getRun(tile.runId); return run !== null && canSee(run); });
1030
+ return respond(response, 200, "application/json", JSON.stringify({ tiles: shown, leads: leadsStillAdmitted(runtime, again, leads) }));
1031
+ }
1032
+ /** This console's key for the Live grid's read cursors: random, in memory only (never where a build can reach), so a
1033
+ * restart makes old cursors start over visibly. A cursor is state, never permission. */
1034
+ const liveCursorKey = randomBytes(32);
1035
+ /** One window of a run's live output for the Live grid (JSON, read-only): its own records or its subagents'. Access
1036
+ * is proved before the read (ceiling, account, this computer's run) and again after it, before a byte is sent. */
1037
+ async function agentsLiveRead(ctx) {
1038
+ const { url, who, response } = ctx;
1039
+ response.setHeader("cache-control", "no-store");
1040
+ // A browser session only (the route says so too): live output is never a token's to read.
1041
+ if (who.via !== "cookie")
1042
+ return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
1043
+ const runId = Number(url.pathname.slice("/agents/live/".length));
1044
+ const file = url.searchParams.get("file");
1045
+ const cursor = url.searchParams.get("cursor");
1046
+ if ((file !== "main" && file !== "sub") || [...url.searchParams.keys()].some(key => key !== "file" && key !== "cursor")) {
1047
+ return respond(response, 400, "application/json", JSON.stringify({ error: "request" }));
1048
+ }
1049
+ const browser = who;
1050
+ // The saved session still held (not just the cookie), and the run still in view from its current row.
1051
+ const allowed = () => {
1052
+ if (rereadBrowser(runtime, ctx.request, browser) === null)
1053
+ return false;
1054
+ const run = store.getRun(runId);
1055
+ const repo = run === null ? null : taskRepoOf(run.taskRef);
1056
+ return run !== null && repo !== null && runVisible(run) && store.accountCanAccess(who.name, repo);
1057
+ };
1058
+ if (!allowed())
1059
+ return respond(response, 404, "application/json", JSON.stringify({ error: "run" }));
1060
+ if (options.localRunner === undefined || store.getRun(runId)?.runner !== options.localRunner) {
1061
+ return allowed() ? respond(response, 200, "application/json", JSON.stringify({ error: "off" })) : respond(response, 404, "application/json", JSON.stringify({ error: "run" }));
1062
+ }
1063
+ const read = { key: liveCursorKey, commands: runLiveCommands(store, runId) };
1064
+ const window = file === "sub" ? readChildWindow(evidenceRoot, runId, cursor, read) : readMainWindow(evidenceRoot, runId, cursor, read);
1065
+ // Re-proved after the read: a narrowed account or a run gone from view gets nothing it read.
1066
+ if (!allowed() || store.getRun(runId)?.runner !== options.localRunner)
1067
+ return respond(response, 404, "application/json", JSON.stringify({ error: "run" }));
1068
+ return respond(response, 200, "application/json", JSON.stringify(window.ok ? window : { error: window.reason }));
1069
+ }
992
1070
  const registrations = handlersOf("pages", {
993
1071
  "projects.browse": projectsBrowse,
994
1072
  "projects.github": projectsGithub,
@@ -1001,6 +1079,8 @@ export function createPagesHandlers(runtime) {
1001
1079
  "system": systemScreen,
1002
1080
  "peek": peekPage,
1003
1081
  "agents": agentsPage,
1082
+ "agents.live": agentsLive,
1083
+ "agents.live.read": agentsLiveRead,
1004
1084
  "fleet": fleetPage,
1005
1085
  "ledger.export": ledgerExport,
1006
1086
  "menu": menuPage,
@@ -359,8 +359,14 @@ export declare function leadAfterComposerHtml(data: {
359
359
  pending: LeadTurn | null;
360
360
  focusTask: TaskChatFocus | null;
361
361
  }): Html;
362
+ /** A conversation shown before it exists (quiet page visits): its page binds sends to this session id, and the first
363
+ * send starts the conversation (chatSend). A visit never starts one. */
364
+ export declare const NOT_STARTED_SESSION = 0;
362
365
  export declare function leadPage(chrome: Chrome, data: LeadThreadRows & {
363
- session: LeadSession;
366
+ /** Null: no conversation yet; the first message starts one. */
367
+ session: LeadSession | null;
368
+ /** Whose conversation this is (the session's approver once it exists). */
369
+ approver: string;
364
370
  /** A project's own thread (v77); null for the lead conversation or a task. */
365
371
  chatProject?: string | null;
366
372
  follow?: {
@@ -390,6 +396,14 @@ export type LiveTurn = {
390
396
  ok: boolean;
391
397
  listeners: Set<() => void>;
392
398
  expiry?: NodeJS.Timeout;
399
+ /** For the Live grid's lead tile (agents-live-lead.ts): the turn's id once it says it, when it started and ended, and
400
+ * when each step began (epoch ms). Never sent to the chat room. */
401
+ turn?: number;
402
+ startedAt?: number;
403
+ endedAt?: number;
404
+ stepAt?: number[];
405
+ /** Per step: its text is the provider's finished answer, secret-checked whole (lead.ts `final`). */
406
+ confirmed?: boolean[];
393
407
  };
394
408
  export type ChatEnablement = {
395
409
  ok: true;
@@ -952,18 +952,25 @@ export function leadThreadHtml(data) {
952
952
  export function leadAfterComposerHtml(data) {
953
953
  return html `<div id="chat-after-composer" data-chat-region="after">${data.messages.length === 0 && data.pending === null ? leadPromptStarters(data.focusTask) : ""}</div>`;
954
954
  }
955
+ /** A conversation shown before it exists (quiet page visits): its page binds sends to this session id, and the first
956
+ * send starts the conversation (chatSend). A visit never starts one. */
957
+ export const NOT_STARTED_SESSION = 0;
955
958
  export function leadPage(chrome, data) {
956
959
  const subscription = isSubscriptionChatProvider(data.config.provider);
957
960
  const chatProject = data.focusTask === null ? data.chatProject ?? null : null;
958
961
  const returnTo = data.focusTask !== null ? taskChatHref(data.focusTask.id) : chatProject !== null ? projectChatHref(chatProject) : "/chat";
962
+ const sessionId = data.session?.id ?? NOT_STARTED_SESSION;
963
+ const approver = data.session?.approver ?? data.approver;
959
964
  const controlsHtml = joinHtml([
960
- html `<details class="lead-follow"><summary>Automatic crew updates${data.follow?.enabled ? ' · On' : ''}</summary><p class="meta">${data.follow?.detail ?? 'Automatic crew updates are off.'}</p>${postForm("/chat/mate/follow", html `${data.follow?.enabled ? '' : html `<p>The lead responds when results or decisions arrive. Uses this conversation’s ${subscription ? 'membership usage, with no dollar maximum' : 'remaining spend allowance'} and daily turn limit. Existing task approvals still apply.</p>`}<button type="submit" class="quiet">${data.follow?.enabled ? 'Pause updates' : 'Enable updates'}</button>`, { returnTo, hidden: { enabled: data.follow?.enabled ? 'no' : 'yes' } })}</details>`,
965
+ data.session === null ? "" : html `<details class="lead-follow"><summary>Automatic crew updates${data.follow?.enabled ? ' · On' : ''}</summary><p class="meta">${data.follow?.detail ?? 'Automatic crew updates are off.'}</p>${postForm("/chat/mate/follow", html `${data.follow?.enabled ? '' : html `<p>The lead responds when results or decisions arrive. Uses this conversation’s ${subscription ? 'membership usage, with no dollar maximum' : 'remaining spend allowance'} and daily turn limit. Existing task approvals still apply.</p>`}<button type="submit" class="quiet">${data.follow?.enabled ? 'Pause updates' : 'Enable updates'}</button>`, { returnTo, hidden: { enabled: data.follow?.enabled ? 'no' : 'yes' } })}</details>`,
961
966
  html `<details class="chat-limits chat-session-details"><summary>Conversation details<span class="meta">${subscription ? "membership" : data.config.provider}</span></summary>\n${joinHtml([
962
967
  html `<div class="chat-budget"><span class="mono">${data.config.provider} · ${data.config.model}</span><span>${data.turnsToday} / ${data.config.dailyTurns} turns today</span>${subscription
963
968
  ? html `<span>membership login · no dollar ceiling</span>`
964
- : html `<span>this conversation: ${chatMoney(data.session.spentMicrousd)} of ${chatMoney(data.session.ceilingMicrousd)}</span><span>this week ${chatMoney(data.weeklySpent)} of ${chatMoney(data.config.weeklyCeilingMicrousd)}</span>`}</div>`,
965
- html `<p class="meta">Started ${data.session.mintedAt.slice(0, 16).replace("T", " ")}Z. It stays open until you end it; only bounded recent context is sent to the model.</p>`,
966
- postForm("/chat/mate/end", html `<button type="submit" class="quiet">End the conversation and forget the thread</button>`, { attrs: { class: "inline" }, returnTo }),
969
+ : html `<span>this conversation: ${chatMoney(data.session?.spentMicrousd ?? 0)} of ${chatMoney(data.session?.ceilingMicrousd ?? 0)}</span><span>this week ${chatMoney(data.weeklySpent)} of ${chatMoney(data.config.weeklyCeilingMicrousd)}</span>`}</div>`,
970
+ data.session === null
971
+ ? html `<p class="meta">No conversation yet. Send a message to start one.</p>`
972
+ : html `<p class="meta">Started ${data.session.mintedAt.slice(0, 16).replace("T", " ")}Z. It stays open until you end it; only bounded recent context is sent to the model.</p>`,
973
+ data.session === null ? "" : postForm("/chat/mate/end", html `<button type="submit" class="quiet">End the conversation and forget the thread</button>`, { attrs: { class: "inline" }, returnTo }),
967
974
  data.recent.length === 0
968
975
  ? ""
969
976
  : html `<p class="meta">Recent turns: ${joinHtml(data.recent
@@ -996,8 +1003,8 @@ export function leadPage(chrome, data) {
996
1003
  html `<label>Message<textarea id="chat-message" name="message" rows="1" maxlength="${LEAD_MESSAGE_MAX_CHARS}" placeholder="${data.focusTask !== null ? "Ask about this task…" : chatProject !== null ? `Ask about ${projectName(chatProject)}…` : "Describe what you want done…"}"></textarea></label>`,
997
1004
  html `<button type="submit" aria-label="${data.pending === null ? "send message" : "send message — it waits for the current reply"}">Send</button>`,
998
1005
  ], "\n"), {
999
- attrs: { class: "card composer", id: latestReply === null && data.pending === null ? "latest" : "chat-composer", "aria-label": "message the lead", "data-chat-session": data.session.id, "data-chat-task": data.focusTask?.id ?? "", "data-chat-user": data.session.approver, "data-chat-busy": data.pending === null ? "0" : "1", "data-chat-version": leadChatVersion(data), "data-chat-approval": data.focusTask?.approval?.digest ?? "" },
1000
- hidden: { request: randomBytes(16).toString("hex"), "request-session": data.session.id, task: data.focusTask?.id, project: chatProject, result: data.resultRunId },
1006
+ attrs: { class: "card composer", id: latestReply === null && data.pending === null ? "latest" : "chat-composer", "aria-label": "message the lead", "data-chat-session": sessionId, "data-chat-task": data.focusTask?.id ?? "", "data-chat-user": approver, "data-chat-busy": data.pending === null ? "0" : "1", "data-chat-version": leadChatVersion(data), "data-chat-approval": data.focusTask?.approval?.digest ?? "" },
1007
+ hidden: { request: randomBytes(16).toString("hex"), "request-session": sessionId, task: data.focusTask?.id, project: chatProject, result: data.resultRunId },
1001
1008
  }),
1002
1009
  // Concise pass (2026-09-13): the status line speaks only when there is
1003
1010
  // a state to report — a reply in progress here, the connection from
@@ -1009,7 +1016,7 @@ export function leadPage(chrome, data) {
1009
1016
  return screen("chat", chatWorkspace(joinHtml(conversation, "\n"), data.projects, false, data.focusTask, data.resultPanel ?? null), { chrome, functional: { script: CHAT_CONTINUITY_SCRIPT + CHAT_UI_SCRIPT + (data.focusTask === null ? "" : RESULT_REVIEW_SCRIPT), fetches: true },
1010
1017
  workspace: {
1011
1018
  conversation: {
1012
- sessionId: data.session.id, user: data.session.approver, version: leadChatVersion(data),
1019
+ sessionId, user: approver, version: leadChatVersion(data),
1013
1020
  messages: leadBrowserMessages(data, data.focusTask === null && chatProject === null ? null : returnTo, { task: data.focusTask?.id ?? null, project: chatProject }),
1014
1021
  pendingTurnId: data.pending?.id ?? null, requestId: randomBytes(16).toString('hex'), maxChars: LEAD_MESSAGE_MAX_CHARS,
1015
1022
  taskId: data.focusTask?.id ?? null, resultRunId: data.resultRunId ?? null, project: chatProject,
@@ -14,7 +14,7 @@ import { type CriterionMatrixRow } from "../proof.js";
14
14
  import { type QualityMode } from "../quality.js";
15
15
  import { type Runner } from "../runner.js";
16
16
  import { type Scope, type UnattendedPermissionMode } from "../scope.js";
17
- import { type Decision, type Incident, type RepairChainRow, type Run, type Store, type Task, type TaskState, type WorktreeRow } from "../store.js";
17
+ import { type Decision, type Incident, type RepairChainRow, type Run, type Store, type TaskState, type WorktreeRow } from "../store.js";
18
18
  import { tally } from "../summary.js";
19
19
  import { type WorkIndexGroup, type WorkIndexPage } from "../work-index.js";
20
20
  import { type WorkFacts, type WorkStatus, type WorkView } from "../workspace-ui.js";
@@ -284,8 +284,8 @@ export declare function workPage(chrome: Chrome, data: {
284
284
  multiProject: boolean;
285
285
  now: Date;
286
286
  limits?: BrowserLimits | null;
287
+ state?: TaskState | null;
287
288
  }): Screen;
288
- export declare function tasksPage(chrome: Chrome, tasks: Task[], state: TaskState | null, problem: string | null, repo?: string | null, prefill?: TaskComposerPrefill | null, permissionDefault?: UnattendedPermissionMode, qualityDefault?: QualityMode, replaced?: ReadonlyMap<string, string>): Screen;
289
289
  export declare function browsePage(chrome: Chrome, data: {
290
290
  at: string;
291
291
  root: string;
@@ -492,13 +492,17 @@ export declare function fleetScript(): string;
492
492
  * (finding 18); missing that, a select counts clean.
493
493
  */
494
494
  export declare function queueScript(): string;
495
- export declare function newTaskPage(chrome: Chrome, project: string | null, projectRevision: number, problem: string | null, candidates?: {
495
+ export declare function newTaskPage(chrome: Chrome, project: string | null,
496
+ /** undefined: keep the revision the refused form posted (its draft), so a project switch since still refuses. */
497
+ projectRevision: number | undefined, problem: string | null, candidates?: {
496
498
  id: string;
497
499
  title: string;
498
500
  }[], permissionDefault?: UnattendedPermissionMode, qualityDefault?: QualityMode, projects?: {
499
501
  path: string;
500
502
  name: string;
501
- }[]): Screen;
503
+ }[],
504
+ /** A shipped template's fields, or a refused filing's own words, back in the form. */
505
+ prefill?: TaskComposerPrefill | null): Screen;
502
506
  /**
503
507
  * The triage flow: everything waiting on a person, one card at a time.
504
508
  * Full context ON the card, the act inline, and every act lands back here
@@ -287,6 +287,10 @@ export function boardBody(data, ciRed) {
287
287
  return `${Math.round(minutes / 60)}h`;
288
288
  return `${Math.round(minutes / (24 * 60))}d`;
289
289
  };
290
+ // A lane's head is a status chip in its own tone (after ReUI's badge with a dot): amber only for the lane that
291
+ // waits on a person, blue for building, green for done, neutral otherwise. The word carries the state.
292
+ const LANE_CHIP = { attention: "chip-needs", building: "chip-live", done: "chip-done", queued: "", waiting: "" };
293
+ const laneChip = (key, title) => html `<span class="chip ${LANE_CHIP[key]}"><span class="chip-dot" aria-hidden="true"></span>${title}</span>`;
290
294
  const lane = (key, title, hint, renderOne) => {
291
295
  const cards = data.cards.filter(card => card.lane === key);
292
296
  // The longest-stalled card leads the board: attention sorts by how
@@ -312,20 +316,21 @@ export function boardBody(data, ciRed) {
312
316
  // A lane is a section that can fold (board pass): open when it holds
313
317
  // cards, folded when empty, so a phone reads the counts first and a
314
318
  // desktop column never spends its height on "nothing here".
315
- return html `<details class="lane lane-${key}"${shown.length === 0 ? null : html ` open`}><summary><h2>${title} <span class="lane-count">${cards.length}${data.saturated ? "+" : ""}</span></h2></summary><p class="hint">${hint}</p>${shown.length === 0 ? html `<p class="meta lane-empty">nothing here</p>` : shown.map((card, index) => renderOne(card, index))}${more > 0 ? html `<a class="lane-more" href="/tasks">+${more} more in the task list</a>` : null}</details>`;
319
+ return html `<details class="lane lane-${key}"${shown.length === 0 ? null : html ` open`}><summary><h2>${laneChip(key === "attention" && cards.length === 0 ? "queued" : key, title)} <span class="lane-count">${cards.length}${data.saturated ? "+" : ""}</span></h2></summary><p class="hint">${hint}</p>${shown.length === 0 ? html `<p class="meta lane-empty">nothing here</p>` : shown.map((card, index) => renderOne(card, index))}${more > 0 ? html `<a class="lane-more" href="/work">+${more} more in Tasks</a>` : null}</details>`;
316
320
  };
317
321
  // The card's facts: mono key–value pairs under the title (board pass) —
318
322
  // task, worker, runtime — the same grammar on every lane, so the eye
319
323
  // learns one card. Chips carry the words (project, reservation).
324
+ // A value is mono only when a machine said it (a worker, model or branch name, a clock); words stay Geist.
320
325
  const facts = (rows) => rows.length === 0
321
326
  ? null
322
- : html `<span class="facts">${rows.map(([k, v]) => html `<span class="fact"><span class="k">${k}</span><span class="v">${v}</span></span>`)}</span>`;
327
+ : html `<span class="facts">${rows.map(([k, v, mono]) => html `<span class="fact"><span class="k">${k}</span><span class="${mono === undefined ? "v" : "v mono"}">${v}</span></span>`)}</span>`;
323
328
  const chips = (parts) => {
324
329
  const kept = parts.filter((one) => one !== null);
325
330
  return kept.length === 0 ? null : html `<span class="chips">${joinHtml(kept, " ")}</span>`;
326
331
  };
327
- const plain = (card) => html `<a class="lane-card" href="${card.href}" data-vt="${card.taskId}"><span class="id">${card.taskId}</span><span class="t">${card.title}</span><span class="why">${card.reason}</span>${facts([
328
- ...(card.stalledSince === null ? [] : [["waiting", age(card.stalledSince)]]),
332
+ const plain = (card) => html `<a class="lane-card" href="${card.href}" data-vt="${card.taskId}"><span class="t">${card.title}</span><span class="why">${card.reason}</span><span class="id">${card.taskId}</span>${facts([
333
+ ...(card.stalledSince === null ? [] : [["waiting", age(card.stalledSince), "mono"]]),
329
334
  ])}${chips([
330
335
  chip(card.repo),
331
336
  ])}</a>`;
@@ -342,8 +347,8 @@ export function boardBody(data, ciRed) {
342
347
  }
343
348
  return heads;
344
349
  })();
345
- const queuedCard = (card) => html `<a class="lane-card" href="${card.href}" data-vt="${card.taskId}"><span class="id">${card.taskId}</span><span class="t">${card.title}</span><span class="why">${card.reason}</span>${facts([
346
- ["worker", card.assignedRunner === null ? "any free worker" : card.assignedRunner],
350
+ const queuedCard = (card) => html `<a class="lane-card" href="${card.href}" data-vt="${card.taskId}"><span class="t">${card.title}</span><span class="why">${card.reason}</span><span class="id">${card.taskId}</span>${facts([
351
+ card.assignedRunner === null ? ["worker", "any free worker"] : ["worker", card.assignedRunner, "mono"],
347
352
  ])}${chips([
348
353
  queuedHeads.get(`${card.assignedRunner ?? ""}|${card.repo ?? ""}`) === card.taskId
349
354
  ? html `<span class="badge">${card.assignedRunner === null ? "next up" : `next for ${card.assignedRunner}`}</span>`
@@ -367,13 +372,15 @@ export function boardBody(data, ciRed) {
367
372
  const steps = step === null
368
373
  ? null
369
374
  : html `<span class="steps" role="img" aria-label="${`step ${step + 1} of ${BUILD_STEPS.length}: ${BUILD_STEPS[step]}`}">${BUILD_STEPS.map((_, index) => html `<i class="${index < step ? "on" : index === step ? "now" : ""}"></i>`)}</span>`;
370
- return html `<a class="lane-card building" href="${card.href}" data-vt="${card.taskId}"><span class="id">${card.taskId}</span><span class="t"><span class="dot dot-ok pulse"></span>${card.title}</span>${steps}<span class="live-line"><span class="stage">${live}</span>${tickingClock(claim.claimedAt, data.now)}</span>${facts([
371
- ["worker", claim.runner],
372
- ...(claim.model === null
373
- ? []
374
- : [["model", `${claim.provider !== null && claim.provider !== "claude" ? `${claim.provider} · ` : ""}${claim.model}`]]),
375
- ...(claim.branch === null ? [] : [["branch", `${claim.branch}${workspace === null ? "" : ` · ${workspace}`}`]]),
376
- ])}${chips([
375
+ // Who, which model and which branch, as one muted line: words in Geist, the model and branch in mono.
376
+ const model = claim.model === null ? null : `${claim.provider !== null && claim.provider !== "claude" ? `${claim.provider} · ` : ""}${claim.model}`;
377
+ const branch = claim.branch === null ? null : `${claim.branch}${workspace === null ? "" : ` · ${workspace}`}`;
378
+ const who = joinHtml([
379
+ html `<span>${claim.runner}</span>`,
380
+ model === null ? null : html `<span class="mono">${model}</span>`,
381
+ branch === null ? null : html `<span class="mono">${branch}</span>`,
382
+ ].filter((one) => one !== null), html `<span aria-hidden="true"> · </span>`);
383
+ return html `<a class="lane-card building" href="${card.href}" data-vt="${card.taskId}"><span class="t"><span class="dot dot-ok pulse"></span>${card.title}</span>${steps}<span class="live-line"><span class="stage">${live}</span>${tickingClock(claim.claimedAt, data.now)}</span><span class="id">${card.taskId}</span><span class="who">${who}</span>${chips([
377
384
  card.attempt === null ? null : html `<span class="badge">Attempt ${card.attempt}</span>`,
378
385
  chip(card.repo),
379
386
  ])}</a>`;
@@ -384,8 +391,8 @@ export function boardBody(data, ciRed) {
384
391
  const pr = row.prNumber === null
385
392
  ? null
386
393
  : html `<span class="badge badge-open">PR #${row.prNumber}</span>${ciRed(row.prNumber) ? html ` <span class="badge badge-failed">CI failing</span>` : null}`;
387
- return html `<a class="lane-card" href="${taskHref(row.taskId)}" data-vt="${row.taskId}"><span class="id">${row.taskId}</span><span class="t">${row.title}</span>${row.handoff === null ? null : html `<span class="why">${row.handoff.length > 120 ? row.handoff.slice(0, 120) + "…" : row.handoff}</span>`}${facts([
388
- ...(row.ranMinutes === null ? [] : [["ran", `${row.ranMinutes}m`]]),
394
+ return html `<a class="lane-card" href="${taskHref(row.taskId)}" data-vt="${row.taskId}"><span class="t">${row.title}</span>${row.handoff === null ? null : html `<span class="why">${row.handoff.length > 120 ? row.handoff.slice(0, 120) + "…" : row.handoff}</span>`}<span class="id">${row.taskId}</span>${facts([
395
+ ...(row.ranMinutes === null ? [] : [["ran", `${row.ranMinutes}m`, "mono"]]),
389
396
  ["usage", runCostWords({ authMode: row.authMode, costUsd: row.costUsd, tokensIn: null, tokensOut: null })],
390
397
  ])}${chips([
391
398
  html `<span class="badge badge-done">${row.outcome === "no-change" ? "No change" : "Built"}</span>`,
@@ -417,11 +424,11 @@ export function boardBody(data, ciRed) {
417
424
  toggle,
418
425
  workerStrip(data.workers, data.cards, data.now),
419
426
  html `<div class="board" data-now="${String(data.now.getTime())}">`,
420
- lane("attention", "needs you", "these wait for a person", plain),
421
- lane("queued", "queued", "starts when a worker is free", queuedCard),
422
- lane("waiting", "waiting", "paused until a time, another task, or a requirement is ready", plain),
423
- lane("building", "building", "one agent per card, in its own workspace", building),
424
- html `<details class="lane lane-done"${data.done.length === 0 ? null : html ` open`}><summary><h2><a href="/done">done recently</a></h2></summary><p class="hint">the most recent — the full list is under done</p>${doneCards}</details>`,
427
+ lane("attention", "Needs you", "These wait for a person.", plain),
428
+ lane("queued", "Queued", "Starts when a worker is free.", queuedCard),
429
+ lane("waiting", "Waiting", "Paused until a time, another task, or a requirement is ready.", plain),
430
+ lane("building", "Building", "One agent per card, in its own workspace.", building),
431
+ html `<details class="lane lane-done"${data.done.length === 0 ? null : html ` open`}><summary><h2>${laneChip("done", html `<a href="/done">Done recently</a>`)}</h2></summary><p class="hint">The most recent. The full list is under Done.</p>${doneCards}</details>`,
425
432
  html `</div>`,
426
433
  ], "\n");
427
434
  }
@@ -503,7 +510,7 @@ export function homePage(chrome, data) {
503
510
  ? joinHtml([
504
511
  html `<div class="card">`,
505
512
  html `<p><strong>Nothing is queued yet — here is the whole loop:</strong></p>`,
506
- html `<p>1. <a href="/tasks">Add a task</a> — plain words for work you want done${data.repo === null ? null : html ` in <span class="mono">${data.repo}</span>`}.</p>`,
513
+ html `<p>1. <a href="/tasks/new">Add a task</a> — plain words for work you want done${data.repo === null ? null : html ` in <span class="mono">${data.repo}</span>`}.</p>`,
507
514
  html `<p>2. Open it and write its scope — the goal, and what it must not become. Approve exactly that.</p>`,
508
515
  html `<p>3. Keep Toolroll running on the builder machine. Approved tasks build unattended, each on its own branch.</p>`,
509
516
  html `<p class="meta">When an agent is unsure it stops and asks — those questions land here, under “waiting on you”.</p>`,
@@ -596,12 +603,15 @@ export function taskComposerHtml(data) {
596
603
  }
597
604
  export const TASK_GROUP_LABEL = { ...ASK_LABEL, building: 'Building', rest: 'Recent' };
598
605
  export function workPage(chrome, data) {
599
- const href = (view, cursor) => {
606
+ // The stored-state filter (the old task list's) rides every link but a change of state, which starts on page one.
607
+ const href = (view, cursor, state = data.state ?? null) => {
600
608
  const query = new URLSearchParams();
601
609
  if (data.projectFilter !== undefined)
602
610
  query.set('project', data.projectFilter);
603
611
  if (view !== 'all')
604
612
  query.set('view', view);
613
+ if (state !== null)
614
+ query.set('state', state);
605
615
  if (cursor !== undefined)
606
616
  query.set('cursor', cursor);
607
617
  return `/work${query.size ? `?${query}` : ''}`;
@@ -614,16 +624,22 @@ export function workPage(chrome, data) {
614
624
  : browserWorkActionHref(row);
615
625
  const action = actionHref === null || row.primaryAction === null ? null : html `<a class="work-action" data-primary-action href="${actionHref}">${row.primaryAction.label} →</a>`;
616
626
  const project = data.multiProject || row.repo === null ? html `<span class="project-label">${row.repo === null ? 'Unplaced' : projectName(row.repo)}</span>` : null;
617
- return html `<article class="work-row" data-task="${row.rootId}" data-work-status="${row.status.token}" data-work-views="${row.status.views.join(' ')}"><div class="work-row-main"><a class="work-title" href="${taskHref(row.rootId)}">${row.title}</a><p class="work-meta">${project}<span>${relativeAge(row.updatedAt, data.now)}</span></p></div><div class="work-row-status">${statusLineHtml(row.status)}${action}${row.familyProblem === null ? null : html `<p class="problem">${row.familyProblem}</p>`}${row.status.views.includes('needs-you') && !['write-scope', 'approve-scope'].includes(row.primaryAction?.code ?? '') && row.status.detail !== row.status.label && row.status.detail !== row.familyProblem ? html `<p class="work-detail">${row.status.detail}</p>` : null}${workDiagnosticsHtml(row.status.diagnostics)}</div></article>`;
627
+ return html `<article class="work-row" data-task="${row.rootId}" data-work-status="${row.status.token}" data-work-views="${row.status.views.join(' ')}"><div class="work-row-main"><a class="work-title" href="${taskHref(row.rootId)}">${row.title}</a><p class="work-meta">${project}<span>${relativeAge(row.updatedAt, data.now)}</span><span class="mono">${row.rootId}</span></p></div><div class="work-row-status">${statusLineHtml(row.status)}${action}${row.familyProblem === null ? null : html `<p class="problem">${row.familyProblem}</p>`}${row.status.views.includes('needs-you') && !['write-scope', 'approve-scope'].includes(row.primaryAction?.code ?? '') && row.status.detail !== row.status.label && row.status.detail !== row.familyProblem ? html `<p class="work-detail">${row.status.detail}</p>` : null}${workDiagnosticsHtml(row.status.diagnostics)}</div></article>`;
618
628
  };
619
629
  const current = WORK_VIEWS.find(one => one.key === data.view);
620
- const list = data.work.items.length === 0
621
- ? html `<div class="work-empty" data-work-empty="${data.view}"><p>${data.previous ? 'There are no more tasks on this page.' : current.empty}</p>${data.view === 'all' && !data.previous ? html `<p><a class="button-link" href="${chrome.chat === true ? '/chat' : '/tasks/new'}">${chrome.chat === true ? 'Start in chat' : 'Add a task'}</a></p>` : html `<a href="${href('all')}">See all tasks →</a>`}</div>`
622
- : html `<div class="work-list">${data.work.items.map(rowHtml)}</div>`;
630
+ const filtered = (data.state ?? null) !== null;
631
+ // The state filter, as real links (the old task list's), so a page without the React view can still change it.
632
+ const stateLinks = html `<nav class="work-states" aria-label="Task state"><span class="meta">State:</span> ${joinHtml([html `<a href="${href(data.view, undefined, null)}"${filtered ? null : html ` aria-current="page"`}>Any</a>`,
633
+ ...TASK_STATES.map(one => html `<a href="${href(data.view, undefined, one)}"${data.state === one ? html ` aria-current="page"` : null}>${one}</a>`)], html ` · `)}</nav>`;
634
+ const list = data.work.items.length === 0 && filtered && !data.previous
635
+ ? html `<div class="work-empty" data-work-empty="${data.view}"><p>No tasks are ${data.state} in this view.</p><a href="${href(data.view, undefined, null)}">Show every state →</a></div>`
636
+ : data.work.items.length === 0
637
+ ? html `<div class="work-empty" data-work-empty="${data.view}"><p>${data.previous ? 'There are no more tasks on this page.' : current.empty}</p>${data.view === 'all' && !data.previous ? html `<p><a class="button-link" href="${chrome.chat === true ? '/chat' : '/tasks/new'}">${chrome.chat === true ? 'Start in chat' : 'Add a task'}</a></p>` : html `<a href="${href('all')}">See all tasks →</a>`}</div>`
638
+ : html `<div class="work-list">${data.work.items.map(rowHtml)}</div>`;
623
639
  const pages = !data.previous && data.work.nextCursor === null ? null : html `<nav class="row work-pagination" aria-label="Task pages">${data.previous ? html `<a class="button-link" href="${href(data.view)}">First page</a>` : null}${data.work.nextCursor === null ? null : html `<a class="button-link" rel="next" href="${href(data.view, data.work.nextCursor)}">Next page</a>`}</nav>`;
624
- const tools = html `<details class="work-tools"><summary>Work tools${CHEVRON_ICON}</summary><nav class="work-tools-menu">${[['/inbox', 'Inbox'], ['/board', 'Board'], ['/board?view=order', 'Order'], ['/tasks', 'Task list'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/workbench', 'Portfolio']]), ['/ledger', 'Action ledger']]
640
+ const tools = html `<details class="work-tools"><summary>Work tools${CHEVRON_ICON}</summary><nav class="work-tools-menu">${[['/inbox', 'Inbox'], ['/board', 'Board'], ['/board?view=order', 'Order'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/workbench', 'Portfolio']]), ['/ledger', 'Action ledger']]
625
641
  .map(([path, label]) => html `<a href="${path}">${label}</a>`)}</nav></details>`;
626
- const toolLinks = [['/inbox', 'Inbox'], ['/board', 'Board'], ['/board?view=order', 'Order'], ['/tasks', 'Task list'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/workbench', 'Portfolio']])];
642
+ const toolLinks = [['/inbox', 'Inbox'], ['/board', 'Board'], ['/board?view=order', 'Order'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/workbench', 'Portfolio']])];
627
643
  const view = {
628
644
  kind: 'tasks',
629
645
  tabs: WORK_VIEWS.map(one => ({ label: one.label, href: href(one.key), count: data.work.totals[one.key], active: one.key === data.view })),
@@ -649,7 +665,7 @@ export function workPage(chrome, data) {
649
665
  notes: (row.status.diagnostics ?? []).map(one => `${one.label} · ${one.detail}`),
650
666
  };
651
667
  }),
652
- empty: data.work.items.length > 0 ? null : {
668
+ empty: data.work.items.length > 0 ? null : filtered && !data.previous ? { text: `No tasks are ${data.state} in this view.`, action: { label: "Show every state", href: href(data.view, undefined, null) } } : {
653
669
  text: data.previous ? 'There are no more tasks on this page.' : current.empty,
654
670
  action: data.view === 'all' && !data.previous ? { label: chrome.chat === true ? 'Start in chat' : 'Add a task', href: chrome.chat === true ? '/chat' : '/tasks/new' } : { label: 'See all tasks', href: href('all') },
655
671
  },
@@ -657,26 +673,10 @@ export function workPage(chrome, data) {
657
673
  tools: toolLinks.map(([path, label]) => ({ label: label, href: path })),
658
674
  newTask: { label: 'New task', href: '/tasks/new' },
659
675
  limits: data.limits ?? null,
676
+ states: [{ label: 'Any state', href: href(data.view, undefined, null), active: (data.state ?? null) === null },
677
+ ...TASK_STATES.map(one => ({ label: one.charAt(0).toUpperCase() + one.slice(1), href: href(data.view, undefined, one), active: data.state === one }))],
660
678
  };
661
- return screen('work', html `<div class="work-head"><h1>Tasks</h1>${tools}</div>${limitsHtml(data.limits ?? null)}${tabs}${list}${pages}`, { chrome, workspace: { view } });
662
- }
663
- export function tasksPage(chrome, tasks, state, problem, repo = null, prefill = null, permissionDefault = "auto", qualityDefault = "default", replaced = new Map()) {
664
- const filters = joinHtml(TASK_STATES.map(one => (one === state ? html `<strong>${one}</strong>` : html `<a href="/tasks?state=${one}">${one}</a>`)), " · ");
665
- const rows = tasks.length === 0
666
- ? html `<p class="meta">${state === null
667
- ? "The queue is empty — add the first task below. It builds once you approve its scope."
668
- : `Nothing is ${state}.`}</p>`
669
- : joinHtml(tasks.map(task => html `<a class="row" href="${taskHref(task.id)}"><span class="mono">${task.id}</span> ${task.title} <span class="right badge badge-${task.state}">${task.state === "cancelled" && replaced.has(task.id) ? `replaced by ${replaced.get(task.id)}` : task.state}</span></a>`), "\n");
670
- return screen("tasks", joinHtml([
671
- html `<h1>Tasks</h1>`,
672
- html `<p class="meta">Work you want done${repo === null ? null : html ` in <strong>${projectName(repo)}</strong>`} — a task builds unattended only after its scope is approved; open one to write or approve its scope</p>`,
673
- repo === null ? null : html `<p class="meta path-words"><span class="mono">${repo}</span></p>`,
674
- problem === null ? null : html `<div class="problem">${problem}</div>`,
675
- html `<p class="meta">Filter: <a href="/tasks">all</a> · ${filters}</p>`,
676
- rows,
677
- html `<h2>Add a task</h2>`,
678
- taskComposerHtml({ project: repo, prefill, permissionDefault, qualityDefault }),
679
- ], "\n"), { chrome });
679
+ return screen('work', html `<div class="work-head"><h1>Tasks</h1>${tools}</div>${limitsHtml(data.limits ?? null)}${tabs}${stateLinks}${list}${pages}`, { chrome, workspace: { view } });
680
680
  }
681
681
  export function browsePage(chrome, data) {
682
682
  const crumb = data.at === data.root ? projectName(data.root) : `${projectName(data.root)}${data.at.slice(data.root.length)}`;
@@ -1273,7 +1273,11 @@ export function queueScript() {
1273
1273
  `.then(function(){tell();});});` +
1274
1274
  `})();`);
1275
1275
  }
1276
- export function newTaskPage(chrome, project, projectRevision, problem, candidates = [], permissionDefault = "auto", qualityDefault = "default", projects = []) {
1276
+ export function newTaskPage(chrome, project,
1277
+ /** undefined: keep the revision the refused form posted (its draft), so a project switch since still refuses. */
1278
+ projectRevision, problem, candidates = [], permissionDefault = "auto", qualityDefault = "default", projects = [],
1279
+ /** A shipped template's fields, or a refused filing's own words, back in the form. */
1280
+ prefill = null) {
1277
1281
  return screen("New task", joinHtml([
1278
1282
  html `<section class="task-intake">`,
1279
1283
  html `<div class="task-intake-hero"><h1>What should get done?</h1></div>`,
@@ -1281,7 +1285,8 @@ export function newTaskPage(chrome, project, projectRevision, problem, candidate
1281
1285
  problem === null ? null : html `<div class="problem">${problem}</div>`,
1282
1286
  taskComposerHtml({
1283
1287
  project,
1284
- projectRevision,
1288
+ ...(projectRevision === undefined ? {} : { projectRevision }),
1289
+ prefill,
1285
1290
  candidates,
1286
1291
  permissionDefault,
1287
1292
  qualityDefault,
@@ -170,7 +170,7 @@ export function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging
170
170
  : joinHtml([
171
171
  html `<h2>Unattended permissions</h2>`,
172
172
  permissionDefault.canManage && csrf !== ""
173
- ? postForm("/settings/permission-default", html `${permissionModeChoices("permission-mode", permissionDefault.mode)}<button type="submit">Save default</button>`, { attrs: { class: "card permission-policy", "data-autosave": true } })
173
+ ? postForm("/settings/permission-default", html `${permissionModeChoices("permission-mode", permissionDefault.mode)}${permissionDefault.mode === "bypassPermissions" ? "" : reauth(html `<label>Your password <span class="meta">(needed for Full access)</span><input type="password" name="password" autocomplete="current-password"></label>`)}<button type="submit">Save default</button>`, { attrs: { class: "card permission-policy" } })
174
174
  : html `<div class="card"><p><strong>${permissionDefault.mode === "bypassPermissions" ? "Full access" : "Auto"}</strong></p><p class="meta">An approver can change this default</p></div>`,
175
175
  permissionDefault.updatedAt === null
176
176
  ? ""