toolroll 0.9.65 → 0.9.67

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 (49) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -2
  3. package/dist/agent-fence.d.ts +69 -2
  4. package/dist/agent-fence.js +291 -11
  5. package/dist/agentconfig.js +4 -1
  6. package/dist/batch-checks.js +3 -2
  7. package/dist/browser/THIRD_PARTY_NOTICES.txt +46 -0
  8. package/dist/browser/terminal.js +134 -23
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/builder.d.ts +4 -0
  11. package/dist/builder.js +54 -12
  12. package/dist/contracts/console-api.d.ts +1 -0
  13. package/dist/contracts/console-api.js +1 -0
  14. package/dist/invoke.d.ts +18 -0
  15. package/dist/invoke.js +137 -14
  16. package/dist/live-terminal.d.ts +24 -13
  17. package/dist/live-terminal.js +79 -42
  18. package/dist/operate.d.ts +2 -0
  19. package/dist/operate.js +60 -2
  20. package/dist/project-delete-ui.d.ts +6 -0
  21. package/dist/project-delete-ui.js +20 -1
  22. package/dist/release-adapters.d.ts +6 -0
  23. package/dist/release-adapters.js +70 -3
  24. package/dist/release.d.ts +7 -1
  25. package/dist/release.js +20 -7
  26. package/dist/result-follow-ups.js +3 -2
  27. package/dist/scope.d.ts +13 -0
  28. package/dist/scope.js +10 -5
  29. package/dist/serve.js +1 -0
  30. package/dist/server/chrome.d.ts +10 -1
  31. package/dist/server/chrome.js +45 -4
  32. package/dist/server/console.css +55 -4
  33. package/dist/server/render-pages.d.ts +28 -3
  34. package/dist/server/render-pages.js +78 -9
  35. package/dist/server/render-tasks.js +2 -1
  36. package/dist/server/route-table.js +1 -0
  37. package/dist/server/settings.js +21 -0
  38. package/dist/server/tasks.js +7 -4
  39. package/dist/store.d.ts +11 -1
  40. package/dist/store.js +33 -2
  41. package/dist/teardown-git.d.ts +22 -0
  42. package/dist/teardown-git.js +136 -2
  43. package/dist/tool-broker.d.ts +12 -0
  44. package/dist/tool-broker.js +66 -2
  45. package/dist/unfenced-test-support.d.ts +2 -0
  46. package/dist/unfenced-test-support.js +11 -0
  47. package/dist/writes-mode.d.ts +9 -0
  48. package/dist/writes-mode.js +8 -0
  49. package/package.json +5 -1
package/dist/scope.d.ts CHANGED
@@ -3,6 +3,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import { isAcpProfile, type AcpProviderId } from "./acp-agents.js";
4
4
  export { isAcpProfile };
5
5
  import { type NetworkProfile } from "./egress.js";
6
+ import { type WritesMode } from "./writes-mode.js";
6
7
  import { type EvidenceKind } from "./contracts/acceptance-terms.js";
7
8
  import { type AcceptanceCriterion, type AcceptanceProblem, type ScopeTerms } from "./contracts/scope.js";
8
9
  import type { Store, Mutation } from "./store.js";
@@ -74,6 +75,9 @@ export type ClaudeProfile = {
74
75
  /** What the build may reach (egress.ts), resolved when the scope was written; absent on approvals sealed before
75
76
  * network policies, which run narrowed (egress.ts legacyNetwork). */
76
77
  network?: NetworkProfile;
78
+ /** Where the build may write (writes-mode.ts), resolved when the scope was written; absent on approvals sealed before
79
+ * write allowlists, which keep writing anywhere their fence allows. */
80
+ writes?: WritesMode;
77
81
  };
78
82
  export type CodexShapedProfile = {
79
83
  provider: "codex" | "openrouter";
@@ -91,6 +95,9 @@ export type CodexShapedProfile = {
91
95
  repairTimeoutSeconds: number;
92
96
  repairModel: string;
93
97
  network?: NetworkProfile;
98
+ /** Where the build may write (writes-mode.ts), resolved when the scope was written; absent on approvals sealed before
99
+ * write allowlists, which keep writing anywhere their fence allows. */
100
+ writes?: WritesMode;
94
101
  };
95
102
  export type GeminiProfile = {
96
103
  provider: "gemini";
@@ -110,6 +117,9 @@ export type GeminiProfile = {
110
117
  repairTimeoutSeconds: number;
111
118
  repairModel: string;
112
119
  network?: NetworkProfile;
120
+ /** Where the build may write (writes-mode.ts), resolved when the scope was written; absent on approvals sealed before
121
+ * write allowlists, which keep writing anywhere their fence allows. */
122
+ writes?: WritesMode;
113
123
  };
114
124
  /**
115
125
  * An agent over the Agent Client Protocol (acp-agents.ts, docs/plans/acp-adapter.md).
@@ -130,6 +140,9 @@ export type AcpProfile = {
130
140
  repairIdleTimeoutSeconds: number;
131
141
  repairModel: string;
132
142
  network?: NetworkProfile;
143
+ /** Where the build may write (writes-mode.ts), resolved when the scope was written; absent on approvals sealed before
144
+ * write allowlists, which keep writing anywhere their fence allows. */
145
+ writes?: WritesMode;
133
146
  };
134
147
  export type ExecutionProfile = ClaudeProfile | CodexShapedProfile | GeminiProfile | AcpProfile;
135
148
  /** Whether a profile's clock is a no-progress window (an ACP profile has both clocks; its overall one is a deadline). */
package/dist/scope.js CHANGED
@@ -39,6 +39,7 @@ export { isAcpProfile };
39
39
  */
40
40
  import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
41
41
  import { networkFromValue, networkWords } from "./egress.js";
42
+ import { isWritesMode, writesWords } from "./writes-mode.js";
42
43
  import { z } from "zod";
43
44
  import {} from "./contracts/acceptance-terms.js";
44
45
  import { parseContract } from "./contracts/contract.js";
@@ -362,9 +363,12 @@ export function profileFromJson(json) {
362
363
  const network = p["network"] === undefined ? undefined : networkFromValue(p["network"]);
363
364
  if (network === null)
364
365
  return null;
365
- const withNetwork = network === undefined ? {} : { network };
366
+ const writes = p["writes"];
367
+ if (writes !== undefined && !isWritesMode(writes))
368
+ return null;
369
+ const withNetwork = { ...(network === undefined ? {} : { network }), ...(writes === undefined ? {} : { writes }) };
366
370
  if (p["provider"] === "claude") {
367
- if (exactKeys(p, [...PROVIDER_KEYS, "permissionArgv"], ["timeoutKind", "network"]) &&
371
+ if (exactKeys(p, [...PROVIDER_KEYS, "permissionArgv"], ["timeoutKind", "network", "writes"]) &&
368
372
  str(p["model"]) &&
369
373
  (p["permissionArgv"] === "auto" || p["permissionArgv"] === "acceptEdits" || p["permissionArgv"] === "bypassPermissions") &&
370
374
  num(p["maxTurns"]) && num(p["repairMaxTurns"]) &&
@@ -386,7 +390,7 @@ export function profileFromJson(json) {
386
390
  return null;
387
391
  }
388
392
  if (p["provider"] === "codex" || p["provider"] === "openrouter") {
389
- if (exactKeys(p, [...PROVIDER_KEYS, "sandboxMode"], ["timeoutKind", "network"]) &&
393
+ if (exactKeys(p, [...PROVIDER_KEYS, "sandboxMode"], ["timeoutKind", "network", "writes"]) &&
390
394
  str(p["model"]) &&
391
395
  (p["sandboxMode"] === "workspace-write" || p["sandboxMode"] === "danger-full-access") &&
392
396
  p["maxTurns"] === "unsupported" && p["repairMaxTurns"] === "unsupported" &&
@@ -408,7 +412,7 @@ export function profileFromJson(json) {
408
412
  return null;
409
413
  }
410
414
  if (p["provider"] === "gemini") {
411
- if (exactKeys(p, [...PROVIDER_KEYS, "approvalArgv"], ["timeoutKind", "network"]) &&
415
+ if (exactKeys(p, [...PROVIDER_KEYS, "approvalArgv"], ["timeoutKind", "network", "writes"]) &&
412
416
  str(p["model"]) &&
413
417
  (p["approvalArgv"] === "auto_edit" || p["approvalArgv"] === "yolo") &&
414
418
  p["maxTurns"] === "unsupported" && p["repairMaxTurns"] === "unsupported" &&
@@ -430,7 +434,7 @@ export function profileFromJson(json) {
430
434
  return null;
431
435
  }
432
436
  if (typeof p["provider"] === "string" && isAcpProvider(p["provider"])) {
433
- if (exactKeys(p, [...PROVIDER_KEYS, "permission", "idleTimeoutSeconds", "repairIdleTimeoutSeconds"], ["network"]) &&
437
+ if (exactKeys(p, [...PROVIDER_KEYS, "permission", "idleTimeoutSeconds", "repairIdleTimeoutSeconds"], ["network", "writes"]) &&
434
438
  str(p["model"]) &&
435
439
  p["permission"] === "full" &&
436
440
  p["maxTurns"] === "unsupported" && p["repairMaxTurns"] === "unsupported" &&
@@ -1278,6 +1282,7 @@ export function describeScope(scope, readiness = NO_READINESS) {
1278
1282
  ...scopeRouteWords(scope, readiness),
1279
1283
  // What the build may reach (egress.ts), part of what the yes agrees to.
1280
1284
  ...(scope.profile === null || scope.profile === undefined ? [] : [` network ${networkWords(scope.profile.network ?? null).replace(/^Network: /, "")}`]),
1285
+ ...(scope.profile === null || scope.profile === undefined ? [] : [` writes ${writesWords(scope.profile.writes)}`]),
1281
1286
  ` reference ${scope.digest}`,
1282
1287
  ` approved ${describeApproval(approval)}`,
1283
1288
  ];
package/dist/serve.js CHANGED
@@ -447,6 +447,7 @@ export function createDecisionServer(options) {
447
447
  builds: projectBuildsOf(repo, who),
448
448
  access: projectAccessOf(repo),
449
449
  network: { setting: store.projectNetwork(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
450
+ writes: { setting: store.projectWrites(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
450
451
  liveView: (() => {
451
452
  const commands = liveCommandsShown(store, repo);
452
453
  const canChange = who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo);
@@ -128,7 +128,9 @@ export type Chrome = {
128
128
  * at the sink like every page, fetched same-origin — and the nonce'd CSP
129
129
  * refuses to execute anything the region could smuggle. Nothing reads on a timer.
130
130
  */
131
- export declare function regionScript(regionId: string, fragmentName: string, path?: string): string;
131
+ export declare function regionScript(regionId: string, fragmentName: string, path?: string, options?: {
132
+ animate?: boolean;
133
+ }): string;
132
134
  /**
133
135
  * The live transcript's DEDICATED poller (arc 1 §4): a JSON byte-offset
134
136
  * protocol, not an HTML fragment — the response is raw sanitized text and
@@ -297,3 +299,10 @@ export declare function safePrUrl(url: string | null): string | null;
297
299
  export declare function shortDigest(digest: string): Html;
298
300
  /** One display line, bounded — turn text is data, never layout. */
299
301
  export declare function oneLineOf(text: string, cap: number): string;
302
+ /**
303
+ * The board's clocks tick in the page: each <time data-tick> counts up from
304
+ * its datetime, measured against the server's own now (the board's data-now)
305
+ * so a skewed device clock cannot run them backwards. The server-rendered
306
+ * minutes are the answer without script.
307
+ */
308
+ export declare function boardTickScript(regionId?: string): string;
@@ -161,7 +161,7 @@ export const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CS
161
161
  * at the sink like every page, fetched same-origin — and the nonce'd CSP
162
162
  * refuses to execute anything the region could smuggle. Nothing reads on a timer.
163
163
  */
164
- export function regionScript(regionId, fragmentName, path) {
164
+ export function regionScript(regionId, fragmentName, path, options = {}) {
165
165
  // Where the fragment lives: the page's own URL by default; an explicit
166
166
  // path when a page embeds another entity's region (the task page embeds
167
167
  // the live run's peek, slice 1c — no proxy route exists for it).
@@ -184,7 +184,7 @@ export function regionScript(regionId, fragmentName, path) {
184
184
  `function keep(){var data={lanes:[],focus:null,pager:-1,fold:{}};` +
185
185
  `region.querySelectorAll("details.lane").forEach(function(d){var k=laneKey(d);if(k)data.fold[k]=d.open;});` +
186
186
  `var act=document.activeElement;` +
187
- `if(act&&region.contains(act)&&act.matches&&act.matches("a.row, a.lane-card"))data.focus=act.getAttribute("href");` +
187
+ `if(act&&region.contains(act)&&act.matches&&act.matches("a.row, a.lane-card, a.worker-job")){data.focus=act.getAttribute("href");data.focusKind=act.matches("a.worker-job")?"a.worker-job":"a.row, a.lane-card";}` +
188
188
  `var board=region.querySelector(".board");` +
189
189
  `if(board&&board.scrollLeft>0){var lanes=board.querySelectorAll(".lane");` +
190
190
  `var bc=board.getBoundingClientRect();var mid=bc.left+bc.width/2;var best=-1,bd=1e9;` +
@@ -208,17 +208,41 @@ export function regionScript(regionId, fragmentName, path) {
208
208
  `if(board){var lanes=board.querySelectorAll(".lane");var at=Math.min(data.pager,lanes.length-1);` +
209
209
  `if(at>=0){var br=board.getBoundingClientRect();var lr=lanes[at].getBoundingClientRect();` +
210
210
  `board.scrollLeft=board.scrollLeft+(lr.left+lr.width/2)-(br.left+br.width/2);}}}` +
211
- `if(data.focus!==null){var links=region.querySelectorAll("a.row, a.lane-card");` +
211
+ `if(data.focus!==null){var links=region.querySelectorAll(data.focusKind);` +
212
212
  `for(var i=0;i<links.length;i++){if(links[i].getAttribute("href")===data.focus){links[i].focus({preventScroll:true});break;}}}}` +
213
213
  `function tell(){if(!stamp||stopped)return;var s=Math.round((Date.now()-last)/1000);` +
214
214
  `stamp.textContent=failed?"stale — retrying ("+s+"s old)":"updated "+s+"s ago";}` +
215
215
  `setInterval(tell,1000);` +
216
+ // The animated swap (the live board): a card that changed lane slides there and a new one drops in, through one
217
+ // same-document view transition keyed by each card's data-vt; a worker whose builds changed flashes once
218
+ // (data-flash). Never under reduced motion, in a hidden tab, or where the browser lacks view transitions — there
219
+ // the swap is the plain instant one. Names are set only for the swap and cleared after it.
220
+ `var animate=${options.animate === true ? "true" : "false"};` +
221
+ // A name spells the whole key in hex — two tasks can never share one, so a card's identity never jumps to another.
222
+ `function vtName(v){var n="so-c";for(var i=0;i<v.length;i++){n+=("000"+v.charCodeAt(i).toString(16)).slice(-4);}return n;}` +
223
+ `function nameAll(){var seen={};region.querySelectorAll("[data-vt]").forEach(function(e){var n=vtName(e.getAttribute("data-vt"));if(seen[n])return;seen[n]=1;e.style.setProperty("view-transition-name",n);});}` +
224
+ `function flashes(){var m={};region.querySelectorAll("[data-flash]").forEach(function(e){m[e.getAttribute("data-flash")]=e.getAttribute("data-flash-value");});return m;}` +
225
+ `function apply(t){var before=animate?flashes():{};var kept=keep();region.innerHTML=t;restore(kept);` +
226
+ `if(animate)region.querySelectorAll("[data-flash]").forEach(function(e){var k=e.getAttribute("data-flash"),v=e.getAttribute("data-flash-value");if(k in before&&before[k]!==v&&v!=="")e.classList.add("flash");});}` +
227
+ // The swap resolves when its transition has finished, and the next fetch waits for it (refresh holds busy), so
228
+ // one transition runs at a time; each cleans up only its own (the generation), never a later one's.
229
+ `var generation=0;` +
230
+ `function swap(t){var calm=!window.matchMedia||matchMedia("(prefers-reduced-motion: reduce)").matches;` +
231
+ `if(!animate||calm||document.hidden||typeof document.startViewTransition!=="function"){apply(t);return Promise.resolve();}` +
232
+ // Bounded: a transition that never settles (or never runs its update) is let go after 1.5 s — the fragment is
233
+ // applied exactly once either way, so the board can never stop refreshing behind an animation.
234
+ `var root=document.documentElement;var mine=++generation;var applied=false;var settled=false;nameAll();root.classList.add("so-swap");` +
235
+ `var once=function(){if(applied)return false;applied=true;apply(t);return true;};` +
236
+ `var end=function(){if(settled)return;settled=true;once();if(mine!==generation)return;root.classList.remove("so-swap");region.querySelectorAll("[data-vt]").forEach(function(e){e.style.removeProperty("view-transition-name");});};` +
237
+ `var vt;try{vt=document.startViewTransition(function(){if(once())nameAll();});}catch(e){end();return Promise.resolve();}` +
238
+ `return new Promise(function(done){var timer=setTimeout(function(){end();done();},1500);` +
239
+ `var fin=function(){clearTimeout(timer);end();done();};try{Promise.resolve(vt&&vt.finished).then(fin,fin);}catch(e){fin();}});}` +
216
240
  `function refresh(){if(stopped)return;if(busy){again=true;return;}busy=true;again=false;` +
217
241
  `var q=location.search?location.search+"&fragment="+${JSON.stringify(fragmentName)}:"?fragment="+${JSON.stringify(fragmentName)};` +
218
242
  `fetch(${target},{redirect:"manual",cache:"no-store"})` +
219
243
  `.then(function(r){if(r.type==="opaqueredirect"||r.status===401||r.status===403){location.href="/login";return null;}` +
220
244
  `return r.ok?r.text():null;})` +
221
- `.then(function(t){if(t){var kept=keep();region.innerHTML=t;restore(kept);last=Date.now();failed=false;}else{failed=true;}})` +
245
+ `.then(function(t){if(t){return swap(t).then(function(){last=Date.now();failed=false;});}failed=true;})` +
222
246
  `.catch(function(){failed=true;})` +
223
247
  // A fragment that marks itself final stops: a finished or abandoned build is not fetched again.
224
248
  `.then(function(){busy=false;tell();if(region.querySelector("[data-region-stop]")){stopped=true;if(stamp)stamp.textContent="";return;}if(again)refresh();});}` +
@@ -760,3 +784,20 @@ export function oneLineOf(text, cap) {
760
784
  const flat = text.replace(/\s+/g, " ").trim();
761
785
  return flat.length <= cap ? flat : `${flat.slice(0, cap - 1)}…`;
762
786
  }
787
+ /**
788
+ * The board's clocks tick in the page: each <time data-tick> counts up from
789
+ * its datetime, measured against the server's own now (the board's data-now)
790
+ * so a skewed device clock cannot run them backwards. The server-rendered
791
+ * minutes are the answer without script.
792
+ */
793
+ export function boardTickScript(regionId = "board-region") {
794
+ return (`(function(){var seen=null,offset=0;var region=document.getElementById(${JSON.stringify(regionId)});` +
795
+ `function fmt(s){if(s<0)s=0;var h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;return h>0?h+"h "+(m<10?"0":"")+m+"m":m+":"+(x<10?"0":"")+x;}` +
796
+ `function run(){var scope=region||document;var anchor=scope.querySelector("[data-now]");var v=anchor?anchor.getAttribute("data-now"):null;` +
797
+ `if(v!==null&&v!==seen){seen=v;var n=Number(v);offset=isFinite(n)?n-Date.now():0;}` +
798
+ `var now=Date.now()+offset;scope.querySelectorAll("time[data-tick]").forEach(function(t){var at=Date.parse(t.getAttribute("datetime")||"");if(isNaN(at))return;var text=fmt(Math.floor((now-at)/1000));if(t.textContent!==text)t.textContent=text;});}` +
799
+ // A swapped region brings back the server's minutes, so a swap (the region's children replaced — never a clock's
800
+ // own text) ticks at once and no clock flickers back to them. Hidden tabs do not tick.
801
+ `run();setInterval(function(){if(!document.hidden)run();},1000);document.addEventListener("visibilitychange",function(){if(!document.hidden)run();});` +
802
+ `if(region&&typeof MutationObserver==="function")new MutationObserver(run).observe(region,{childList:true});})();`);
803
+ }
@@ -1825,7 +1825,7 @@
1825
1825
  building, then the rest. A header is a 2.75rem tap; a drawn chevron
1826
1826
  says which way it folds. */
1827
1827
  @media (max-width: 760px) {
1828
- .board { display: flex; flex-direction: column; gap: .5rem; padding-bottom: 0; }
1828
+ .board { display: flex; flex-direction: column; align-items: stretch; gap: .5rem; padding-bottom: 0; }
1829
1829
  .board .lane { min-height: 0; padding: 0 .625rem .125rem; }
1830
1830
  .board .lane > summary {
1831
1831
  cursor: pointer; display: flex; align-items: center; min-height: 2.75rem; margin: 0;
@@ -1864,7 +1864,7 @@
1864
1864
  }
1865
1865
  .lane-attention h2::before { background: var(--brand); opacity: 1; }
1866
1866
  .lane-building h2::before { background: var(--running); opacity: 1; }
1867
- .lane-done h2::before { background: var(--success); opacity: 1; }
1867
+ .lane-done h2::before { background: var(--muted-foreground); opacity: 1; }
1868
1868
  .lane-card {
1869
1869
  display: block; text-decoration: none; color: inherit;
1870
1870
  background: var(--card); border: 1px solid var(--border);
@@ -1891,6 +1891,37 @@
1891
1891
  font-family: var(--font-mono); font-size: .6875rem; color: var(--running); font-variant-numeric: tabular-nums;
1892
1892
  }
1893
1893
  .lane-card .live-line .clock { color: var(--muted-foreground); }
1894
+ /* The build's steps — set up, writing, checking and committing: three quiet
1895
+ ticks, the current one in the running hue. A stage, never a percent. */
1896
+ .lane-card .steps { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .1875rem; margin-top: .5rem; }
1897
+ .lane-card .steps i { height: .1875rem; border-radius: 999px; background: var(--border); }
1898
+ .lane-card .steps i.on { background: color-mix(in srgb, var(--running) 45%, var(--border)); }
1899
+ .lane-card .steps i.now { background: var(--running); }
1900
+
1901
+ /* The worker strip: one quiet tile per worker above the lanes — what it is
1902
+ building here, the stage, the clock. Ink and lines only; the running dot
1903
+ is the one hue. */
1904
+ .crew { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: .625rem; margin: .25rem 0 .875rem; }
1905
+ .crew-empty { margin: .25rem 0 .875rem; }
1906
+ .worker {
1907
+ border: 1px solid var(--border); border-radius: var(--radius); background: var(--card);
1908
+ padding: .625rem .75rem; min-width: 0; display: flex; flex-direction: column; gap: .375rem;
1909
+ }
1910
+ .worker-busy { border-color: color-mix(in srgb, var(--running) 30%, var(--border)); }
1911
+ .worker-offline { opacity: .6; }
1912
+ .worker-head { display: flex; align-items: center; gap: .5rem; min-width: 0; }
1913
+ .worker-head .name { font-family: var(--font-mono); font-size: .75rem; font-weight: 500; }
1914
+ .worker-head .kind { font-size: .6875rem; color: var(--muted-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
1915
+ .worker-head .slots { font-family: var(--font-mono); font-size: .6875rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
1916
+ .worker-job { display: flex; flex-direction: column; gap: .0625rem; text-decoration: none; color: inherit; min-width: 0; margin: 0; }
1917
+ .worker-job .t { font-size: .8125rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1918
+ .worker-job .sub { display: flex; justify-content: space-between; gap: .5rem; font-family: var(--font-mono); font-size: .6875rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
1919
+ .worker-job.idle { font-size: .75rem; color: var(--muted-foreground); }
1920
+ @media (hover: hover) and (pointer: fine) { a.worker-job:hover .t { text-decoration: underline; } }
1921
+ @media (max-width: 760px) {
1922
+ .crew { grid-template-columns: minmax(0, 1fr); gap: .375rem; }
1923
+ .worker { padding: .5rem .625rem; }
1924
+ }
1894
1925
  .lane-empty { margin: .75rem 0 .25rem; }
1895
1926
  .plan-doc { white-space: pre-wrap; overflow-wrap: anywhere; font-size: .8125rem; max-height: 24rem; overflow-y: auto; }
1896
1927
  .lane-more { display: block; margin-top: .5rem; font-size: .75rem; }
@@ -2674,8 +2705,10 @@ button { min-height: 44px; }
2674
2705
  @media (hover: hover) and (pointer: fine) { .next-pager .skip:hover { border-color: color-mix(in srgb, var(--border) 60%, var(--muted-foreground)); } }
2675
2706
 
2676
2707
  /* Motion: only where a human caused the change — navigation, presses,
2677
- overlays. Liveness swaps stay instant; the pulse dot is the one "alive"
2678
- signal. Everything dies under prefers-reduced-motion; auto-refresh pages
2708
+ overlays — and one exception, the live board: a card that changes lane
2709
+ slides there and a worker that picks up work flashes once (the board's
2710
+ swap, html.so-swap, below). Every other liveness swap stays instant; the
2711
+ pulse dot is the one "alive" signal. Everything dies under prefers-reduced-motion; auto-refresh pages
2679
2712
  opt out of the navigation cross-fade separately (see shell()). */
2680
2713
  @view-transition { navigation: auto; }
2681
2714
  /* A page change fades THROUGH, not across: the old page leaves before the new
@@ -2776,3 +2809,21 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
2776
2809
  .meta, .hint { line-height: 1.35; }
2777
2810
  details { margin: var(--so-phone-row) 0; }
2778
2811
  }
2812
+
2813
+ /* The live board's swap (regionScript animate): only cards move — the page
2814
+ itself does not fade — a short slide for a card that changed lane, a drop
2815
+ for a new one, a fade for one that left. Transform and opacity only, and
2816
+ the reduce block kills it with everything else (the script also skips the
2817
+ transition under reduced motion). */
2818
+ html.so-swap::view-transition-old(root), html.so-swap::view-transition-new(root) { animation: none; }
2819
+ html.so-swap::view-transition-group(*) { animation-duration: 320ms; animation-timing-function: cubic-bezier(.2, .8, .2, 1); }
2820
+ html.so-swap::view-transition-new(*):only-child { animation: so-card-in 240ms ease-out both; }
2821
+ html.so-swap::view-transition-old(*):only-child { animation: so-card-out 160ms ease-in both; }
2822
+ @keyframes so-card-in { from { opacity: 0; transform: translateY(-.375rem); } }
2823
+ @keyframes so-card-out { to { opacity: 0; } }
2824
+ .worker.flash { animation: so-worker-claim 900ms ease-out; }
2825
+ @keyframes so-worker-claim { from { background: color-mix(in srgb, var(--running) 14%, var(--card)); } }
2826
+ @media (prefers-reduced-motion: reduce) {
2827
+ html.so-swap::view-transition-group(*), html.so-swap::view-transition-new(*), html.so-swap::view-transition-old(*) { animation: none !important; }
2828
+ .worker.flash { animation: none; }
2829
+ }
@@ -146,14 +146,39 @@ export declare function systemPage(chrome: Chrome, data: {
146
146
  }[];
147
147
  now: Date;
148
148
  }): Screen;
149
+ /**
150
+ * A build's stage as one of three steps — set up, writing, checking and
151
+ * committing — read from the machine's own phase, never a percent. The
152
+ * steps only move forward: evidence and proof checks run both before and
153
+ * after the commit (builder.ts), so everything after the agent's turn is
154
+ * one step, and the stage words say which part. Null for runs that are not
155
+ * builds (planning, review, investigation): those have no honest steps.
156
+ */
157
+ export declare const BUILD_STEPS: readonly ["setting up", "writing", "checking and committing"];
158
+ export declare function buildStepOf(claim: NonNullable<BoardCard["claim"]>): number | null;
159
+ /**
160
+ * The workers a board's strip may show: not retired, bound to at least one
161
+ * project the server's ceiling admits (never a runner serving only projects
162
+ * this server must not speak about), and — on a one-project board — bound to
163
+ * that project.
164
+ */
165
+ export declare function boardWorkers(runners: readonly Runner[], scope: {
166
+ all: boolean;
167
+ project: string | null;
168
+ visible: (repo: string) => boolean;
169
+ }): Runner[];
149
170
  /**
150
171
  * The board: the pipeline as lanes, position as meaning — attention, then
151
- * queued, then waiting, then building, then recently done. Read-only by
152
- * construction (every card is a link, no forms, no nonces), which is what
153
- * makes it safe to re-render itself while somebody watches.
172
+ * queued, then waiting, then building, then recently done — under a strip
173
+ * of the workers doing it. Read-only by construction (every card is a link,
174
+ * no forms, no nonces), which is what makes it safe to re-render itself
175
+ * while somebody watches; a card that changes lane slides there
176
+ * (regionScript's animate swap, keyed by data-vt).
154
177
  */
155
178
  export declare function boardBody(data: {
156
179
  cards: BoardCard[];
180
+ /** The workers this board's work can go to (retired ones left out). */
181
+ workers: readonly Runner[];
157
182
  done: ReturnType<Store["listCompletedWorkScoped"]>;
158
183
  saturated: boolean;
159
184
  now: Date;
@@ -207,11 +207,74 @@ export function systemPage(chrome, data) {
207
207
  : html `<span class="meta">syncing normally</span>`}</p>`), "\n")}`,
208
208
  ], "\n"), { chrome, refreshSeconds: data.building.length > 0 ? 10 : 60 });
209
209
  }
210
+ /**
211
+ * A build's stage as one of three steps — set up, writing, checking and
212
+ * committing — read from the machine's own phase, never a percent. The
213
+ * steps only move forward: evidence and proof checks run both before and
214
+ * after the commit (builder.ts), so everything after the agent's turn is
215
+ * one step, and the stage words say which part. Null for runs that are not
216
+ * builds (planning, review, investigation): those have no honest steps.
217
+ */
218
+ export const BUILD_STEPS = ["setting up", "writing", "checking and committing"];
219
+ export function buildStepOf(claim) {
220
+ if (claim.role === "planner" || claim.role === "reviewer" || claim.role === "scout")
221
+ return null;
222
+ if (claim.model === null)
223
+ return 0;
224
+ if (claim.phase === null || claim.phase === "agent-running")
225
+ return 1;
226
+ return 2;
227
+ }
228
+ /** A clock that ticks in the page (boardTickScript) from the server's own start time; the text is the no-script answer. */
229
+ function tickingClock(startedIso, now) {
230
+ const minutes = Math.max(0, Math.round((now.getTime() - new Date(startedIso).getTime()) / 60_000));
231
+ return html `<time class="clock" datetime="${startedIso}" data-tick>${minutes}m</time>`;
232
+ }
233
+ /**
234
+ * The workers a board's strip may show: not retired, bound to at least one
235
+ * project the server's ceiling admits (never a runner serving only projects
236
+ * this server must not speak about), and — on a one-project board — bound to
237
+ * that project.
238
+ */
239
+ export function boardWorkers(runners, scope) {
240
+ return runners.filter(runner => runner.retiredAt === null &&
241
+ runner.repos.some(repo => scope.visible(repo)) &&
242
+ (scope.all || scope.project === null || runner.repos.includes(scope.project)));
243
+ }
244
+ /**
245
+ * The worker strip across the top of the board: every worker this board's
246
+ * work can go to, what it is building here, and its stage and clock. Only
247
+ * builds the board already shows are named — a worker busy in a project
248
+ * this reader cannot see reads "no build on this board", never "free".
249
+ */
250
+ function workerStrip(runners, cards, now) {
251
+ if (runners.length === 0)
252
+ return html `<p class="meta crew-empty">No workers yet — work waits in the queue until one joins.</p>`;
253
+ const tiles = runners.map(runner => {
254
+ const builds = cards
255
+ .filter(card => card.lane === "building" && card.claim !== null && card.claim.runner === runner.name)
256
+ .sort((a, b) => a.claim.claimedAt.localeCompare(b.claim.claimedAt));
257
+ const online = runnerAlive(runner, now);
258
+ const state = !online ? "offline" : builds.length > 0 ? "busy" : "idle";
259
+ const dot = state === "busy" ? html `<span class="dot dot-ok pulse"></span>` : html `<span class="dot dot-off"></span>`;
260
+ const jobs = builds.length > 0
261
+ ? builds.map(card => {
262
+ const claim = card.claim;
263
+ const words = claim.model === null ? "preparing workspace" : claim.phase === null ? "the agent is working" : phaseWords(claim.phase);
264
+ return html `<a class="worker-job" href="${card.href}"><span class="t">${card.title}</span><span class="sub"><span>${words}</span>${tickingClock(claim.claimedAt, now)}</span></a>`;
265
+ })
266
+ : html `<p class="worker-job idle">${online ? "no build on this board" : html `offline since ${whenTime(runner.heartbeatAt)}`}</p>`;
267
+ return html `<div class="worker worker-${state}" data-flash="${runner.name}" data-flash-value="${builds.map(card => card.taskId).join(" ")}"><div class="worker-head">${dot}<span class="name">${runner.name}</span><span class="kind">${runner.agents.join(" · ")}</span>${runner.capacity > 1 ? html `<span class="slots">${builds.length}/${runner.capacity}</span>` : null}</div>${jobs}</div>`;
268
+ });
269
+ return html `<section class="crew" aria-label="Workers">${tiles}</section>`;
270
+ }
210
271
  /**
211
272
  * The board: the pipeline as lanes, position as meaning — attention, then
212
- * queued, then waiting, then building, then recently done. Read-only by
213
- * construction (every card is a link, no forms, no nonces), which is what
214
- * makes it safe to re-render itself while somebody watches.
273
+ * queued, then waiting, then building, then recently done — under a strip
274
+ * of the workers doing it. Read-only by construction (every card is a link,
275
+ * no forms, no nonces), which is what makes it safe to re-render itself
276
+ * while somebody watches; a card that changes lane slides there
277
+ * (regionScript's animate swap, keyed by data-vt).
215
278
  */
216
279
  export function boardBody(data, ciRed) {
217
280
  const chip = (repo) => !data.all || repo === null ? null : html `<span class="badge">${projectName(repo)}</span>`;
@@ -261,7 +324,7 @@ export function boardBody(data, ciRed) {
261
324
  const kept = parts.filter((one) => one !== null);
262
325
  return kept.length === 0 ? null : html `<span class="chips">${joinHtml(kept, " ")}</span>`;
263
326
  };
264
- const plain = (card) => html `<a class="lane-card" href="${card.href}"><span class="id">${card.taskId}</span><span class="t">${card.title}</span><span class="why">${card.reason}</span>${facts([
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([
265
328
  ...(card.stalledSince === null ? [] : [["waiting", age(card.stalledSince)]]),
266
329
  ])}${chips([
267
330
  chip(card.repo),
@@ -279,7 +342,7 @@ export function boardBody(data, ciRed) {
279
342
  }
280
343
  return heads;
281
344
  })();
282
- const queuedCard = (card) => html `<a class="lane-card" href="${card.href}"><span class="id">${card.taskId}</span><span class="t">${card.title}</span><span class="why">${card.reason}</span>${facts([
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([
283
346
  ["worker", card.assignedRunner === null ? "any free worker" : card.assignedRunner],
284
347
  ])}${chips([
285
348
  queuedHeads.get(`${card.assignedRunner ?? ""}|${card.repo ?? ""}`) === card.taskId
@@ -292,14 +355,19 @@ export function boardBody(data, ciRed) {
292
355
  const claim = card.claim;
293
356
  if (claim === null)
294
357
  return plain(card);
295
- const minutes = Math.max(1, Math.round((data.now.getTime() - new Date(claim.claimedAt).getTime()) / 60_000));
296
358
  const workspace = claim.worktree === null ? null : (claim.worktree.split("/").pop() ?? claim.worktree);
297
359
  // Chip copy: unknown phases show nothing rather than raw tokens.
298
360
  const phase = claim.phase === null ? undefined : PHASE_WORDS[claim.phase];
299
361
  // The live strip is the run's own phase and clock — never a percent:
300
362
  // a build has no honest progress figure, only a stage and an elapsed.
301
363
  const live = claim.model === null ? "preparing workspace" : (phase ?? "the agent is working");
302
- return html `<a class="lane-card building" href="${card.href}"><span class="id">${card.taskId}</span><span class="t"><span class="dot dot-ok pulse"></span>${card.title}</span><span class="live-line"><span class="stage">${live}</span><span class="clock">${minutes}m</span></span>${facts([
364
+ // The steps: where the build is among set up → writing → checking →
365
+ // committing, from its phase — a stage, never a percent.
366
+ const step = buildStepOf(claim);
367
+ const steps = step === null
368
+ ? null
369
+ : 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([
303
371
  ["worker", claim.runner],
304
372
  ...(claim.model === null
305
373
  ? []
@@ -316,7 +384,7 @@ export function boardBody(data, ciRed) {
316
384
  const pr = row.prNumber === null
317
385
  ? null
318
386
  : html `<span class="badge badge-open">PR #${row.prNumber}</span>${ciRed(row.prNumber) ? html ` <span class="badge badge-failed">CI failing</span>` : null}`;
319
- return html `<a class="lane-card" href="${taskHref(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([
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([
320
388
  ...(row.ranMinutes === null ? [] : [["ran", `${row.ranMinutes}m`]]),
321
389
  ["usage", runCostWords({ authMode: row.authMode, costUsd: row.costUsd, tokensIn: null, tokensOut: null })],
322
390
  ])}${chips([
@@ -347,7 +415,8 @@ export function boardBody(data, ciRed) {
347
415
  data.all ? null : html `<p class="meta board-view"><strong>State</strong> · <a href="/board?view=order">order →</a> <span class="meta">drag to reorder, or to reserve a task for one worker</span></p>`,
348
416
  deltaLine,
349
417
  toggle,
350
- html `<div class="board">`,
418
+ workerStrip(data.workers, data.cards, data.now),
419
+ html `<div class="board" data-now="${String(data.now.getTime())}">`,
351
420
  lane("attention", "needs you", "these wait for a person", plain),
352
421
  lane("queued", "queued", "starts when a worker is free", queuedCard),
353
422
  lane("waiting", "waiting", "paused until a time, another task, or a requirement is ready", plain),
@@ -1,5 +1,6 @@
1
1
  /** Task pages: approval, agents, plans, controls, decisions and the task body. */
2
2
  import { networkWords } from "../egress.js";
3
+ import { writesWords } from "../writes-mode.js";
3
4
  import { ACP_AGENTS, isAcpProfile } from "../acp-agents.js";
4
5
  import { html, htmlString, joinHtml, postForm, replaceMarkup, reauth } from "../html.js";
5
6
  import { closedQuestionReason, closedQuestionWords } from "../decision.js";
@@ -541,7 +542,7 @@ export function profileWords(scope) {
541
542
  ? html `<p class="meta">Runs on <span class="mono">${ACP_AGENTS[profile.provider].name} (via ACP) · ${profile.model}</span> — full access inside the OS fence; the agent runs any tool it chooses and nothing asks. No turn limit: stops after ${Math.round(profile.timeoutSeconds / 60)} min, or ${Math.round(profile.idleTimeoutSeconds / 60)} min silent; repairs on ${repair}, ${Math.round(profile.repairTimeoutSeconds / 60)} min, or ${Math.round(profile.repairIdleTimeoutSeconds / 60)} min silent.</p>`
542
543
  : html `<p class="meta">Runs on <span class="mono">${profile.provider} · ${profile.model}</span> — ${profile.sandboxMode === "danger-full-access" ? "FULL permissions via --dangerously-bypass-approvals-and-sandbox; nothing asks" : "workspace-write sandbox"}, no turn limit (${Math.round(profile.timeoutSeconds / 60)} min ${profile.timeoutKind === "idle" ? "without progress" : "per attempt"}); repairs on ${repair}, ${Math.round(profile.repairTimeoutSeconds / 60)} min</p>`;
543
544
  // What the build may reach is part of what is approved (egress.ts): said on every approval form.
544
- return html `${base}<p class="meta" data-scope-network>${networkWords(profile.network ?? null)}</p>`;
545
+ return html `${base}<p class="meta" data-scope-network>${networkWords(profile.network ?? null)}</p><p class="meta" data-scope-writes>Writes: ${writesWords(profile.writes)}.</p>`;
545
546
  }
546
547
  export const ROLE_NOUN = { plan: "Planner", build: "Builder", repair: "Repair", review: "Reviewer" };
547
548
  export function agentsStandingWords(view) {
@@ -283,6 +283,7 @@ post("settings", "spend.budget", "/spend/budget", { scope: "step-up", ...operato
283
283
  post("settings", "settings.project-concurrency", "/settings/project/concurrency");
284
284
  post("settings", "settings.project-live-view", "/settings/project/live-view", { scope: "step-up", callers: ["cookie"] });
285
285
  post("settings", "settings.project-network", "/settings/project/network", { scope: "step-up", callers: ["cookie"] });
286
+ post("settings", "settings.project-writes", "/settings/project/writes", { scope: "step-up", callers: ["cookie"] });
286
287
  post("settings", "settings.project-delete", "/settings/project/delete", { scope: "step-up", ...operator("An instance operator deletes projects.", "/settings/project") });
287
288
  // Lead sweeps (docs/plans/lead-fan-out.md): the table, and cancelling what isn't ready. The handler shows a sweep only in
288
289
  // a project the reader can see, and acts only for an approver.
@@ -80,6 +80,7 @@ import { policyHtml } from "../policy-ui.js";
80
80
  import { checkPolicy, parseList, policyParts } from "../policy.js";
81
81
  import { parseProjectConcurrency, saveProjectConcurrency } from "../project-concurrency.js";
82
82
  import { MAX_NETWORK_HOSTS, normalizeHost } from "../egress.js";
83
+ import { isWritesMode } from "../writes-mode.js";
83
84
  import { projectDeleteConfirmHtml, projectSettingsHtml } from "../project-delete-ui.js";
84
85
  import { deleteProject, holdingsWords } from "../project-delete.js";
85
86
  import { applySavedKnowledge, changeKnowledge, knowledgeVersion, knowledgeView } from "../project-knowledge.js";
@@ -2483,6 +2484,7 @@ ${modeWords(terms)
2483
2484
  "settings.project-concurrency": projectConcurrency,
2484
2485
  "settings.project-live-view": projectLiveView,
2485
2486
  "settings.project-network": projectNetwork,
2487
+ "settings.project-writes": projectWrites,
2486
2488
  "settings.project-delete": projectDelete,
2487
2489
  "settings.policy-send": policySave,
2488
2490
  "settings.approval-send": approvalSave,
@@ -2566,5 +2568,24 @@ ${modeWords(terms)
2566
2568
  "edge.google-callback": googleCallback,
2567
2569
  "edge.connect-callback": connectCallback,
2568
2570
  });
2571
+ // Settings → Project → Where builds write (writes-mode.ts). An approver for the project changes it with their password
2572
+ // typed now; the ledger keeps before → after. New scopes use it; approved ones keep theirs.
2573
+ async function projectWrites(ctx) {
2574
+ const { who, response, now, posted } = ctx;
2575
+ const body = readForm(posted, CONSOLE_FORMS.projectWrites);
2576
+ const repo = body.get("repo") ?? "";
2577
+ if (!consoleProjects().includes(repo) || !visible(repo))
2578
+ return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
2579
+ if (who.via !== "cookie" || who.role !== "approver" || store.isDemo() || !store.accountCanAccess(who.name, repo))
2580
+ return refuse(response, who, 403, "An approver for this project changes where its builds may write.", "/settings/project");
2581
+ const back = (key, words) => redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}#writes`);
2582
+ const wanted = body.get("writes");
2583
+ if (!isWritesMode(wanted))
2584
+ return back("problem", "Choose where builds may write. Nothing changed.");
2585
+ if (!authenticateCredential(who, body.get("password") ?? "").ok)
2586
+ return back("problem", "Enter your Toolroll password to change where builds may write.");
2587
+ const changed = store.setProjectWrites(repo, wanted, who.name, now);
2588
+ return back("said", changed.before === changed.after ? "Nothing changed." : "Saved. Tasks scoped from now on use it; tasks already approved keep what they were approved with.");
2589
+ }
2569
2590
  return { registrations, modelsView };
2570
2591
  }
@@ -61,9 +61,9 @@ import { agentQuestion, answerAgentQuestion, markAgentSeen } from "../agent-boar
61
61
  import { WorkIndexCursorError, workIndexPage } from "../work-index.js";
62
62
  import { buildProgressOf, parseWorkView } from "../workspace-ui.js";
63
63
  import { requestContext } from "./request-context.js";
64
- import { oneLineOf, QUEUE_VIEW, refuse, regionScript, screen, transcriptScript, whenTime } from "./chrome.js";
64
+ import { boardTickScript, oneLineOf, QUEUE_VIEW, refuse, regionScript, screen, transcriptScript, whenTime } from "./chrome.js";
65
65
  import { chatReturnWithSaid, matchTaskPath, redirect, respond, reviewHref, safeChatReturn, safeReturn, SAFETY, taskChatHref, taskHref } from "./http.js";
66
- import { boardBody, donePage, inboxFingerprints, inboxPage, newTaskPage, nextPage, parseInboxTab, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, tasksPage, workPage } from "./render-pages.js";
66
+ import { boardBody, boardWorkers, donePage, inboxFingerprints, inboxPage, newTaskPage, nextPage, parseInboxTab, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, tasksPage, workPage } from "./render-pages.js";
67
67
  import { runEfficiencyOf } from "../run-efficiency.js";
68
68
  import { ACCEPT_ANYWAY_NEEDS_REASON, editorFileHref, proofBundleView, rankReviewQueue, RESULT_REFUSALS, REVIEW_QUEUE_CAP, reviewCockpitDetailParts, reviewCockpitPage, reviewPriorityOf, runFactsFragment, runOutcomeBadge, runPage, RUNS_PAGE, runsPage, withRefusal } from "./render-results.js";
69
69
  import { approvalFormDigest, approveRefusalWords, checkProgressHtml, consentDoorOf, decisionPage, nonceHashOf, PermissionsWouldChange, requirementsFromEditor, resumeDigestOf, taskOf } from "./render-tasks.js";
@@ -399,7 +399,10 @@ export function createTasksHandlers(runtime) {
399
399
  });
400
400
  });
401
401
  const laneCards = [...cards, ...unverifiedCards];
402
- const body = boardBody({ cards: laneCards, done: verifiedDone, saturated: snapshot.saturated, now, all, project, delta }, pr => store.ciFailureObserved(pr));
402
+ // The worker strip: the workers this board's work can go to — bound to
403
+ // this project, or (rolled up) to any project the ceiling admits.
404
+ const workers = boardWorkers(store.listRunners(), { all, project, visible });
405
+ const body = boardBody({ cards: laneCards, workers, done: verifiedDone, saturated: snapshot.saturated, now, all, project, delta }, pr => store.ciFailureObserved(pr));
403
406
  if (url.searchParams.get("fragment") === "1") {
404
407
  // The live region alone — the in-page swapper's diet. Same auth,
405
408
  // same ceiling, no shell, no scripts (finding 2).
@@ -423,7 +426,7 @@ export function createTasksHandlers(runtime) {
423
426
  const regionBody = html `<div id="board-region">${body}</div><p class="meta" id="board-region-stamp"></p>`;
424
427
  return sendScreen(response, 200, screen("board", regionBody, {
425
428
  chrome: chromeFor(project, "board", undefined, all ? "board-all" : undefined),
426
- functional: { script: regionScript("board-region", "1"), fetches: true },
429
+ functional: { script: regionScript("board-region", "1", undefined, { animate: true }) + boardTickScript(), fetches: true },
427
430
  }));
428
431
  }
429
432
  /** A completed (or attempted) row as the review list ranks it: its proof reasons, CI standing, and the assignment