toolroll 0.9.63 → 0.9.65

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 (292) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -5
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +50 -2
  16. package/dist/agent-fence.js +157 -10
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +36 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
  32. package/dist/browser/terminal.css +37 -0
  33. package/dist/browser/terminal.js +117 -0
  34. package/dist/browser/workspace.css +2 -2
  35. package/dist/browser/workspace.js +42 -38
  36. package/dist/browser-shell.js +6 -3
  37. package/dist/browser-workspace.d.ts +235 -1
  38. package/dist/browser-workspace.js +60 -0
  39. package/dist/builder.d.ts +6 -1
  40. package/dist/builder.js +34 -13
  41. package/dist/chat-actions.d.ts +13 -1
  42. package/dist/chat-actions.js +197 -15
  43. package/dist/chat-ask.d.ts +4 -0
  44. package/dist/chat-ask.js +14 -0
  45. package/dist/chat-channel.js +20 -0
  46. package/dist/chat-continuity.js +5 -4
  47. package/dist/chat-decide.d.ts +0 -14
  48. package/dist/chat-decide.js +4 -1
  49. package/dist/chat-delivery.d.ts +9 -0
  50. package/dist/chat-delivery.js +45 -6
  51. package/dist/chat-messages.d.ts +8 -0
  52. package/dist/chat-messages.js +36 -4
  53. package/dist/check-levels-ui.js +2 -2
  54. package/dist/checkout-proof.d.ts +28 -0
  55. package/dist/checkout-proof.js +62 -0
  56. package/dist/claim.d.ts +12 -0
  57. package/dist/claim.js +19 -0
  58. package/dist/cli-help.d.ts +1 -1
  59. package/dist/cli-help.js +4 -1
  60. package/dist/cli.d.ts +3 -0
  61. package/dist/cli.js +21 -2
  62. package/dist/coding-ui.js +6 -4
  63. package/dist/console-instance.d.ts +124 -0
  64. package/dist/console-instance.js +337 -0
  65. package/dist/contracts/chat-actions.d.ts +16 -0
  66. package/dist/contracts/chat-actions.js +4 -0
  67. package/dist/contracts/chat-content.d.ts +19 -0
  68. package/dist/contracts/chat-content.js +6 -0
  69. package/dist/contracts/console-api.d.ts +12 -4
  70. package/dist/contracts/console-api.js +12 -4
  71. package/dist/contracts/flow-card.d.ts +4 -0
  72. package/dist/contracts/flow-card.js +3 -0
  73. package/dist/contracts/flow.d.ts +46 -0
  74. package/dist/contracts/flow.js +11 -2
  75. package/dist/contracts/lead-tools.d.ts +92 -0
  76. package/dist/contracts/lead-tools.js +13 -0
  77. package/dist/contracts/provider.d.ts +11 -1
  78. package/dist/contracts/provider.js +1 -1
  79. package/dist/contracts/route.d.ts +76 -1
  80. package/dist/contracts/route.js +1 -1
  81. package/dist/contracts/store-json.d.ts +2 -0
  82. package/dist/control-setup.d.ts +1 -1
  83. package/dist/control-ui.js +2 -2
  84. package/dist/converse.d.ts +16 -0
  85. package/dist/converse.js +32 -4
  86. package/dist/demo-chat.js +4 -4
  87. package/dist/demo.js +2 -2
  88. package/dist/desktop-update-gate.js +6 -0
  89. package/dist/desktop-update.d.ts +1 -1
  90. package/dist/discord-chat.js +4 -1
  91. package/dist/doctor.js +1 -1
  92. package/dist/egress-bridge.d.ts +17 -0
  93. package/dist/egress-bridge.js +111 -0
  94. package/dist/egress.d.ts +119 -0
  95. package/dist/egress.js +423 -0
  96. package/dist/exec.d.ts +78 -0
  97. package/dist/exec.js +367 -9
  98. package/dist/export-ui.js +2 -2
  99. package/dist/flow-engine.js +24 -5
  100. package/dist/flow-gallery-ui.js +2 -2
  101. package/dist/flow-gallery.js +27 -8
  102. package/dist/flow-share.js +16 -5
  103. package/dist/flow-triggers.d.ts +77 -8
  104. package/dist/flow-triggers.js +517 -70
  105. package/dist/flows-cli.js +8 -2
  106. package/dist/flows-ui.d.ts +0 -2
  107. package/dist/flows-ui.js +52 -4
  108. package/dist/flows.d.ts +4 -0
  109. package/dist/flows.js +9 -1
  110. package/dist/html.d.ts +9 -0
  111. package/dist/html.js +11 -0
  112. package/dist/invoke.d.ts +17 -0
  113. package/dist/invoke.js +217 -21
  114. package/dist/keys.d.ts +18 -14
  115. package/dist/keys.js +5 -0
  116. package/dist/lead-capabilities.js +3 -2
  117. package/dist/lead-contract.js +2 -0
  118. package/dist/lead-doors.d.ts +2 -0
  119. package/dist/lead-doors.js +7 -0
  120. package/dist/lead-follow.d.ts +2 -0
  121. package/dist/lead-follow.js +58 -2
  122. package/dist/lead-progress.js +1 -0
  123. package/dist/lead-queue-runtime.d.ts +46 -0
  124. package/dist/lead-queue-runtime.js +191 -0
  125. package/dist/lead-queue.d.ts +104 -0
  126. package/dist/lead-queue.js +187 -0
  127. package/dist/lead-sweep.d.ts +67 -0
  128. package/dist/lead-sweep.js +99 -0
  129. package/dist/lead-tools.d.ts +22 -0
  130. package/dist/lead-tools.js +152 -1
  131. package/dist/lead.d.ts +13 -5
  132. package/dist/lead.js +89 -29
  133. package/dist/limits-ui.d.ts +8 -1
  134. package/dist/limits-ui.js +26 -0
  135. package/dist/live-bus.d.ts +8 -0
  136. package/dist/live-bus.js +40 -4
  137. package/dist/live-detail.d.ts +27 -0
  138. package/dist/live-detail.js +57 -0
  139. package/dist/live-notes.d.ts +20 -0
  140. package/dist/live-notes.js +47 -0
  141. package/dist/live-records.d.ts +140 -0
  142. package/dist/live-records.js +935 -0
  143. package/dist/live-terminal.d.ts +258 -0
  144. package/dist/live-terminal.js +649 -0
  145. package/dist/live.d.ts +38 -27
  146. package/dist/live.js +142 -124
  147. package/dist/mcp-oauth.d.ts +6 -3
  148. package/dist/mcp-oauth.js +8 -4
  149. package/dist/memory-pass.js +2 -2
  150. package/dist/mobile-viewport.d.ts +5 -1
  151. package/dist/mobile-viewport.js +18 -2
  152. package/dist/model-catalog.js +4 -0
  153. package/dist/monitoring-ui.js +2 -2
  154. package/dist/needs-you.d.ts +1 -1
  155. package/dist/needs-you.js +11 -4
  156. package/dist/operate.d.ts +2 -0
  157. package/dist/operate.js +275 -17
  158. package/dist/passwordless.d.ts +23 -0
  159. package/dist/passwordless.js +26 -0
  160. package/dist/peek-cli.d.ts +12 -4
  161. package/dist/peek-cli.js +19 -11
  162. package/dist/phase-routing.js +10 -3
  163. package/dist/planner-source.d.ts +3 -0
  164. package/dist/planner-source.js +14 -0
  165. package/dist/planner.d.ts +2 -1
  166. package/dist/planner.js +7 -1
  167. package/dist/policy-ui.js +2 -2
  168. package/dist/policy.d.ts +1 -20
  169. package/dist/policy.js +29 -2
  170. package/dist/project-delete-ui.d.ts +12 -0
  171. package/dist/project-delete-ui.js +41 -9
  172. package/dist/project-delete.js +2 -2
  173. package/dist/project-memory.d.ts +3 -1
  174. package/dist/project-memory.js +57 -25
  175. package/dist/project-tools.d.ts +5 -3
  176. package/dist/project-tools.js +12 -9
  177. package/dist/provider-auth.d.ts +4 -3
  178. package/dist/provider-auth.js +5 -4
  179. package/dist/provider-connection.d.ts +2 -1
  180. package/dist/provider-connection.js +5 -0
  181. package/dist/provider.d.ts +25 -2
  182. package/dist/provider.js +63 -9
  183. package/dist/pull-request-ui.js +2 -2
  184. package/dist/push.js +1 -1
  185. package/dist/quick-start.d.ts +24 -0
  186. package/dist/quick-start.js +127 -0
  187. package/dist/release.d.ts +3 -0
  188. package/dist/release.js +14 -8
  189. package/dist/request-budget-ui.js +2 -2
  190. package/dist/result-review.d.ts +1 -1
  191. package/dist/result-review.js +9 -3
  192. package/dist/retention-ui.js +2 -2
  193. package/dist/retention.js +10 -1
  194. package/dist/run-efficiency.d.ts +73 -0
  195. package/dist/run-efficiency.js +228 -0
  196. package/dist/runner.d.ts +5 -1
  197. package/dist/runner.js +22 -1
  198. package/dist/scope.d.ts +170 -5
  199. package/dist/scope.js +251 -31
  200. package/dist/scout-net.d.ts +4 -1
  201. package/dist/scout-net.js +37 -12
  202. package/dist/scout.d.ts +20 -6
  203. package/dist/scout.js +27 -3
  204. package/dist/serve.js +247 -40
  205. package/dist/server/chat.d.ts +1 -1
  206. package/dist/server/chat.js +122 -31
  207. package/dist/server/chrome.d.ts +6 -1
  208. package/dist/server/chrome.js +18 -7
  209. package/dist/server/console.css +52 -38
  210. package/dist/server/flows.js +38 -8
  211. package/dist/server/guards.d.ts +1 -0
  212. package/dist/server/guards.js +23 -9
  213. package/dist/server/http.d.ts +7 -0
  214. package/dist/server/http.js +3 -2
  215. package/dist/server/live.d.ts +34 -1
  216. package/dist/server/live.js +39 -17
  217. package/dist/server/pages-assets.js +5 -0
  218. package/dist/server/pages.js +90 -13
  219. package/dist/server/people-tokens.d.ts +3 -0
  220. package/dist/server/people-tokens.js +55 -17
  221. package/dist/server/remote-hooks.js +12 -6
  222. package/dist/server/remote.d.ts +2 -1
  223. package/dist/server/remote.js +14 -5
  224. package/dist/server/render-chat.d.ts +12 -3
  225. package/dist/server/render-chat.js +47 -16
  226. package/dist/server/render-pages.d.ts +4 -1
  227. package/dist/server/render-pages.js +5 -4
  228. package/dist/server/render-people.d.ts +3 -2
  229. package/dist/server/render-people.js +3 -2
  230. package/dist/server/render-results.d.ts +18 -2
  231. package/dist/server/render-results.js +37 -28
  232. package/dist/server/render-settings.js +6 -5
  233. package/dist/server/render-tasks.d.ts +35 -10
  234. package/dist/server/render-tasks.js +110 -27
  235. package/dist/server/request-context.d.ts +2 -0
  236. package/dist/server/request-context.js +2 -1
  237. package/dist/server/route-table.d.ts +3 -0
  238. package/dist/server/route-table.js +46 -5
  239. package/dist/server/runtime.d.ts +33 -2
  240. package/dist/server/session.d.ts +25 -1
  241. package/dist/server/session.js +18 -4
  242. package/dist/server/settings.js +121 -10
  243. package/dist/server/sweeps.d.ts +4 -0
  244. package/dist/server/sweeps.js +58 -0
  245. package/dist/server/task-views.d.ts +2 -1
  246. package/dist/server/task-views.js +96 -6
  247. package/dist/server/tasks.js +320 -74
  248. package/dist/server/terminals.d.ts +31 -0
  249. package/dist/server/terminals.js +365 -0
  250. package/dist/setup-guide.js +3 -2
  251. package/dist/slack-chat.js +4 -1
  252. package/dist/spend-ui.js +3 -3
  253. package/dist/spend.js +2 -1
  254. package/dist/steer-capabilities.d.ts +32 -0
  255. package/dist/steer-capabilities.js +60 -0
  256. package/dist/steer-controls.d.ts +14 -0
  257. package/dist/steer-controls.js +36 -0
  258. package/dist/steer-status.js +2 -2
  259. package/dist/storage-ui.js +4 -4
  260. package/dist/store-baseline.d.ts +1 -1
  261. package/dist/store-baseline.js +1 -1
  262. package/dist/store.d.ts +430 -15
  263. package/dist/store.js +1041 -52
  264. package/dist/subagent-desk.js +1 -1
  265. package/dist/summary.js +3 -0
  266. package/dist/task-source.d.ts +58 -0
  267. package/dist/task-source.js +93 -0
  268. package/dist/team-leads.js +1 -1
  269. package/dist/teams-chat.js +4 -1
  270. package/dist/telegram-lead.d.ts +3 -0
  271. package/dist/telegram-lead.js +44 -7
  272. package/dist/telegram.d.ts +11 -2
  273. package/dist/telegram.js +51 -17
  274. package/dist/terminal-catalog.d.ts +11 -0
  275. package/dist/terminal-catalog.js +129 -0
  276. package/dist/text-limits.d.ts +2 -0
  277. package/dist/text-limits.js +2 -0
  278. package/dist/tool-broker.d.ts +2 -0
  279. package/dist/tool-broker.js +1 -0
  280. package/dist/toolroll-update-ui.js +2 -2
  281. package/dist/toolroll-update.js +13 -3
  282. package/dist/tools-ui.js +2 -2
  283. package/dist/webhook-limits.d.ts +23 -0
  284. package/dist/webhook-limits.js +30 -0
  285. package/dist/webhook-readers.d.ts +56 -0
  286. package/dist/webhook-readers.js +138 -0
  287. package/dist/webhook-signing.d.ts +27 -0
  288. package/dist/webhook-signing.js +119 -0
  289. package/dist/work-index.d.ts +29 -0
  290. package/dist/work-index.js +16 -2
  291. package/dist/workspace-revision.js +3 -1
  292. package/package.json +11 -2
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The agent states' words, tones and counts (docs/plans/agents-cockpit.md §1), with nothing the browser can't load:
3
+ * agent-state.ts derives the states; the console's pages and the sidebar say them with these.
4
+ */
5
+ /** The display states, strongest first: a roll-up takes the first one any member has. */
6
+ export const DISPLAY_STATES = ["needs-you", "failed", "stuck", "paused", "working", "waiting", "done", "idle"];
7
+ export const STATE_WORDS = {
8
+ "needs-you": "Needs you", failed: "Failed", stuck: "Stuck", paused: "Paused", working: "Working", waiting: "Waiting", done: "Done", idle: "Idle",
9
+ };
10
+ /** For someone who may not act on it: the obligation is still there, and still counts. */
11
+ export const NEEDS_SOMEONE = "Needs someone";
12
+ /** Each state's tone, for the console's one palette (attention, problem, live, ready, done, muted). */
13
+ export const STATE_TONE = {
14
+ "needs-you": "attention", failed: "problem", stuck: "problem", paused: "muted", working: "live", waiting: "muted", done: "ready", idle: "muted",
15
+ };
16
+ export const NO_COUNTS = Object.freeze({ "needs-you": 0, failed: 0, stuck: 0, paused: 0, working: 0, waiting: 0, done: 0, idle: 0 });
17
+ /** The strongest state among counts, and the counts themselves. Nothing counted is idle. */
18
+ export function rollUp(counts) {
19
+ return { state: DISPLAY_STATES.find(state => state !== "idle" && counts[state] > 0) ?? "idle", counts };
20
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * One set of agent states (docs/plans/agents-cockpit.md §1): what a person sees at a glance for an attempt, a task, a
3
+ * flow or a project. A display state is derived from four separate facts — what the agent is doing, how it ended, what
4
+ * someone must do, and whether this person has seen it — and never stored as one. Looking clears only `done`: a failure,
5
+ * an approval or a result to accept stays until it is dealt with. The words and colours of each state live here only.
6
+ */
7
+ import { type AgentFacts } from "./agents-board.js";
8
+ import type { Store } from "./store.js";
9
+ import type { WorkSummaryAccess } from "./work-summary.js";
10
+ import { type DisplayState, type StateCounts } from "./agent-state-words.js";
11
+ export { DISPLAY_STATES, NEEDS_SOMEONE, NO_COUNTS, rollUp, STATE_TONE, STATE_WORDS, type DisplayState, type StateCounts } from "./agent-state-words.js";
12
+ /** What an attempt is doing. */
13
+ export type Activity = "queued" | "waiting" | "running" | "stuck" | "pausing" | "paused" | "stopping" | "stopped" | "finished";
14
+ /** How it ended (checks-failed: built, but the project's checks failed on its result). */
15
+ export type Outcome = "none" | "built" | "no-change" | "failed" | "checks-failed";
16
+ export type StateFacts = {
17
+ activity: Activity;
18
+ outcome: Outcome;
19
+ /** Someone must act: a question, a plan or scope to approve, a decision, a result to accept, a sign-in. */
20
+ obligation: boolean;
21
+ /** This person hasn't seen its finished version. */
22
+ unread: boolean;
23
+ /** A newer attempt of the same task is running: an older failure no longer leads. */
24
+ newerRunning?: boolean;
25
+ };
26
+ /** The one glance a set of facts gives. */
27
+ export declare function displayStateOf(facts: StateFacts): DisplayState;
28
+ /** The four facts of one build attempt, from what the Agents board reads (agents-board.ts): its own record only — a
29
+ * stopped or cancelled attempt is not a failure, a parked one is a decision only while it is unanswered and its task is open, and one being
30
+ * repaired is at work, not stuck. */
31
+ export declare function factsOfAttempt(facts: AgentFacts, activityAt: string | null, paused: boolean, now: Date, decisionOpen?: boolean): StateFacts;
32
+ /** One task family's strongest state in a project, and its one line when an agent is at work on it. */
33
+ export type FamilyState = {
34
+ rootId: string;
35
+ state: DisplayState;
36
+ line: string | null;
37
+ /** What it needs a person for, as an identity (task, question, hold, result…), and the agent it is about. */
38
+ ask?: {
39
+ version: string;
40
+ run: number | null;
41
+ at: string;
42
+ };
43
+ };
44
+ export type ProjectState = {
45
+ repo: string | null;
46
+ state: DisplayState;
47
+ counts: StateCounts;
48
+ /** Each family that counted, strongest first (flow cards count without one). */
49
+ families: FamilyState[];
50
+ /** Flow cards waiting at a person's decision, by identity (newest first, at most FLOW_ASKS per flow). */
51
+ flowAsks: {
52
+ card: number;
53
+ stage: string;
54
+ entry: number;
55
+ at: string;
56
+ }[];
57
+ };
58
+ /**
59
+ * A project's states for one person: complete counts over every task family they may see, with the Tasks list's own
60
+ * admission (never a page or the board's rows). Each family counts once for what it needs or how it ended; each live
61
+ * attempt counts once for what it is doing (working, stuck, paused); an attempt that itself needs a person (crashed,
62
+ * asking) counts there unless its family already does. Flow cards at a person's decision count too. Every visible
63
+ * project is present, idle when nothing counts. Its cost is the Tasks list's own read, plus open runs.
64
+ */
65
+ export declare function projectStates(store: Store, access: WorkSummaryAccess, account: string, now: Date, root?: string, flowRepos?: readonly string[]): ProjectState[];
@@ -0,0 +1,229 @@
1
+ /**
2
+ * One set of agent states (docs/plans/agents-cockpit.md §1): what a person sees at a glance for an attempt, a task, a
3
+ * flow or a project. A display state is derived from four separate facts — what the agent is doing, how it ended, what
4
+ * someone must do, and whether this person has seen it — and never stored as one. Looking clears only `done`: a failure,
5
+ * an approval or a result to accept stays until it is dealt with. The words and colours of each state live here only.
6
+ */
7
+ import { agentFactsFor, agentGroupOf } from "./agents-board.js";
8
+ import { stuckSince } from "./agent-board.js";
9
+ import { flowDefinitionOf } from "./flow-engine.js";
10
+ import { runActivityOf } from "./task-activity.js";
11
+ import { workStateRows } from "./work-index.js";
12
+ import { assignmentChecksForRun } from "./assignment.js";
13
+ import { DISPLAY_STATES, NO_COUNTS, rollUp } from "./agent-state-words.js";
14
+ export { DISPLAY_STATES, NEEDS_SOMEONE, NO_COUNTS, rollUp, STATE_TONE, STATE_WORDS } from "./agent-state-words.js";
15
+ /** The one glance a set of facts gives. */
16
+ export function displayStateOf(facts) {
17
+ if (facts.obligation)
18
+ return "needs-you";
19
+ // A failure leads until it is dealt with (a retry, a revision, a stop, a cancel): looking never clears it.
20
+ if ((facts.outcome === "failed" || facts.outcome === "checks-failed") && facts.activity === "finished" && facts.newerRunning !== true)
21
+ return "failed";
22
+ if (facts.activity === "stuck")
23
+ return "stuck";
24
+ if (facts.activity === "paused")
25
+ return "paused";
26
+ if (facts.activity === "running" || facts.activity === "pausing" || facts.activity === "stopping")
27
+ return "working";
28
+ if (facts.activity === "queued" || facts.activity === "waiting")
29
+ return "waiting";
30
+ // Only a good finish is news to be read; looking clears it, and nothing else does.
31
+ if (facts.activity === "finished" && (facts.outcome === "built" || facts.outcome === "no-change") && facts.unread)
32
+ return "done";
33
+ return "idle";
34
+ }
35
+ /** The four facts of one build attempt, from what the Agents board reads (agents-board.ts): its own record only — a
36
+ * stopped or cancelled attempt is not a failure, a parked one is a decision only while it is unanswered and its task is open, and one being
37
+ * repaired is at work, not stuck. */
38
+ export function factsOfAttempt(facts, activityAt, paused, now, decisionOpen) {
39
+ const settled = facts.taskState === "done" || facts.taskState === "cancelled";
40
+ if (facts.outcome === null) {
41
+ const { group } = agentGroupOf(facts, activityAt, now);
42
+ const stuck = !facts.repairing && facts.turn === "busy"
43
+ && stuckSince({ state: "busy", stateAt: facts.stateAt ?? facts.startedAt, transcriptAt: facts.transcriptAt ?? facts.startedAt, toolAt: facts.toolAt }, activityAt, now) !== null;
44
+ // An open run asking, crashed or blocked is an obligation (the board's "needs you"), whatever else it is doing.
45
+ return { activity: paused ? "paused" : stuck ? "stuck" : "running", outcome: "none", obligation: group === "needs-you", unread: false };
46
+ }
47
+ const unread = !(facts.seen || facts.legacy);
48
+ if (facts.outcome === "built" || facts.outcome === "no-change")
49
+ return { activity: "finished", outcome: facts.outcome, obligation: false, unread };
50
+ // Stopped by a person or a restart, in either spelling (a legacy outcome, or a failure whose reason is the interruption).
51
+ if (facts.outcome === "interrupted" || facts.outcome === "refused" || (facts.outcome === "failed" && facts.reason === "interrupted"))
52
+ return { activity: "stopped", outcome: "none", obligation: false, unread };
53
+ // A parked attempt asks only while its decision is unanswered (when known) and its task is open.
54
+ if (facts.outcome === "parked")
55
+ return { activity: "finished", outcome: "none", obligation: !settled && decisionOpen !== false, unread };
56
+ // A failure on a task since settled (done another way, or cancelled) no longer leads.
57
+ return { activity: "finished", outcome: settled ? "none" : "failed", obligation: false, unread };
58
+ }
59
+ /** A family's display state from the Tasks list's own classification (work-index.ts): failures that lead, what waits
60
+ * on a person (a result to accept included), work waiting its turn; a complete result is news only until this person
61
+ * has seen it. Running work is counted by its attempts instead. The Tasks list's rules are the rules here — where it
62
+ * says a task waits on a person, so does this — so the two never disagree. */
63
+ const WAITING_CODES = new Set(["queued", "planning-ready", "scouting-ready", "updating", "retry-scheduled", "waiting-dependency", "worker-at-capacity"]);
64
+ /** Codes whose family is at work: its attempts say how. */
65
+ const AT_WORK = new Set(["running", "stopping"]);
66
+ /** Flow cards named per flow in a project's asks; the count stays complete beyond them. */
67
+ const FLOW_ASKS = 50;
68
+ /**
69
+ * A project's states for one person: complete counts over every task family they may see, with the Tasks list's own
70
+ * admission (never a page or the board's rows). Each family counts once for what it needs or how it ended; each live
71
+ * attempt counts once for what it is doing (working, stuck, paused); an attempt that itself needs a person (crashed,
72
+ * asking) counts there unless its family already does. Flow cards at a person's decision count too. Every visible
73
+ * project is present, idle when nothing counts. Its cost is the Tasks list's own read, plus open runs.
74
+ */
75
+ export function projectStates(store, access, account, now, root, flowRepos) {
76
+ const counts = new Map();
77
+ const strongest = new Map();
78
+ const asked = new Map();
79
+ const flowAsks = new Map();
80
+ const bump = (repo, state, rootId, line = null) => {
81
+ const current = counts.get(repo) ?? { ...NO_COUNTS };
82
+ current[state] += 1;
83
+ counts.set(repo, current);
84
+ if (rootId === undefined || state === "waiting" || state === "idle")
85
+ return;
86
+ const before = strongest.get(rootId);
87
+ if (before === undefined || DISPLAY_STATES.indexOf(state) < DISPLAY_STATES.indexOf(before.state))
88
+ strongest.set(rootId, { repo, rootId, state, line: line ?? before?.line ?? null });
89
+ else if (before.line === null && line !== null)
90
+ before.line = line;
91
+ };
92
+ for (const repo of access.repos ?? store.listProjects().map(one => one.path))
93
+ counts.set(repo, { ...NO_COUNTS });
94
+ const families = workStateRows(store, now, access);
95
+ const refs = families.flatMap(row => row.members);
96
+ // Every live attempt of these families, every role, in one read.
97
+ const open = refs.length === 0 ? [] : store.handle.prepare(`SELECT r.id, r.task_ref, r.role, r.runner, r.started_at FROM run r
98
+ WHERE r.outcome IS NULL AND r.role IN ('planner','builder','scout','reviewer','repair') AND r.task_ref IN (SELECT value FROM json_each(?))`).all(JSON.stringify(refs));
99
+ const paused = new Set(open.length === 0 ? [] : store.handle.prepare(`SELECT run FROM run_pause WHERE run IN (SELECT value FROM json_each(?))
100
+ AND paused_at IS NOT NULL AND resumed_at IS NULL AND ended_at IS NULL`).all(JSON.stringify(open.map(row => Number(row["id"])))).map(row => Number(row["run"])));
101
+ // Complete results that are news to this person: recorded since seeing was (a keyed attempt), and not yet seen.
102
+ const completed = JSON.stringify(families.flatMap(row => row.code === "complete" && row.resultRun !== null ? [row.resultRun] : []));
103
+ const unseen = new Set(store.handle.prepare(`SELECT r.id FROM run r WHERE r.id IN (SELECT value FROM json_each(?)) AND r.attempt_key IS NOT NULL
104
+ AND NOT EXISTS (SELECT 1 FROM agent_seen s WHERE s.account = ? AND s.run = r.id)`).all(completed, account).map(row => Number(row["id"])));
105
+ // A result's outcome and its recorded check, for the families whose result is waiting on a person.
106
+ const resultFacts = new Map(store.handle.prepare(`SELECT r.id, r.outcome, (SELECT c.status FROM run_check c WHERE c.run = r.id) AS recorded FROM run r
107
+ WHERE r.id IN (SELECT value FROM json_each(?))`).all(JSON.stringify(families.flatMap(row => row.resultRun ?? []))).map(row => [Number(row["id"]), row]));
108
+ // The result's checks as the result reads them (follow-up checks included); the recorded check only when nothing
109
+ // else was read, so a passing follow-up clears an earlier failure.
110
+ const checksFailed = (run) => {
111
+ const status = assignmentChecksForRun(store, root, run, now).status;
112
+ if (status === "failed" || status === "passed")
113
+ return status === "failed";
114
+ return resultFacts.get(run)?.["recorded"] === "failed";
115
+ };
116
+ const familyOf = new Map();
117
+ for (const row of families)
118
+ for (const ref of row.members)
119
+ familyOf.set(ref, row);
120
+ const attempting = new Set(open.map(run => familyOf.get(Number(run["task_ref"]))?.rootId));
121
+ const needing = new Set();
122
+ for (const row of families) {
123
+ // At work: its attempts are counted below; a stop whose run has ended but whose process is not yet confirmed gone
124
+ // is still at work.
125
+ if (AT_WORK.has(row.code)) {
126
+ if (!attempting.has(row.rootId))
127
+ bump(row.repo, "working", row.rootId);
128
+ continue;
129
+ }
130
+ const result = row.resultRun === null ? null : resultFacts.get(row.resultRun);
131
+ if (row.code === "failed" || row.code === "vanished-run") {
132
+ bump(row.repo, "failed", row.rootId);
133
+ continue;
134
+ }
135
+ // Waiting its turn, as the Tasks list ranks it: confirmations another computer owes included.
136
+ if (row.waits) {
137
+ bump(row.repo, "waiting", row.rootId);
138
+ continue;
139
+ }
140
+ // A result waiting on a person is a failure when it failed or its checks failed (as its headline reads).
141
+ const asks = row.code === "ready-to-check" || row.code === "result-needs-attention";
142
+ if (asks && (result?.["outcome"] === "failed" || (row.resultRun !== null && checksFailed(row.resultRun)))) {
143
+ bump(row.repo, "failed", row.rootId);
144
+ continue;
145
+ }
146
+ // A family the person's lead has taken on waits on nobody (the Tasks list's rule); a failure still leads, above.
147
+ if (row.leadClaim) {
148
+ bump(row.repo, "waiting", row.rootId);
149
+ continue;
150
+ }
151
+ if (asks || row.personNeed) {
152
+ bump(row.repo, "needs-you", row.rootId);
153
+ needing.add(row.rootId);
154
+ asked.set(row.rootId, { version: row.version, run: row.askRun, at: row.at });
155
+ continue;
156
+ }
157
+ if (WAITING_CODES.has(row.code)) {
158
+ bump(row.repo, "waiting", row.rootId);
159
+ continue;
160
+ }
161
+ if (row.code === "complete" && row.resultRun !== null && unseen.has(row.resultRun))
162
+ bump(row.repo, "done", row.rootId);
163
+ }
164
+ // Each live attempt: paused, stuck or at work; one that needs a person counts there, unless its family already does.
165
+ for (const run of open) {
166
+ const family = familyOf.get(Number(run["task_ref"]));
167
+ if (family === undefined)
168
+ continue;
169
+ const id = Number(run["id"]);
170
+ // What the agent did last, in the task page's own words: the rail's line for this family.
171
+ const activity = runActivityOf(store, { id, runner: String(run["runner"]), startedAt: String(run["started_at"]) }, now);
172
+ const line = activity.what === "" ? null : activity.what;
173
+ if (paused.has(id)) {
174
+ bump(family.repo, "paused", family.rootId, line);
175
+ continue;
176
+ }
177
+ if (run["role"] !== "builder") {
178
+ bump(family.repo, "working", family.rootId, line);
179
+ continue;
180
+ } // only builds are watched for quiet
181
+ const facts = agentFactsFor(store, id, { repos: null }, account);
182
+ if (facts === null || facts.outcome !== null) {
183
+ bump(family.repo, "working", family.rootId, line);
184
+ continue;
185
+ }
186
+ const at = activity.at;
187
+ const state = displayStateOf(factsOfAttempt(facts, at, false, now));
188
+ if (state === "needs-you") {
189
+ if (!needing.has(family.rootId)) {
190
+ bump(family.repo, "needs-you", family.rootId, line);
191
+ needing.add(family.rootId);
192
+ asked.set(family.rootId, { version: `${family.version}|agent:${id}:${facts.question?.id ?? facts.turn ?? ""}`, run: id, at: family.at });
193
+ }
194
+ else
195
+ bump(family.repo, "working", family.rootId, line);
196
+ continue;
197
+ }
198
+ bump(family.repo, state === "stuck" ? "stuck" : "working", family.rootId, line);
199
+ }
200
+ // Flow cards waiting at a person's decision (an approval or a choice), in the flows this person may see: one uncapped
201
+ // count per flow over its deciding zones.
202
+ const repos = flowRepos ?? (access.repos ?? store.listProjects().map(one => one.path));
203
+ const deciding = store.handle.prepare("SELECT COUNT(*) AS n FROM flow_card WHERE flow = ? AND state = 'active' AND stage IN (SELECT value FROM json_each(?))");
204
+ // A card's visit began when it entered its stage (its newest arrival there): fixed for the visit, unlike updated_at.
205
+ const namedCards = store.handle.prepare(`SELECT c.id, c.stage, c.entry, COALESCE((SELECT MAX(e.at) FROM flow_event e WHERE e.card = c.id AND e.to_stage = c.stage), c.created_at) AS entered_at
206
+ FROM flow_card c WHERE c.flow = ? AND c.state = 'active' AND c.stage IN (SELECT value FROM json_each(?)) ORDER BY entered_at DESC, c.id DESC LIMIT ?`);
207
+ for (const flow of store.listFlows(repos)) {
208
+ const definition = flowDefinitionOf(flow);
209
+ if (definition === null)
210
+ continue;
211
+ const zones = definition.stages.filter(one => one.kind === "approval" || one.kind === "choose").map(one => one.id);
212
+ if (zones.length === 0)
213
+ continue;
214
+ const n = Number(deciding.get(flow.id, JSON.stringify(zones))?.["n"] ?? 0);
215
+ for (let i = 0; i < n; i++)
216
+ bump(flow.repo, "needs-you");
217
+ if (n > 0)
218
+ flowAsks.set(flow.repo, [...(flowAsks.get(flow.repo) ?? []), ...namedCards.all(flow.id, JSON.stringify(zones), FLOW_ASKS)
219
+ .map(row => ({ card: Number(row["id"]), stage: String(row["stage"]), entry: Number(row["entry"]), at: String(row["entered_at"]) }))]);
220
+ }
221
+ const rank = (one) => DISPLAY_STATES.indexOf(one.state);
222
+ return [...counts.entries()].map(([repo, value]) => ({ repo, ...rollUp(value),
223
+ families: [...strongest.values()].filter(one => one.repo === repo).map(({ rootId, state, line }) => {
224
+ const ask = state === "needs-you" ? asked.get(rootId) : undefined;
225
+ return { rootId, state, line, ...(ask === undefined ? {} : { ask }) };
226
+ }).sort((a, b) => rank(a) - rank(b)),
227
+ flowAsks: flowAsks.get(repo) ?? [] }))
228
+ .sort((a, b) => (a.repo ?? "").localeCompare(b.repo ?? ""));
229
+ }
@@ -19,8 +19,10 @@
19
19
  * The resolved pair is validated before anything is claimed or spent, and
20
20
  * the caller snapshots it into the run row.
21
21
  */
22
+ import { PROJECT_NETWORK_DEFAULT, repositoryEcosystems, resolveNetwork } from "./egress.js";
22
23
  import { isProviderId, validateSpec } from "./provider.js";
23
- import { CLAUDE_LIMITS, CODEX_SHAPED_LIMITS, GEMINI_LIMITS } from "./scope.js";
24
+ import { ACP_LIMITS, CLAUDE_LIMITS, CODEX_SHAPED_LIMITS, GEMINI_LIMITS } from "./scope.js";
25
+ import { ACP_AGENTS, isAcpProvider } from "./acp-agents.js";
24
26
  import { exactModelId, legOf, PHASES, recommendRoute, routeFromJson, routeProblems, sameSpec } from "./phase-routing.js";
25
27
  export const INSTALLATION_SCOPE = "installation";
26
28
  /**
@@ -32,6 +34,10 @@ export const INSTALLATION_SCOPE = "installation";
32
34
  * resolved: pinned, flagged, or configured.
33
35
  */
34
36
  function reviewEligible(phase, spec) {
37
+ // An ACP agent works with full access inside the fence: it builds and repairs, and never reviews or plans.
38
+ if ((phase === "review" || phase === "plan") && isAcpProvider(spec.provider)) {
39
+ return `${ACP_AGENTS[spec.provider].name} builds and repairs only — pin ${phase === "review" ? "review" : "planning"} to claude or codex`;
40
+ }
35
41
  return phase === "review" && spec.provider === "gemini"
36
42
  ? "gemini has no isolation posture for the review phase yet (its own config can load hooks and MCP servers) — pin review to claude or codex"
37
43
  : null;
@@ -162,18 +168,35 @@ export function resolveScopeProfile(store, repo, ref, flags) {
162
168
  repairTimeoutSeconds: GEMINI_LIMITS.repairTimeoutSeconds,
163
169
  repairModel,
164
170
  }
165
- : {
166
- provider,
167
- model,
168
- sandboxMode: permissionMode === "bypassPermissions" ? "danger-full-access" : "workspace-write",
169
- maxTurns: "unsupported",
170
- repairMaxTurns: "unsupported",
171
- timeoutSeconds: CODEX_SHAPED_LIMITS.timeoutSeconds,
172
- timeoutKind: "idle",
173
- repairTimeoutSeconds: CODEX_SHAPED_LIMITS.repairTimeoutSeconds,
174
- repairModel,
175
- };
176
- return { ok: true, profile, provenance: { resolvedFrom: build.source, repairFrom } };
171
+ : isAcpProvider(provider)
172
+ ? {
173
+ provider,
174
+ model,
175
+ // Full access inside the OS fence is the only posture an ACP agent has (scope.ts AcpProfile).
176
+ permission: "full",
177
+ maxTurns: "unsupported",
178
+ repairMaxTurns: "unsupported",
179
+ timeoutSeconds: ACP_LIMITS.timeoutSeconds,
180
+ idleTimeoutSeconds: ACP_LIMITS.idleTimeoutSeconds,
181
+ repairTimeoutSeconds: ACP_LIMITS.repairTimeoutSeconds,
182
+ repairIdleTimeoutSeconds: ACP_LIMITS.repairIdleTimeoutSeconds,
183
+ repairModel,
184
+ }
185
+ : {
186
+ provider,
187
+ model,
188
+ sandboxMode: permissionMode === "bypassPermissions" ? "danger-full-access" : "workspace-write",
189
+ maxTurns: "unsupported",
190
+ repairMaxTurns: "unsupported",
191
+ timeoutSeconds: CODEX_SHAPED_LIMITS.timeoutSeconds,
192
+ timeoutKind: "idle",
193
+ repairTimeoutSeconds: CODEX_SHAPED_LIMITS.repairTimeoutSeconds,
194
+ repairModel,
195
+ };
196
+ // What the build may reach (egress.ts): resolved now, from the project's setting, the provider's own hosts and the
197
+ // registries the repository's own HEAD names, and sealed with the rest of the profile.
198
+ const network = resolveNetwork(provider, repo === null ? PROJECT_NETWORK_DEFAULT : store.projectNetwork(repo), repo === null ? [] : repositoryEcosystems(repo));
199
+ return { ok: true, profile: { ...profile, network }, provenance: { resolvedFrom: build.source, repairFrom } };
177
200
  }
178
201
  const PHASE_NOUN = { plan: "planner", build: "builder", repair: "repair", review: "reviewer" };
179
202
  export function resolveRouteCandidates(store, repo, pins = {}) {
@@ -8,7 +8,7 @@
8
8
  * stats); nothing here verifies evidence or grants an action.
9
9
  */
10
10
  import { type AgentTurn, type ToolKind } from "./agent-board.js";
11
- import type { AgentGroupKey, BrowserAgentRow } from "./browser-workspace.js";
11
+ import type { AgentGroupKey, AgentLaneWindow, BrowserAgentLanes, BrowserAgentRow } from "./browser-workspace.js";
12
12
  import type { Store } from "./store.js";
13
13
  export declare const AGENT_GROUPS: readonly {
14
14
  key: AgentGroupKey;
@@ -62,7 +62,6 @@ export declare function agentFacts(store: Store, now: Date, access: {
62
62
  }, account: string): AgentFacts[];
63
63
  /** The board's query, exported for its query-plan test. */
64
64
  export declare function agentFactsSql(): string;
65
- /** One exact build attempt, whatever its age (the opened row), in the admitted projects: a repair opens as its parent. */
66
65
  export declare function agentFactsFor(store: Store, run: number, access: {
67
66
  repos: readonly string[] | null;
68
67
  }, account: string): AgentFacts | null;
@@ -98,4 +97,37 @@ export declare function agentsBoardOf(store: Store, now: Date, access: {
98
97
  }[];
99
98
  /** A board fingerprint for one exact build: what its open view shows moved, its repairs included. */
100
99
  export declare function agentRunFingerprint(store: Store, run: number): string | null;
100
+ /** The timeline's windows, all rolling (so no server time zone decides what "today" means); two hours by default. */
101
+ export declare const LANE_WINDOWS: readonly {
102
+ key: AgentLaneWindow;
103
+ label: string;
104
+ }[];
105
+ /** Rows the timeline shows: twenty running tasks read as twelve rows and a count, not a wall. */
106
+ export declare const LANE_ROWS = 12;
107
+ export declare function laneWindowOf(value: string | null): AgentLaneWindow;
108
+ export declare function laneSinceOf(window: AgentLaneWindow, now: Date): Date;
109
+ /** The window's tasks, one row each with whether a run is live and when it was last active, newest activity first,
110
+ * and the total and running counts over all of them. The candidates come from bounded index ranges (run_started;
111
+ * work_unfinished and agent_board_recent, named so SQLite never walks every run) and drive the join (CROSS JOIN), so no
112
+ * task's history is walked; the admitted projects only. */
113
+ export declare function agentLanesSql(): string;
114
+ /** Segments one row keeps: its newest runs, so a task retried all day stays one readable row. */
115
+ export declare const LANE_SEGMENTS = 40;
116
+ /** One chosen task's live runs: all of them, through work_unfinished, so no cap can hide one. */
117
+ export declare function laneLiveRunsSql(): string;
118
+ /** Rows one task's lookups read at most, newest first: a task with thousands of runs costs the same as one with a few. */
119
+ export declare const LANE_TASK_READ = 2000;
120
+ /** One chosen task's runs that finished in the window: a seek on its own runs from its first finished run in the
121
+ * window (lead_status_task_run), reading at most LANE_TASK_READ of its newest runs; the newest LANE_SEGMENTS. */
122
+ export declare function laneRunsSql(): string;
123
+ /** A task's newest build, whenever it ran, among its newest 500 runs: the row's state comes from it even when it
124
+ * finished before the window. A task with more runs than that since its last build reads as planning or idle. */
125
+ export declare function laneNewestBuildSql(): string;
126
+ /** The timeline: one row per task that ran in the window, running ones first, then the most recently active, the
127
+ * first LANE_ROWS of them; the counts cover every task in the window. A row's state is worked out from its own
128
+ * newest build as the board would (planning while only a planner runs, otherwise idle). With nothing in the window but something in the last day,
129
+ * the timeline stays (empty) so its window choice does too; with nothing in a day it is null. */
130
+ export declare function agentLanesOf(store: Store, now: Date, access: {
131
+ repos: readonly string[] | null;
132
+ }, account: string, window: AgentLaneWindow, path?: string): BrowserAgentLanes | null;
101
133
  export {};
@@ -83,8 +83,11 @@ export function agentFactsSql() {
83
83
  ORDER BY r.id DESC LIMIT ${BOARD_LIMIT}`;
84
84
  }
85
85
  /** One exact build attempt, whatever its age (the opened row), in the admitted projects: a repair opens as its parent. */
86
+ /** One attempt opened by its run: a build (a protocol repair opens as its build), or a planner, scout or reviewer —
87
+ * the board lists builds, but any running agent can be opened to see it and what it takes (cockpit §4). */
88
+ const OPENED_FACTS = FACTS.replace("WHERE r.role = 'builder'", "WHERE r.role IN ('builder', 'planner', 'scout', 'reviewer')");
86
89
  export function agentFactsFor(store, run, access, account) {
87
- const row = store.handle.prepare(`${FACTS} AND r.id = (SELECT CASE WHEN role = 'repair' AND parent_run IS NOT NULL THEN parent_run ELSE id END FROM run WHERE id = $run) AND ($all = 1 OR tr.repo IN (SELECT value FROM json_each($repos)))`)
90
+ const row = store.handle.prepare(`${OPENED_FACTS} AND r.id = (SELECT CASE WHEN role = 'repair' AND parent_run IS NOT NULL THEN parent_run ELSE id END FROM run WHERE id = $run) AND ($all = 1 OR tr.repo IN (SELECT value FROM json_each($repos)))`)
88
91
  .get({ $account: account, $run: run, $all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []) });
89
92
  return row === undefined ? null : readFacts(row);
90
93
  }
@@ -223,3 +226,109 @@ export function agentRunFingerprint(store, run) {
223
226
  FROM run r LEFT JOIN agent_now n ON n.run = r.id AND n.attempt_key = r.attempt_key WHERE r.id = ?`).get(run);
224
227
  return row === undefined ? null : Object.values(row).map(value => String(value)).join("|");
225
228
  }
229
+ /** The timeline's windows, all rolling (so no server time zone decides what "today" means); two hours by default. */
230
+ export const LANE_WINDOWS = [{ key: "30m", label: "30m" }, { key: "2h", label: "2h" }, { key: "24h", label: "24h" }];
231
+ /** Rows the timeline shows: twenty running tasks read as twelve rows and a count, not a wall. */
232
+ export const LANE_ROWS = 12;
233
+ /** Runs are found by their start back this far; a builder that finished in the window is also found by its finish
234
+ * (agent_board_recent), however long it ran. A planner, review, scout or repair run that lasted over a day and ended
235
+ * in the window is the one case left out: finding it would need a finish-time index over every role. */
236
+ const LANE_LOOKBACK_MS = 24 * 60 * 60_000;
237
+ const WINDOW_MS = { "30m": 30 * 60_000, "2h": 2 * 60 * 60_000, "24h": 24 * 60 * 60_000 };
238
+ const LANE_ROLES = "('planner', 'builder', 'repair', 'reviewer', 'scout')";
239
+ export function laneWindowOf(value) {
240
+ return value === "30m" || value === "24h" ? value : "2h";
241
+ }
242
+ export function laneSinceOf(window, now) {
243
+ return new Date(now.getTime() - WINDOW_MS[window]);
244
+ }
245
+ /** The window's tasks, one row each with whether a run is live and when it was last active, newest activity first,
246
+ * and the total and running counts over all of them. The candidates come from bounded index ranges (run_started;
247
+ * work_unfinished and agent_board_recent, named so SQLite never walks every run) and drive the join (CROSS JOIN), so no
248
+ * task's history is walked; the admitted projects only. */
249
+ export function agentLanesSql() {
250
+ return `WITH candidate AS (
251
+ SELECT DISTINCT id FROM (
252
+ SELECT id FROM run WHERE started_at >= $lookback
253
+ UNION ALL SELECT id FROM run INDEXED BY work_unfinished WHERE outcome IS NULL
254
+ UNION ALL SELECT id FROM run INDEXED BY agent_board_recent WHERE role = 'builder' AND outcome IS NOT NULL AND finished_at >= $since
255
+ )
256
+ ), active AS (
257
+ SELECT r.task_ref, tr.external_id AS task_id, tr.repo, t.title,
258
+ MAX(r.outcome IS NULL) AS live, MAX(coalesce(r.finished_at, $now)) AS last_at, MAX(r.id) AS last_run, MIN(CASE WHEN r.outcome IS NOT NULL THEN r.id END) AS first_done
259
+ FROM candidate c CROSS JOIN run r ON r.id = c.id
260
+ CROSS JOIN task_ref tr ON tr.id = r.task_ref AND tr.backend = 'built-in' AND tr.repo IS NOT NULL
261
+ CROSS JOIN task t ON t.id = tr.external_id
262
+ WHERE r.role IN ${LANE_ROLES} AND (r.outcome IS NULL OR r.finished_at >= $since)
263
+ AND ($all = 1 OR tr.repo IN (SELECT value FROM json_each($repos)))
264
+ GROUP BY r.task_ref
265
+ )
266
+ SELECT a.*, (SELECT count(*) FROM active) AS total, (SELECT coalesce(sum(live), 0) FROM active) AS running
267
+ FROM active a ORDER BY a.live DESC, a.last_at DESC, a.last_run DESC LIMIT $rows`;
268
+ }
269
+ /** Segments one row keeps: its newest runs, so a task retried all day stays one readable row. */
270
+ export const LANE_SEGMENTS = 40;
271
+ /** One chosen task's live runs: all of them, through work_unfinished, so no cap can hide one. */
272
+ export function laneLiveRunsSql() {
273
+ return `SELECT id, role, provider, outcome, started_at, finished_at FROM run INDEXED BY work_unfinished
274
+ WHERE task_ref = $ref AND outcome IS NULL AND role IN ${LANE_ROLES}`;
275
+ }
276
+ /** Rows one task's lookups read at most, newest first: a task with thousands of runs costs the same as one with a few. */
277
+ export const LANE_TASK_READ = 2000;
278
+ /** One chosen task's runs that finished in the window: a seek on its own runs from its first finished run in the
279
+ * window (lead_status_task_run), reading at most LANE_TASK_READ of its newest runs; the newest LANE_SEGMENTS. */
280
+ export function laneRunsSql() {
281
+ return `SELECT id, role, provider, outcome, started_at, finished_at FROM (
282
+ SELECT id, role, provider, outcome, started_at, finished_at FROM run WHERE task_ref = $ref AND id >= $from ORDER BY id DESC LIMIT ${LANE_TASK_READ}
283
+ ) WHERE outcome IS NOT NULL AND finished_at >= $since AND role IN ${LANE_ROLES}
284
+ ORDER BY id DESC LIMIT ${LANE_SEGMENTS}`;
285
+ }
286
+ /** A task's newest build, whenever it ran, among its newest 500 runs: the row's state comes from it even when it
287
+ * finished before the window. A task with more runs than that since its last build reads as planning or idle. */
288
+ export function laneNewestBuildSql() {
289
+ return `SELECT id FROM (SELECT id, role FROM run WHERE task_ref = $ref ORDER BY id DESC LIMIT 500) WHERE role = 'builder' LIMIT 1`;
290
+ }
291
+ /** The timeline: one row per task that ran in the window, running ones first, then the most recently active, the
292
+ * first LANE_ROWS of them; the counts cover every task in the window. A row's state is worked out from its own
293
+ * newest build as the board would (planning while only a planner runs, otherwise idle). With nothing in the window but something in the last day,
294
+ * the timeline stays (empty) so its window choice does too; with nothing in a day it is null. */
295
+ export function agentLanesOf(store, now, access, account, window, path = "/agents") {
296
+ const read = (since, rows) => store.handle.prepare(agentLanesSql()).all({
297
+ $lookback: new Date(since.getTime() - LANE_LOOKBACK_MS).toISOString(), $since: since.toISOString(), $now: now.toISOString(),
298
+ $all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []), $rows: rows,
299
+ });
300
+ const since = laneSinceOf(window, now);
301
+ const tasks = read(since, LANE_ROWS);
302
+ if (tasks.length === 0 && (window === "24h" || read(laneSinceOf("24h", now), 1).length === 0))
303
+ return null;
304
+ const doneOf = store.handle.prepare(laneRunsSql()), liveOf = store.handle.prepare(laneLiveRunsSql()), newestBuildOf = store.handle.prepare(laneNewestBuildSql());
305
+ const lanes = tasks.map(row => {
306
+ const ref = Number(row["task_ref"]);
307
+ const taskId = String(row["task_id"]);
308
+ const done = row["first_done"] == null ? [] : doneOf.all({ $ref: ref, $from: Number(row["first_done"]), $since: since.toISOString() });
309
+ const runs = [...liveOf.all({ $ref: ref }), ...done].sort((a, b) => Number(a["id"]) - Number(b["id"]));
310
+ const segments = runs.map(run => {
311
+ const role = String(run["role"]);
312
+ return {
313
+ runId: Number(run["id"]), role: role === "repair" ? "builder" : role, agent: providerName(String(run["provider"] ?? "claude")),
314
+ startedAt: String(run["started_at"]), endedAt: str(run, "finished_at"), outcome: str(run, "outcome"),
315
+ // Every role opens in the agent view (planners, scouts and reviewers too): what it is doing and what it takes.
316
+ href: `/agents?run=${Number(run["id"])}`,
317
+ };
318
+ });
319
+ // The row's state from the task's own newest build, as the board would place it (never the board's capped list),
320
+ // even when that build finished before the window.
321
+ const build = newestBuildOf.get({ $ref: ref });
322
+ const facts = build === undefined ? null : agentFactsFor(store, Number(build["id"]), access, account);
323
+ const group = facts !== null ? agentGroupOf(facts, facts.outcome === null ? runActivityOf(store, { id: facts.runId, runner: facts.runner, startedAt: facts.startedAt }, now).at : null, now).group
324
+ : segments.some(one => one.role === "planner" && one.endedAt === null) ? "planning" : "idle";
325
+ return { taskId, title: String(row["title"]), project: str(row, "repo") === null ? null : projectName(str(row, "repo")), href: `/t/${encodeURIComponent(taskId)}`, group, segments };
326
+ });
327
+ const total = tasks.length === 0 ? 0 : Number(tasks[0]["total"]);
328
+ return {
329
+ since: since.toISOString(), now: now.toISOString(),
330
+ windows: LANE_WINDOWS.map(one => ({ ...one, href: `${path}?window=${one.key}`, active: one.key === window })),
331
+ lanes, more: Math.max(0, total - lanes.length),
332
+ running: tasks.length === 0 ? 0 : Number(tasks[0]["running"]),
333
+ };
334
+ }
@@ -4,7 +4,7 @@
4
4
  * the identity provider); everyone else reads them. Every change is in the
5
5
  * ledger, before → after.
6
6
  */
7
- import { html, postForm } from "./html.js";
7
+ import { html, postForm, reauth } from "./html.js";
8
8
  export const APPROVAL_RULES_CSS = `.approval-rules{max-width:720px;min-width:0}.approval-rules fieldset{border:0;padding:0;margin:18px 0 0;min-width:0}` +
9
9
  `.approval-rules legend{font-weight:600;font-size:.875rem;margin-bottom:8px}.approval-rules .choice{display:grid;grid-template-columns:auto minmax(0,1fr);gap:10px;align-items:start;margin:0 0 10px;font-weight:400}` +
10
10
  `.approval-rules .choice input{margin:.2rem 0 0}.approval-rules .choice strong{display:block;font-size:.875rem;font-weight:500}.approval-rules .choice small{display:block;color:var(--muted-foreground);font-size:.8125rem;margin-top:2px}` +
@@ -30,5 +30,5 @@ export function approvalRulesHtml(view, notice) {
30
30
  const protect = view.rules.protectProject ? "project" : view.rules.protectedPaths.length > 0 ? "paths" : "none";
31
31
  const radio = (value, title, hint) => html `<label class="choice"><input type="radio" name="protect" value="${value}"${protect === value ? html ` checked` : ""}><span><strong>${title}</strong><small>${hint}</small></span></label>`;
32
32
  const few = view.approvers < 2 ? html `<p class="meta">Only ${view.approvers === 1 ? "one person" : "no one"} can approve on this project, so protected work would wait for a second approver. Add one in People first.</p>` : "";
33
- return html `<section class="approval-rules">${note}${current}${postForm("/settings/approval", html `<fieldset><legend>Who approves</legend><label class="choice"><input type="checkbox" name="not_requester" value="1"${view.rules.notRequester ? html ` checked` : ""}><span><strong>Someone other than the requester</strong><small>The person who filed a task, or asked for a revision, can't approve it. Operating modes stop approving their signer's own filings here.</small></span></label></fieldset><fieldset><legend>Protected work</legend>${radio("none", "Nothing protected", html `One approval is enough.`)}${radio("project", "The whole project", html `Every task needs two different people to approve the same scope.`)}${radio("paths", "Only these paths", html `Tasks that touch them, or don't say what they touch, need two people.`)}<div class="paths"><label>Protected paths, one per line<textarea name="paths" spellcheck="false" placeholder="infra/**&#10;migrations/**">${view.rules.protectedPaths.join("\n")}</textarea></label></div>${few}</fieldset><div class="step-up"><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label></div><button type="submit">Save rules</button>`, { hidden: { repo: view.repo } })}</section>`;
33
+ return html `<section class="approval-rules">${note}${current}${postForm("/settings/approval", html `<fieldset><legend>Who approves</legend><label class="choice"><input type="checkbox" name="not_requester" value="1"${view.rules.notRequester ? html ` checked` : ""}><span><strong>Someone other than the requester</strong><small>The person who filed a task, or asked for a revision, can't approve it. Operating modes stop approving their signer's own filings here.</small></span></label></fieldset><fieldset><legend>Protected work</legend>${radio("none", "Nothing protected", html `One approval is enough.`)}${radio("project", "The whole project", html `Every task needs two different people to approve the same scope.`)}${radio("paths", "Only these paths", html `Tasks that touch them, or don't say what they touch, need two people.`)}<div class="paths"><label>Protected paths, one per line<textarea name="paths" spellcheck="false" placeholder="infra/**&#10;migrations/**">${view.rules.protectedPaths.join("\n")}</textarea></label></div>${few}</fieldset><div class="step-up">${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`)}</div><button type="submit">Save rules</button>`, { hidden: { repo: view.repo } })}</section>`;
34
34
  }
@@ -47,6 +47,20 @@ export declare function assignmentInbox(store: Store, owner: AssignmentOwner, qu
47
47
  consumer: string;
48
48
  limit?: number;
49
49
  }, now: Date): AssignmentInboxResult;
50
+ /**
51
+ * What the lead's follow inbox would hand over next, without issuing anything (lead sweeps, docs/plans/lead-fan-out.md):
52
+ * whether a page is already issued (it always replays at once — never held), and the unread events' tasks.
53
+ */
54
+ export declare function leadFollowUnread(store: Store, grant: {
55
+ id: number;
56
+ repos: readonly string[];
57
+ }, consumer: string): {
58
+ issued: boolean;
59
+ events: {
60
+ task: string;
61
+ state: string;
62
+ }[];
63
+ };
50
64
  /** The built-in lead's inbox: the same page, replay and acknowledgment as a coordinator's, over its follow grant. */
51
65
  export declare function leadFollowInbox(store: Store, grant: {
52
66
  id: number;
@@ -97,6 +97,19 @@ function pendingBatch(store, reader, key) {
97
97
  export function assignmentInbox(store, owner, query, now) {
98
98
  return inbox(store, coordinatorReader(store, owner), query, now);
99
99
  }
100
+ /**
101
+ * What the lead's follow inbox would hand over next, without issuing anything (lead sweeps, docs/plans/lead-fan-out.md):
102
+ * whether a page is already issued (it always replays at once — never held), and the unread events' tasks.
103
+ */
104
+ export function leadFollowUnread(store, grant, consumer) {
105
+ const reader = followReader(grant), key = reader.key(consumer);
106
+ if (pendingBatch(store, reader, key) !== null)
107
+ return { issued: true, events: [] };
108
+ const after = store.serviceCursor(`assignment-inbox:${key}:acked`);
109
+ const rows = store.handle.prepare("SELECT task_id, json_extract(outcome, '$.assignment.state') AS state FROM action_ledger WHERE id > ? AND actor = ? AND action = ? AND source = 'work' ORDER BY id LIMIT 100")
110
+ .all(after, reader.actor, ASSIGNMENT_STATUS_ACTION);
111
+ return { issued: false, events: rows.map(row => ({ task: String(row["task_id"]), state: String(row["state"] ?? "") })) };
112
+ }
100
113
  /** The built-in lead's inbox: the same page, replay and acknowledgment as a coordinator's, over its follow grant. */
101
114
  export function leadFollowInbox(store, grant, query, now) {
102
115
  return inbox(store, followReader(grant), query, now);