toolroll 0.9.62 → 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 (345) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +66 -8
  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 +203 -10
  16. package/dist/agent-fence.js +623 -41
  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 +263 -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 +199 -17
  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/check-levels.js +3 -0
  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 +20 -1
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +5 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +24 -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/cli.d.ts +8 -0
  71. package/dist/contracts/cli.js +3 -1
  72. package/dist/contracts/console-api.d.ts +30 -4
  73. package/dist/contracts/console-api.js +13 -4
  74. package/dist/contracts/flow-card.d.ts +4 -0
  75. package/dist/contracts/flow-card.js +3 -0
  76. package/dist/contracts/flow.d.ts +46 -0
  77. package/dist/contracts/flow.js +11 -2
  78. package/dist/contracts/integration-metadata.d.ts +2 -1
  79. package/dist/contracts/integration-metadata.js +2 -1
  80. package/dist/contracts/lead-tools.d.ts +92 -0
  81. package/dist/contracts/lead-tools.js +13 -0
  82. package/dist/contracts/provider.d.ts +11 -1
  83. package/dist/contracts/provider.js +1 -1
  84. package/dist/contracts/route.d.ts +76 -1
  85. package/dist/contracts/route.js +1 -1
  86. package/dist/contracts/store-json.d.ts +2 -0
  87. package/dist/control-setup.d.ts +1 -1
  88. package/dist/control-ui.js +2 -2
  89. package/dist/converse.d.ts +16 -0
  90. package/dist/converse.js +32 -4
  91. package/dist/demo-chat.js +4 -4
  92. package/dist/demo.js +2 -2
  93. package/dist/desktop-update-gate.js +6 -0
  94. package/dist/discord-chat.js +4 -1
  95. package/dist/dispatch.js +7 -4
  96. package/dist/doctor.d.ts +53 -0
  97. package/dist/doctor.js +427 -0
  98. package/dist/egress-bridge.d.ts +17 -0
  99. package/dist/egress-bridge.js +111 -0
  100. package/dist/egress.d.ts +119 -0
  101. package/dist/egress.js +423 -0
  102. package/dist/evidence-pack.d.ts +1 -1
  103. package/dist/evidence-pack.js +2 -2
  104. package/dist/exec.d.ts +81 -0
  105. package/dist/exec.js +368 -9
  106. package/dist/export-ui.js +2 -2
  107. package/dist/flow-code.d.ts +2 -2
  108. package/dist/flow-code.js +4 -15
  109. package/dist/flow-engine.js +35 -11
  110. package/dist/flow-gallery-ui.js +2 -2
  111. package/dist/flow-gallery.js +27 -8
  112. package/dist/flow-share.js +16 -5
  113. package/dist/flow-triggers.d.ts +84 -11
  114. package/dist/flow-triggers.js +554 -87
  115. package/dist/flow-unattended-words.d.ts +28 -0
  116. package/dist/flow-unattended-words.js +50 -0
  117. package/dist/flow-unattended.d.ts +227 -0
  118. package/dist/flow-unattended.js +776 -0
  119. package/dist/flows-cli.d.ts +28 -1
  120. package/dist/flows-cli.js +63 -12
  121. package/dist/flows-ui.d.ts +2 -3
  122. package/dist/flows-ui.js +56 -5
  123. package/dist/flows.d.ts +4 -0
  124. package/dist/flows.js +9 -1
  125. package/dist/google-mail.d.ts +1 -0
  126. package/dist/google-mail.js +61 -18
  127. package/dist/html.d.ts +9 -0
  128. package/dist/html.js +11 -0
  129. package/dist/integrations-ui.d.ts +2 -1
  130. package/dist/integrations-ui.js +6 -3
  131. package/dist/integrations.d.ts +17 -3
  132. package/dist/integrations.js +38 -18
  133. package/dist/invoke.d.ts +17 -0
  134. package/dist/invoke.js +510 -182
  135. package/dist/keys.d.ts +18 -14
  136. package/dist/keys.js +5 -0
  137. package/dist/landed-cleanup.d.ts +3 -2
  138. package/dist/landed-cleanup.js +12 -5
  139. package/dist/lead-capabilities.js +3 -2
  140. package/dist/lead-contract.js +2 -0
  141. package/dist/lead-doors.d.ts +2 -0
  142. package/dist/lead-doors.js +7 -0
  143. package/dist/lead-follow.d.ts +2 -0
  144. package/dist/lead-follow.js +58 -2
  145. package/dist/lead-progress.js +1 -0
  146. package/dist/lead-queue-runtime.d.ts +46 -0
  147. package/dist/lead-queue-runtime.js +191 -0
  148. package/dist/lead-queue.d.ts +104 -0
  149. package/dist/lead-queue.js +187 -0
  150. package/dist/lead-sweep.d.ts +67 -0
  151. package/dist/lead-sweep.js +99 -0
  152. package/dist/lead-tools.d.ts +22 -0
  153. package/dist/lead-tools.js +163 -4
  154. package/dist/lead.d.ts +13 -5
  155. package/dist/lead.js +89 -29
  156. package/dist/limits-ui.d.ts +8 -1
  157. package/dist/limits-ui.js +26 -0
  158. package/dist/live-bus.d.ts +8 -0
  159. package/dist/live-bus.js +40 -4
  160. package/dist/live-detail.d.ts +27 -0
  161. package/dist/live-detail.js +57 -0
  162. package/dist/live-notes.d.ts +20 -0
  163. package/dist/live-notes.js +47 -0
  164. package/dist/live-records.d.ts +140 -0
  165. package/dist/live-records.js +935 -0
  166. package/dist/live-terminal.d.ts +258 -0
  167. package/dist/live-terminal.js +649 -0
  168. package/dist/live.d.ts +38 -27
  169. package/dist/live.js +142 -124
  170. package/dist/mcp-connect.d.ts +8 -7
  171. package/dist/mcp-connect.js +49 -51
  172. package/dist/mcp-oauth.d.ts +6 -3
  173. package/dist/mcp-oauth.js +8 -4
  174. package/dist/memory-pass.js +2 -2
  175. package/dist/mobile-viewport.d.ts +5 -1
  176. package/dist/mobile-viewport.js +18 -2
  177. package/dist/model-catalog.js +4 -0
  178. package/dist/monitoring-ui.js +2 -2
  179. package/dist/needs-you.d.ts +1 -1
  180. package/dist/needs-you.js +11 -4
  181. package/dist/oauth-run-token.d.ts +51 -0
  182. package/dist/oauth-run-token.js +127 -0
  183. package/dist/operate.d.ts +2 -0
  184. package/dist/operate.js +282 -18
  185. package/dist/passwordless.d.ts +23 -0
  186. package/dist/passwordless.js +26 -0
  187. package/dist/peek-cli.d.ts +12 -4
  188. package/dist/peek-cli.js +19 -11
  189. package/dist/phase-routing.js +10 -3
  190. package/dist/plan-auto.d.ts +11 -0
  191. package/dist/plan-auto.js +62 -11
  192. package/dist/planner-source.d.ts +3 -0
  193. package/dist/planner-source.js +14 -0
  194. package/dist/planner.d.ts +2 -1
  195. package/dist/planner.js +7 -1
  196. package/dist/policy-ui.js +2 -2
  197. package/dist/policy.d.ts +1 -20
  198. package/dist/policy.js +29 -2
  199. package/dist/process-recovery-eligibility.d.ts +1 -1
  200. package/dist/project-access.d.ts +3 -0
  201. package/dist/project-access.js +3 -0
  202. package/dist/project-delete-ui.d.ts +12 -0
  203. package/dist/project-delete-ui.js +41 -9
  204. package/dist/project-delete.d.ts +3 -1
  205. package/dist/project-delete.js +12 -6
  206. package/dist/project-file.d.ts +3 -0
  207. package/dist/project-file.js +16 -0
  208. package/dist/project-memory.d.ts +3 -1
  209. package/dist/project-memory.js +57 -25
  210. package/dist/project-tools.d.ts +103 -27
  211. package/dist/project-tools.js +399 -100
  212. package/dist/provider-auth.d.ts +4 -3
  213. package/dist/provider-auth.js +5 -4
  214. package/dist/provider-connection.d.ts +2 -1
  215. package/dist/provider-connection.js +5 -0
  216. package/dist/provider-home.d.ts +22 -0
  217. package/dist/provider-home.js +99 -0
  218. package/dist/provider.d.ts +42 -5
  219. package/dist/provider.js +97 -19
  220. package/dist/pull-request-ui.js +2 -2
  221. package/dist/push.js +1 -1
  222. package/dist/quick-start.d.ts +24 -0
  223. package/dist/quick-start.js +127 -0
  224. package/dist/release.d.ts +3 -0
  225. package/dist/release.js +14 -8
  226. package/dist/remote-main.d.ts +4 -0
  227. package/dist/remote-main.js +3 -3
  228. package/dist/request-budget-ui.js +2 -2
  229. package/dist/result-review.d.ts +1 -1
  230. package/dist/result-review.js +9 -3
  231. package/dist/retention-ui.js +2 -2
  232. package/dist/retention.js +10 -1
  233. package/dist/run-efficiency.d.ts +73 -0
  234. package/dist/run-efficiency.js +228 -0
  235. package/dist/runner.d.ts +5 -1
  236. package/dist/runner.js +22 -1
  237. package/dist/scope.d.ts +171 -6
  238. package/dist/scope.js +251 -31
  239. package/dist/scout-net.d.ts +4 -1
  240. package/dist/scout-net.js +37 -12
  241. package/dist/scout.d.ts +20 -6
  242. package/dist/scout.js +27 -3
  243. package/dist/serve.js +258 -43
  244. package/dist/server/chat.d.ts +1 -1
  245. package/dist/server/chat.js +122 -31
  246. package/dist/server/chrome.d.ts +6 -1
  247. package/dist/server/chrome.js +18 -7
  248. package/dist/server/console.css +52 -38
  249. package/dist/server/flows.js +65 -13
  250. package/dist/server/guards.d.ts +1 -0
  251. package/dist/server/guards.js +23 -9
  252. package/dist/server/http.d.ts +7 -0
  253. package/dist/server/http.js +3 -2
  254. package/dist/server/live.d.ts +34 -1
  255. package/dist/server/live.js +39 -17
  256. package/dist/server/pages-assets.js +5 -0
  257. package/dist/server/pages.js +90 -13
  258. package/dist/server/people-tokens.d.ts +3 -0
  259. package/dist/server/people-tokens.js +55 -17
  260. package/dist/server/remote-hooks.js +12 -6
  261. package/dist/server/remote.d.ts +2 -1
  262. package/dist/server/remote.js +14 -5
  263. package/dist/server/render-chat.d.ts +12 -3
  264. package/dist/server/render-chat.js +47 -16
  265. package/dist/server/render-pages.d.ts +4 -1
  266. package/dist/server/render-pages.js +5 -4
  267. package/dist/server/render-people.d.ts +3 -2
  268. package/dist/server/render-people.js +3 -2
  269. package/dist/server/render-results.d.ts +18 -2
  270. package/dist/server/render-results.js +37 -28
  271. package/dist/server/render-settings.js +6 -5
  272. package/dist/server/render-tasks.d.ts +35 -10
  273. package/dist/server/render-tasks.js +110 -27
  274. package/dist/server/request-context.d.ts +2 -0
  275. package/dist/server/request-context.js +2 -1
  276. package/dist/server/route-table.d.ts +3 -0
  277. package/dist/server/route-table.js +49 -5
  278. package/dist/server/runtime.d.ts +34 -3
  279. package/dist/server/session.d.ts +25 -1
  280. package/dist/server/session.js +18 -4
  281. package/dist/server/settings.js +132 -13
  282. package/dist/server/sweeps.d.ts +4 -0
  283. package/dist/server/sweeps.js +58 -0
  284. package/dist/server/task-views.d.ts +2 -1
  285. package/dist/server/task-views.js +96 -6
  286. package/dist/server/tasks.js +320 -74
  287. package/dist/server/terminals.d.ts +31 -0
  288. package/dist/server/terminals.js +365 -0
  289. package/dist/setup-guide.js +3 -2
  290. package/dist/slack-chat.js +4 -1
  291. package/dist/spend-ui.js +3 -3
  292. package/dist/spend.js +2 -1
  293. package/dist/steer-capabilities.d.ts +32 -0
  294. package/dist/steer-capabilities.js +60 -0
  295. package/dist/steer-controls.d.ts +14 -0
  296. package/dist/steer-controls.js +36 -0
  297. package/dist/steer-status.js +2 -2
  298. package/dist/storage-sweep-child.js +3 -1
  299. package/dist/storage-sweep-process.d.ts +12 -5
  300. package/dist/storage-sweep-process.js +23 -6
  301. package/dist/storage-ui.js +4 -4
  302. package/dist/store-baseline.d.ts +1 -1
  303. package/dist/store-baseline.js +1 -1
  304. package/dist/store.d.ts +466 -19
  305. package/dist/store.js +1185 -63
  306. package/dist/subagent-desk.js +1 -1
  307. package/dist/subagent-tools.d.ts +1 -1
  308. package/dist/summary.js +3 -0
  309. package/dist/surface.js +6 -4
  310. package/dist/task-source.d.ts +58 -0
  311. package/dist/task-source.js +93 -0
  312. package/dist/team-leads.js +1 -1
  313. package/dist/teams-chat.js +4 -1
  314. package/dist/teardown-git.d.ts +14 -0
  315. package/dist/teardown-git.js +119 -0
  316. package/dist/telegram-lead.d.ts +3 -0
  317. package/dist/telegram-lead.js +44 -7
  318. package/dist/telegram.d.ts +11 -2
  319. package/dist/telegram.js +51 -17
  320. package/dist/terminal-catalog.d.ts +11 -0
  321. package/dist/terminal-catalog.js +129 -0
  322. package/dist/text-limits.d.ts +2 -0
  323. package/dist/text-limits.js +2 -0
  324. package/dist/tool-broker.d.ts +39 -0
  325. package/dist/tool-broker.js +370 -0
  326. package/dist/tool-launcher.js +5 -4
  327. package/dist/tool-secret-store.d.ts +170 -0
  328. package/dist/tool-secret-store.js +834 -0
  329. package/dist/toolroll-update-ui.js +2 -2
  330. package/dist/toolroll-update.js +14 -3
  331. package/dist/tools-ui.d.ts +7 -1
  332. package/dist/tools-ui.js +19 -10
  333. package/dist/webhook-limits.d.ts +23 -0
  334. package/dist/webhook-limits.js +30 -0
  335. package/dist/webhook-readers.d.ts +56 -0
  336. package/dist/webhook-readers.js +138 -0
  337. package/dist/webhook-signing.d.ts +27 -0
  338. package/dist/webhook-signing.js +119 -0
  339. package/dist/work-index.d.ts +29 -0
  340. package/dist/work-index.js +21 -5
  341. package/dist/workspace-revision.js +3 -1
  342. package/dist/worktree.d.ts +10 -4
  343. package/dist/worktree.js +70 -29
  344. package/package.json +13 -2
  345. package/scripts/provider-canary.mjs +210 -31
@@ -1,3 +1,4 @@
1
+ import { sweepLine, sweepTable } from "./lead-sweep.js";
1
2
  import { TEXT_LIMITS } from "./text-limits.js";
2
3
  import { connectionsOf, oneClickOf } from "./mcp-connect.js";
3
4
  import { checkLevelFromWords, isCheckLevel } from "./check-levels.js";
@@ -44,11 +45,12 @@ import { diagnoseTaskDispatch, withDispatchDiagnoses } from "./dispatch.js";
44
45
  import { agentChoicesFor, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.js";
45
46
  import { isNewModel, modelWords, priceWords, runtimeStates, seenModels } from "./model-catalog.js";
46
47
  import { agentsSummary, chosenWords, PHASES, postureWords, routeProblems, sameSpec, sizeSourceWords, sizeWords, specWords } from "./phase-routing.js";
47
- import { TOOL_CATALOG, discoverTools, projectToolsOf, secretsSetFor, toolCommandLine, toolStanding } from "./project-tools.js";
48
+ import { TOOL_CATALOG, credentialModeOf, discoverTools, projectToolsOf, secretsSetFor, toolCommandLine, toolSecretStorage, toolStanding } from "./project-tools.js";
48
49
  import { deciderOf, durationWords, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowFromSteps, stepsFor, withKeptSteps } from "./flows.js";
49
50
  import { parseContract, toModelSchema } from "./contracts/contract.js";
50
51
  import { LEAD_TOOL_INPUTS, LEAD_TOOL_READ_INPUTS, LEAD_TOOL_OPTIONS, LEAD_TOOL_OUTPUTS, reportToolOutput } from "./contracts/lead-tools.js";
51
52
  import { flowCardHref, flowDefinitionOf } from "./flow-engine.js";
53
+ import { activeUnattendedGrant } from "./flow-unattended.js";
52
54
  import { planInChat } from "./chat-decide.js";
53
55
  import { flowDecisionAt } from "./telegram-flow.js";
54
56
  import { flowPersonOf } from "./flow-send.js";
@@ -371,7 +373,102 @@ export function agentsOver(store, taskId, now) {
371
373
  choices: Object.fromEntries(PHASES.filter(phase => phase !== "review").map(phase => [ROLE_WORD[phase], choices[phase].filter(one => one.selectable).map(one => ({ provider: one.provider, model: one.model, current: one.current }))])),
372
374
  };
373
375
  }
376
+ /**
377
+ * The tools every request advertises (docs/plans/lead-deferred-tools.md): the ones most replies use, and every tool a
378
+ * guard requires read in an earlier step (search_project_memory before any proposal, get_capabilities before work that
379
+ * needs an agent, get_result before propose_review, get_decision before propose_answer). The rest are found with
380
+ * find_tools and advertised from the next request of the same reply.
381
+ */
382
+ export const LEAD_CORE_TOOLS = new Set([
383
+ "find_tools", "get_brief", "recap", "list_repos", "get_project_context", "search_project_memory", "get_capabilities",
384
+ "list_tasks", "get_task", "get_result", "get_diff", "get_check_log", "list_decisions", "get_decision",
385
+ "get_actions", "get_action_status", "propose_action", "propose_task", "propose_scope", "propose_steer",
386
+ "propose_answer", "propose_review", "remember", "ask_owner", "commit_to",
387
+ // The lead's queue verbs: basic, and small enough that deferring them saves almost nothing.
388
+ "queue", "propose_next", "propose_reserve", "propose_hold", "propose_unhold", "propose_cancel",
389
+ // Task and result acts a reply uses often (links to a control, a result's images and evidence, the task's own acts):
390
+ // small, so deferral is kept for the large, occasional families (flows, subagents, sweeps, project tools).
391
+ "show_control", "get_controls", "get_result_images", "get_acceptance_evidence", "offer_approval", "propose_task_action", "propose_agents",
392
+ "get_agents", "propose_dependency_repair",
393
+ ]);
394
+ /** Words a deferred tool is found by, beyond its name and description. */
395
+ const LEAD_TOOL_KEYWORDS = {
396
+ propose_flow: ["flow", "automation", "workflow", "schedule", "trigger", "every", "when", "email", "inbox", "sort", "follow-up", "nudge"],
397
+ get_flows: ["flow", "automation", "workflow", "running", "card"],
398
+ get_flow_insights: ["flow", "insights", "stats", "how flows"],
399
+ propose_subagent: ["subagent", "assistant", "rep", "support", "sales", "teammate", "rules", "persona"],
400
+ get_subagents: ["subagent", "assistant", "teammate", "rules"],
401
+ propose_sweep: ["sweep", "fan-out", "many", "parallel", "batch", "each", "list of"],
402
+ get_sweep: ["sweep", "fan-out", "batch", "table", "verdict"],
403
+ propose_sweep_cancel: ["sweep", "cancel", "stop the rest"],
404
+ get_models: ["model", "models", "provider", "price"],
405
+ get_project_tools: ["tools", "mcp", "server", "integration", "connect", "stripe", "notion", "linear", "sentry", "jira"],
406
+ get_integrations: ["integration", "slack", "telegram", "discord", "teams", "email", "connected"],
407
+ get_skills: ["skill", "skills", "guide"],
408
+ get_project_knowledge: ["knowledge", "docs", "notes", "memory"],
409
+ get_task_conversation: ["conversation", "chat", "history", "messages"],
410
+ release_commitment: ["promise", "commitment", "release", "forget"],
411
+ get_person: ["person", "people", "who", "member", "teammate"],
412
+ };
413
+ /** At most this many find_tools searches per reply, results per search, and bytes of found tool schemas per reply. */
414
+ export const FIND_TOOLS_PER_TURN = 3;
415
+ export const FIND_TOOLS_MAX_RESULTS = 6;
416
+ export const FIND_TOOLS_SCHEMA_BYTES = 48_000;
417
+ const FIND_TOOLS_NONE = "Nothing matched. The other tools cover flows, subagents, sweeps, agents and models, project tools and integrations, controls and settings pages, holds and the queue, task actions (retry, stop, merge), result images and evidence, people, and promises — search again with one of those words.";
418
+ const MAKE_WORDS = new Set(["make", "add", "create", "set", "draft", "new", "start", "change", "edit", "build", "run", "hold", "cancel", "release", "move", "switch", "turn"]);
419
+ const LOOK_WORDS = new Set(["show", "list", "see", "what", "which", "how", "check", "read", "look", "find", "open", "where"]);
420
+ /** The deferred tools a query finds, best first: an exact name, then words in the name, keywords and description. */
421
+ export function findLeadTools(query) {
422
+ // A plural matches its singular ("sweeps" finds sweep tools).
423
+ const words = query.toLowerCase().split(/[^a-z0-9-]+/).filter(one => one.length >= 3).map(one => one.length > 4 && one.endsWith("s") && !one.endsWith("ss") ? one.slice(0, -1) : one);
424
+ const exact = query.trim().toLowerCase();
425
+ const scored = LEAD_TOOLS.filter(tool => !LEAD_CORE_TOOLS.has(tool.name)).map(tool => {
426
+ const name = tool.name.toLowerCase(), keywords = (LEAD_TOOL_KEYWORDS[tool.name] ?? []).join(" ").toLowerCase();
427
+ const description = tool.description.toLowerCase();
428
+ let score = name === exact ? 100 : 0;
429
+ for (const word of words)
430
+ score += (name.includes(word) ? 5 : 0) + (keywords.includes(word) ? 3 : 0) + (description.includes(word) ? 1 : 0);
431
+ // What the person wants done leans the match: making or changing something → a proposal; looking → a read.
432
+ if (score > 0 && words.some(word => MAKE_WORDS.has(word)))
433
+ score += name.startsWith("propose_") ? 4 : 0;
434
+ if (score > 0 && words.some(word => LOOK_WORDS.has(word)))
435
+ score += name.startsWith("get_") || name === "show_control" ? 4 : 0;
436
+ return { tool, score };
437
+ }).filter(one => one.score > 0).sort((a, b) => b.score - a.score || a.tool.name.localeCompare(b.tool.name));
438
+ return scored.slice(0, FIND_TOOLS_MAX_RESULTS).map(({ tool }) => ({ name: tool.name, summary: summaryLine(tool.description) }));
439
+ }
440
+ /** A description's first sentence, at most 160 characters. */
441
+ function summaryLine(description) {
442
+ const first = /^(.+?[.!?])(\s|$)/.exec(description)?.[1] ?? description;
443
+ return first.length <= 160 ? first : `${first.slice(0, 157).trimEnd()}…`;
444
+ }
374
445
  const LEAD_TOOL_HANDLERS = {
446
+ find_tools: {
447
+ // Counted before the call is read, so an invalid search still uses one of the reply's three.
448
+ prepare: (ctx, raw) => { if (ctx.findTools !== undefined)
449
+ ctx.findTools.searches++; return raw; },
450
+ description: "Find the lead's other tools by what you want to do — flows, subagents, sweeps, agents and models, project tools and integrations, controls and settings pages, holds and the queue, task actions (retry, stop, merge), result images and evidence, people, promises. Search before saying something can't be done. Matching tools become available from your next step in this reply: call them then, not in the same step.",
451
+ handle: (ctx, args) => {
452
+ const state = ctx.findTools;
453
+ if (state === undefined)
454
+ return { ok: false, message: "find_tools works inside a conversation reply." };
455
+ if (state.searches > FIND_TOOLS_PER_TURN)
456
+ return { ok: false, message: `You have searched ${FIND_TOOLS_PER_TURN} times this reply. Use the tools you found, or answer with what you have.` };
457
+ const bytesOf = (name) => Buffer.byteLength(JSON.stringify(LEAD_TOOL_SCHEMAS.find(one => one.name === name) ?? {}), "utf8");
458
+ let used = [...state.found].reduce((sum, name) => sum + bytesOf(name), 0);
459
+ const tools = [];
460
+ for (const one of findLeadTools(args.query)) {
461
+ if (!state.found.has(one.name)) {
462
+ if (used + bytesOf(one.name) > FIND_TOOLS_SCHEMA_BYTES)
463
+ continue;
464
+ used += bytesOf(one.name);
465
+ state.found.add(one.name);
466
+ }
467
+ tools.push(one);
468
+ }
469
+ return { ok: true, body: { tools, next: tools.length === 0 ? FIND_TOOLS_NONE : "These are available from your next step in this reply: call one then." } };
470
+ },
471
+ },
375
472
  get_brief: {
376
473
  description: 'Read current tasks, decisions, results and project knowledge from the local database. No model or mutation.',
377
474
  handle: (ctx, args) => {
@@ -756,13 +853,14 @@ const LEAD_TOOL_HANDLERS = {
756
853
  rule: "Builds in this project get exactly these tools and nothing else configured on this computer. A tool reaches work approved after it was added; earlier approvals need approving again. Reviewers and this chat never get tools; Gemini builds get none.",
757
854
  tools: tools.map(tool => {
758
855
  const set = secretsSetFor(repo, tool.spec);
759
- return { name: tool.name, about: tool.spec.about, starts: toolCommandLine(tool.spec), standing: toolStanding(tool, set).words,
856
+ return { name: tool.name, about: tool.spec.about, starts: toolCommandLine(tool.spec), standing: toolStanding(tool, set).words, credentials: credentialModeOf(tool.spec, set)?.words ?? null,
760
857
  secrets: tool.spec.secrets.map(one => ({ name: one.name, optional: one.optional, set: set.includes(one.name) })),
761
858
  lastTest: tool.lastTest === null ? null : { ok: tool.lastTest.ok, at: tool.lastTest.at, tools: tool.lastTest.tools.slice(0, 40), problem: tool.lastTest.problem } };
762
859
  }),
763
860
  connectBySigningIn: connectionsOf(ctx.store, repo).map(one => ({ service: one.id, label: one.label, about: one.about, state: one.state, connect: `/settings/tools?repo=${encodeURIComponent(repo)}&connect=${one.id}#connect` })),
764
861
  commonTools: TOOL_CATALOG.filter(one => !names.has(one.name) && oneClickOf(one.name) === null).map(one => ({ catalog: one.name, label: one.label, about: one.about, needs: one.secrets.filter(secret => !secret.optional).map(secret => secret.name) })),
765
862
  foundOnThisComputer: found.filter(one => !names.has(one.spec.name)).map(one => ({ name: one.spec.name, source: one.source })),
863
+ secretsKept: toolSecretStorage().words,
766
864
  notice: "Never ask for, accept or repeat a secret's value in chat. Name the secret and open the Tools page (show_control tools with this repo).",
767
865
  } };
768
866
  },
@@ -837,7 +935,13 @@ const LEAD_TOOL_HANDLERS = {
837
935
  return { trigger: trigger.id, kind: trigger.kind, what: config === null ? "can't be read" : describeTrigger(config, ctx.store),
838
936
  startsIn: titleOf(config?.zone ?? definition.start), state: trigger.state, lastCheck: trigger.lastOutcome };
839
937
  }),
840
- rule: "Change it with propose_flow. Build and research steps file ordinary tasks under the usual approvals.",
938
+ // "Runs on its own" is signed with the owner's password on the flow page, never in chat; revoking is one click there.
939
+ runsOnItsOwn: (() => {
940
+ const own = activeUnattendedGrant(ctx.store, flow.id, ctx.now);
941
+ return own === null || own.terms.flowRevision !== flow.revision ? { on: false, change: `Turn it on (the owner's password) on the flow page: /flows/${flow.id}` }
942
+ : { on: true, until: own.absoluteExpiry, change: `Revoke it on the flow page: /flows/${flow.id}` };
943
+ })(),
944
+ rule: "Change it with propose_flow. Build and research steps file ordinary tasks under the usual approvals, unless runsOnItsOwn is on: then its schedule's cards are approved by the owner's signed grant. Signing or revoking that is on the flow page; send the person there.",
841
945
  } };
842
946
  }
843
947
  const repo = args["repo"] === undefined ? null : repoPathOf(ctx.who, args["repo"]);
@@ -1227,7 +1331,8 @@ const LEAD_TOOL_HANDLERS = {
1227
1331
  const thread = ctx.store.liveLeadThreadFor(ctx.who.name, { kind: "task", key: root });
1228
1332
  if (thread === null || thread.ceilingDigest !== ctx.who.ceilingDigest)
1229
1333
  return { ok: true, body: { task: root, messages: [], notice: "No conversation about this task yet." } };
1230
- const messages = ctx.store.listLeadMessages(thread.id, limit).map(one => ({ from: one.role === "operator" ? "operator" : "lead", text: one.text.length > 1_200 ? `${one.text.slice(0, 1_200)}…` : one.text, at: one.createdAt }));
1334
+ // What the lead reads of a task's conversation is what it has been told: admitted messages only (v128).
1335
+ const messages = ctx.store.listLeadHistory(thread.id, limit).map(one => ({ from: one.role === "operator" ? "operator" : "lead", text: one.text.length > 1_200 ? `${one.text.slice(0, 1_200)}…` : one.text, at: one.createdAt }));
1231
1336
  return { ok: true, body: { task: root, title: family?.root.title ?? null, messages, notice: messages.length === limit ? "Only the most recent messages are shown." : null } };
1232
1337
  },
1233
1338
  },
@@ -1632,6 +1737,60 @@ const LEAD_TOOL_HANDLERS = {
1632
1737
  return { ok: true, body: { proposal: id, kind: "task", repo: args["repo"], deliverable: report ? "report" : "branch", planning: report ? "not needed for a scout" : planning, awaiting: "the operator's confirmation" } };
1633
1738
  },
1634
1739
  },
1740
+ propose_sweep: {
1741
+ description: "Several independent items under ONE shared rule set (a bug sweep, a batch of small fixes): 2-20 items, each becomes its own task in its own worktree, started with one approval, at most max_parallel (default 3) at once, each reporting a verdict into one table. brief holds the shared rules every item gets (e.g. reproduce first; 'not reproducible' is a valid result; prove the fix); each item is only its own report. Not for one task, and not for steps that depend on each other.",
1742
+ handle: (ctx, args) => {
1743
+ const repo = repoPathOf(ctx.who, args["repo"]);
1744
+ if (repo === null)
1745
+ return { ok: false, message: "repo must be one of the ids from list_repos" };
1746
+ const items = Array.isArray(args["items"]) ? args["items"] : [];
1747
+ for (const one of [{ title: args["title"], goal: args["brief"] }, ...items.map(item => ({ title: item["title"], goal: item["item"] }))]) {
1748
+ if (typeof one.title !== "string" || typeof one.goal !== "string")
1749
+ return { ok: false, message: "each title, the brief and each item are text" };
1750
+ const bad = validateTaskText({ title: one.title, goal: one.goal, outOfScope: null });
1751
+ if (bad !== null)
1752
+ return { ok: false, message: bad.message };
1753
+ if ([one.title, one.goal].some(text => typeof text === "string" && scanForSecrets(text).length > 0))
1754
+ return { ok: false, message: "Task text cannot contain credentials." };
1755
+ }
1756
+ const acceptance = readAcceptanceArg(args["acceptance"]);
1757
+ if (acceptance === null)
1758
+ return { ok: false, message: "acceptance is required: at least one criterion with an id, statement, and evidence kinds (shared by every item)" };
1759
+ try {
1760
+ const action = prepareSharedAction(ctx.store, ctx.who, "sweep_file", { repo, title: args["title"], brief: args["brief"], items: items.map(item => ({ title: item["title"], item: item["item"] })),
1761
+ acceptance, maxParallel: typeof args["max_parallel"] === "number" ? args["max_parallel"] : 3 }, ctx.evidenceRoot, ctx.now);
1762
+ const id = ctx.draft("action", { ...action });
1763
+ return id === null ? tooMany() : { ok: true, body: { proposal: id, label: action.title, awaiting: "human review in the secure confirmation screen", executed: false } };
1764
+ }
1765
+ catch (error) {
1766
+ return { ok: false, message: error instanceof Error ? error.message : "That sweep couldn't be drafted." };
1767
+ }
1768
+ },
1769
+ },
1770
+ get_sweep: {
1771
+ description: "A sweep's table as facts: each item's state, its verdict (fixed, not reproducible, blocked, done, or none yet) and its result, plus the one-line count. Read this, don't poll each task.",
1772
+ handle: (ctx, args) => {
1773
+ const table = sweepTable(ctx.store, Number(args["sweep"]), ctx.now, ctx.who.repos, ctx.evidenceRoot);
1774
+ if (table === null)
1775
+ return { ok: false, message: "No such sweep in your projects." };
1776
+ return { ok: true, body: { sweep: table.id, title: table.title, line: sweepLine(table), table: `/sweeps/${table.id}`, maxParallel: table.maxParallel,
1777
+ items: table.rows.map(row => ({ item: row.ordinal + 1, label: row.label, task: row.task, state: row.state, detail: row.detail.slice(0, 300),
1778
+ verdict: row.verdict?.verdict ?? null, why: row.verdict?.why ?? null, result: row.result })) } };
1779
+ },
1780
+ },
1781
+ propose_sweep_cancel: {
1782
+ description: "Cancel every item of a sweep that isn't ready yet (finished results stay); one confirmation. To steer, cancel or retry ONE item, use that task's own actions.",
1783
+ handle: (ctx, args) => {
1784
+ try {
1785
+ const action = prepareSharedAction(ctx.store, ctx.who, "sweep_cancel_rest", { sweep: Number(args["sweep"]) }, ctx.evidenceRoot, ctx.now);
1786
+ const id = ctx.draft("action", { ...action });
1787
+ return id === null ? tooMany() : { ok: true, body: { proposal: id, label: action.title, awaiting: "human confirmation", executed: false } };
1788
+ }
1789
+ catch (error) {
1790
+ return { ok: false, message: error instanceof Error ? error.message : "That couldn't be drafted." };
1791
+ }
1792
+ },
1793
+ },
1635
1794
  propose_next: {
1636
1795
  description: "Move a task to its queue column front; intervening queue changes invalidate confirmation.",
1637
1796
  handle: (ctx, args) => {
package/dist/lead.d.ts CHANGED
@@ -27,6 +27,13 @@ export type LeadTurnInput = {
27
27
  queuedMessageId?: number;
28
28
  /** Called inside turn admission. A failed queue fence rolls admission back. */
29
29
  onAdmitted?: (turn: number) => void;
30
+ /** A personal queue claim (lead-queue.ts) this turn runs: with `queuedMessageId`, the queued message of the person's
31
+ * own thread this turn binds; admission lets it through the person's backlog (store.openLeadTurn). */
32
+ queueClaim?: {
33
+ message: number;
34
+ runner: string;
35
+ generation: number;
36
+ };
30
37
  /** Safe, server-authored context for this turn only. Kept out of the
31
38
  * visible thread so a task-scoped composer still reads like a normal
32
39
  * conversation. */
@@ -67,8 +74,8 @@ export type LeadTurnInput = {
67
74
  };
68
75
  /** Why a chat's standing changed under a turn: each has its own plain words (LEAD_CHANNEL_COPY). */
69
76
  export type LeadChannelProblem = "unpaired" | "not-approver" | "projects-changed" | "projects-unreadable" | "member-changed" | "access-changed";
70
- export type LeadRefusal = "empty-message" | "secret-in-message" | "secret-in-context" | "standing" | "unpriced" | "ceiling-changed" | "not-yours" | "thread-closed" | "latched" | "concurrent" | "daily-cap" | "session-exhausted" | "session-ended" | "over-budget" | "monthly-budget" | "policy" | "invalid-request" | "request-changed" | "channel";
71
- export type LeadFailure = "provider-error" | "timeout" | "malformed-reply" | "secret-refused" | "latched" | "revoked" | "superseded";
77
+ export type LeadRefusal = "empty-message" | "secret-in-message" | "secret-in-context" | "standing" | "unpriced" | "ceiling-changed" | "not-yours" | "thread-closed" | "latched" | "concurrent" | "backlog" | "daily-cap" | "session-exhausted" | "session-ended" | "over-budget" | "monthly-budget" | "policy" | "invalid-request" | "request-changed" | "channel";
78
+ export type LeadFailure = "provider-error" | "timeout" | "malformed-reply" | "secret-refused" | "latched" | "revoked" | "superseded" | "over-reservation";
72
79
  export type LeadTurnOutcome = {
73
80
  ok: true;
74
81
  replayed?: false;
@@ -115,16 +122,17 @@ export declare const LEAD_FAILURE_COPY: {
115
122
  readonly tooLongUnknownCost: string;
116
123
  readonly 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.";
117
124
  readonly 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.";
125
+ readonly 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.";
118
126
  readonly empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.";
119
127
  readonly unusable: "The lead's answer couldn't be used, so it was thrown away. Nothing was kept. Send your message again.";
120
128
  };
121
129
  /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
122
- export declare function leadFailureText(reason: string | null): string;
130
+ export declare function leadFailureText(reason: string | null, unknownSpend?: boolean): string;
123
131
  /** How long a provider that ignored its abort at the turn's deadline is waited for before the turn ends without it.
124
132
  * Its process group was already killed at the deadline; this only bounds the wait for the transport to say so. */
125
133
  export declare const LEAD_ABORT_GRACE_MS = 5000;
126
134
  /** The last messages of the thread as provider-neutral history, newest kept first until the byte cap. */
127
- export declare function historyFor(store: Store, thread: number, queuedMessageId?: number): LeadHistoryMessage[];
135
+ export declare function historyFor(store: Store, thread: number, queuedMessageId?: number, named?: boolean): LeadHistoryMessage[];
128
136
  /** "read 3 · proposed 1 · 2 steps" — counts only, never results (ruling 11). */
129
- export declare function activitySummary(reads: number, proposals: number, steps: number): string;
137
+ export declare function activitySummary(reads: number, proposals: number, steps: number, cacheShare?: number | null): string;
130
138
  export declare function runLeadTurn(input: LeadTurnInput): Promise<LeadTurnOutcome>;
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)