toolroll 0.9.63 → 0.9.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (292) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -5
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +50 -2
  16. package/dist/agent-fence.js +157 -10
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +36 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
  32. package/dist/browser/terminal.css +37 -0
  33. package/dist/browser/terminal.js +117 -0
  34. package/dist/browser/workspace.css +2 -2
  35. package/dist/browser/workspace.js +42 -38
  36. package/dist/browser-shell.js +6 -3
  37. package/dist/browser-workspace.d.ts +235 -1
  38. package/dist/browser-workspace.js +60 -0
  39. package/dist/builder.d.ts +6 -1
  40. package/dist/builder.js +34 -13
  41. package/dist/chat-actions.d.ts +13 -1
  42. package/dist/chat-actions.js +197 -15
  43. package/dist/chat-ask.d.ts +4 -0
  44. package/dist/chat-ask.js +14 -0
  45. package/dist/chat-channel.js +20 -0
  46. package/dist/chat-continuity.js +5 -4
  47. package/dist/chat-decide.d.ts +0 -14
  48. package/dist/chat-decide.js +4 -1
  49. package/dist/chat-delivery.d.ts +9 -0
  50. package/dist/chat-delivery.js +45 -6
  51. package/dist/chat-messages.d.ts +8 -0
  52. package/dist/chat-messages.js +36 -4
  53. package/dist/check-levels-ui.js +2 -2
  54. package/dist/checkout-proof.d.ts +28 -0
  55. package/dist/checkout-proof.js +62 -0
  56. package/dist/claim.d.ts +12 -0
  57. package/dist/claim.js +19 -0
  58. package/dist/cli-help.d.ts +1 -1
  59. package/dist/cli-help.js +4 -1
  60. package/dist/cli.d.ts +3 -0
  61. package/dist/cli.js +21 -2
  62. package/dist/coding-ui.js +6 -4
  63. package/dist/console-instance.d.ts +124 -0
  64. package/dist/console-instance.js +337 -0
  65. package/dist/contracts/chat-actions.d.ts +16 -0
  66. package/dist/contracts/chat-actions.js +4 -0
  67. package/dist/contracts/chat-content.d.ts +19 -0
  68. package/dist/contracts/chat-content.js +6 -0
  69. package/dist/contracts/console-api.d.ts +12 -4
  70. package/dist/contracts/console-api.js +12 -4
  71. package/dist/contracts/flow-card.d.ts +4 -0
  72. package/dist/contracts/flow-card.js +3 -0
  73. package/dist/contracts/flow.d.ts +46 -0
  74. package/dist/contracts/flow.js +11 -2
  75. package/dist/contracts/lead-tools.d.ts +92 -0
  76. package/dist/contracts/lead-tools.js +13 -0
  77. package/dist/contracts/provider.d.ts +11 -1
  78. package/dist/contracts/provider.js +1 -1
  79. package/dist/contracts/route.d.ts +76 -1
  80. package/dist/contracts/route.js +1 -1
  81. package/dist/contracts/store-json.d.ts +2 -0
  82. package/dist/control-setup.d.ts +1 -1
  83. package/dist/control-ui.js +2 -2
  84. package/dist/converse.d.ts +16 -0
  85. package/dist/converse.js +32 -4
  86. package/dist/demo-chat.js +4 -4
  87. package/dist/demo.js +2 -2
  88. package/dist/desktop-update-gate.js +6 -0
  89. package/dist/desktop-update.d.ts +1 -1
  90. package/dist/discord-chat.js +4 -1
  91. package/dist/doctor.js +1 -1
  92. package/dist/egress-bridge.d.ts +17 -0
  93. package/dist/egress-bridge.js +111 -0
  94. package/dist/egress.d.ts +119 -0
  95. package/dist/egress.js +423 -0
  96. package/dist/exec.d.ts +78 -0
  97. package/dist/exec.js +367 -9
  98. package/dist/export-ui.js +2 -2
  99. package/dist/flow-engine.js +24 -5
  100. package/dist/flow-gallery-ui.js +2 -2
  101. package/dist/flow-gallery.js +27 -8
  102. package/dist/flow-share.js +16 -5
  103. package/dist/flow-triggers.d.ts +77 -8
  104. package/dist/flow-triggers.js +517 -70
  105. package/dist/flows-cli.js +8 -2
  106. package/dist/flows-ui.d.ts +0 -2
  107. package/dist/flows-ui.js +52 -4
  108. package/dist/flows.d.ts +4 -0
  109. package/dist/flows.js +9 -1
  110. package/dist/html.d.ts +9 -0
  111. package/dist/html.js +11 -0
  112. package/dist/invoke.d.ts +17 -0
  113. package/dist/invoke.js +217 -21
  114. package/dist/keys.d.ts +18 -14
  115. package/dist/keys.js +5 -0
  116. package/dist/lead-capabilities.js +3 -2
  117. package/dist/lead-contract.js +2 -0
  118. package/dist/lead-doors.d.ts +2 -0
  119. package/dist/lead-doors.js +7 -0
  120. package/dist/lead-follow.d.ts +2 -0
  121. package/dist/lead-follow.js +58 -2
  122. package/dist/lead-progress.js +1 -0
  123. package/dist/lead-queue-runtime.d.ts +46 -0
  124. package/dist/lead-queue-runtime.js +191 -0
  125. package/dist/lead-queue.d.ts +104 -0
  126. package/dist/lead-queue.js +187 -0
  127. package/dist/lead-sweep.d.ts +67 -0
  128. package/dist/lead-sweep.js +99 -0
  129. package/dist/lead-tools.d.ts +22 -0
  130. package/dist/lead-tools.js +152 -1
  131. package/dist/lead.d.ts +13 -5
  132. package/dist/lead.js +89 -29
  133. package/dist/limits-ui.d.ts +8 -1
  134. package/dist/limits-ui.js +26 -0
  135. package/dist/live-bus.d.ts +8 -0
  136. package/dist/live-bus.js +40 -4
  137. package/dist/live-detail.d.ts +27 -0
  138. package/dist/live-detail.js +57 -0
  139. package/dist/live-notes.d.ts +20 -0
  140. package/dist/live-notes.js +47 -0
  141. package/dist/live-records.d.ts +140 -0
  142. package/dist/live-records.js +935 -0
  143. package/dist/live-terminal.d.ts +258 -0
  144. package/dist/live-terminal.js +649 -0
  145. package/dist/live.d.ts +38 -27
  146. package/dist/live.js +142 -124
  147. package/dist/mcp-oauth.d.ts +6 -3
  148. package/dist/mcp-oauth.js +8 -4
  149. package/dist/memory-pass.js +2 -2
  150. package/dist/mobile-viewport.d.ts +5 -1
  151. package/dist/mobile-viewport.js +18 -2
  152. package/dist/model-catalog.js +4 -0
  153. package/dist/monitoring-ui.js +2 -2
  154. package/dist/needs-you.d.ts +1 -1
  155. package/dist/needs-you.js +11 -4
  156. package/dist/operate.d.ts +2 -0
  157. package/dist/operate.js +275 -17
  158. package/dist/passwordless.d.ts +23 -0
  159. package/dist/passwordless.js +26 -0
  160. package/dist/peek-cli.d.ts +12 -4
  161. package/dist/peek-cli.js +19 -11
  162. package/dist/phase-routing.js +10 -3
  163. package/dist/planner-source.d.ts +3 -0
  164. package/dist/planner-source.js +14 -0
  165. package/dist/planner.d.ts +2 -1
  166. package/dist/planner.js +7 -1
  167. package/dist/policy-ui.js +2 -2
  168. package/dist/policy.d.ts +1 -20
  169. package/dist/policy.js +29 -2
  170. package/dist/project-delete-ui.d.ts +12 -0
  171. package/dist/project-delete-ui.js +41 -9
  172. package/dist/project-delete.js +2 -2
  173. package/dist/project-memory.d.ts +3 -1
  174. package/dist/project-memory.js +57 -25
  175. package/dist/project-tools.d.ts +5 -3
  176. package/dist/project-tools.js +12 -9
  177. package/dist/provider-auth.d.ts +4 -3
  178. package/dist/provider-auth.js +5 -4
  179. package/dist/provider-connection.d.ts +2 -1
  180. package/dist/provider-connection.js +5 -0
  181. package/dist/provider.d.ts +25 -2
  182. package/dist/provider.js +63 -9
  183. package/dist/pull-request-ui.js +2 -2
  184. package/dist/push.js +1 -1
  185. package/dist/quick-start.d.ts +24 -0
  186. package/dist/quick-start.js +127 -0
  187. package/dist/release.d.ts +3 -0
  188. package/dist/release.js +14 -8
  189. package/dist/request-budget-ui.js +2 -2
  190. package/dist/result-review.d.ts +1 -1
  191. package/dist/result-review.js +9 -3
  192. package/dist/retention-ui.js +2 -2
  193. package/dist/retention.js +10 -1
  194. package/dist/run-efficiency.d.ts +73 -0
  195. package/dist/run-efficiency.js +228 -0
  196. package/dist/runner.d.ts +5 -1
  197. package/dist/runner.js +22 -1
  198. package/dist/scope.d.ts +170 -5
  199. package/dist/scope.js +251 -31
  200. package/dist/scout-net.d.ts +4 -1
  201. package/dist/scout-net.js +37 -12
  202. package/dist/scout.d.ts +20 -6
  203. package/dist/scout.js +27 -3
  204. package/dist/serve.js +247 -40
  205. package/dist/server/chat.d.ts +1 -1
  206. package/dist/server/chat.js +122 -31
  207. package/dist/server/chrome.d.ts +6 -1
  208. package/dist/server/chrome.js +18 -7
  209. package/dist/server/console.css +52 -38
  210. package/dist/server/flows.js +38 -8
  211. package/dist/server/guards.d.ts +1 -0
  212. package/dist/server/guards.js +23 -9
  213. package/dist/server/http.d.ts +7 -0
  214. package/dist/server/http.js +3 -2
  215. package/dist/server/live.d.ts +34 -1
  216. package/dist/server/live.js +39 -17
  217. package/dist/server/pages-assets.js +5 -0
  218. package/dist/server/pages.js +90 -13
  219. package/dist/server/people-tokens.d.ts +3 -0
  220. package/dist/server/people-tokens.js +55 -17
  221. package/dist/server/remote-hooks.js +12 -6
  222. package/dist/server/remote.d.ts +2 -1
  223. package/dist/server/remote.js +14 -5
  224. package/dist/server/render-chat.d.ts +12 -3
  225. package/dist/server/render-chat.js +47 -16
  226. package/dist/server/render-pages.d.ts +4 -1
  227. package/dist/server/render-pages.js +5 -4
  228. package/dist/server/render-people.d.ts +3 -2
  229. package/dist/server/render-people.js +3 -2
  230. package/dist/server/render-results.d.ts +18 -2
  231. package/dist/server/render-results.js +37 -28
  232. package/dist/server/render-settings.js +6 -5
  233. package/dist/server/render-tasks.d.ts +35 -10
  234. package/dist/server/render-tasks.js +110 -27
  235. package/dist/server/request-context.d.ts +2 -0
  236. package/dist/server/request-context.js +2 -1
  237. package/dist/server/route-table.d.ts +3 -0
  238. package/dist/server/route-table.js +46 -5
  239. package/dist/server/runtime.d.ts +33 -2
  240. package/dist/server/session.d.ts +25 -1
  241. package/dist/server/session.js +18 -4
  242. package/dist/server/settings.js +121 -10
  243. package/dist/server/sweeps.d.ts +4 -0
  244. package/dist/server/sweeps.js +58 -0
  245. package/dist/server/task-views.d.ts +2 -1
  246. package/dist/server/task-views.js +96 -6
  247. package/dist/server/tasks.js +320 -74
  248. package/dist/server/terminals.d.ts +31 -0
  249. package/dist/server/terminals.js +365 -0
  250. package/dist/setup-guide.js +3 -2
  251. package/dist/slack-chat.js +4 -1
  252. package/dist/spend-ui.js +3 -3
  253. package/dist/spend.js +2 -1
  254. package/dist/steer-capabilities.d.ts +32 -0
  255. package/dist/steer-capabilities.js +60 -0
  256. package/dist/steer-controls.d.ts +14 -0
  257. package/dist/steer-controls.js +36 -0
  258. package/dist/steer-status.js +2 -2
  259. package/dist/storage-ui.js +4 -4
  260. package/dist/store-baseline.d.ts +1 -1
  261. package/dist/store-baseline.js +1 -1
  262. package/dist/store.d.ts +430 -15
  263. package/dist/store.js +1041 -52
  264. package/dist/subagent-desk.js +1 -1
  265. package/dist/summary.js +3 -0
  266. package/dist/task-source.d.ts +58 -0
  267. package/dist/task-source.js +93 -0
  268. package/dist/team-leads.js +1 -1
  269. package/dist/teams-chat.js +4 -1
  270. package/dist/telegram-lead.d.ts +3 -0
  271. package/dist/telegram-lead.js +44 -7
  272. package/dist/telegram.d.ts +11 -2
  273. package/dist/telegram.js +51 -17
  274. package/dist/terminal-catalog.d.ts +11 -0
  275. package/dist/terminal-catalog.js +129 -0
  276. package/dist/text-limits.d.ts +2 -0
  277. package/dist/text-limits.js +2 -0
  278. package/dist/tool-broker.d.ts +2 -0
  279. package/dist/tool-broker.js +1 -0
  280. package/dist/toolroll-update-ui.js +2 -2
  281. package/dist/toolroll-update.js +13 -3
  282. package/dist/tools-ui.js +2 -2
  283. package/dist/webhook-limits.d.ts +23 -0
  284. package/dist/webhook-limits.js +30 -0
  285. package/dist/webhook-readers.d.ts +56 -0
  286. package/dist/webhook-readers.js +138 -0
  287. package/dist/webhook-signing.d.ts +27 -0
  288. package/dist/webhook-signing.js +119 -0
  289. package/dist/work-index.d.ts +29 -0
  290. package/dist/work-index.js +16 -2
  291. package/dist/workspace-revision.js +3 -1
  292. package/package.json +11 -2
package/dist/lead.js CHANGED
@@ -4,11 +4,11 @@ import { createHash } from "node:crypto";
4
4
  import { leadTimeoutNotice } from "./store.js";
5
5
  import { RESULT_IMAGES_PER_TURN_CAP } from "./chat-evidence.js";
6
6
  import { isVerifiedApprover, reproveApprover } from "./principal.js";
7
- import { LEAD_MAX_CALLS_PER_STEP, LEAD_MAX_STEPS, LEAD_TOOL_RESULT_CAP_BYTES, TURN_WALL_CLOCK_MS, composeLeadRequest, credentialKeyOf, isDirectChatProvider, leadWorstCaseForPrice, performLeadRequest, priceForConfig, settleForPrice, subscriptionCredentialKey, } from "./converse.js";
7
+ import { LEAD_MAX_CALLS_PER_STEP, LEAD_MAX_STEPS, LEAD_TOOL_RESULT_CAP_BYTES, TURN_WALL_CLOCK_MS, composeLeadRequest, credentialKeyOf, isDirectChatProvider, leadWorstCaseForPrice, performLeadRequest, priceForConfig, cacheWorstPrice, settleWithCache, subscriptionCredentialKey, } from "./converse.js";
8
8
  import { redactSecretLines, scanForSecrets } from "./evidence.js";
9
9
  import { redactSecretAssignments } from "./builder.js";
10
10
  import { LEAD_CONTRACT } from "./lead-contract.js";
11
- import { LEAD_MAX_PROPOSALS_PER_TURN, LEAD_TOOL_SCHEMAS, executeLeadTool, isLeadTool, leadViewContextFor, projectLabelForLead, redactForLead, toolResultBytes } from "./lead-tools.js";
11
+ import { LEAD_CORE_TOOLS, LEAD_MAX_PROPOSALS_PER_TURN, LEAD_TOOL_SCHEMAS, executeLeadTool, isLeadTool, leadViewContextFor, projectLabelForLead, redactForLead, toolResultBytes } from "./lead-tools.js";
12
12
  import { composeSubscriptionLeadPrompt, performSubscriptionLeadRequest } from "./subscription-chat.js";
13
13
  import { LEAD_REPLY_LIMITS, leadContext } from './lead-context.js';
14
14
  import { shortenAsk } from './text-limits.js';
@@ -34,6 +34,7 @@ export const LEAD_REFUSAL_COPY = {
34
34
  "thread-closed": "This conversation is closed. Start a new one to keep going.",
35
35
  latched: "An earlier reply stopped before its cost was known, so chat is paused. Acknowledge that reply on the Chat page, then send again.",
36
36
  concurrent: "The lead is still answering your last message. Wait for that reply, then send this one.",
37
+ backlog: "Your earlier messages are still waiting for the lead. This one goes after them.",
37
38
  "daily-cap": "You've reached today's limit on chat replies, so this one wasn't sent. Try again tomorrow.",
38
39
  "session-exhausted": "This chat has used its spending limit. Start a new chat to keep going.",
39
40
  "session-ended": "This chat has ended. Start a new chat to keep going.",
@@ -69,12 +70,25 @@ export const LEAD_FAILURE_COPY = {
69
70
  tooLongUnknownCost: leadTimeoutNotice(false, true),
70
71
  lostUnknownCost: "The chat provider stopped responding partway through, and the cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
71
72
  unreadableUnknownCost: "The chat provider sent back an answer that couldn't be read, and its cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
73
+ overReserved: "This reply reported more use than was set aside for it, so its cost is counted as unknown and chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
72
74
  empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.",
73
75
  unusable: "The lead's answer couldn't be used, so it was thrown away. Nothing was kept. Send your message again.",
74
76
  };
75
77
  /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
76
- export function leadFailureText(reason) {
78
+ export function leadFailureText(reason, unknownSpend = false) {
79
+ // A turn whose cost is unknown paused chat: say what happened (its reason's first sentence), then that the person must
80
+ // confirm the cost before chat runs again — never "send it again", which would only meet the pause.
81
+ if (unknownSpend) {
82
+ if (reason === "malformed-reply")
83
+ return LEAD_FAILURE_COPY.unreadableUnknownCost;
84
+ if (reason === "over-reservation")
85
+ return LEAD_FAILURE_COPY.overReserved;
86
+ // Whether proposals were kept depends on how it ended (a timeout keeps what it drafted): this says nothing about them.
87
+ const happened = /^[^.]*\./.exec(leadFailureText(reason))?.[0] ?? "The lead's reply didn't finish.";
88
+ return `${happened} Its cost isn't known, so chat is paused. Confirm the cost on the Chat page to turn chat back on.`;
89
+ }
77
90
  switch (reason) {
91
+ case "over-reservation": return "This reply reported more use than was set aside for it, so it was stopped and its cost counted. Send your message again.";
78
92
  case "timeout":
79
93
  case "crashed": return LEAD_FAILURE_COPY.tooLong;
80
94
  case "malformed-reply": return LEAD_FAILURE_COPY.unreadable;
@@ -98,20 +112,22 @@ function settleBy(work, ms) {
98
112
  }
99
113
  const READ_TOOLS = new Set(["get_brief", "get_project_context", "get_actions", "get_action_status", "get_skills", "get_acceptance_evidence", "recap", "list_repos", "list_tasks", "get_task", "get_result", "get_result_images", "get_controls", "get_agents", "get_project_knowledge", "get_task_conversation", "get_diff", "get_check_log", "get_project_tools", "get_flows", "get_flow_insights", "list_decisions", "get_decision", "queue"]);
100
114
  /** The last messages of the thread as provider-neutral history, newest kept first until the byte cap. */
101
- export function historyFor(store, thread, queuedMessageId) {
102
- const rows = store.listLeadMessages(thread, LEAD_HISTORY_MAX_MESSAGES);
115
+ export function historyFor(store, thread, queuedMessageId, named = queuedMessageId !== undefined) {
116
+ // Admitted messages only (v128): a queued message — this one, a teammate's, or one removed while waiting — never
117
+ // reaches the model before its own turn takes it.
118
+ const rows = store.listLeadHistory(thread, LEAD_HISTORY_MAX_MESSAGES);
103
119
  const kept = [];
104
120
  let bytes = 0;
105
121
  for (let index = rows.length - 1; index >= 0; index--) {
106
122
  const row = rows[index];
107
- // Other teammates may have already queued later messages. They do not
108
- // enter model history until admitted, and the current message appears once.
109
- if (queuedMessageId !== undefined && (row.id === queuedMessageId || row.role === 'operator' && row.turn === null))
123
+ // The current message appears once (it is added after the history). Messages still waiting — a teammate's or this
124
+ // person's — are already left out by listLeadHistory (pending); a mirrored channel question is history like any other.
125
+ if (row.id === queuedMessageId)
110
126
  continue;
111
127
  bytes += Buffer.byteLength(row.text, "utf8");
112
128
  if (bytes > LEAD_HISTORY_CAP_BYTES)
113
129
  break;
114
- const author = queuedMessageId !== undefined && row.role === 'operator' && row.turn !== null ? store.getLeadTurn(row.turn)?.approver : undefined;
130
+ const author = named && row.role === 'operator' && row.turn !== null ? store.getLeadTurn(row.turn)?.approver : undefined;
115
131
  kept.unshift(row.role === "operator" ? { role: "operator", text: author ? `From ${author}:\n${row.text}` : row.text } : { role: "assistant", text: row.text, calls: [] });
116
132
  }
117
133
  // A history must open with the operator: a leading assistant reply without its question is dropped.
@@ -120,8 +136,9 @@ export function historyFor(store, thread, queuedMessageId) {
120
136
  return kept;
121
137
  }
122
138
  /** "read 3 · proposed 1 · 2 steps" — counts only, never results (ruling 11). */
123
- export function activitySummary(reads, proposals, steps) {
124
- return [`read ${reads}`, `proposed ${proposals}`, `${steps} step${steps === 1 ? "" : "s"}`].join(" · ");
139
+ export function activitySummary(reads, proposals, steps, cacheShare = null) {
140
+ // How much of what it read came from the provider's cache, when every request reported it (docs/plans/run-efficiency.md).
141
+ return [`read ${reads}`, `proposed ${proposals}`, `${steps} step${steps === 1 ? "" : "s"}`, ...(cacheShare === null ? [] : [`${Math.round(cacheShare * 100)}% cached`])].join(" · ");
125
142
  }
126
143
  /** A tool result as the model sees it, measured as embedded; over the cap it becomes a typed refusal. */
127
144
  function capped(value) {
@@ -175,7 +192,12 @@ export async function runLeadTurn(input) {
175
192
  const sharedThread = liveThread !== null && store.canUseTeamMateThread(who.name, who.generation, liveThread.id);
176
193
  if (liveSession?.approver !== who.name || (liveThread?.approver !== who.name && !sharedThread) || liveSession.credentialKey !== credentialKey)
177
194
  return refuse("not-yours");
178
- if (input.queuedMessageId !== undefined && (!sharedThread || !Number.isSafeInteger(input.queuedMessageId) || input.onAdmitted === undefined))
195
+ // A queued message binds only from a shared team thread, or as the exact personal queue claim of this person's own
196
+ // thread (its row is this approver's, in this thread, claimed under that runner and generation).
197
+ const personalQueued = input.queueClaim !== undefined && input.queueClaim.message === input.queuedMessageId &&
198
+ store.handle.prepare("SELECT 1 FROM lead_queue WHERE message = ? AND approver = ? AND thread = ? AND status = 'running' AND runner = ? AND generation = ? AND turn_id IS NULL")
199
+ .get(input.queueClaim.message, who.name, thread.id, input.queueClaim.runner, input.queueClaim.generation) !== undefined;
200
+ if (input.queuedMessageId !== undefined && ((!sharedThread && !personalQueued) || !Number.isSafeInteger(input.queuedMessageId) || input.onAdmitted === undefined))
179
201
  return refuse('invalid-request');
180
202
  if (liveSession.endedAt !== null)
181
203
  return refuse("session-ended");
@@ -197,18 +219,29 @@ export async function runLeadTurn(input) {
197
219
  const document = leadContext(store, who.repos, now, { owner: who.name, thread: thread.id, redact: text => redactForLead(text, view),
198
220
  projectName: (path, index) => projectLabelForLead(path, index, view.names),
199
221
  ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.channel === undefined ? {} : { channel: input.channel }), ...(input.leadName === undefined ? {} : { leadName: input.leadName }) });
200
- const authoredMessage = input.queuedMessageId === undefined ? message : `From ${who.name}:\n${message}`;
222
+ // A team room names who wrote each message; a person's own queued message is just theirs.
223
+ const authoredMessage = input.queuedMessageId === undefined || personalQueued ? message : `From ${who.name}:\n${message}`;
201
224
  const historyMessage = input.context === undefined ? authoredMessage : `${redactForLead(input.context, view)}\n\n${authoredMessage}`;
202
- const history = [...historyFor(store, thread.id, input.queuedMessageId), { role: "operator", text: historyMessage }];
203
- const composeDirect = (key) => {
225
+ const history = [...historyFor(store, thread.id, input.queuedMessageId, input.queuedMessageId !== undefined && !personalQueued), { role: "operator", text: historyMessage }];
226
+ // Tools on demand (docs/plans/lead-deferred-tools.md): a request advertises the core tools, then the ones find_tools
227
+ // found earlier in this reply, in that order (so the core prefix stays the same as tools are found).
228
+ const findTools = { searches: 0, found: new Set() };
229
+ const advertisedTools = () => [
230
+ ...LEAD_TOOL_SCHEMAS.filter(one => LEAD_CORE_TOOLS.has(one.name)),
231
+ ...[...findTools.found].map(name => LEAD_TOOL_SCHEMAS.find(one => one.name === name)).filter((one) => one !== undefined),
232
+ ];
233
+ const composeDirect = (key, tools) => {
204
234
  if (!direct)
205
235
  throw new Error("not a direct chat provider");
206
- return composeLeadRequest({ provider: directProvider, model: config.model, key, system: LEAD_CONTRACT, dataDocument: document, history, tools: LEAD_TOOL_SCHEMAS });
236
+ // Anthropic prompt caching: the core tools (always first) end the first breakpoint (docs/plans/lead-deferred-tools.md).
237
+ const cache = directProvider === "anthropic-api" ? { coreTools: tools.filter(one => LEAD_CORE_TOOLS.has(one.name)).length } : undefined;
238
+ return composeLeadRequest({ provider: directProvider, model: config.model, key, system: LEAD_CONTRACT, dataDocument: document, history, tools, ...(cache === undefined ? {} : { cache }) });
207
239
  };
208
- const composeSubscription = () => composeSubscriptionLeadPrompt({ system: LEAD_CONTRACT, dataDocument: document, history, tools: LEAD_TOOL_SCHEMAS });
209
- // The exact outbound base is scanned whole. Only direct API traffic needs
210
- // a worst-case dollar reservation; subscription traffic records zero.
211
- const base = direct ? composeDirect("").body : composeSubscription();
240
+ const composeSubscription = (tools) => composeSubscriptionLeadPrompt({ system: LEAD_CONTRACT, dataDocument: document, history, tools });
241
+ // The reservation-only base: the FULL registry, serialized as a request would be, so any tool found later in this
242
+ // reply is already covered by the worst case. What is sent each round is smaller (the advertised set) and is scanned
243
+ // whole before every dispatch. Only direct API traffic needs a dollar reservation; subscription traffic records zero.
244
+ const base = direct ? composeDirect("", LEAD_TOOL_SCHEMAS).body : composeSubscription(LEAD_TOOL_SCHEMAS);
212
245
  if (scanForSecrets(base).length > 0)
213
246
  return refuse("secret-in-context");
214
247
  // Fit the bounded tool loop to the already-authorized remaining allowance.
@@ -216,7 +249,9 @@ export async function runLeadTurn(input) {
216
249
  // Each chosen step retains the same worst-case call/output accounting.
217
250
  let maxSteps = LEAD_MAX_STEPS;
218
251
  const baseBytes = Buffer.byteLength(base, "utf8");
219
- const reserveFor = (steps) => direct ? leadWorstCaseForPrice(price, baseBytes, { steps }) : 0;
252
+ // With prompt caching on (Anthropic), the worst case reserves every input token at the cache-write rate (the dearest).
253
+ const reservePrice = direct && directProvider === "anthropic-api" ? cacheWorstPrice(price) : price;
254
+ const reserveFor = (steps) => direct ? leadWorstCaseForPrice(reservePrice, baseBytes, { steps }) : 0;
220
255
  if (direct) {
221
256
  const remaining = Math.min(liveSession.ceilingMicrousd - liveSession.spentMicrousd, config.weeklyCeilingMicrousd - store.chatWeeklySpendMicrousd(credentialKey, now));
222
257
  while (maxSteps > 1 && reserveFor(maxSteps) > remaining)
@@ -239,6 +274,7 @@ export async function runLeadTurn(input) {
239
274
  weeklyCeilingMicrousd: config.weeklyCeilingMicrousd,
240
275
  deadlineMs: TURN_WALL_CLOCK_MS + 10_000,
241
276
  provider: config.provider,
277
+ ...(input.queueClaim === undefined ? {} : { queueClaim: input.queueClaim }),
242
278
  }, now);
243
279
  if (!opened.ok)
244
280
  return opened;
@@ -249,7 +285,8 @@ export async function runLeadTurn(input) {
249
285
  if (input.queuedMessageId === undefined)
250
286
  store.appendLeadMessage({ thread: thread.id, turn: turnId, role: "operator", text: message }, now);
251
287
  else {
252
- const bound = store.handle.prepare("UPDATE lead_message SET turn=? WHERE id=? AND thread=? AND role='operator' AND turn IS NULL AND text=?")
288
+ // Binding admits it: the same statement clears pending, so it joins history exactly when its turn exists (v128).
289
+ const bound = store.handle.prepare("UPDATE lead_message SET turn=?, pending=0 WHERE id=? AND thread=? AND role='operator' AND turn IS NULL AND text=?")
253
290
  .run(turnId, input.queuedMessageId, thread.id, message);
254
291
  if (Number(bound.changes) !== 1)
255
292
  throw new Error('The queued message changed before admission.');
@@ -281,6 +318,8 @@ export async function runLeadTurn(input) {
281
318
  /** The turn's one question to its owner: shown with the reply only once the turn answers; a failed turn drops it. */
282
319
  const ask = (question, options) => store.recordLeadAsk({ turn: turnId, thread: thread.id, question, options }, clock());
283
320
  let tokensIn = 0;
321
+ // What its requests read from the provider's cache; known only while every request reported it.
322
+ let cacheRead = 0, cacheKnown = true;
284
323
  let tokensOut = 0;
285
324
  let settled = 0;
286
325
  const draft = (kind, payload) => {
@@ -363,7 +402,10 @@ export async function runLeadTurn(input) {
363
402
  const remainingMs = TURN_WALL_CLOCK_MS - (now.getTime() - turnStartedAt);
364
403
  if (remainingMs <= 0)
365
404
  return timedOut(false);
366
- const request = direct ? composeDirect(input.key) : composeSubscription();
405
+ // This request's advertised tools: fixed for the request; a tool found in its reply is advertised from the next one.
406
+ const advertised = advertisedTools();
407
+ const advertisedNames = new Set(advertised.map(one => one.name));
408
+ const request = direct ? composeDirect(input.key, advertised) : composeSubscription(advertised);
367
409
  // Tool results join the outbound body: scanned again before every dispatch.
368
410
  const outbound = typeof request === "string" ? request : request.body;
369
411
  if (scanForSecrets(outbound).length > 0) {
@@ -402,7 +444,7 @@ export async function runLeadTurn(input) {
402
444
  system: LEAD_CONTRACT,
403
445
  dataDocument: document,
404
446
  history,
405
- tools: LEAD_TOOL_SCHEMAS,
447
+ tools: advertised,
406
448
  timeoutMs: remainingMs,
407
449
  signal: controller.signal,
408
450
  // The reply as it is written; text that looks like a secret is
@@ -452,11 +494,24 @@ export async function runLeadTurn(input) {
452
494
  }
453
495
  // The pinned math, or the provider's own reported charge when HIGHER.
454
496
  const stepSettled = direct
455
- ? Math.max(settleForPrice(price, answer.tokensIn, answer.tokensOut), answer.reportedCostMicrousd ?? 0)
497
+ ? Math.max(settleWithCache(price, answer), answer.reportedCostMicrousd ?? 0)
456
498
  : 0;
457
- finishStep({ state: "answered", tokensIn: answer.tokensIn, tokensOut: answer.tokensOut, settledMicrousd: stepSettled, replyBytes: Buffer.byteLength(answer.text, "utf8") });
499
+ // The reservation is the bound: a step whose reported usage would settle past what this reply reserved (the
500
+ // estimate counts a token per three bytes; a provider may report more) ends the turn with its cost unknown, which
501
+ // settles the whole reservation and pauses chat until the cost is confirmed — never a debit past the reservation
502
+ // or the session's ceiling.
503
+ if (direct && settled + stepSettled > reserved) {
504
+ finishStep({ state: "failed", failureReason: "over-budget", settledMicrousd: null, unknownSpend: true });
505
+ return fail("over-reservation", LEAD_FAILURE_COPY.overReserved, true);
506
+ }
507
+ finishStep({ state: "answered", tokensIn: answer.tokensIn, tokensOut: answer.tokensOut, settledMicrousd: stepSettled, replyBytes: Buffer.byteLength(answer.text, "utf8"),
508
+ cacheReadTokens: answer.cacheReadTokens ?? null, cacheWriteTokens: answer.cacheWriteTokens ?? null });
458
509
  tokensIn += answer.tokensIn;
459
510
  tokensOut += answer.tokensOut;
511
+ if (answer.cacheReadTokens === undefined)
512
+ cacheKnown = false;
513
+ else
514
+ cacheRead += answer.cacheReadTokens;
460
515
  settled += stepSettled;
461
516
  lastText = answer.text;
462
517
  // After the wait (finding 5): the row may have been failed under us by
@@ -502,7 +557,11 @@ export async function runLeadTurn(input) {
502
557
  // Position within this turn is unique even if a provider reuses call ids.
503
558
  const progressId = `${turnId}:${steps}:${index}`;
504
559
  progress({ kind: "tool", turn: turnId, step: steps, id: progressId, label: leadToolLabel(call.name) });
505
- const outcome = executeLeadTool({ store, who, now: clock(), draft, selectEvidence, step: steps, readDecisions, readResults, searchedMemory, checkedCapabilities, ask, ...(input.integrations === undefined ? {} : { integrations: input.integrations }), thread: thread.id, turn: turnId, ...(promiseChannelOfTurn(input.channel) === undefined ? {} : { channel: promiseChannelOfTurn(input.channel) }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) }, call.name, call.args, view);
560
+ // A registered tool this request did not advertise is refused before it is read or run (the only production
561
+ // caller of executeLeadTool): the model learns the road (find_tools, then the next step) and the reply goes on.
562
+ const outcome = !advertisedNames.has(call.name)
563
+ ? { ok: false, message: `${call.name} isn't available in this step. Call find_tools with what you want to do; tools it finds can be called from your next step.` }
564
+ : executeLeadTool({ store, who, now: clock(), draft, selectEvidence, step: steps, readDecisions, readResults, searchedMemory, checkedCapabilities, ask, findTools, ...(input.integrations === undefined ? {} : { integrations: input.integrations }), thread: thread.id, turn: turnId, ...(promiseChannelOfTurn(input.channel) === undefined ? {} : { channel: promiseChannelOfTurn(input.channel) }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) }, call.name, call.args, view);
506
565
  let displayOutcome = { state: "succeeded" };
507
566
  if (!outcome.ok) {
508
567
  const reason = leadToolFailureReason(call.name, outcome.message);
@@ -510,7 +569,8 @@ export async function runLeadTurn(input) {
510
569
  ? reason : `That step didn't work (${leadToolLabel(call.name)}).` };
511
570
  }
512
571
  progress({ kind: "tool-result", turn: turnId, step: steps, id: progressId, outcome: displayOutcome });
513
- if (READ_TOOLS.has(call.name))
572
+ // A call refused because this request did not advertise it read nothing.
573
+ if (READ_TOOLS.has(call.name) && advertisedNames.has(call.name))
514
574
  reads++;
515
575
  if (call.name === "show_control" && outcome.ok)
516
576
  shownControl = true;
@@ -534,7 +594,7 @@ export async function runLeadTurn(input) {
534
594
  if (scanForSecrets(reply).length > 0)
535
595
  return fail("secret-refused", LEAD_FAILURE_COPY.secretReply, false);
536
596
  now = clock();
537
- const activity = activitySummary(reads, proposals, steps);
597
+ const activity = activitySummary(reads, proposals, steps, cacheKnown && tokensIn > 0 ? Math.min(1, cacheRead / tokensIn) : null);
538
598
  // One write (finding 12): settle, debit, promote the drafts, append the assistant text.
539
599
  const finalized = store.finalizeLeadTurn(turnId, started.generation, { state: "answered", settledMicrousd: settled, tokensIn, tokensOut, message: { text: reply, activity } }, now);
540
600
  if (!finalized)
@@ -4,7 +4,7 @@
4
4
  * costs nothing per run, so its windows are what bind it; budgets bind work
5
5
  * billed to an API key. An instance operator's view.
6
6
  */
7
- import type { BrowserLimits } from "./browser-workspace.js";
7
+ import type { BrowserLimits, BrowserUsageChip } from "./browser-workspace.js";
8
8
  import { type BudgetState } from "./spend.js";
9
9
  import { type Html } from "./html.js";
10
10
  /** "in 25 min", "in 2 h 10 min", or a day and hour ("Fri 11 PM") beyond a day. */
@@ -22,6 +22,13 @@ export declare function limitsView(windows: readonly {
22
22
  project: (repo: string) => string;
23
23
  subagent: (id: number) => string;
24
24
  }, now: Date): BrowserLimits | null;
25
+ /**
26
+ * The usage chip (cockpit §3, R16): the subscription window with the highest use the providers last reported, as
27
+ * "Claude 5-hour · 82% · resets 3h". A reading older than 30 minutes stays visibly stale ("as of 2h ago"), judged by
28
+ * the reading's age as the Limits view judges it; a window with no known reset says "resets: unknown". API-key budgets
29
+ * are not compared here (they stay in the details). An operator's view, as the Limits view is. Null: nothing reported.
30
+ */
31
+ export declare function usageChipOf(windows: Parameters<typeof limitsView>[0], now: Date): BrowserUsageChip | null;
25
32
  /** The same tiles for the page without scripts. */
26
33
  export declare function limitsHtml(limits: BrowserLimits | null): Html;
27
34
  export declare const LIMITS_CSS: string;
package/dist/limits-ui.js CHANGED
@@ -48,6 +48,32 @@ export function limitsView(windows, budgets, names, now) {
48
48
  }
49
49
  return tiles.length === 0 ? null : { tiles };
50
50
  }
51
+ /** One short reset: "3h", "25 min", or "Fri 11 PM". */
52
+ const shortReset = (resetsAt, now) => {
53
+ const minutes = Math.max(1, Math.round((Date.parse(resetsAt) - now.getTime()) / 60_000));
54
+ return minutes < 60 ? `${minutes} min` : minutes < 24 * 60 ? `${Math.round(minutes / 60)}h` : resetWords(resetsAt, now);
55
+ };
56
+ /**
57
+ * The usage chip (cockpit §3, R16): the subscription window with the highest use the providers last reported, as
58
+ * "Claude 5-hour · 82% · resets 3h". A reading older than 30 minutes stays visibly stale ("as of 2h ago"), judged by
59
+ * the reading's age as the Limits view judges it; a window with no known reset says "resets: unknown". API-key budgets
60
+ * are not compared here (they stay in the details). An operator's view, as the Limits view is. Null: nothing reported.
61
+ */
62
+ export function usageChipOf(windows, now) {
63
+ // A window whose reset has passed since its reading is not measured again yet: it ranks below every current one, and
64
+ // shows what was last read, as stale — never an inferred 0%.
65
+ const expired = (one) => one.resetsAt !== null && Date.parse(one.resetsAt) <= now.getTime();
66
+ const rank = (one) => (expired(one) ? -1_000 : 0) + one.usedPercent + (one.reached && !expired(one) ? 1_000 : 0);
67
+ const top = [...windows].sort((a, b) => rank(b) - rank(a))[0];
68
+ if (top === undefined)
69
+ return null;
70
+ const old = expired(top);
71
+ const stale = old || now.getTime() - Date.parse(top.observedAt) > 30 * 60_000;
72
+ // A window with no known length or reset has no reset to name.
73
+ const reset = old ? "window reset" : top.resetsAt === null || top.windowMinutes === null ? "resets: unknown" : `resets ${shortReset(top.resetsAt, now)}`;
74
+ const text = `${PROVIDER_NAMES[top.provider] ?? top.provider} ${windowLabel(top.window, top.windowMinutes)} · ${Math.round(top.usedPercent)}% · ${reset}${stale ? ` · as of ${ago(top.observedAt, now)}` : ""}`;
75
+ return { text, tone: old ? "neutral" : toneOf(top.usedPercent, top.reached), stale, href: "/work", title: `What the providers last reported: ${text}` };
76
+ }
51
77
  /** The same tiles for the page without scripts. */
52
78
  export function limitsHtml(limits) {
53
79
  if (limits === null)
@@ -85,6 +85,7 @@ export declare class LiveConnection {
85
85
  private readonly proofs;
86
86
  private readonly timer;
87
87
  private closed;
88
+ private refusing;
88
89
  constructor(response: ServerResponse, options?: {
89
90
  limitBytes?: number;
90
91
  keepAliveMs?: number;
@@ -95,6 +96,10 @@ export declare class LiveConnection {
95
96
  get open(): boolean;
96
97
  /** An event for one room: its data says which. */
97
98
  send(room: string, event: string, data?: Record<string, unknown>): void;
99
+ /** A room leaves because its proof failed (a sign-out, a revoked or narrowed account): if that departure ends the
100
+ * connection, the page is told the stream `ended` and stops, rather than reconnecting at once into a request that
101
+ * can't sign in. Any other close — the page going, a later shutdown, an overflow — says nothing of it. */
102
+ refuse(leave: () => void): void;
98
103
  /** Run when the connection ends; returns the undo for a room that leaves first. */
99
104
  onClose(leave: () => void): () => void;
100
105
  close(): void;
@@ -120,6 +125,9 @@ export type LiveRoomOptions = {
120
125
  bus?: LiveBus | null;
121
126
  safetyNetMs?: number;
122
127
  minIntervalMs?: number;
128
+ /** A room whose read is costly reads at most once per this window after a signal; every viewer is still re-proved
129
+ * the moment a signal arrives (and again at the read). */
130
+ readSettleMs?: number;
123
131
  };
124
132
  /**
125
133
  * Rooms of open pages that hear the bus. `presence` says who else is here, as
package/dist/live-bus.js CHANGED
@@ -201,6 +201,7 @@ export class LiveConnection {
201
201
  proofs = new Set();
202
202
  timer;
203
203
  closed = false;
204
+ refusing = false;
204
205
  constructor(response, options = {}) {
205
206
  this.response = response;
206
207
  this.stream = new LiveStream(response, options.limitBytes, () => this.close());
@@ -222,6 +223,19 @@ export class LiveConnection {
222
223
  get open() { return !this.closed && this.stream.open; }
223
224
  /** An event for one room: its data says which. */
224
225
  send(room, event, data = {}) { this.stream.send(event, { room, ...data }); }
226
+ /** A room leaves because its proof failed (a sign-out, a revoked or narrowed account): if that departure ends the
227
+ * connection, the page is told the stream `ended` and stops, rather than reconnecting at once into a request that
228
+ * can't sign in. Any other close — the page going, a later shutdown, an overflow — says nothing of it. */
229
+ refuse(leave) {
230
+ const outer = this.refusing;
231
+ this.refusing = true;
232
+ try {
233
+ leave();
234
+ }
235
+ finally {
236
+ this.refusing = outer;
237
+ }
238
+ }
225
239
  /** Run when the connection ends; returns the undo for a room that leaves first. */
226
240
  onClose(leave) { this.leaves.add(leave); return () => { this.leaves.delete(leave); }; }
227
241
  close() {
@@ -233,6 +247,10 @@ export class LiveConnection {
233
247
  leave();
234
248
  this.leaves.clear();
235
249
  this.proofs.clear();
250
+ if (this.refusing && this.stream.open) {
251
+ this.response.end("event: ended\ndata: {}\n\n");
252
+ return;
253
+ }
236
254
  if (!this.response.writableEnded)
237
255
  this.response.end();
238
256
  }
@@ -251,6 +269,7 @@ export function createLiveRooms(fingerprint, presence, options = {}) {
251
269
  const bus = options.bus ?? null;
252
270
  const safetyNetMs = options.safetyNetMs ?? SAFETY_NET_MS;
253
271
  const minIntervalMs = options.minIntervalMs ?? 0;
272
+ const readSettleMs = options.readSettleMs ?? 0;
254
273
  const rooms = new Map();
255
274
  let unsubscribe = null;
256
275
  const announce = (key, room) => {
@@ -287,6 +306,8 @@ export function createLiveRooms(fingerprint, presence, options = {}) {
287
306
  clearTimeout(room.retry);
288
307
  if (room.holding !== null)
289
308
  clearTimeout(room.holding);
309
+ if (room.reading !== null)
310
+ clearTimeout(room.reading);
290
311
  rooms.delete(key);
291
312
  if (rooms.size === 0 && unsubscribe !== null) {
292
313
  unsubscribe();
@@ -312,7 +333,10 @@ export function createLiveRooms(fingerprint, presence, options = {}) {
312
333
  viewer.connection.send(viewer.room, "gone");
313
334
  }
314
335
  finally {
315
- leave(key, viewer);
336
+ if (valid)
337
+ leave(key, viewer);
338
+ else
339
+ viewer.connection.refuse(() => leave(key, viewer));
316
340
  }
317
341
  return false;
318
342
  }
@@ -366,8 +390,20 @@ export function createLiveRooms(fingerprint, presence, options = {}) {
366
390
  deliver(key, room, viewer, "change", { at: room.last, revision: room.revision });
367
391
  };
368
392
  const heard = (change) => {
369
- for (const [key, room] of [...rooms])
370
- refresh(key, room, change.revision);
393
+ for (const [key, room] of [...rooms]) {
394
+ if (readSettleMs <= 0) {
395
+ refresh(key, room, change.revision);
396
+ continue;
397
+ }
398
+ // Re-prove now; read once the burst settles.
399
+ for (const viewer of [...room.viewers.keys()])
400
+ deliver(key, room, viewer);
401
+ if (rooms.get(key) !== room || room.reading !== null)
402
+ continue;
403
+ room.reading = setTimeout(() => { room.reading = null; if (rooms.get(key) === room)
404
+ refresh(key, room, null); }, readSettleMs);
405
+ room.reading.unref();
406
+ }
371
407
  };
372
408
  return {
373
409
  join(key, viewer) {
@@ -378,7 +414,7 @@ export function createLiveRooms(fingerprint, presence, options = {}) {
378
414
  last = fingerprint(key);
379
415
  }
380
416
  catch { /* the next signal or the safety net reads it */ }
381
- const created = { viewers: new Map(), last, timer: setInterval(() => refresh(key, created, null), safetyNetMs), settling: null, retry: null, saidAt: 0, holding: null, revision: null };
417
+ const created = { viewers: new Map(), last, timer: setInterval(() => refresh(key, created, null), safetyNetMs), settling: null, retry: null, saidAt: 0, holding: null, revision: null, reading: null };
382
418
  created.timer.unref();
383
419
  room = created;
384
420
  rooms.set(key, room);
@@ -0,0 +1,27 @@
1
+ import type { Store } from "./store.js";
2
+ /** The consent a "Show commands" form carries: a single-use nonce minted for this person and these exact terms. */
3
+ export declare const LIVE_VIEW_CONSENT = "live-view";
4
+ export declare function liveViewConsentDigest(repo: string): string;
5
+ export type LiveDetailSetting = {
6
+ repo: string;
7
+ commands: boolean;
8
+ updatedBy: string;
9
+ updatedAt: string;
10
+ };
11
+ /** Whether this project's live conversations show commands. Unknown or absent is off. */
12
+ export declare function liveCommandsShown(store: Store, repo: string | null): boolean;
13
+ export declare function liveDetailSetting(store: Store, repo: string): LiveDetailSetting | null;
14
+ /** Save the setting and its ledger line in one transaction. The caller has proved who is asking (and, to turn it on, their password). */
15
+ export declare function setLiveCommands(store: Store, change: {
16
+ repo: string;
17
+ commands: boolean;
18
+ by: string;
19
+ via: string;
20
+ }, now: Date): {
21
+ said: string;
22
+ };
23
+ /** A run's trusted context for its live file: its own checkout, and its project's setting, read now. */
24
+ export declare function liveLogContext(store: Store, taskRef: number, worktree: string | null): {
25
+ base: string | null;
26
+ commands: boolean;
27
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A project's "Show commands in the live view" (docs/plans/live-conversation.md):
3
+ * whether a running agent's command text and command output are written to
4
+ * its live conversation. Off unless an approver turned it on for the
5
+ * project with their password; turning it off is one step. Every change is
6
+ * an action-ledger line.
7
+ *
8
+ * The writer reads it once, when it opens a run's live file: what is off
9
+ * never reaches disk, and a change applies from the next run.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ /** The consent a "Show commands" form carries: a single-use nonce minted for this person and these exact terms. */
13
+ export const LIVE_VIEW_CONSENT = "live-view";
14
+ export function liveViewConsentDigest(repo) {
15
+ return createHash("sha256").update(JSON.stringify(["live-view", repo, "commands-on"])).digest("hex");
16
+ }
17
+ /** Whether this project's live conversations show commands. Unknown or absent is off. */
18
+ export function liveCommandsShown(store, repo) {
19
+ if (repo === null)
20
+ return false;
21
+ try {
22
+ const row = store.handle.prepare("SELECT commands FROM live_detail_setting WHERE repo = ?").get(repo);
23
+ return row !== undefined && Number(row["commands"]) === 1;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ export function liveDetailSetting(store, repo) {
30
+ const row = store.handle.prepare("SELECT repo, commands, updated_by, updated_at FROM live_detail_setting WHERE repo = ?").get(repo);
31
+ if (row === undefined)
32
+ return null;
33
+ return { repo: String(row["repo"]), commands: Number(row["commands"]) === 1, updatedBy: String(row["updated_by"]), updatedAt: String(row["updated_at"]) };
34
+ }
35
+ /** Save the setting and its ledger line in one transaction. The caller has proved who is asking (and, to turn it on, their password). */
36
+ export function setLiveCommands(store, change, now) {
37
+ store.transact(() => {
38
+ store.handle
39
+ .prepare(`INSERT INTO live_detail_setting (repo, commands, updated_by, updated_at) VALUES (?,?,?,?)
40
+ ON CONFLICT (repo) DO UPDATE SET commands = excluded.commands, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
41
+ .run(change.repo, change.commands ? 1 : 0, change.by, now.toISOString());
42
+ store.recordAction({ at: now.toISOString(), actor: change.by, repo: change.repo, taskId: null, runId: null,
43
+ action: "live view commands", outcome: change.commands ? "on" : "off", source: "request", detail: `via ${change.via}` });
44
+ });
45
+ return { said: change.commands ? "Commands now show in the live view for this project, from the next run." : "Commands are hidden in the live view for this project, from the next run." };
46
+ }
47
+ /** A run's trusted context for its live file: its own checkout, and its project's setting, read now. */
48
+ export function liveLogContext(store, taskRef, worktree) {
49
+ let repo = null;
50
+ try {
51
+ repo = store.refForId(taskRef)?.repo ?? null;
52
+ }
53
+ catch {
54
+ repo = null;
55
+ }
56
+ return { base: worktree, commands: liveCommandsShown(store, repo) };
57
+ }
@@ -0,0 +1,20 @@
1
+ import type { Store } from "./store.js";
2
+ export declare const RUN_NOTES_SHOWN = 20;
3
+ export type RunNote = {
4
+ id: number;
5
+ author: string;
6
+ /** Whether the person looking wrote it: only then does the view say "You". */
7
+ mine: boolean;
8
+ at: string;
9
+ text: string;
10
+ standing: string;
11
+ readAt: string | null;
12
+ };
13
+ /** This run's notes, oldest first: the newest RUN_NOTES_SHOWN, and how many older ones the task page keeps. */
14
+ export declare function runNotes(store: Store, run: {
15
+ id: number;
16
+ taskRef: number;
17
+ }, viewer: string, now: Date): {
18
+ notes: RunNote[];
19
+ older: number;
20
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The steering notes that belong in one run's live conversation
3
+ * (docs/plans/live-conversation.md §5): every note this run reached —
4
+ * attached to its brief, mailed into its folder, or read by it, as kept in
5
+ * steer_note_run when it happened — and, only while this run is the task's
6
+ * live build, notes still waiting for it. A note never shows on a run it did
7
+ * not reach, and is credited as read only to the run that read it.
8
+ */
9
+ import { steerStandingParts } from "./steer-status.js";
10
+ export const RUN_NOTES_SHOWN = 20;
11
+ /** Where a note stands as seen from this run. */
12
+ function standingFor(note, run, live) {
13
+ if (note.authorshipState === "verified" && note.supersededAt === null && note.deliveredAt === null && !live) {
14
+ // This run ended (or is not the one reading notes) without reading it: it waits for the next attempt.
15
+ return { standing: "Not read in this attempt — the next one gets it", readAt: null };
16
+ }
17
+ // Once read, a note's attached run is the run that read it (ackSteerNote, settleSteerDelivered).
18
+ if (note.deliveredAt !== null && note.attachedRun !== run)
19
+ return { standing: "Read in another attempt", readAt: null };
20
+ const { words, readAt } = steerStandingParts(note, live);
21
+ return { standing: words, readAt };
22
+ }
23
+ /** This run's notes, oldest first: the newest RUN_NOTES_SHOWN, and how many older ones the task page keeps. */
24
+ export function runNotes(store, run, viewer, now) {
25
+ const live = store.steeringLiveBuild(run.taskRef, now) === run.id;
26
+ const reached = new Map(store.steerNotesReaching(run.id).filter(note => note.taskRef === run.taskRef).map(note => [note.id, note]));
27
+ if (live) {
28
+ for (const note of store.listSteerNotes(run.taskRef)) {
29
+ const waiting = note.authorshipState === "verified" && note.deliveredAt === null && note.supersededAt === null;
30
+ if (waiting && !reached.has(note.id))
31
+ reached.set(note.id, note);
32
+ }
33
+ }
34
+ const all = [...reached.values()].sort((a, b) => a.id - b.id);
35
+ const shown = all.slice(-RUN_NOTES_SHOWN);
36
+ return {
37
+ older: all.length - shown.length,
38
+ notes: shown.map(note => ({
39
+ id: note.id,
40
+ author: note.author,
41
+ mine: note.author === viewer,
42
+ at: note.createdAt,
43
+ text: note.note,
44
+ ...standingFor(note, run.id, live),
45
+ })),
46
+ };
47
+ }