toolroll 0.9.63 → 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 (302) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +33 -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 +117 -2
  16. package/dist/agent-fence.js +439 -12
  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 +39 -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/batch-checks.js +3 -2
  32. package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
  33. package/dist/browser/terminal.css +37 -0
  34. package/dist/browser/terminal.js +228 -0
  35. package/dist/browser/workspace.css +2 -2
  36. package/dist/browser/workspace.js +42 -38
  37. package/dist/browser-shell.js +6 -3
  38. package/dist/browser-workspace.d.ts +235 -1
  39. package/dist/browser-workspace.js +60 -0
  40. package/dist/builder.d.ts +10 -1
  41. package/dist/builder.js +87 -24
  42. package/dist/chat-actions.d.ts +13 -1
  43. package/dist/chat-actions.js +197 -15
  44. package/dist/chat-ask.d.ts +4 -0
  45. package/dist/chat-ask.js +14 -0
  46. package/dist/chat-channel.js +20 -0
  47. package/dist/chat-continuity.js +5 -4
  48. package/dist/chat-decide.d.ts +0 -14
  49. package/dist/chat-decide.js +4 -1
  50. package/dist/chat-delivery.d.ts +9 -0
  51. package/dist/chat-delivery.js +45 -6
  52. package/dist/chat-messages.d.ts +8 -0
  53. package/dist/chat-messages.js +36 -4
  54. package/dist/check-levels-ui.js +2 -2
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +19 -0
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +4 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +21 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/console-api.d.ts +13 -4
  71. package/dist/contracts/console-api.js +13 -4
  72. package/dist/contracts/flow-card.d.ts +4 -0
  73. package/dist/contracts/flow-card.js +3 -0
  74. package/dist/contracts/flow.d.ts +46 -0
  75. package/dist/contracts/flow.js +11 -2
  76. package/dist/contracts/lead-tools.d.ts +92 -0
  77. package/dist/contracts/lead-tools.js +13 -0
  78. package/dist/contracts/provider.d.ts +11 -1
  79. package/dist/contracts/provider.js +1 -1
  80. package/dist/contracts/route.d.ts +76 -1
  81. package/dist/contracts/route.js +1 -1
  82. package/dist/contracts/store-json.d.ts +2 -0
  83. package/dist/control-setup.d.ts +1 -1
  84. package/dist/control-ui.js +2 -2
  85. package/dist/converse.d.ts +16 -0
  86. package/dist/converse.js +32 -4
  87. package/dist/demo-chat.js +4 -4
  88. package/dist/demo.js +2 -2
  89. package/dist/desktop-update-gate.js +6 -0
  90. package/dist/desktop-update.d.ts +1 -1
  91. package/dist/discord-chat.js +4 -1
  92. package/dist/doctor.js +1 -1
  93. package/dist/egress-bridge.d.ts +17 -0
  94. package/dist/egress-bridge.js +111 -0
  95. package/dist/egress.d.ts +119 -0
  96. package/dist/egress.js +423 -0
  97. package/dist/exec.d.ts +78 -0
  98. package/dist/exec.js +367 -9
  99. package/dist/export-ui.js +2 -2
  100. package/dist/flow-engine.js +24 -5
  101. package/dist/flow-gallery-ui.js +2 -2
  102. package/dist/flow-gallery.js +27 -8
  103. package/dist/flow-share.js +16 -5
  104. package/dist/flow-triggers.d.ts +77 -8
  105. package/dist/flow-triggers.js +517 -70
  106. package/dist/flows-cli.js +8 -2
  107. package/dist/flows-ui.d.ts +0 -2
  108. package/dist/flows-ui.js +52 -4
  109. package/dist/flows.d.ts +4 -0
  110. package/dist/flows.js +9 -1
  111. package/dist/html.d.ts +9 -0
  112. package/dist/html.js +11 -0
  113. package/dist/invoke.d.ts +35 -0
  114. package/dist/invoke.js +347 -28
  115. package/dist/keys.d.ts +18 -14
  116. package/dist/keys.js +5 -0
  117. package/dist/lead-capabilities.js +3 -2
  118. package/dist/lead-contract.js +2 -0
  119. package/dist/lead-doors.d.ts +2 -0
  120. package/dist/lead-doors.js +7 -0
  121. package/dist/lead-follow.d.ts +2 -0
  122. package/dist/lead-follow.js +58 -2
  123. package/dist/lead-progress.js +1 -0
  124. package/dist/lead-queue-runtime.d.ts +46 -0
  125. package/dist/lead-queue-runtime.js +191 -0
  126. package/dist/lead-queue.d.ts +104 -0
  127. package/dist/lead-queue.js +187 -0
  128. package/dist/lead-sweep.d.ts +67 -0
  129. package/dist/lead-sweep.js +99 -0
  130. package/dist/lead-tools.d.ts +22 -0
  131. package/dist/lead-tools.js +152 -1
  132. package/dist/lead.d.ts +13 -5
  133. package/dist/lead.js +89 -29
  134. package/dist/limits-ui.d.ts +8 -1
  135. package/dist/limits-ui.js +26 -0
  136. package/dist/live-bus.d.ts +8 -0
  137. package/dist/live-bus.js +40 -4
  138. package/dist/live-detail.d.ts +27 -0
  139. package/dist/live-detail.js +57 -0
  140. package/dist/live-notes.d.ts +20 -0
  141. package/dist/live-notes.js +47 -0
  142. package/dist/live-records.d.ts +140 -0
  143. package/dist/live-records.js +935 -0
  144. package/dist/live-terminal.d.ts +269 -0
  145. package/dist/live-terminal.js +686 -0
  146. package/dist/live.d.ts +38 -27
  147. package/dist/live.js +142 -124
  148. package/dist/mcp-oauth.d.ts +6 -3
  149. package/dist/mcp-oauth.js +8 -4
  150. package/dist/memory-pass.js +2 -2
  151. package/dist/mobile-viewport.d.ts +5 -1
  152. package/dist/mobile-viewport.js +18 -2
  153. package/dist/model-catalog.js +4 -0
  154. package/dist/monitoring-ui.js +2 -2
  155. package/dist/needs-you.d.ts +1 -1
  156. package/dist/needs-you.js +11 -4
  157. package/dist/operate.d.ts +4 -0
  158. package/dist/operate.js +335 -19
  159. package/dist/passwordless.d.ts +23 -0
  160. package/dist/passwordless.js +26 -0
  161. package/dist/peek-cli.d.ts +12 -4
  162. package/dist/peek-cli.js +19 -11
  163. package/dist/phase-routing.js +10 -3
  164. package/dist/planner-source.d.ts +3 -0
  165. package/dist/planner-source.js +14 -0
  166. package/dist/planner.d.ts +2 -1
  167. package/dist/planner.js +7 -1
  168. package/dist/policy-ui.js +2 -2
  169. package/dist/policy.d.ts +1 -20
  170. package/dist/policy.js +29 -2
  171. package/dist/project-delete-ui.d.ts +18 -0
  172. package/dist/project-delete-ui.js +60 -9
  173. package/dist/project-delete.js +2 -2
  174. package/dist/project-memory.d.ts +3 -1
  175. package/dist/project-memory.js +57 -25
  176. package/dist/project-tools.d.ts +5 -3
  177. package/dist/project-tools.js +12 -9
  178. package/dist/provider-auth.d.ts +4 -3
  179. package/dist/provider-auth.js +5 -4
  180. package/dist/provider-connection.d.ts +2 -1
  181. package/dist/provider-connection.js +5 -0
  182. package/dist/provider.d.ts +25 -2
  183. package/dist/provider.js +63 -9
  184. package/dist/pull-request-ui.js +2 -2
  185. package/dist/push.js +1 -1
  186. package/dist/quick-start.d.ts +24 -0
  187. package/dist/quick-start.js +127 -0
  188. package/dist/release-adapters.d.ts +6 -0
  189. package/dist/release-adapters.js +70 -3
  190. package/dist/release.d.ts +10 -1
  191. package/dist/release.js +34 -15
  192. package/dist/request-budget-ui.js +2 -2
  193. package/dist/result-follow-ups.js +3 -2
  194. package/dist/result-review.d.ts +1 -1
  195. package/dist/result-review.js +9 -3
  196. package/dist/retention-ui.js +2 -2
  197. package/dist/retention.js +10 -1
  198. package/dist/run-efficiency.d.ts +73 -0
  199. package/dist/run-efficiency.js +228 -0
  200. package/dist/runner.d.ts +5 -1
  201. package/dist/runner.js +22 -1
  202. package/dist/scope.d.ts +183 -5
  203. package/dist/scope.js +256 -31
  204. package/dist/scout-net.d.ts +4 -1
  205. package/dist/scout-net.js +37 -12
  206. package/dist/scout.d.ts +20 -6
  207. package/dist/scout.js +27 -3
  208. package/dist/serve.js +248 -40
  209. package/dist/server/chat.d.ts +1 -1
  210. package/dist/server/chat.js +122 -31
  211. package/dist/server/chrome.d.ts +16 -2
  212. package/dist/server/chrome.js +63 -11
  213. package/dist/server/console.css +107 -42
  214. package/dist/server/flows.js +38 -8
  215. package/dist/server/guards.d.ts +1 -0
  216. package/dist/server/guards.js +23 -9
  217. package/dist/server/http.d.ts +7 -0
  218. package/dist/server/http.js +3 -2
  219. package/dist/server/live.d.ts +34 -1
  220. package/dist/server/live.js +39 -17
  221. package/dist/server/pages-assets.js +5 -0
  222. package/dist/server/pages.js +90 -13
  223. package/dist/server/people-tokens.d.ts +3 -0
  224. package/dist/server/people-tokens.js +55 -17
  225. package/dist/server/remote-hooks.js +12 -6
  226. package/dist/server/remote.d.ts +2 -1
  227. package/dist/server/remote.js +14 -5
  228. package/dist/server/render-chat.d.ts +12 -3
  229. package/dist/server/render-chat.js +47 -16
  230. package/dist/server/render-pages.d.ts +32 -4
  231. package/dist/server/render-pages.js +83 -13
  232. package/dist/server/render-people.d.ts +3 -2
  233. package/dist/server/render-people.js +3 -2
  234. package/dist/server/render-results.d.ts +18 -2
  235. package/dist/server/render-results.js +37 -28
  236. package/dist/server/render-settings.js +6 -5
  237. package/dist/server/render-tasks.d.ts +35 -10
  238. package/dist/server/render-tasks.js +111 -27
  239. package/dist/server/request-context.d.ts +2 -0
  240. package/dist/server/request-context.js +2 -1
  241. package/dist/server/route-table.d.ts +3 -0
  242. package/dist/server/route-table.js +47 -5
  243. package/dist/server/runtime.d.ts +33 -2
  244. package/dist/server/session.d.ts +25 -1
  245. package/dist/server/session.js +18 -4
  246. package/dist/server/settings.js +142 -10
  247. package/dist/server/sweeps.d.ts +4 -0
  248. package/dist/server/sweeps.js +58 -0
  249. package/dist/server/task-views.d.ts +2 -1
  250. package/dist/server/task-views.js +96 -6
  251. package/dist/server/tasks.js +327 -78
  252. package/dist/server/terminals.d.ts +31 -0
  253. package/dist/server/terminals.js +365 -0
  254. package/dist/setup-guide.js +3 -2
  255. package/dist/slack-chat.js +4 -1
  256. package/dist/spend-ui.js +3 -3
  257. package/dist/spend.js +2 -1
  258. package/dist/steer-capabilities.d.ts +32 -0
  259. package/dist/steer-capabilities.js +60 -0
  260. package/dist/steer-controls.d.ts +14 -0
  261. package/dist/steer-controls.js +36 -0
  262. package/dist/steer-status.js +2 -2
  263. package/dist/storage-ui.js +4 -4
  264. package/dist/store-baseline.d.ts +1 -1
  265. package/dist/store-baseline.js +1 -1
  266. package/dist/store.d.ts +440 -15
  267. package/dist/store.js +1072 -52
  268. package/dist/subagent-desk.js +1 -1
  269. package/dist/summary.js +3 -0
  270. package/dist/task-source.d.ts +58 -0
  271. package/dist/task-source.js +93 -0
  272. package/dist/team-leads.js +1 -1
  273. package/dist/teams-chat.js +4 -1
  274. package/dist/teardown-git.d.ts +22 -0
  275. package/dist/teardown-git.js +136 -2
  276. package/dist/telegram-lead.d.ts +3 -0
  277. package/dist/telegram-lead.js +44 -7
  278. package/dist/telegram.d.ts +11 -2
  279. package/dist/telegram.js +51 -17
  280. package/dist/terminal-catalog.d.ts +11 -0
  281. package/dist/terminal-catalog.js +129 -0
  282. package/dist/text-limits.d.ts +2 -0
  283. package/dist/text-limits.js +2 -0
  284. package/dist/tool-broker.d.ts +14 -0
  285. package/dist/tool-broker.js +67 -2
  286. package/dist/toolroll-update-ui.js +2 -2
  287. package/dist/toolroll-update.js +13 -3
  288. package/dist/tools-ui.js +2 -2
  289. package/dist/unfenced-test-support.d.ts +2 -0
  290. package/dist/unfenced-test-support.js +11 -0
  291. package/dist/webhook-limits.d.ts +23 -0
  292. package/dist/webhook-limits.js +30 -0
  293. package/dist/webhook-readers.d.ts +56 -0
  294. package/dist/webhook-readers.js +138 -0
  295. package/dist/webhook-signing.d.ts +27 -0
  296. package/dist/webhook-signing.js +119 -0
  297. package/dist/work-index.d.ts +29 -0
  298. package/dist/work-index.js +16 -2
  299. package/dist/workspace-revision.js +3 -1
  300. package/dist/writes-mode.d.ts +9 -0
  301. package/dist/writes-mode.js +8 -0
  302. package/package.json +15 -2
@@ -1,3 +1,4 @@
1
+ import { freshFacts, freshlyApprovable, freshnessFor, spendingOnlyOnSuccess, withFreshness } from "../scope.js";
1
2
  import { handlersOf } from './handler-registry.js';
2
3
  import { DEPRECATED_PAGE } from "../deprecations.js";
3
4
  /** chat handlers, moved without changing their route bodies. */
@@ -34,9 +35,9 @@ import { runActivityOf } from "../task-activity.js";
34
35
  import { teamWorkspaceHtml } from '../team-ui.js';
35
36
  import { loadBotToken, redactToken } from "../telegram.js";
36
37
  import { WORK_INDEX_MAX_LIMIT, workIndexPage } from "../work-index.js";
37
- import { html, htmlString, postForm } from "../html.js";
38
+ import { html, htmlString, postForm, reauth } from "../html.js";
38
39
  import { requestContext } from "./request-context.js";
39
- import { refuse, screen } from "./chrome.js";
40
+ import { refuse, screen, whenTime } from "./chrome.js";
40
41
  import { chatResultHref, chatReturnWithLatest, chatReturnWithSaid, projectChatHref, redirect, respond, safeChatReturn, safeReturn, taskChatHref, taskHref } from "./http.js";
41
42
  import { chatAckPage, chatPage, coordinatorProposalsSection, decisionsFor, leadAfterComposerHtml, leadChatVersion, leadMintCard, leadPage, leadThreadHtml, PHONE_CARD_FACT, TASK_COMPOSER_MODES, taskChatLiveRegion, teamProposalCardParts } from "./render-chat.js";
42
43
  import {} from "./render-pages.js";
@@ -60,17 +61,18 @@ export function createChatHandlers(runtime) {
60
61
  }
61
62
  store.sweepStaleLeadTurns(now);
62
63
  const request = url.searchParams.get("request") ?? "";
63
- const receipt = /^[a-f0-9]{32}$/.test(request) ? store.leadRequestReceipt(session.id, request) : null;
64
+ // 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);
64
66
  const requestedTask = url.searchParams.get("task");
65
67
  const focusTask = requestedTask === null || requestedTask === "" ? null : taskChatFocus(requestedTask, now, who, { mintNonce: false });
66
68
  if (requestedTask !== null && requestedTask !== "" && focusTask === null) {
67
69
  // The lens's task is gone or no longer admitted: the page keeps its
68
70
  // draft and says so; it never falls back to the unified thread.
69
- return respond(response, 200, "application/json", JSON.stringify({ session: session.id, task: requestedTask, unavailable: true, received: receipt !== null }));
71
+ return respond(response, 200, "application/json", JSON.stringify({ session: session.id, task: requestedTask, unavailable: true, received }));
70
72
  }
71
73
  const chatProject = focusTask !== null ? null : chatProjectOf(url.searchParams.getAll("project").filter(one => one !== ""));
72
74
  if (chatProject === undefined)
73
- return respond(response, 200, "application/json", JSON.stringify({ session: session.id, unavailable: true, received: receipt !== null }));
75
+ return respond(response, 200, "application/json", JSON.stringify({ session: session.id, unavailable: true, received }));
74
76
  const rows = leadConversationRows(who, principal, focusTask, now, chatProject);
75
77
  const version = leadChatVersion({ ...rows, focusTask });
76
78
  const known = url.searchParams.get("version") ?? "";
@@ -87,7 +89,7 @@ export function createChatHandlers(runtime) {
87
89
  task: focusTask?.id ?? "",
88
90
  version,
89
91
  pending: rows.pending !== null,
90
- received: receipt !== null,
92
+ received,
91
93
  approval: focusTask?.approval?.digest ?? "",
92
94
  ...(fragments === null ? {} : { fragments }),
93
95
  }));
@@ -357,7 +359,7 @@ export function createChatHandlers(runtime) {
357
359
  // The one nonce in chat — this screen restates exact financial terms
358
360
  // and re-enables spend, which is precisely what nonces are for.
359
361
  const nonce = mintApprovalNonce(who.name, `chat-ack-${turn.id}`, String(turn.reservedMicrousd));
360
- return sendScreen(response, 200, chatAckPage(chromeFor(project, "chat"), turn, nonce));
362
+ return sendScreen(response, 200, chatAckPage(chromeFor(project, "chat"), turn, nonce, store.leadTurnOfStep(turn.id)));
361
363
  }
362
364
  async function sharedActionReview(ctx) {
363
365
  const { url, response } = ctx;
@@ -391,7 +393,22 @@ export function createChatHandlers(runtime) {
391
393
  const review = mintSharedActionReview(store, principal, id, evidenceRoot, clock()), action = review.payload;
392
394
  const terms = action.terms.map(term => html `<p style="white-space:pre-wrap;overflow-wrap:anywhere">${term}</p>`);
393
395
  const evidenceLink = action.operation === 'result_accept' ? html `<a href="/r/${Number(action.request['run'])}">Inspect this result</a>` : '';
394
- const content = html `<section class="shared-action"><h1>${action.title}</h1>${evidenceLink}${terms}${postForm(`/chat/proposal/${id}/confirm`, html `<label class="arm"><input type="checkbox" name="confirm" value="yes" required>I confirm this exact action</label>${CHAT_ACTIONS[action.operation].password ? html `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>` : ''}<button>${CHAT_ACTIONS[action.operation].label}</button>`, { returnTo: '/chat', hidden: { nonce: review.nonce } })}</section>`;
396
+ // Cockpit B2: approving standard work may stand on a password remembered on this session, exactly as on the task
397
+ // page (the same rule approve() applies when the form comes back); the field then may be left empty.
398
+ const freshSince = (() => {
399
+ const task = typeof action.request['task'] === 'string' ? action.request['task'] : null;
400
+ if (action.operation !== 'scope_approve' || task === null)
401
+ return null;
402
+ const scope = store.getScope(task), fresh = withFreshness('approve-standard', () => freshnessFor(who.name, Date.now(), true));
403
+ if (scope === null || fresh === null)
404
+ return null;
405
+ const gate = store.approvalGate(task, who.name, 'person');
406
+ return freshlyApprovable(store, task, scope, gate.verdict === 'seal' && !gate.protectedWork) ? new Date(fresh.at) : null;
407
+ })();
408
+ const passwordField = !CHAT_ACTIONS[action.operation].password ? '' : reauth(freshSince === null
409
+ ? html `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>`
410
+ : html `<label>Your password<input type="password" name="token" autocomplete="current-password"></label><p class="approval-password-note" data-approval-fresh>Your password from ${whenTime(freshSince.toISOString())} is remembered on this computer — leave this blank to approve with it, or type it again.</p>`);
411
+ const content = html `<section class="shared-action"><h1>${action.title}</h1>${evidenceLink}${terms}${postForm(`/chat/proposal/${id}/confirm`, html `<label class="arm"><input type="checkbox" name="confirm" value="yes" required>I confirm this exact action</label>${passwordField}<button>${CHAT_ACTIONS[action.operation].label}</button>`, { returnTo: '/chat', hidden: { nonce: review.nonce } })}</section>`;
395
412
  return sendScreen(response, 200, screen('Review action', content, { chrome: chromeFor(action.repo, 'chat') }));
396
413
  }
397
414
  catch (error) {
@@ -665,6 +682,22 @@ export function createChatHandlers(runtime) {
665
682
  leadSaid.delete(who.session.csrf);
666
683
  return redirect(response, back);
667
684
  }
685
+ /** Remove a message still waiting in the person's lead queue: theirs only, only while it waits; its words are forgotten. */
686
+ async function leadQueuedRemove(ctx) {
687
+ const { url, who, request, response, now, posted } = ctx;
688
+ const body = readForm(posted, CONSOLE_FORMS.leadQueuedRemove);
689
+ if (who.via !== "cookie")
690
+ return refuse(response, who, 403, "the lead is a browser surface");
691
+ const wantsJson = String(request.headers["accept"] ?? "").includes("application/json");
692
+ const back = safeChatReturn(body.get("return"));
693
+ const outcome = runtime.personalQueue.queue.remove(who.name, Number(url.pathname.split("/")[3]), now);
694
+ const words = outcome === "removed" ? "Removed. It won't be sent." : outcome === "started" ? "Already sent: it started before it could be removed." : "That message isn't waiting.";
695
+ if (wantsJson)
696
+ return respond(response, outcome === "not-found" ? 404 : outcome === "started" ? 409 : 200, "application/json", JSON.stringify({ ok: outcome === "removed", said: words }));
697
+ if (outcome === "not-found")
698
+ return refuse(response, who, 404, words, back);
699
+ return redirect(response, chatReturnWithSaid(back, words));
700
+ }
668
701
  async function leadStop(ctx) {
669
702
  const { who, response, now, posted } = ctx;
670
703
  const body = readForm(posted, CONSOLE_FORMS.leadStop);
@@ -722,7 +755,12 @@ export function createChatHandlers(runtime) {
722
755
  noteLead(who.session.csrf, null, "that proposal was already acted on");
723
756
  return redirect(response, chatReturnWithLatest(back));
724
757
  }
725
- const outcome = confirmLeadProposal(store, principal, id, now, { chatProvider: teamChatProvider, confirm: body.get("confirm") === "yes", via: "web", evidenceRoot, ...(body.has("nonce") ? { actionReview: { nonce: body.get("nonce") ?? "", password: body.get("token") ?? "" } } : {}) });
758
+ // A scope approval's re-check is spent only if the whole confirmation commits (one transaction: the proposal's
759
+ // settlement included). Any other action spends it whatever comes of it (its effects aren't all in the database).
760
+ const confirmIt = () => confirmLeadProposal(store, principal, id, now, { chatProvider: teamChatProvider, confirm: body.get("confirm") === "yes", via: "web", evidenceRoot, ...(body.has("nonce") ? { actionReview: { nonce: body.get("nonce") ?? "", password: body.get("token") ?? "" } } : {}) });
761
+ const proposed = store.getLeadProposal(id);
762
+ const approving = proposed?.kind === "action" && sharedActionPayload(proposed.payload)?.operation === "scope_approve";
763
+ const outcome = approving ? spendingOnlyOnSuccess(confirmIt, result => !result.ok || ("already" in result && result.already === true)) : confirmIt();
726
764
  if (cardJson) {
727
765
  if (!outcome.ok)
728
766
  return cardAnswer(outcome.reason === "standing" ? 403 : outcome.reason === "not-yours" ? 404 : 409, false, outcome.said);
@@ -839,10 +877,12 @@ export function createChatHandlers(runtime) {
839
877
  // its own terms and the thread shows why.
840
878
  const leadSession = who.role === "approver" ? store.activeLeadSession(who.name) : null;
841
879
  if (leadSession !== null) {
842
- const requestId = body.get("request");
843
- if (requestId !== null && (body.getAll("request").length !== 1 || body.getAll("request-session").length !== 1 || body.get("request-session") !== String(leadSession.id))) {
880
+ 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))) {
844
882
  return said(409, "This conversation changed. Reload it before sending your message.");
845
883
  }
884
+ // Every send has one identity (a form without one gets it here): a message that waits is known by it.
885
+ const requestId = sentRequest ?? randomBytes(16).toString("hex");
846
886
  const principal = leadPrincipal(who);
847
887
  if (principal === null)
848
888
  return wantsJson ? said(403, "your approver standing changed — sign in again") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
@@ -851,15 +891,50 @@ export function createChatHandlers(runtime) {
851
891
  return said(400, `a message is 1 to ${LEAD_MESSAGE_MAX_CHARS} characters`, leadSession.id);
852
892
  }
853
893
  const opened = store.openLeadThread(who.name, principal.ceilingDigest, now, chatScopeOf(focusTask, chatProject));
854
- const onProgress = beginLiveTurn(opened.thread.id);
855
- void runLeadTurn({ store, who: principal, session: leadSession, thread: opened.thread, config: enabled.config, key: enabled.key, message, onProgress, channel: "console", ...(requestId === null ? {} : { requestId }), ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}${modeContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot })
894
+ // The personal queue (docs/plans/lead-message-queue.md): while this person's lead is busy, or something of theirs
895
+ // waits, the message waits its turn instead of being refused. A request the engine already took replays as before.
896
+ const queueContext = focusTask !== null
897
+ ? { task: focusTask.id, ...(viewedRun === null ? {} : { result: viewedRun.id }), ...(modeValue === null ? {} : { mode: modeValue }) }
898
+ : chatProject !== null ? { project: chatProject } : null;
899
+ const queueIt = () => runtime.personalQueue.queue.sendOrQueue({ approver: who.name, approverGeneration: principal.generation, session: leadSession.id,
900
+ ceilingDigest: principal.ceilingDigest, thread: opened.thread.id, text: message, requestId, context: queueContext }, clock());
901
+ // A message the engine would refuse for a secret never waits (nothing stored): it goes straight to the engine,
902
+ // which refuses it before any row exists, exactly as when the lead is free.
903
+ if (scanForSecrets(message).length === 0 && store.leadRequestReceipt(leadSession.id, requestId) === null) {
904
+ const decided = queueIt();
905
+ if (decided.kind === "conflict")
906
+ return said(409, "This message was already sent with different words. Reload the conversation to see it.", leadSession.id);
907
+ if (decided.kind === "full")
908
+ return said(409, decided.message, leadSession.id);
909
+ if (decided.kind === "queued") {
910
+ runtime.personalQueue.kick();
911
+ return wantsJson
912
+ ? respond(response, 202, "application/json", JSON.stringify({ ok: true, queued: true, session: leadSession.id, task: focusTask?.id ?? "", ...(chatProject === null ? {} : { project: chatProject }), request: requestId, message: decided.row.message, status: decided.row.status }))
913
+ : redirect(response, chatReturnWithLatest(back));
914
+ }
915
+ }
916
+ // The live display starts once this message holds a turn: a refusal (a secret, a turn still running) never blanks
917
+ // the display of a reply that is still running in the same conversation.
918
+ let onProgress;
919
+ const ended = (ok) => { if (onProgress !== undefined)
920
+ endLiveTurn(opened.thread.id, ok, onProgress); };
921
+ // Detached work never carries this request's browser session (Passwordless reads it): it runs with no session facts.
922
+ void freshFacts.run(null, () => runLeadTurn({ store, who: principal, session: leadSession, thread: opened.thread, config: enabled.config, key: enabled.key, message,
923
+ onAdmitted: () => { onProgress = beginLiveTurn(opened.thread.id); }, onProgress: event => onProgress?.(event), channel: "console", requestId, ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}${modeContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot }))
856
924
  .then(outcome => {
925
+ // Lost a race to another turn of this person's between deciding and admission: it waits instead of being refused.
926
+ if (!outcome.ok && "refused" in outcome && (outcome.refused === "concurrent" || outcome.refused === "backlog") && queueIt().kind === "queued") {
927
+ ended(false);
928
+ return;
929
+ }
857
930
  // A turn that saved its outcome in the thread (one stopped at its deadline) is said there, once.
858
931
  if (!outcome.ok && !("saved" in outcome && outcome.saved === true))
859
932
  noteLead(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
860
- endLiveTurn(opened.thread.id, outcome.ok);
933
+ ended(outcome.ok);
861
934
  })
862
- .catch(() => { noteLead(who.session.csrf, null, "the turn failed unexpectedly"); endLiveTurn(opened.thread.id, false); });
935
+ .catch(() => { noteLead(who.session.csrf, null, "the turn failed unexpectedly"); ended(false); })
936
+ // A personal turn ended: the next waiting message (this person's or anyone's) may start now.
937
+ .finally(() => runtime.personalQueue.kick());
863
938
  // Accepted for the engine, not received: the receipt is written by
864
939
  // the turn's own admission, and the status poll is where the page
865
940
  // learns of it — the same road as after a native send.
@@ -877,10 +952,6 @@ export function createChatHandlers(runtime) {
877
952
  }
878
953
  // The password, typed again, on EVERY message (v2 ruling 2): chat is
879
954
  // spend, and a seven-day cookie is not a spend credential.
880
- const password = body.get("token") ?? "";
881
- if (password === "" || !authenticateApprover(who, password).ok) {
882
- return redirect(response, `/chat?said=${encodeURIComponent("chat spends — your password, typed again, with every message")}`);
883
- }
884
955
  const message = (body.get("message") ?? "").trim();
885
956
  if (message === "" || message.length > 2_000) {
886
957
  return redirect(response, `/chat?said=${encodeURIComponent("a message is 1 to 2000 characters")}`);
@@ -912,16 +983,28 @@ export function createChatHandlers(runtime) {
912
983
  if (disallowed !== null)
913
984
  return redirect(response, `/chat?said=${encodeURIComponent(disallowed)}`);
914
985
  const reserved = worstCaseForPrice(enabled.price, Buffer.byteLength(composed.body, "utf8"));
915
- const opened = store.openChatTurn({
916
- approver: who.name,
917
- credentialKey: enabled.credentialKey,
918
- provider: enabled.config.provider,
919
- model: enabled.config.model,
920
- reservedMicrousd: reserved,
921
- dailyTurns: enabled.config.dailyTurns,
922
- weeklyCeilingMicrousd: enabled.config.weeklyCeilingMicrousd,
923
- deadlineMs: TURN_WALL_CLOCK_MS + 10_000,
924
- }, now);
986
+ // The password comes last, right where the turn opens: every refusal above leaves an explicit re-check unspent,
987
+ // and a turn that doesn't open (a cap, a budget) puts it back.
988
+ const password = body.get("token") ?? "";
989
+ const opened = spendingOnlyOnSuccess(() => {
990
+ // Typed now, or (cockpit B2) a password or explicit provider re-check given on this session, on a direct local
991
+ // request, within its window. An ordinary provider sign-in never starts that window.
992
+ if (!withFreshness("chat-send", () => authenticateApprover(who, password)).ok)
993
+ return null;
994
+ return store.openChatTurn({
995
+ approver: who.name,
996
+ credentialKey: enabled.credentialKey,
997
+ provider: enabled.config.provider,
998
+ model: enabled.config.model,
999
+ reservedMicrousd: reserved,
1000
+ dailyTurns: enabled.config.dailyTurns,
1001
+ weeklyCeilingMicrousd: enabled.config.weeklyCeilingMicrousd,
1002
+ deadlineMs: TURN_WALL_CLOCK_MS + 10_000,
1003
+ }, now);
1004
+ }, result => result === null || !result.ok);
1005
+ if (opened === null) {
1006
+ return redirect(response, `/chat?said=${encodeURIComponent("chat spends — your password, typed again, with every message")}`);
1007
+ }
925
1008
  if (!opened.ok) {
926
1009
  const said = opened.reason === "latched"
927
1010
  ? "a turn with unknown cost blocks this credential — acknowledge it below first"
@@ -1046,6 +1129,9 @@ export function createChatHandlers(runtime) {
1046
1129
  const wanted = values[0] ?? "";
1047
1130
  return values.length === 1 && visible(wanted) && managedRepos().includes(wanted) ? wanted : undefined;
1048
1131
  }
1132
+ /** Which live display each progress handle started: ending a turn ends only its own display (a turn stopped from
1133
+ * another window that settles late can never end the next turn's). */
1134
+ const progressOf = new WeakMap();
1049
1135
  function beginLiveTurn(thread) {
1050
1136
  const previous = liveTurns.get(thread);
1051
1137
  if (previous?.expiry)
@@ -1055,7 +1141,7 @@ export function createChatHandlers(runtime) {
1055
1141
  // A page that has this conversation open follows the reply (GET /live, its chat room).
1056
1142
  runtime.liveTurnStarted(thread);
1057
1143
  const current = () => live.steps.at(-1) ?? (live.steps.push({ tools: [], toolCalls: [], text: "" }), live.steps.at(-1));
1058
- return event => {
1144
+ const progress = (event) => {
1059
1145
  if (live.done)
1060
1146
  return;
1061
1147
  if (event.kind === "step")
@@ -1078,11 +1164,15 @@ export function createChatHandlers(runtime) {
1078
1164
  for (const listener of live.listeners)
1079
1165
  listener();
1080
1166
  };
1167
+ progressOf.set(progress, live);
1168
+ return progress;
1081
1169
  }
1082
- function endLiveTurn(thread, ok) {
1170
+ function endLiveTurn(thread, ok, progress) {
1083
1171
  const live = liveTurns.get(thread);
1084
1172
  if (live === undefined || live.done)
1085
1173
  return;
1174
+ if (progress !== undefined && progressOf.get(progress) !== live)
1175
+ return;
1086
1176
  live.done = true;
1087
1177
  live.ok = ok;
1088
1178
  for (const listener of live.listeners)
@@ -1388,6 +1478,7 @@ export function createChatHandlers(runtime) {
1388
1478
  "chat.mate-follow": leadFollow,
1389
1479
  "chat.mate-end": leadEnd,
1390
1480
  "chat.mate-stop": leadStop,
1481
+ "chat.queued-remove": leadQueuedRemove,
1391
1482
  "chat.proposal.confirm": chatProposalAct("confirm"),
1392
1483
  "chat.proposal.dismiss": chatProposalAct("dismiss"),
1393
1484
  "coordinator.proposal.confirm": coordinatorProposalAct("confirm"),
@@ -128,13 +128,16 @@ 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
135
137
  * the ONLY sink is textContent, so nothing here can become markup. One
136
138
  * request in flight, visibility-paused, stops on `final`, and a `replaced`
137
- * answer restarts from zero with a visible line — never a silent re-read.
139
+ * answer — or another file's generation — restarts from zero with a visible
140
+ * line, never a silent re-read.
138
141
  */
139
142
  export declare function transcriptScript(path?: string, elementId?: string): string;
140
143
  /**
@@ -170,6 +173,10 @@ export type Screen = {
170
173
  /** Render sensitive even when no password field is visible — one-time
171
174
  * secrets and judgment calls the classifier cannot see. */
172
175
  forceSensitive?: boolean;
176
+ /** The page's sensitivity, said explicitly (cockpit §2, R14): true for a page that asks for a password or shows a
177
+ * secret even when no password field renders (an SSO step-up, a fresh-password grant); false only where a page is
178
+ * known safe. Unset: judged from the page as authored. A sensitive page has no sidebar states and no summary room. */
179
+ sensitive?: boolean;
173
180
  /** Structured conversation; complex guarded forms stay native islands. */
174
181
  workspace?: Partial<Pick<BrowserWorkspace, 'conversation' | 'team' | 'focus' | 'result' | 'notices' | 'view' | 'firstRun' | 'phone' | 'home'>> & {
175
182
  /** Markup the workspace shows in place; it becomes bytes where the page's JSON is written (sendScreen). */
@@ -292,3 +299,10 @@ export declare function safePrUrl(url: string | null): string | null;
292
299
  export declare function shortDigest(digest: string): Html;
293
300
  /** One display line, bounded — turn text is data, never layout. */
294
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,24 +208,56 @@ 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();});}` +
225
249
  `function heard(name){if(name==="change"||name==="reload")refresh();}` +
226
- `if(document.documentElement.dataset.live==="1"){addEventListener("so:live",function(e){heard(e.detail&&e.detail.event);});}` +
227
- `else if(typeof EventSource!=="undefined"){var es=new EventSource("/live?room=workspace");` +
250
+ // On a workspace page the page's one stream (browser/live.ts) carries the workspace room: ask it, never open a second.
251
+ `function mine(e){var d=e.detail;if(d&&(d.room==="workspace"||d.room===null))heard(d.event);}` +
252
+ `if(document.getElementById("standing-orders-workspace")){(window.__soLiveRooms=window.__soLiveRooms||[]).push("workspace");addEventListener("so:live",mine);}` +
253
+ `else if(document.documentElement.dataset.live==="1"){addEventListener("so:live",mine);}` +
254
+ // Refused (signed out, access lost): stop instead of reconnecting at once into a request that can't sign in, and
255
+ // listen again in 30 s, as the page's own stream does (browser/live.ts RETRY_MS).
256
+ `else if(typeof EventSource!=="undefined"){var listen=function(){var es=new EventSource("/live?room=workspace");` +
228
257
  `es.addEventListener("change",function(){heard("change");});es.addEventListener("reload",function(){heard("reload");});` +
258
+ // A retry the server answers with an error (still signed out) is given up by the browser: try again later too.
259
+ `var again=function(){es.close();setTimeout(listen,30000);};` +
260
+ `es.addEventListener("ended",again);es.addEventListener("error",function(){if(es.readyState===2)again();});};listen();` +
229
261
  `document.addEventListener("visibilitychange",function(){if(!document.hidden)refresh();});}` +
230
262
  `})();`);
231
263
  }
@@ -234,23 +266,24 @@ export function regionScript(regionId, fragmentName, path) {
234
266
  * protocol, not an HTML fragment — the response is raw sanitized text and
235
267
  * the ONLY sink is textContent, so nothing here can become markup. One
236
268
  * request in flight, visibility-paused, stops on `final`, and a `replaced`
237
- * answer restarts from zero with a visible line — never a silent re-read.
269
+ * answer — or another file's generation — restarts from zero with a visible
270
+ * line, never a silent re-read.
238
271
  */
239
272
  export function transcriptScript(path, elementId = "live-transcript") {
240
273
  const base = path === undefined ? "location.pathname" : JSON.stringify(path);
241
274
  return (`(function(){var out=document.getElementById(${JSON.stringify(elementId)});if(!out)return;` +
242
- `var from=0,busy=false,stopped=false;` +
275
+ `var from=0,gen="",inkey=false,ktail="",busy=false,stopped=false;` +
243
276
  `function near(){return out.scrollHeight-out.scrollTop-out.clientHeight<40;}` +
244
277
  `function go(){if(stopped)return;if(busy||document.hidden){later();return;}busy=true;` +
245
- `fetch(${base}+"?fragment=transcript&from="+from,{redirect:"manual",cache:"no-store"})` +
278
+ `fetch(${base}+"?fragment=transcript&from="+from+(gen?"&gen="+encodeURIComponent(gen):"")+(inkey?"&inkey=1":"")+(ktail?"&ktail="+encodeURIComponent(ktail):""),{redirect:"manual",cache:"no-store"})` +
246
279
  `.then(function(r){if(r.type==="opaqueredirect"||r.status===401||r.status===403){stopped=true;return null;}` +
247
280
  `if(r.status===409)return{error:"replaced"};return r.ok?r.json():null;})` +
248
281
  `.then(function(d){busy=false;if(d===null){later();return;}if(stopped)return;` +
249
- `if(d.error==="replaced"){from=0;out.textContent="[the view restarted]\\n";later();return;}` +
282
+ `if(d.error==="replaced"){from=0;gen="";inkey=false;ktail="";out.textContent="[the view restarted]\\n";later();return;}` +
250
283
  `if(d.error){stopped=true;return;}` +
251
284
  `var stick=near();` +
252
285
  `if(typeof d.text==="string"&&d.text!==""){out.appendChild(document.createTextNode(d.text));if(stick)out.scrollTop=out.scrollHeight;}` +
253
- `if(typeof d.nextOffset==="number"&&d.nextOffset>=from){from=d.nextOffset;}` +
286
+ `if(typeof d.nextOffset==="number"&&d.nextOffset>=from){from=d.nextOffset;}if(typeof d.gen==="string")gen=d.gen;if(typeof d.inKey==="boolean")inkey=d.inKey;if(typeof d.keyTail==="string")ktail=d.keyTail;` +
254
287
  `if(d.final===true){stopped=true;var m=document.getElementById(${JSON.stringify(`${elementId}-state`)});` +
255
288
  `if(m)m.textContent="the agent finished — the record on this page is the story";return;}` +
256
289
  `later();})` +
@@ -680,6 +713,8 @@ export const NAV_ICONS = {
680
713
  export function workToolRows(scoped = false, _offersChat = false, offersCode = false) {
681
714
  const rows = [
682
715
  // D5: coding sessions are deprecated; /code still opens this release, but nothing links to it.
716
+ // Live terminals: an operator's own Claude Code, Codex or shell, in a real terminal (docs/plans/live-terminal.md).
717
+ ...(offersCode ? [{ key: "code", href: "/term", label: "Live", hint: "your own terminals: Claude Code, Codex or a shell" }] : []),
683
718
  { key: "inbox", href: "/inbox", label: "Inbox", hint: "questions, approvals, and repairs to act on" },
684
719
  { key: "board", href: "/board", label: "Board", hint: "lanes by state, with the order view" },
685
720
  { key: "tasks", href: "/tasks", label: "Task list", hint: "everything, filterable by state" },
@@ -749,3 +784,20 @@ export function oneLineOf(text, cap) {
749
784
  const flat = text.replace(/\s+/g, " ").trim();
750
785
  return flat.length <= cap ? flat : `${flat.slice(0, cap - 1)}…`;
751
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
+ }