toolroll 0.9.73 → 0.9.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/dist/agent-turns.js +5 -0
  4. package/dist/agents-live.d.ts +31 -0
  5. package/dist/agents-live.js +43 -0
  6. package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
  7. package/dist/browser/workspace.css +2 -2
  8. package/dist/browser/workspace.js +31 -30
  9. package/dist/browser-workspace.d.ts +6 -0
  10. package/dist/builder.js +1 -1
  11. package/dist/chat-continuity.js +10 -0
  12. package/dist/chat-quiet.js +3 -3
  13. package/dist/deploy-gate.js +5 -2
  14. package/dist/desktop-update.d.ts +1 -1
  15. package/dist/dispose.js +35 -2
  16. package/dist/exec.js +13 -16
  17. package/dist/first-run.d.ts +17 -1
  18. package/dist/first-run.js +38 -0
  19. package/dist/invoke.d.ts +13 -0
  20. package/dist/invoke.js +21 -12
  21. package/dist/knowledge-ui.d.ts +4 -1
  22. package/dist/knowledge-ui.js +10 -5
  23. package/dist/lead-progress.d.ts +4 -1
  24. package/dist/lead-progress.js +3 -1
  25. package/dist/lead.js +4 -0
  26. package/dist/live-bus.d.ts +2 -0
  27. package/dist/live-bus.js +1 -0
  28. package/dist/live-children-read.d.ts +52 -0
  29. package/dist/live-children-read.js +333 -0
  30. package/dist/live-children.d.ts +73 -0
  31. package/dist/live-children.js +318 -0
  32. package/dist/live-detail.d.ts +4 -3
  33. package/dist/live-detail.js +9 -5
  34. package/dist/live-input.js +7 -8
  35. package/dist/live-main-read.d.ts +19 -0
  36. package/dist/live-main-read.js +91 -0
  37. package/dist/live-open.d.ts +28 -0
  38. package/dist/live-open.js +125 -0
  39. package/dist/live-records.d.ts +10 -1
  40. package/dist/live-records.js +16 -5
  41. package/dist/live.d.ts +54 -22
  42. package/dist/live.js +218 -69
  43. package/dist/operate.js +3 -2
  44. package/dist/peek-cli.d.ts +10 -3
  45. package/dist/peek-cli.js +17 -8
  46. package/dist/planner.js +1 -1
  47. package/dist/provider-limits.d.ts +3 -1
  48. package/dist/provider-limits.js +5 -2
  49. package/dist/provider.js +6 -12
  50. package/dist/release-adapters.js +4 -0
  51. package/dist/release.d.ts +3 -0
  52. package/dist/release.js +3 -0
  53. package/dist/repository-context-ui.js +2 -1
  54. package/dist/run-efficiency.d.ts +3 -3
  55. package/dist/run-efficiency.js +20 -8
  56. package/dist/scout.d.ts +3 -0
  57. package/dist/scout.js +5 -3
  58. package/dist/serve.js +34 -23
  59. package/dist/server/agents-live-lead.d.ts +47 -0
  60. package/dist/server/agents-live-lead.js +116 -0
  61. package/dist/server/agents-live-room.d.ts +23 -0
  62. package/dist/server/agents-live-room.js +88 -0
  63. package/dist/server/chat.js +84 -30
  64. package/dist/server/chrome.js +2 -2
  65. package/dist/server/console.css +68 -40
  66. package/dist/server/live.d.ts +4 -0
  67. package/dist/server/live.js +42 -2
  68. package/dist/server/pages.js +81 -1
  69. package/dist/server/render-chat.d.ts +15 -1
  70. package/dist/server/render-chat.js +14 -7
  71. package/dist/server/render-pages.d.ts +8 -4
  72. package/dist/server/render-pages.js +55 -50
  73. package/dist/server/render-tasks.js +10 -3
  74. package/dist/server/route-table.js +5 -1
  75. package/dist/server/runtime.d.ts +1 -1
  76. package/dist/server/settings.js +3 -3
  77. package/dist/server/task-views.js +1 -1
  78. package/dist/server/tasks.js +59 -41
  79. package/dist/store.d.ts +31 -1
  80. package/dist/store.js +75 -2
  81. package/dist/stream-scope.d.ts +18 -0
  82. package/dist/stream-scope.js +28 -0
  83. package/dist/subscription-chat.js +5 -3
  84. package/package.json +1 -1
@@ -39,7 +39,7 @@ import { html, htmlString, postForm, reauth } from "../html.js";
39
39
  import { requestContext } from "./request-context.js";
40
40
  import { refuse, screen, whenTime } from "./chrome.js";
41
41
  import { chatResultHref, chatReturnWithLatest, chatReturnWithSaid, projectChatHref, redirect, respond, safeChatReturn, safeReturn, taskChatHref, taskHref } from "./http.js";
42
- import { chatAckPage, chatPage, coordinatorProposalsSection, decisionsFor, leadAfterComposerHtml, leadChatVersion, leadMintCard, leadPage, leadThreadHtml, PHONE_CARD_FACT, TASK_COMPOSER_MODES, taskChatLiveRegion, teamProposalCardParts } from "./render-chat.js";
42
+ import { chatAckPage, chatPage, NOT_STARTED_SESSION, coordinatorProposalsSection, decisionsFor, leadAfterComposerHtml, leadChatVersion, leadMintCard, leadPage, leadThreadHtml, PHONE_CARD_FACT, TASK_COMPOSER_MODES, taskChatLiveRegion, teamProposalCardParts } from "./render-chat.js";
43
43
  import {} from "./render-pages.js";
44
44
  import { completionForm, hardBlockerOf, homePhaseWords, owedAcceptanceOf, resultPanelHtml } from "./render-results.js";
45
45
  import { browserCaller, oneLineUa } from "./session.js";
@@ -54,25 +54,31 @@ export function createChatHandlers(runtime) {
54
54
  async function mateStatus(ctx) {
55
55
  const { url, response, now } = ctx;
56
56
  const who = browserCaller(ctx.who);
57
- const session = store.activeLeadSession(who.name);
57
+ const found = store.activeLeadSession(who.name);
58
58
  const principal = leadPrincipal(who);
59
- if (session === null || principal === null || session.ceilingDigest !== principal.ceilingDigest || session.approverGeneration !== principal.generation) {
59
+ // Quiet page visits: a membership conversation not started yet is still a page to keep current (its task's
60
+ // progress and approval); it reads as session 0, and nothing here starts it.
61
+ const enabled = chatEnablement();
62
+ const notStarted = principal !== null && enabled.ok && enabled.billing === "subscription" && (found === null || found.ceilingDigest !== principal.ceilingDigest);
63
+ const session = notStarted ? null : found;
64
+ if (principal === null || (!notStarted && (session === null || session.ceilingDigest !== principal.ceilingDigest || session.approverGeneration !== principal.generation))) {
60
65
  return respond(response, 200, "application/json", JSON.stringify({ session: null }));
61
66
  }
67
+ const sessionId = session?.id ?? NOT_STARTED_SESSION;
62
68
  store.sweepStaleLeadTurns(now);
63
69
  const request = url.searchParams.get("request") ?? "";
64
70
  // Received: the engine took it, or the lead queue holds it (a queued acknowledgement lost on the way still frees the draft).
65
- const received = /^[a-f0-9]{32}$/.test(request) && store.leadRequestReceived(session.id, request);
71
+ const received = session !== null && /^[a-f0-9]{32}$/.test(request) && store.leadRequestReceived(session.id, request);
66
72
  const requestedTask = url.searchParams.get("task");
67
73
  const focusTask = requestedTask === null || requestedTask === "" ? null : taskChatFocus(requestedTask, now, who, { mintNonce: false });
68
74
  if (requestedTask !== null && requestedTask !== "" && focusTask === null) {
69
75
  // The lens's task is gone or no longer admitted: the page keeps its
70
76
  // draft and says so; it never falls back to the unified thread.
71
- return respond(response, 200, "application/json", JSON.stringify({ session: session.id, task: requestedTask, unavailable: true, received }));
77
+ return respond(response, 200, "application/json", JSON.stringify({ session: sessionId, task: requestedTask, unavailable: true, received }));
72
78
  }
73
79
  const chatProject = focusTask !== null ? null : chatProjectOf(url.searchParams.getAll("project").filter(one => one !== ""));
74
80
  if (chatProject === undefined)
75
- return respond(response, 200, "application/json", JSON.stringify({ session: session.id, unavailable: true, received }));
81
+ return respond(response, 200, "application/json", JSON.stringify({ session: sessionId, unavailable: true, received }));
76
82
  const rows = leadConversationRows(who, principal, focusTask, now, chatProject);
77
83
  const version = leadChatVersion({ ...rows, focusTask });
78
84
  const known = url.searchParams.get("version") ?? "";
@@ -80,12 +86,12 @@ export function createChatHandlers(runtime) {
80
86
  const fragments = known === version
81
87
  ? null
82
88
  : {
83
- thread: htmlString(leadThreadHtml({ ...rows, focusTask, csrf, now, problem: takeLeadNote(csrf, session.id), chatProject })),
89
+ thread: htmlString(leadThreadHtml({ ...rows, focusTask, csrf, now, problem: takeLeadNote(csrf, session?.id ?? null), chatProject })),
84
90
  after: htmlString(leadAfterComposerHtml({ messages: rows.messages, pending: rows.pending, focusTask })),
85
91
  live: focusTask === null ? null : htmlString(taskChatLiveRegion(focusTask, csrf, true, rows.pending !== null)),
86
92
  };
87
93
  return respond(response, 200, "application/json", JSON.stringify({
88
- session: session.id,
94
+ session: sessionId,
89
95
  task: focusTask?.id ?? "",
90
96
  version,
91
97
  pending: rows.pending !== null,
@@ -237,24 +243,22 @@ export function createChatHandlers(runtime) {
237
243
  // The mate (mate arc §5): while a mate session is live, /chat IS the
238
244
  // thread — the same rows the CLI reads. Without one, fleet chat as
239
245
  // before, plus the card that mints a session.
240
- let leadSession = enabled.ok && who.role === "approver" ? store.activeLeadSession(who.name) : null;
246
+ const leadSession = enabled.ok && who.role === "approver" ? store.activeLeadSession(who.name) : null;
241
247
  const principal = enabled.ok && who.role === "approver" ? leadPrincipal(who) : null;
242
248
  // Chat opens ready to talk (2026-09-23): an approver in good standing
243
- // needs no second password to start their own conversation. The one
244
- // exception to "a GET writes nothing": the strict same-site session
245
- // cookie keeps other sites out, and starting spends nothing. Actions
246
- // the lead proposes still go through their own confirmation cards.
247
- // Membership chat only: it spends no dollars. Direct-API chat keeps a
248
- // one-tap Start with its visible spending limit (no password).
249
+ // needs no second password to start their own conversation. Quiet page
250
+ // visits (2026-10-27): a visit only shows it. With no membership
251
+ // conversation yet (or only one under another ceiling), the page shows
252
+ // the composer and the first message starts one (chatSend); nothing
253
+ // here writes. Direct-API chat keeps a one-tap Start with its visible
254
+ // spending limit (no password).
249
255
  const settingsView = url.searchParams.get("settings") === "1";
250
- if (!settingsView && enabled.ok && enabled.billing === "subscription" && principal !== null && (leadSession === null || leadSession.ceilingDigest !== principal.ceilingDigest) && !requestContext.getStore()?.workspaceRead) {
251
- startLeadConversation(who, principal, enabled, 0, false, now);
252
- leadSession = store.activeLeadSession(who.name);
253
- }
256
+ const notStarted = !settingsView && enabled.ok && enabled.billing === "subscription" && principal !== null &&
257
+ (leadSession === null || leadSession.ceilingDigest !== principal.ceilingDigest);
254
258
  // A session under another ceiling is not continuable from here; a GET
255
259
  // writes nothing (slice-2 review, finding 7) — the mint card below
256
260
  // starts a new conversation, and minting ends the old session.
257
- const ceilingStale = enabled.ok && leadSession !== null && principal !== null && leadSession.ceilingDigest !== principal.ceilingDigest;
261
+ const ceilingStale = !notStarted && enabled.ok && leadSession !== null && principal !== null && leadSession.ceilingDigest !== principal.ceilingDigest;
258
262
  const catchUp = leadBriefHtml(assignmentCatchUp(store, now, { principal: "operator", repos }, { limit: 6 }, evidenceRoot));
259
263
  const chatProjects = repos.map((repo, index) => {
260
264
  let peek = null;
@@ -288,11 +292,12 @@ export function createChatHandlers(runtime) {
288
292
  const withFirstRun = (shown) => firstRun === undefined && phone === undefined && home === null ? shown
289
293
  : { ...shown, ...(home === null ? {} : { refreshSeconds: liveRefreshSeconds() }),
290
294
  workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
291
- if (enabled.ok && leadSession !== null && principal !== null && !ceilingStale) {
295
+ if (enabled.ok && principal !== null && (notStarted || (leadSession !== null && !ceilingStale))) {
292
296
  {
293
- const said = takeLeadNote(who.session.csrf, leadSession.id);
297
+ const said = takeLeadNote(who.session.csrf, notStarted ? null : leadSession?.id ?? null);
294
298
  return sendScreen(response, 200, withFirstRun(leadPage(chromeFor(null, "chat", undefined, "all"), {
295
- session: leadSession,
299
+ session: notStarted ? null : leadSession,
300
+ approver: who.name,
296
301
  ...leadConversationRows(who, principal, focusTask, now, chatProject),
297
302
  chatProject,
298
303
  latched,
@@ -663,7 +668,9 @@ export function createChatHandlers(runtime) {
663
668
  const body = readForm(posted, CONSOLE_FORMS.leadFollow);
664
669
  if (who.via !== "cookie")
665
670
  return refuse(response, who, 403, "Open the conversation to change automatic updates.");
666
- const principal = leadPrincipal(who), session = store.activeLeadSession(who.name), thread = store.liveLeadThreadFor(who.name);
671
+ const principal = leadPrincipal(who), session = store.activeLeadSession(who.name);
672
+ // A conversation its first message started from a task or project page has no main thread yet: this act opens it.
673
+ const thread = principal && session && session.ceilingDigest === principal.ceilingDigest ? store.openLeadThread(who.name, principal.ceilingDigest, now).thread : null;
667
674
  if (!principal || !session || !thread || !configureLeadFollow(store, principal, session, thread, body.get("enabled") === "yes", now))
668
675
  return refuse(response, who, 409, "Conversation access changed. Start chat again.", "/chat");
669
676
  return redirect(response, safeChatReturn(body.get("return")));
@@ -875,10 +882,52 @@ export function createChatHandlers(runtime) {
875
882
  // A live mate session: the message is a mate turn — no password, the
876
883
  // session's ceremony already covered it (§1); the engine refuses on
877
884
  // its own terms and the thread shows why.
878
- const leadSession = who.role === "approver" ? store.activeLeadSession(who.name) : null;
885
+ let leadSession = who.role === "approver" ? store.activeLeadSession(who.name) : null;
886
+ // Quiet page visits: a membership conversation starts with its first message, never with a page view. A page
887
+ // shown before one existed binds its sends to session 0; that send starts it (the same approver standing a visit
888
+ // used to need), and a retry or double submit of the same request lands in the conversation it started.
889
+ let startedHere = false;
890
+ const binding = body.getAll("request-session");
891
+ const unbound = binding.length === 0 || (binding.length === 1 && binding[0] === String(NOT_STARTED_SESSION));
892
+ if (who.role === "approver" && enabled.billing === "subscription" && unbound) {
893
+ const principal = leadPrincipal(who);
894
+ if (principal === null)
895
+ return wantsJson ? said(403, "your approver standing changed — sign in again") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
896
+ const sentRequest = body.get("request");
897
+ if (sentRequest !== null && (body.getAll("request").length !== 1 || !/^[a-f0-9]{32}$/.test(sentRequest)))
898
+ return said(409, "This conversation changed. Reload it before sending your message.");
899
+ const usable = leadSession !== null && leadSession.ceilingDigest === principal.ceilingDigest && leadSession.approverGeneration === principal.generation;
900
+ // The conversation this very request started, if it started one: a retry or double submit lands only there.
901
+ const startedBy = sentRequest === null ? null : store.leadStartReceipt(who.name, sentRequest);
902
+ if (startedBy !== null) {
903
+ if (!usable || leadSession === null || leadSession.id !== startedBy.session)
904
+ return said(409, "This conversation ended or changed. Reload it before sending your message.", leadSession?.id ?? null);
905
+ startedHere = true;
906
+ }
907
+ else if (usable && leadSession !== null) {
908
+ // A page that showed no conversation never sends into one it did not start.
909
+ if (binding.length === 1)
910
+ return said(409, "This conversation changed. Reload it before sending your message.", leadSession.id);
911
+ }
912
+ else {
913
+ // Nothing starts for a message that would be refused anyway.
914
+ const message = (body.get("message") ?? "").trim();
915
+ if (message === "" || message.length > LEAD_MESSAGE_MAX_CHARS)
916
+ return said(400, `a message is 1 to ${LEAD_MESSAGE_MAX_CHARS} characters`);
917
+ if (scanForSecrets(message).length > 0)
918
+ return said(400, "that looks like a credential — chat never forwards or stores those");
919
+ const scope = chatScopeOf(focusTask, chatProject);
920
+ // The start and its record are one write: a retry finds it, and never starts a second conversation.
921
+ const started = startLeadConversation(who, principal, enabled, 0, false, now, scope, sentRequest ?? undefined);
922
+ leadSession = store.activeLeadSession(who.name);
923
+ if (leadSession === null || leadSession.id !== started)
924
+ return said(409, "This conversation changed. Reload it before sending your message.");
925
+ startedHere = true;
926
+ }
927
+ }
879
928
  if (leadSession !== null) {
880
929
  const sentRequest = body.get("request");
881
- if (sentRequest !== null && (body.getAll("request").length !== 1 || body.getAll("request-session").length !== 1 || body.get("request-session") !== String(leadSession.id))) {
930
+ if (sentRequest !== null && !startedHere && (body.getAll("request").length !== 1 || body.getAll("request-session").length !== 1 || body.get("request-session") !== String(leadSession.id))) {
882
931
  return said(409, "This conversation changed. Reload it before sending your message.");
883
932
  }
884
933
  // Every send has one identity (a form without one gets it here): a message that waits is known by it.
@@ -1136,16 +1185,18 @@ export function createChatHandlers(runtime) {
1136
1185
  const previous = liveTurns.get(thread);
1137
1186
  if (previous?.expiry)
1138
1187
  clearTimeout(previous.expiry);
1139
- const live = { steps: [], done: false, ok: false, listeners: previous?.listeners ?? new Set() };
1188
+ const live = { steps: [], done: false, ok: false, listeners: previous?.listeners ?? new Set(), startedAt: clock().getTime(), stepAt: [] };
1140
1189
  liveTurns.set(thread, live);
1141
1190
  // A page that has this conversation open follows the reply (GET /live, its chat room).
1142
1191
  runtime.liveTurnStarted(thread);
1143
- const current = () => live.steps.at(-1) ?? (live.steps.push({ tools: [], toolCalls: [], text: "" }), live.steps.at(-1));
1192
+ const newStep = () => { live.steps.push({ tools: [], toolCalls: [], text: "" }); live.stepAt.push(clock().getTime()); };
1193
+ const current = () => live.steps.at(-1) ?? (newStep(), live.steps.at(-1));
1144
1194
  const progress = (event) => {
1145
1195
  if (live.done)
1146
1196
  return;
1197
+ live.turn ??= event.turn;
1147
1198
  if (event.kind === "step")
1148
- live.steps.push({ tools: [], toolCalls: [], text: "" });
1199
+ newStep();
1149
1200
  else if (event.kind === "tool") {
1150
1201
  current().tools.push(event.label);
1151
1202
  current().toolCalls.push({ id: event.id, label: event.label, state: "running" });
@@ -1159,8 +1210,10 @@ export function createChatHandlers(runtime) {
1159
1210
  }
1160
1211
  }
1161
1212
  }
1162
- else if (event.kind === "text")
1213
+ else if (event.kind === "text") {
1163
1214
  current().text = event.text;
1215
+ (live.confirmed ??= [])[live.steps.length - 1] = event.final === true;
1216
+ }
1164
1217
  for (const listener of live.listeners)
1165
1218
  listener();
1166
1219
  };
@@ -1175,6 +1228,7 @@ export function createChatHandlers(runtime) {
1175
1228
  return;
1176
1229
  live.done = true;
1177
1230
  live.ok = ok;
1231
+ live.endedAt = clock().getTime();
1178
1232
  for (const listener of live.listeners)
1179
1233
  listener();
1180
1234
  live.expiry = setTimeout(() => { if (liveTurns.get(thread) === live)
@@ -396,7 +396,7 @@ export function chromeScript() {
396
396
  `if(ev.key==="?"){toggleHelp();ev.preventDefault();return;}` +
397
397
  `if(ev.key==="j"||ev.key==="k"){rove(ev.key==="j"?1:-1,ev);return;}` +
398
398
  `if(pending==="g"){pending=null;` +
399
- `var map={b:"/board",i:"/",w:"/workbench",d:"/done",q:"/board?view=order",f:"/fleet",t:"/tasks",a:"/activity",p:"/projects"};` +
399
+ `var map={b:"/board",i:"/",w:"/workbench",d:"/done",q:"/board?view=order",f:"/fleet",t:"/work",a:"/activity",p:"/projects"};` +
400
400
  `if(map[ev.key]){go(map[ev.key]);ev.preventDefault();}return;}` +
401
401
  `if(ev.key==="g"){pending="g";setTimeout(function(){pending=null;},800);}});` +
402
402
  `})();`);
@@ -717,7 +717,7 @@ export function workToolRows(scoped = false, _offersChat = false, offersCode = f
717
717
  ...(offersCode ? [{ key: "code", href: "/term", label: "Live", hint: "your own terminals: Claude Code, Codex or a shell" }] : []),
718
718
  { key: "inbox", href: "/inbox", label: "Inbox", hint: "questions, approvals, and repairs to act on" },
719
719
  { key: "board", href: "/board", label: "Board", hint: "lanes by state, with the order view" },
720
- { key: "tasks", href: "/tasks", label: "Task list", hint: "everything, filterable by state" },
720
+ { key: "tasks", href: "/work", label: "Task list", hint: "everything, filterable by state" },
721
721
  { key: "recipes", href: "/recipes", label: "Recipes", hint: "choose, customize, and reuse a workflow" },
722
722
  { key: "workbench", href: "/workbench", label: "Portfolio", hint: "every project and live build in one place" },
723
723
  { key: "ledger", href: "/ledger", label: "Action ledger", hint: "who acted, what happened, and the result" },
@@ -172,6 +172,30 @@
172
172
  color: color-mix(in srgb, var(--running) 72%, var(--foreground)); border-color: var(--border);
173
173
  }
174
174
  .badge-cut { background: var(--muted); }
175
+ /* The status chip (components/ui/badge.tsx `size="chip"`, after ReUI's light badge with a dot): 20px, 6px
176
+ corners, a soft wash, 11.5px words and a 6px dot. Needs you is the amber wash with its words leaned toward ink
177
+ (4.5:1); the dot is decoration and the word carries the meaning. */
178
+ .chip {
179
+ display: inline-flex; align-items: center; gap: .375rem; height: 1.25rem; padding: 0 .375rem; border-radius: var(--so-radius-sm);
180
+ font-family: var(--font-sans); font-size: .71875rem; font-weight: 500; line-height: 1; white-space: nowrap; vertical-align: middle;
181
+ background: var(--so-neutral-soft); color: var(--so-ink);
182
+ }
183
+ /* Ink words, the tone in the dot (the Agents → Live chip); Needs you keeps amber words leaned toward ink. */
184
+ .chip-dot { width: .375rem; height: .375rem; flex-shrink: 0; border-radius: var(--so-radius-full); background: var(--so-neutral-ink); }
185
+ .chip-needs { background: var(--so-warning-soft); color: color-mix(in oklab, var(--so-warning) 85%, var(--so-ink)); }
186
+ .chip-needs .chip-dot { background: var(--so-warning); }
187
+ .chip-live { background: var(--so-info-soft); }
188
+ .chip-live .chip-dot { background: var(--so-info); }
189
+ .chip-done { background: var(--so-success-soft); }
190
+ .chip-done .chip-dot { background: var(--so-success); }
191
+ .chip-failed { background: var(--so-danger-soft); }
192
+ .chip-failed .chip-dot { background: var(--so-danger); }
193
+ /* The frame (components/ui/frame.tsx, after ReUI's Frame): a raised well with a hairline, its heading on the
194
+ well's fill, and paper panels whose corners sit concentric inside it. No shadow on a panel. */
195
+ .frame { display: flex; flex-direction: column; gap: .25rem; padding: .25rem; border: 1px solid var(--so-line); border-radius: var(--so-radius-card); background: var(--so-raised); }
196
+ .frame-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; min-height: 2rem; padding: .25rem .625rem; }
197
+ .frame-panel { position: relative; border: 1px solid var(--so-line); border-radius: calc(var(--so-radius-card) - .25rem - 1px); background: var(--so-paper); padding: .75rem .875rem; }
198
+ .frame-panel.waits { border-color: var(--so-warning); }
175
199
 
176
200
  .card {
177
201
  border: 1px solid var(--glass-border); border-radius: var(--radius); background: var(--glass);
@@ -1749,13 +1773,13 @@
1749
1773
  says which way it folds. */
1750
1774
  @media (max-width: 760px) {
1751
1775
  .board { display: flex; flex-direction: column; align-items: stretch; gap: .5rem; padding-bottom: 0; }
1752
- .board .lane { min-height: 0; padding: 0 .625rem .125rem; }
1776
+ .board .lane { min-height: 0; padding: 0 .25rem .25rem; }
1753
1777
  .board .lane > summary {
1754
1778
  cursor: pointer; display: flex; align-items: center; min-height: 2.75rem; margin: 0;
1755
1779
  -webkit-tap-highlight-color: transparent;
1756
1780
  }
1757
- .board .lane > summary h2 { flex: 1; font-size: .75rem; }
1758
- .board .lane > summary .lane-count { border: 1px solid var(--border); border-radius: 999px; padding: .04rem .5rem; margin-left: .25rem; }
1781
+ .board .lane > summary { padding: 0 .375rem; }
1782
+ .board .lane > summary h2 { flex: 1; }
1759
1783
  .board .lane > summary::after {
1760
1784
  content: ""; width: .5rem; height: .5rem; flex: none; margin-right: .25rem;
1761
1785
  border-right: 1.5px solid var(--muted-foreground); border-bottom: 1.5px solid var(--muted-foreground);
@@ -1763,63 +1787,66 @@
1763
1787
  }
1764
1788
  .board .lane[open] > summary::after { transform: rotate(225deg) translateY(-.125rem); }
1765
1789
  .board .lane .hint { margin-bottom: .25rem; }
1766
- .board .lane .lane-card:last-of-type { margin-bottom: .5rem; }
1790
+ .board .lane .lane-card:last-of-type { margin-bottom: 0; }
1767
1791
  .board .lane-attention { order: 0; }
1768
1792
  .board .lane-building { order: 1; }
1769
1793
  .board .lane-queued { order: 2; }
1770
1794
  .board .lane-waiting { order: 3; }
1771
1795
  .board .lane-done { order: 4; }
1772
1796
  }
1797
+ /* A lane is a frame (components/ui/frame.tsx, after ReUI's Frame): a raised well with a hairline, its head a
1798
+ status chip and a mono count on the well's fill, its cards paper panels at concentric corners. Only the lane
1799
+ that waits on a person is amber: its chip, and each card's thin full outline. No fill, no stripe, no shadow. */
1773
1800
  .lane {
1774
- background: color-mix(in srgb, var(--card) 45%, var(--background)); border: 1px solid var(--border);
1775
- border-radius: var(--radius); padding: .625rem; min-height: 12rem;
1801
+ background: var(--so-raised); border: 1px solid var(--so-line);
1802
+ border-radius: var(--so-radius-card); padding: .25rem; min-height: 12rem;
1776
1803
  }
1777
- .lane > summary { list-style: none; cursor: default; margin: 0 0 .125rem; }
1804
+ .lane > summary { list-style: none; cursor: default; margin: 0; padding: .375rem .375rem .125rem; }
1778
1805
  .lane > summary::-webkit-details-marker { display: none; }
1779
- .lane h2 { display: flex; align-items: center; gap: .4rem; margin: 0; font-size: .6875rem; }
1806
+ .lane h2 { display: flex; align-items: center; gap: .5rem; margin: 0; font-size: .8125rem; font-weight: 600; }
1780
1807
  .lane h2 a { color: inherit; text-decoration: none; }
1781
1808
  @media (hover: hover) and (pointer: fine) { .lane h2 a:hover { text-decoration: underline; } }
1782
- .lane .hint { margin-top: 0; }
1783
- .lane-count { color: var(--muted-foreground); font-weight: 400; font-variant-numeric: tabular-nums; }
1784
- .lane h2::before {
1785
- content: ""; width: .375rem; height: .375rem; border-radius: var(--so-radius-full); flex: none;
1786
- background: var(--muted-foreground); opacity: .55;
1787
- }
1788
- .lane-attention h2::before { background: var(--brand); opacity: 1; }
1789
- .lane-building h2::before { background: var(--running); opacity: 1; }
1790
- .lane-done h2::before { background: var(--muted-foreground); opacity: 1; }
1809
+ .lane .hint { margin: 0; padding: .125rem .375rem .375rem; }
1810
+ .lane-count { color: var(--muted-foreground); font-family: var(--font-mono); font-size: .6875rem; font-weight: 500; font-variant-numeric: tabular-nums; }
1791
1811
  .lane-card {
1792
- display: block; text-decoration: none; color: inherit;
1793
- background: var(--card); border: 1px solid var(--border);
1794
- border-radius: calc(var(--radius) - 2px); padding: .5rem .625rem; margin-top: .5rem;
1795
- }
1796
- @media (hover: hover) and (pointer: fine) { .lane-card:hover { border-color: color-mix(in srgb, var(--border) 55%, var(--muted-foreground)); } }
1797
- .lane-card .id { display: block; font-family: var(--font-mono); font-size: .6875rem; color: var(--muted-foreground); margin-bottom: .125rem; overflow-wrap: anywhere; }
1798
- .lane-card .t { display: block; font-size: .8125rem; font-weight: 500; line-height: 1.35; }
1812
+ display: flex; flex-direction: column; text-decoration: none; color: inherit;
1813
+ background: var(--so-paper); border: 1px solid var(--so-line);
1814
+ border-radius: calc(var(--so-radius-card) - .25rem - 1px); padding: .625rem .75rem; margin-top: .25rem;
1815
+ }
1816
+ .lane-attention .lane-card { border-color: var(--so-warning); }
1817
+ /* The order view's columns are frames too, with roomier insides (a form, a note) and a quiet dot by the name. */
1818
+ .lane.queue-column { padding: .5rem; }
1819
+ .queue-column > h2::before { content: ""; width: .375rem; height: .375rem; border-radius: var(--so-radius-full); flex: none; background: var(--muted-foreground); opacity: .55; }
1820
+ @media (hover: hover) and (pointer: fine) { .lane-card:hover { background: var(--so-raised); } }
1821
+ /* The title leads; the task id is a machine fact under it, in mono. */
1822
+ .lane-card .t { display: block; font-size: .84375rem; font-weight: 500; line-height: 1.35; }
1823
+ .lane-card .why { display: block; margin-top: .125rem; font-size: .75rem; color: var(--muted-foreground); }
1824
+ .lane-card .id { display: block; margin-top: .375rem; font-family: var(--font-mono); font-size: .75rem; color: var(--muted-foreground); overflow-wrap: anywhere; }
1825
+ .lane-card .who { display: block; margin-top: .125rem; font-size: .75rem; color: var(--muted-foreground); overflow-wrap: anywhere; }
1826
+ .lane-card .who .mono { display: inline; margin: 0; font-family: var(--font-mono); font-size: .6875rem; }
1799
1827
  .lane-card .dot { margin-right: .4rem; }
1800
1828
  .lane-card .meta, .lane-card .mono { display: block; margin-top: .125rem; font-size: .75rem; }
1801
- .lane-card .why { display: block; margin-top: .125rem; font-size: .75rem; color: var(--muted-foreground); }
1802
- /* The facts: mono key–value pairs, keys dim, values ink — one grammar on every lane. */
1803
- .lane-card .facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .0625rem .625rem; margin-top: .4rem; font-size: .6875rem; line-height: 1.5; }
1829
+ /* The facts: a muted word, then the value; only a machine's own words (a name, a model, a branch, a clock) are mono. */
1830
+ .lane-card .facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .0625rem .625rem; margin-top: .25rem; font-size: .75rem; line-height: 1.5; }
1804
1831
  .lane-card .fact { display: contents; }
1805
- .lane-card .fact .k { color: var(--muted-foreground); font-family: var(--font-mono); }
1806
- .lane-card .fact .v { font-family: var(--font-mono); color: var(--foreground); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
1807
- .lane-card .chips { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .4rem; }
1832
+ .lane-card .fact .k { color: var(--muted-foreground); }
1833
+ .lane-card .fact .v { color: var(--foreground); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
1834
+ .lane-card .fact .v.mono { display: inline; margin: 0; font-family: var(--font-mono); font-size: .6875rem; }
1835
+ .lane-card .chips { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .5rem; }
1808
1836
  .lane-card .chips .badge { margin: 0; }
1809
- /* The live strip on a building card: stage and clock in an inset well —
1810
- the run's own facts, never a percent. */
1837
+ /* The live strip on a building card: the stage in words, the clock in mono, on a sunken well. */
1811
1838
  .lane-card .live-line {
1812
1839
  display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-top: .375rem;
1813
- padding: .3rem .5rem; border-radius: calc(var(--radius) - 4px); background: var(--muted);
1814
- font-family: var(--font-mono); font-size: .6875rem; color: var(--running); font-variant-numeric: tabular-nums;
1840
+ padding: .3rem .5rem; border-radius: var(--so-radius-sm); background: var(--so-raised);
1841
+ font-size: .75rem; color: var(--so-info);
1815
1842
  }
1816
- .lane-card .live-line .clock { color: var(--muted-foreground); }
1843
+ .lane-card .live-line .clock { color: var(--muted-foreground); font-family: var(--font-mono); font-size: .6875rem; font-variant-numeric: tabular-nums; }
1817
1844
  /* The build's steps — set up, writing, checking and committing: three quiet
1818
1845
  ticks, the current one in the running hue. A stage, never a percent. */
1819
1846
  .lane-card .steps { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .1875rem; margin-top: .5rem; }
1820
- .lane-card .steps i { height: .1875rem; border-radius: 999px; background: var(--border); }
1821
- .lane-card .steps i.on { background: color-mix(in srgb, var(--running) 45%, var(--border)); }
1822
- .lane-card .steps i.now { background: var(--running); }
1847
+ .lane-card .steps i { height: .1875rem; border-radius: var(--so-radius-full); background: var(--so-line); }
1848
+ .lane-card .steps i.on { background: color-mix(in srgb, var(--so-info) 45%, var(--so-line)); }
1849
+ .lane-card .steps i.now { background: var(--so-info); }
1823
1850
 
1824
1851
  /* The worker strip: one quiet tile per worker above the lanes — what it is
1825
1852
  building here, the stage, the clock. Ink and lines only; the running dot
@@ -2613,8 +2640,9 @@ button { min-height: 44px; }
2613
2640
  .chat-workspace.projects-open::before { animation: fade 180ms ease-out; }
2614
2641
  .switcher[open] .switcher-menu { animation: rise 160ms ease-out; }
2615
2642
  @media (hover: hover) and (pointer: fine) {
2616
- .lane-card, .decide-card, .menu-row, .chat-overview-item, .proposal { transition: border-color .15s, transform .15s, box-shadow .15s; }
2617
- .lane-card:hover, .decide-card:hover, .menu-row:hover {
2643
+ .lane-card, .decide-card, .menu-row, .chat-overview-item, .proposal { transition: border-color .15s, background-color .15s, transform .15s, box-shadow .15s; }
2644
+ /* Board cards are panels in a frame: a hover fills them raised, never a shadow. */
2645
+ .decide-card:hover, .menu-row:hover {
2618
2646
 
2619
2647
  box-shadow: 0 2px 8px -2px rgb(0 0 0 / .35);
2620
2648
  }
@@ -45,6 +45,10 @@ export type LiveRoom = {
45
45
  } | {
46
46
  kind: "agent";
47
47
  run: number;
48
+ }
49
+ /** The Agents view's Live grid (agents-live-room.ts): per viewer, watching only. */
50
+ | {
51
+ kind: "agents-live";
48
52
  };
49
53
  /** A room's name, read strictly: anything else is not a room. */
50
54
  export declare function parseLiveRoom(name: string): LiveRoom | null;
@@ -9,9 +9,11 @@ import { handlersOf } from "./handler-registry.js";
9
9
  import { respond } from "./http.js";
10
10
  import { adapterPolicy } from "./route-policy.js";
11
11
  import { requestContext } from "./request-context.js";
12
+ import { createAgentsLiveRooms } from "./agents-live-room.js";
13
+ import { leadTilesFor } from "./agents-live-lead.js";
12
14
  /** A page asks for a few rooms at once: its workspace, what it shows, its conversation. */
13
15
  export const LIVE_ROOMS_PER_PAGE = 6;
14
- const ROOM_NAME = /^(workspace|task|flow|chat|team|agent|summary)(?::([^?]{1,400}))?(?:\?([^#]{0,600}))?$/;
16
+ const ROOM_NAME = /^(workspace|task|flow|chat|team|agent|summary|agents-live)(?::([^?]{1,400}))?(?:\?([^#]{0,600}))?$/;
15
17
  /** At most one workspace nudge a second: a burst of writes is one re-read, the first heard at once. */
16
18
  const WORKSPACE_SETTLE_MS = 1_000;
17
19
  /** A running reply's preview: at most one snapshot every 80 ms; the last one always goes out. */
@@ -34,6 +36,7 @@ export function parseLiveRoom(name) {
34
36
  switch (kind) {
35
37
  case "workspace": return key === null && query === undefined ? { kind } : null;
36
38
  case "summary": return key === null && query === undefined ? { kind } : null;
39
+ case "agents-live": return key === null && query === undefined ? { kind } : null;
37
40
  case "task": return key !== null && key !== "" && query === undefined ? { kind, task: key } : null;
38
41
  case "flow": {
39
42
  const flow = Number(key), card = params.get("card");
@@ -92,6 +95,11 @@ export function createLiveHandlers(runtime, live) {
92
95
  const chatViewers = new Set();
93
96
  // One agent's open view (Agents board): what it shows, keyed by the exact run.
94
97
  const agentRooms = createLiveRooms(run => agentRunFingerprint(store, run), null, { bus: live.bus });
98
+ // The Live grid: one room per viewer and scope, watching only (no lease, no seen mark).
99
+ // Each Live page's own lead tiles, read as its viewer's saved session now (a page's room key carries the page).
100
+ const liveLeads = new Map();
101
+ const agentsLive = createAgentsLiveRooms({ store, bus: live.bus, evidenceRoot: runtime.evidenceRoot, localRunner: runtime.options.localRunner, clock, visible,
102
+ leads: key => liveLeads.get(key)?.() ?? null, forget: key => liveLeads.delete(key) });
95
103
  const readBrowser = (request, who) => rereadBrowser(runtime, request, who);
96
104
  const sameBrowser = (request, who) => () => readBrowser(request, who) !== null;
97
105
  function admitted(viewer, thread) {
@@ -196,10 +204,11 @@ export function createLiveHandlers(runtime, live) {
196
204
  if ([...url.searchParams.keys()].some(key => key !== "room") || names.length === 0 || names.length > LIVE_ROOMS_PER_PAGE || new Set(names).size !== names.length)
197
205
  return json(response, 400, { error: "room" });
198
206
  const rooms = names.map(name => ({ name, room: parseLiveRoom(name) }));
199
- if (rooms.some(one => one.room === null) || rooms.filter(one => one.room?.kind === "agent").length > 1 || rooms.filter(one => one.room?.kind === "workspace").length > 1 || rooms.filter(one => one.room?.kind === "chat").length > 1 || rooms.filter(one => one.room?.kind === "team").length > 1 || rooms.filter(one => one.room?.kind === "summary").length > 1)
207
+ if (rooms.some(one => one.room === null) || rooms.filter(one => one.room?.kind === "agent").length > 1 || rooms.filter(one => one.room?.kind === "workspace").length > 1 || rooms.filter(one => one.room?.kind === "chat").length > 1 || rooms.filter(one => one.room?.kind === "team").length > 1 || rooms.filter(one => one.room?.kind === "summary").length > 1 || rooms.filter(one => one.room?.kind === "agents-live").length > 1)
200
208
  return json(response, 400, { error: "room" });
201
209
  // Admit every room before the stream opens, with the answers each page's own stream gave.
202
210
  const joins = [];
211
+ let refusedJoin = false;
203
212
  for (const { name, room } of rooms) {
204
213
  if (room.kind !== "team" && who.via !== "cookie")
205
214
  return json(response, 403, { error: "session" });
@@ -216,6 +225,33 @@ export function createLiveHandlers(runtime, live) {
216
225
  joins.push(connection => summaryRooms.join(summaryKey(who.name, scope), { name: who.name, room: name, connection,
217
226
  valid: () => signedIn() && live.projectScopeFor(who.name) === scope }));
218
227
  }
228
+ else if (room.kind === "agents-live") {
229
+ const browser = who;
230
+ const scope = live.projectScopeFor(who.name);
231
+ if (scope === null)
232
+ return json(response, 404, { error: "agents-live" });
233
+ const signedIn = sameBrowser(request, browser);
234
+ const valid = () => signedIn() && live.projectScopeFor(who.name) === scope;
235
+ joins.push(connection => {
236
+ // Proved before the room is made or read: an invalid viewer never causes a look at a file.
237
+ let ok = false;
238
+ try {
239
+ ok = valid();
240
+ }
241
+ catch {
242
+ ok = false;
243
+ }
244
+ if (!ok) {
245
+ connection.send(name, "gone");
246
+ refusedJoin = true;
247
+ return;
248
+ }
249
+ // Each page's own room (its key carries this connection): a newcomer is never handed a digest computed earlier.
250
+ const key = agentsLive.keyOf(who.name, scope, randomBytes(9).toString("hex"));
251
+ liveLeads.set(key, () => { const again = readBrowser(request, browser); return again === null ? null : leadTilesFor(runtime, again); });
252
+ agentsLive.rooms.join(key, { name: who.name, room: name, connection, valid });
253
+ });
254
+ }
219
255
  else if (room.kind === "task") {
220
256
  const browser = who;
221
257
  const family = familyOf(room.task);
@@ -342,6 +378,9 @@ export function createLiveHandlers(runtime, live) {
342
378
  for (const join of joins)
343
379
  if (connection.open)
344
380
  join(connection);
381
+ // A page whose only room was refused at its join ends now (`ended`), its keep-alive stopped, not left open.
382
+ if (refusedJoin && connection.rooms === 0)
383
+ connection.refuse(() => connection.close());
345
384
  request.once("close", () => connection.close());
346
385
  }
347
386
  const detachChat = live.bus.subscribe(() => {
@@ -357,6 +396,7 @@ export function createLiveHandlers(runtime, live) {
357
396
  summaryRooms.close();
358
397
  teamRooms.close();
359
398
  agentRooms.close();
399
+ agentsLive.rooms.close();
360
400
  for (const viewer of [...chatViewers])
361
401
  viewer.connection.close();
362
402
  },