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
package/dist/store.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { PROJECT_NETWORK_DEFAULT, projectNetworkFromValue, runNetworkFromValue } from "./egress.js";
2
+ import { isAcpProfile, isAcpProvider } from "./acp-agents.js";
3
+ import { ACP_CONFORMANCE_VERSION, launchFromJson } from "./acp-attest.js";
1
4
  import { reclaimDatabase } from "./database-reclaim.js";
2
5
  import { BASELINE_SCHEMA, BASELINE_SCHEMA_VERSION } from "./store-baseline.js";
3
6
  import { CHAT_PROVIDERS, CHAT_SCHEMA } from "./contracts/chat-tables.js";
@@ -74,9 +77,11 @@ import { isProviderId } from "./provider.js";
74
77
  import { authenticate as runnerAuthenticate } from "./runner.js";
75
78
  import { stampNewProjectLevel } from "./check-levels.js";
76
79
  import { parseCheckProgressSnapshot } from "./check-progress.js";
77
- import { normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
80
+ import { accountReachesSql, normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
78
81
  import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installLedgerTriggers } from "./action-ledger.js";
79
82
  import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
83
+ import { CONSOLE_INSTANCE_SCHEMA } from "./console-instance.js";
84
+ import { endUnattendedGrants, FLOW_UNATTENDED_SCHEMA, grantStandsSql } from "./flow-unattended.js";
80
85
  import { RECIPE_SCHEMA } from "./recipes.js";
81
86
  import { POLICY_SCHEMA, agentRefusal, approvalRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
82
87
  import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
@@ -272,6 +277,29 @@ export function leadTurnKeepsProposals(turn) {
272
277
  /** What the lead promised to follow up on (lead-commitments.ts; no version bump: additive only, so a build that
273
278
  * predates it still opens the store). `condition_json` is a task, attempt, check or time; `check_at` is when to look
274
279
  * next; `channel` is where it reports (`chat`: the conversation it was promised in); a promise lapses at `expires_at`. */
280
+ /** Lead fan-out (docs/plans/lead-fan-out.md): a sweep is N ordinary tasks under one shared brief, started with one yes,
281
+ * at most `max_parallel` of them claimed at once. Membership is by task family root, so a revision of a child is that
282
+ * child. Additive (no schema version bump), like lead_commitment. */
283
+ export const LEAD_SWEEP_SCHEMA = `
284
+ CREATE TABLE IF NOT EXISTS lead_sweep (
285
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
286
+ owner TEXT NOT NULL,
287
+ thread INTEGER,
288
+ repo TEXT NOT NULL,
289
+ title TEXT NOT NULL,
290
+ brief TEXT NOT NULL,
291
+ max_parallel INTEGER NOT NULL CHECK (max_parallel BETWEEN 1 AND 16),
292
+ proposal INTEGER UNIQUE,
293
+ created_at TEXT NOT NULL
294
+ );
295
+ CREATE TABLE IF NOT EXISTS lead_sweep_task (
296
+ sweep INTEGER NOT NULL REFERENCES lead_sweep(id),
297
+ root_task TEXT NOT NULL UNIQUE,
298
+ ordinal INTEGER NOT NULL,
299
+ label TEXT NOT NULL,
300
+ PRIMARY KEY (sweep, ordinal)
301
+ );
302
+ `;
275
303
  export const LEAD_COMMITMENT_SCHEMA = `
276
304
  CREATE TABLE IF NOT EXISTS lead_commitment (
277
305
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -408,6 +436,25 @@ CREATE TABLE IF NOT EXISTS run_resume (
408
436
  );
409
437
  CREATE INDEX IF NOT EXISTS run_resume_by_run ON run_resume (run, id);
410
438
  `;
439
+ /** A project's "Show commands in the live view" (live-detail.ts): absent means off. */
440
+ export const LIVE_DETAIL_SCHEMA = `
441
+ CREATE TABLE IF NOT EXISTS live_detail_setting (
442
+ repo TEXT PRIMARY KEY,
443
+ commands INTEGER NOT NULL CHECK (commands IN (0,1)),
444
+ updated_by TEXT NOT NULL,
445
+ updated_at TEXT NOT NULL
446
+ );
447
+ -- Every run a steering note reached, kept as it happens (live-notes.ts): a
448
+ -- note's own attached/mailed fields move to the next attempt, this does not.
449
+ CREATE TABLE IF NOT EXISTS steer_note_run (
450
+ note INTEGER NOT NULL REFERENCES task_steer(id) ON DELETE CASCADE,
451
+ run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,
452
+ how TEXT NOT NULL CHECK (how IN ('attached','mailed','read')),
453
+ at TEXT NOT NULL,
454
+ PRIMARY KEY (note, run, how)
455
+ );
456
+ CREATE INDEX IF NOT EXISTS steer_note_run_by_run ON steer_note_run (run, note);
457
+ `;
411
458
  export const CHAT_APPROVAL_SCHEMA = `
412
459
  CREATE TABLE IF NOT EXISTS chat_approval_setting (
413
460
  approver TEXT NOT NULL,
@@ -475,6 +522,23 @@ CREATE TABLE IF NOT EXISTS integration_check (
475
522
  error_at TEXT
476
523
  );
477
524
  `;
525
+ /** Tool secrets (no version bump): which sealing key Toolroll made for the secrets it keeps in the keychain, as a
526
+ * digest of the key, never the key. A key found beside them that isn't this one was put there by something else and is
527
+ * refused (tool-secret-store.ts). */
528
+ export const TOOL_SECRET_KEY_SCHEMA = `
529
+ CREATE TABLE IF NOT EXISTS tool_secret_key (
530
+ id INTEGER PRIMARY KEY CHECK (id = 1),
531
+ digest TEXT NOT NULL CHECK (length(digest) = 64),
532
+ made_at TEXT NOT NULL
533
+ );
534
+ -- Where secrets were kept before Toolroll's folder, decided once at this database's first start (null: none) and never
535
+ -- probed again, so a folder a build makes later under an old name is never migrated in.
536
+ CREATE TABLE IF NOT EXISTS tool_secret_legacy (
537
+ id INTEGER PRIMARY KEY CHECK (id = 1),
538
+ dir TEXT,
539
+ decided_at TEXT NOT NULL
540
+ );
541
+ `;
478
542
  /** Sprint 8: database backups. One settings row (off, or every so many hours, keeping the newest N in a folder; no
479
543
  * folder means `backups` beside the database), every backup's outcome, and a lease the console's backup loop holds
480
544
  * and renews while it runs: one process backs up at a time, and a restore sees that the console is running. */
@@ -624,7 +688,34 @@ const REGISTERED_AT_FILING = "again, as it was filed";
624
688
  // v122 adds, and changes no saved row: the remote URL an owner's project add recorded (project.remote_url), the only
625
689
  // remote whose main automatic completion trusts (remote-main.ts; saved projects read none until it is recorded), and
626
690
  // fresh base's record of a task branch moved to current main before an attempt (branch_base_move).
627
- export const SCHEMA_VERSION = 122;
691
+ // v123 adds, and changes no saved row: flows that run on their own (flow_script_provenance, flow_unattended_grant,
692
+ // flow_unattended_run, plan_authorization.basis, flow_trigger_event.on_time and ran_script) and the tool-secret
693
+ // sealing key's record (tool_secret_key, tool_secret_legacy).
694
+ // v124 adds, and changes no saved row: the live conversation's project setting (live_detail_setting: "Show commands in
695
+ // the live view", absent means off) and the runs each steering note reached (steer_note_run; older notes have none).
696
+ // v125 changes no saved row: ACP agents become providers (the provider CHECKs of phase_config, phase_tier_config,
697
+ // phase_tier_alternate and provider_readiness widen, and provider_auth_pause's auth mode admits 'agent-login'; each
698
+ // table is rebuilt with every row and id carried over) and a run keeps what its agent itself reported it used
699
+ // (run.agent_reported_usage_json, never priced; saved runs read none).
700
+ // v126 adds, and changes no saved row: signed webhooks into tasks (docs/plans/webhook-tasks.md): a receipt's typed state,
701
+ // when its card was made and the item it holds while it waits for room (flow_trigger_event.state, accepted_at,
702
+ // item_json); which saved secret a trigger's deliveries are proved with and its turned-away deliveries (flow_trigger.hook_credential,
703
+ // dropped_count, dropped_since, limit_noted_at); who made a card (flow_card.created_kind); and the outside source a
704
+ // task was filed from (task_source). Saved receipts read their state from their card, saved cards read a person.
705
+ // v127 adds, and changes no saved row: the running console's instance record (console_instance, one row: its secret,
706
+ // pid, port and build) that bare `toolroll` proves a console with before attaching (docs/plans/agents-cockpit.md B1).
707
+ // v128 adds, and changes no saved row's existing columns: the personal lead queue (lead_queue; docs/plans/
708
+ // lead-message-queue.md) and whether a lead message is still waiting for its turn (lead_message.pending, indexed with
709
+ // its thread so the model's history reads only admitted messages). A saved message reads waiting exactly when it is an
710
+ // operator message of a team queue row never bound to a turn (V128_ADDED_COLUMNS).
711
+ // v129 adds, and changes no saved row: how a run went (docs/plans/run-efficiency.md) — the agent's turn count where its
712
+ // provider reports it (run.agent_turns), a lead request's cache read and write tokens (chat_turn.cache_read_tokens,
713
+ // cache_write_tokens), each NULL (unknown) on saved rows, and the derived, display-only run_efficiency row a result's
714
+ // How it ran reads (a cache of the run's own records, written on first view, never a verdict).
715
+ // v130 adds, and changes no saved row: what a project's builds may reach on the network (project_network; egress.ts,
716
+ // docs/plans/agent-baseline-hardening.md arc 1) and whether this installation lets an agent run with no OS confinement
717
+ // (confinement_setting, one row; off unless an instance operator turns it on).
718
+ export const SCHEMA_VERSION = 130;
628
719
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
629
720
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
630
721
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -751,9 +842,13 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
751
842
  * three nullable task_steer columns (the running build's steering folder), checked the same way. v121 only adds
752
843
  * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
753
844
  * table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
754
- * same way.
845
+ * same way. v123 only adds new tables (unattended flows, tool secrets) and the columns in V123_ADDED_COLUMNS ('mode',
846
+ * 0 or null), checked the same way. v124 only adds new tables (the live conversation's setting and note history).
847
+ * v125 rebuilds five tables with wider CHECKs, every row and id carried over (the rehearsal's history rules find them
848
+ * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
849
+ * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
755
850
  */
756
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122]);
851
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130]);
757
852
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
758
853
  export const V119_ADDED_COLUMNS = Object.freeze({
759
854
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -804,6 +899,141 @@ export const V121_ADDED_COLUMNS = Object.freeze({
804
899
  export const V122_ADDED_COLUMNS = Object.freeze({
805
900
  project: { remote_url: { definition: "TEXT", saved: "NULL" } },
806
901
  });
902
+ /** v123: the columns it adds, with the value every row saved before it reads: which authority armed a plan's
903
+ * unchanged-plan approval (saved rows read the operating mode), whether a trigger's firing was the clock's own (saved
904
+ * firings read not), and what code an on-time script firing a grant signed for ran (saved firings read none). */
905
+ export const V123_ADDED_COLUMNS = Object.freeze({
906
+ plan_authorization: { basis: { definition: "TEXT NOT NULL DEFAULT 'mode' CHECK (basis IN ('mode', 'grant'))", saved: "'mode'" } },
907
+ flow_trigger_event: {
908
+ on_time: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" },
909
+ ran_script: { definition: "TEXT", saved: "NULL" },
910
+ },
911
+ });
912
+ /** v125: the column it adds, with the value every row saved before it reads: what a run's ACP agent itself reported it
913
+ * used (acp-client.ts; never priced). Runs saved before it read none. */
914
+ export const V125_ADDED_COLUMNS = Object.freeze({
915
+ run: {
916
+ agent_reported_usage_json: { definition: "TEXT", saved: "NULL" },
917
+ // An ACP run's billing, stamped when it is admitted (AcpBillingStamp); every other run, and every saved one, none.
918
+ acp_billing_json: { definition: "TEXT", saved: "NULL" },
919
+ },
920
+ });
921
+ /** v126: the columns it adds, with the value every row saved before it reads (docs/plans/webhook-tasks.md). A saved
922
+ * receipt reads no state (accepted when it has a card, skipped otherwise), no acceptance time (its arrival counts) and
923
+ * no held item; a saved trigger is on its first credential generation with nothing turned away; a saved card was made
924
+ * by a person. */
925
+ export const V126_ADDED_COLUMNS = Object.freeze({
926
+ flow_trigger_event: {
927
+ state: { definition: "TEXT CHECK (state IN ('accepted','skipped','held','ignored'))", saved: "NULL" },
928
+ accepted_at: { definition: "TEXT", saved: "NULL" },
929
+ item_json: { definition: "TEXT", saved: "NULL" },
930
+ },
931
+ flow_trigger: {
932
+ // Which saved secret proves its deliveries: 'legacy' (the address every secret saved before v126 has), '' (none:
933
+ // deliveries are refused until one is saved), or the id of one written whole before the trigger pointed at it.
934
+ hook_credential: { definition: "TEXT NOT NULL DEFAULT 'legacy'", saved: "'legacy'" },
935
+ dropped_count: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" },
936
+ dropped_since: { definition: "TEXT", saved: "NULL" },
937
+ limit_noted_at: { definition: "TEXT", saved: "NULL" },
938
+ },
939
+ flow_card: {
940
+ created_kind: { definition: "TEXT CHECK (created_kind IN ('person','automation'))", saved: "NULL" },
941
+ },
942
+ });
943
+ /** v128: whether a lead message waits for its turn. A message saved before v128 waits exactly when it is an operator
944
+ * message that a team queue row holds and no turn has taken (the team runtime's own unadmitted messages); every other
945
+ * saved message is history (`saved` is evaluated per row). */
946
+ export const V128_ADDED_COLUMNS = Object.freeze({
947
+ lead_message: {
948
+ pending: {
949
+ definition: "INTEGER NOT NULL DEFAULT 0 CHECK (pending IN (0, 1))",
950
+ saved: "(CASE WHEN role = 'operator' AND turn IS NULL AND EXISTS (SELECT 1 FROM team_message q WHERE q.message = lead_message.id) THEN 1 ELSE 0 END)",
951
+ },
952
+ },
953
+ });
954
+ /** v129: how a run went (docs/plans/run-efficiency.md). Recorded from now on; a saved row reads unknown (NULL), never 0. */
955
+ export const V129_ADDED_COLUMNS = Object.freeze({
956
+ run: { agent_turns: { definition: "INTEGER CHECK (agent_turns IS NULL OR agent_turns >= 0)", saved: "NULL" } },
957
+ chat_turn: {
958
+ cache_read_tokens: { definition: "INTEGER CHECK (cache_read_tokens IS NULL OR cache_read_tokens >= 0)", saved: "NULL" },
959
+ cache_write_tokens: { definition: "INTEGER CHECK (cache_write_tokens IS NULL OR cache_write_tokens >= 0)", saved: "NULL" },
960
+ },
961
+ });
962
+ /** v130: a project's network (an approver's setting, egress.ts ProjectNetwork as JSON) and the installation's choice
963
+ * whether an agent may run where no OS confinement exists (one row; 0 until an instance operator turns it on). */
964
+ export const CONFINEMENT_SCHEMA = `
965
+ CREATE TABLE IF NOT EXISTS project_network (
966
+ repo TEXT PRIMARY KEY,
967
+ network_json TEXT NOT NULL CHECK (length(network_json) <= 16384),
968
+ updated_at TEXT NOT NULL,
969
+ updated_by TEXT NOT NULL
970
+ );
971
+ CREATE TABLE IF NOT EXISTS confinement_setting (
972
+ id INTEGER PRIMARY KEY CHECK (id = 1),
973
+ unfenced_allowed INTEGER NOT NULL DEFAULT 0 CHECK (unfenced_allowed IN (0, 1)),
974
+ updated_at TEXT,
975
+ updated_by TEXT
976
+ );
977
+ INSERT OR IGNORE INTO confinement_setting (id, unfenced_allowed) VALUES (1, 0);
978
+ `;
979
+ /** v129: a finished run's How it ran, derived from its own records on first view (display only: never a verdict). Its
980
+ * parser version says when to derive again; `steps` says how complete the step detail was, and why not. */
981
+ export const RUN_EFFICIENCY_SCHEMA = `
982
+ CREATE TABLE IF NOT EXISTS run_efficiency (
983
+ run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,
984
+ parser_version INTEGER NOT NULL,
985
+ steps TEXT NOT NULL CHECK (steps IN ('complete','truncated','expired','absent')),
986
+ steps_reason TEXT,
987
+ payload_json TEXT NOT NULL CHECK (length(payload_json) <= 16384),
988
+ derived_at TEXT NOT NULL
989
+ );
990
+ `;
991
+ /** v128: the personal lead queue (docs/plans/lead-message-queue.md). A message sent while one of its author's personal
992
+ * lead turns runs (or while earlier ones wait) waits here, one turn per message, in order. Its words are the
993
+ * lead_message row (pending until admitted); terminal rows keep only their request identity (a tombstone). */
994
+ export const LEAD_QUEUE_SCHEMA = `
995
+ CREATE TABLE IF NOT EXISTS lead_queue (
996
+ message INTEGER PRIMARY KEY REFERENCES lead_message(id) ON DELETE CASCADE,
997
+ thread INTEGER NOT NULL REFERENCES lead_thread(id) ON DELETE CASCADE,
998
+ approver TEXT NOT NULL,
999
+ approver_generation INTEGER NOT NULL,
1000
+ session INTEGER NOT NULL,
1001
+ ceiling_digest TEXT NOT NULL,
1002
+ request_id TEXT NOT NULL,
1003
+ payload_hash TEXT NOT NULL,
1004
+ context_json TEXT,
1005
+ status TEXT NOT NULL CHECK (status IN ('queued','running','answered','failed','cancelled','uncertain')),
1006
+ revision INTEGER NOT NULL DEFAULT 1,
1007
+ generation INTEGER NOT NULL DEFAULT 0,
1008
+ runner TEXT,
1009
+ claimed_at TEXT,
1010
+ turn_id INTEGER REFERENCES mate_turn(id),
1011
+ error TEXT,
1012
+ created_at TEXT NOT NULL,
1013
+ settled_at TEXT,
1014
+ UNIQUE (session, request_id)
1015
+ );
1016
+ CREATE INDEX IF NOT EXISTS lead_queue_backlog ON lead_queue (approver, status, message);
1017
+ CREATE UNIQUE INDEX IF NOT EXISTS lead_queue_one_running ON lead_queue (approver) WHERE status = 'running';
1018
+ CREATE INDEX IF NOT EXISTS lead_queue_admitted ON lead_queue (turn_id) WHERE turn_id IS NOT NULL;
1019
+ `;
1020
+ /** v126: the outside source a task was filed from, kept with the task (a card's own task link moves on; this never
1021
+ * does). Written in the filing's transaction, copied whole to a revision, never changed. */
1022
+ export const TASK_SOURCE_SCHEMA = `
1023
+ CREATE TABLE IF NOT EXISTS task_source (
1024
+ task_id TEXT PRIMARY KEY,
1025
+ flow INTEGER NOT NULL,
1026
+ card INTEGER NOT NULL,
1027
+ card_entry INTEGER NOT NULL,
1028
+ source_json TEXT NOT NULL,
1029
+ at TEXT NOT NULL
1030
+ );
1031
+ CREATE TRIGGER IF NOT EXISTS task_source_immutable BEFORE UPDATE ON task_source
1032
+ BEGIN
1033
+ SELECT RAISE(ABORT, 'a task source is immutable');
1034
+ END;
1035
+ CREATE INDEX IF NOT EXISTS flow_trigger_event_accepted ON flow_trigger_event (trigger, accepted_at);
1036
+ `;
807
1037
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
808
1038
  export function updateSafeSchema(version) {
809
1039
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -938,6 +1168,19 @@ export const BUILT_IN = "built-in";
938
1168
  /** States from which no further work is dispatched. */
939
1169
  export const TERMINAL_STATES = ["done", "failed", "cancelled"];
940
1170
  export const LEAD_THREAD = { kind: "lead" };
1171
+ /** A lead_message row as a LeadMessage. */
1172
+ function readLeadMessageRow(row) {
1173
+ return {
1174
+ id: Number(row["id"]),
1175
+ thread: Number(row["thread"]),
1176
+ turn: row["turn"] === null ? null : Number(row["turn"]),
1177
+ role: String(row["role"]),
1178
+ text: String(row["text"]),
1179
+ activity: row["activity"] === null ? null : String(row["activity"]),
1180
+ createdAt: String(row["created_at"]),
1181
+ ...(Number(row["pending"] ?? 0) === 1 ? { pending: true } : {}),
1182
+ };
1183
+ }
941
1184
  function readSubagentMemory(row) {
942
1185
  return { id: Number(row["id"]), subagent: Number(row["subagent"]), text: String(row["text"]), source: String(row["source"]), card: row["card"] === null ? null : Number(row["card"]),
943
1186
  createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedBy: String(row["updated_by"]), updatedAt: String(row["updated_at"]), usedAt: row["used_at"] === null ? null : String(row["used_at"]) };
@@ -1000,7 +1243,9 @@ function readFlowCardRow(row) {
1000
1243
  const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
1001
1244
  return { id: Number(row["id"]), flow: Number(row["flow"]), title: String(row["title"]), description: optional("description"), stage: String(row["stage"]), entry: Number(row["entry"]),
1002
1245
  state: String(row["state"]), task: optional("task"), primaryTask: optional("primary_task"), note: optional("note"), waiting: optional("waiting"), outputs, attached,
1003
- createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedAt: String(row["updated_at"]), source: readFlowCardSource(row["source_json"]), owner: optional("owner") };
1246
+ createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedAt: String(row["updated_at"]), source: readFlowCardSource(row["source_json"]), owner: optional("owner"),
1247
+ // v126: a card saved before it was a person's (what filing always assumed).
1248
+ createdKind: row["created_kind"] === "automation" ? "automation" : "person" };
1004
1249
  }
1005
1250
  function readFlowCardSource(value) {
1006
1251
  if (typeof value !== "string")
@@ -1012,12 +1257,17 @@ function readFlowCardSource(value) {
1012
1257
  const parsed = read.value;
1013
1258
  const mail = parsed["mail"] !== null && typeof parsed["mail"] === "object" ? parsed["mail"] : null;
1014
1259
  const chat = parsed["chat"] !== null && typeof parsed["chat"] === "object" ? parsed["chat"] : null;
1260
+ const external = parsed["external"] !== null && typeof parsed["external"] === "object" ? parsed["external"] : null;
1015
1261
  return { kind: String(parsed["kind"] ?? ""), label: parsed["label"], url: typeof parsed["url"] === "string" ? parsed["url"] : null,
1016
1262
  ...(mail === null || typeof mail["from"] !== "string" ? {} : { mail: { id: typeof mail["id"] === "string" ? mail["id"] : null, references: Array.isArray(mail["references"]) ? mail["references"].filter((one) => typeof one === "string") : [],
1017
1263
  subject: typeof mail["subject"] === "string" ? mail["subject"] : "", from: mail["from"] } }),
1018
1264
  ...(chat === null || typeof chat["app"] !== "string" || typeof chat["chat"] !== "string" || typeof chat["thread"] !== "string" || typeof chat["binding"] !== "number" ? {} : { chat: {
1019
1265
  app: chat["app"], installation: String(chat["installation"] ?? ""), chat: chat["chat"], conversation: typeof chat["conversation"] === "string" ? chat["conversation"] : chat["chat"], thread: chat["thread"], binding: chat["binding"]
1020
- } }) };
1266
+ } }),
1267
+ // v126: kept as they were written at receipt; a card saved before them has none.
1268
+ ...(typeof parsed["verified"] === "string" ? { verified: parsed["verified"] } : {}),
1269
+ ...(external === null ? {} : { external: { id: typeof external["id"] === "string" ? external["id"] : null, url: typeof external["url"] === "string" ? external["url"] : null } }),
1270
+ ...(typeof parsed["receipt"] === "string" ? { receipt: parsed["receipt"] } : {}) };
1021
1271
  }
1022
1272
  catch {
1023
1273
  return null;
@@ -1027,7 +1277,14 @@ function readFlowTriggerRow(row) {
1027
1277
  const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
1028
1278
  return { id: Number(row["id"]), flow: Number(row["flow"]), kind: String(row["kind"]), configJson: String(row["config_json"]), state: String(row["state"]),
1029
1279
  hookHash: optional("hook_hash"), cursor: optional("cursor"), nextAt: optional("next_at"), lastAt: optional("last_at"), lastOutcome: optional("last_outcome"),
1030
- failures: Number(row["failures"] ?? 0), createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedAt: String(row["updated_at"]) };
1280
+ failures: Number(row["failures"] ?? 0), createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedAt: String(row["updated_at"]),
1281
+ hookCredential: row["hook_credential"] === null || row["hook_credential"] === undefined ? "legacy" : String(row["hook_credential"]), droppedCount: Number(row["dropped_count"] ?? 0), droppedSince: optional("dropped_since") };
1282
+ }
1283
+ function readFlowReceipt(row) {
1284
+ const card = row["card"] === null || row["card"] === undefined ? null : Number(row["card"]);
1285
+ const state = row["state"] === null || row["state"] === undefined ? (card === null ? "skipped" : "accepted") : String(row["state"]);
1286
+ const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
1287
+ return { state, card, note: optional("note"), at: String(row["at"]), acceptedAt: optional("accepted_at"), itemJson: optional("item_json") };
1031
1288
  }
1032
1289
  /** `kind:name`, the unambiguous way a requirement names a capability. */
1033
1290
  export function capabilityKey(capability) {
@@ -1281,7 +1538,7 @@ CREATE TABLE IF NOT EXISTS mcp_idempotency (
1281
1538
  CREATE TABLE IF NOT EXISTS phase_config (
1282
1539
  scope TEXT NOT NULL,
1283
1540
  phase TEXT NOT NULL CHECK (phase IN ('plan','build','repair','review')),
1284
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
1541
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
1285
1542
  model TEXT,
1286
1543
  updated_at TEXT NOT NULL,
1287
1544
  updated_by TEXT NOT NULL,
@@ -1297,7 +1554,7 @@ CREATE TABLE IF NOT EXISTS phase_tier_config (
1297
1554
  scope TEXT NOT NULL,
1298
1555
  phase TEXT NOT NULL CHECK (phase IN ('plan','build','repair','review')),
1299
1556
  tier TEXT NOT NULL CHECK (tier IN ('light','strong')),
1300
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
1557
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
1301
1558
  model TEXT,
1302
1559
  updated_at TEXT NOT NULL,
1303
1560
  updated_by TEXT NOT NULL,
@@ -1313,7 +1570,7 @@ CREATE TABLE IF NOT EXISTS phase_tier_config (
1313
1570
  -- substitutes for it.
1314
1571
  CREATE TABLE IF NOT EXISTS provider_readiness (
1315
1572
  runner TEXT NOT NULL REFERENCES runner(name) ON DELETE CASCADE,
1316
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
1573
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
1317
1574
  state TEXT NOT NULL CHECK (state IN ('ready','unavailable','unknown')),
1318
1575
  reason TEXT NOT NULL,
1319
1576
  probe TEXT NOT NULL,
@@ -2603,7 +2860,7 @@ CREATE TABLE IF NOT EXISTS quota (
2603
2860
  -- key. auth_mode + a stable NON-SECRET
2604
2861
  -- credential fingerprint join the key. Defaults keep every pre-v30 row
2605
2862
  -- identical (mode 'subscription', empty fp) since that is what they were.
2606
- auth_mode TEXT NOT NULL DEFAULT 'subscription' CHECK (auth_mode IN ('subscription','api-key')),
2863
+ auth_mode TEXT NOT NULL DEFAULT 'subscription' CHECK (auth_mode IN ('subscription','api-key','agent-login')),
2607
2864
  credential_fp TEXT NOT NULL DEFAULT '',
2608
2865
  state TEXT NOT NULL CHECK (state IN ('exhausted','half-open')),
2609
2866
  reason TEXT NOT NULL,
@@ -3527,11 +3784,16 @@ function initializeStore(db, file) {
3527
3784
  db.exec(LEDGER_CHAIN_SCHEMA);
3528
3785
  db.exec(MONITORING_SCHEMA);
3529
3786
  db.exec(INTEGRATIONS_SCHEMA);
3787
+ // v123: the tool-secret sealing key's record and where secrets were kept before (tool-secret-store.ts): new tables.
3788
+ db.exec(TOOL_SECRET_KEY_SCHEMA);
3530
3789
  db.exec(LEAD_ASK_SCHEMA);
3531
3790
  // Decisions in the chat app (no version bump: additive only): result, plan and merge buttons, and Request changes.
3532
3791
  db.exec(CHAT_DECIDE_SCHEMA);
3533
3792
  // An owner's lasting chat-approval setting (no version bump: additive only; absent means off).
3534
3793
  db.exec(CHAT_APPROVAL_SCHEMA);
3794
+ // v124: the live conversation: a project's "Show commands in the live view" (absent means off) and every run a
3795
+ // steering note reached (older notes simply have no rows). New tables only.
3796
+ db.exec(LIVE_DETAIL_SCHEMA);
3535
3797
  // What a running agent did last (no version bump: additive only; absent means no word yet).
3536
3798
  db.exec(RUN_ACTIVITY_SCHEMA);
3537
3799
  // v122: a task branch moved to current main before an attempt (fresh base): a new table, nothing carried over.
@@ -3559,6 +3821,18 @@ function initializeStore(db, file) {
3559
3821
  addColumn(db, "task_ref", "route_tier", "TEXT");
3560
3822
  widenPhaseTierSchema(db);
3561
3823
  db.exec(PHASE_TIER_ALTERNATE_SCHEMA);
3824
+ // v125: ACP agents as providers, their own sign-in as an auth mode (rows carry over, CHECKs widen; the auth schema
3825
+ // runs again to put back the open-pause index the rebuild dropped), and what an agent itself reported it used
3826
+ // (V125_ADDED_COLUMNS: never priced, saved runs read none).
3827
+ widenForAcp(db);
3828
+ db.exec(PROVIDER_AUTH_SCHEMA);
3829
+ db.exec(ACP_ATTESTATION_SCHEMA);
3830
+ db.exec(ACP_BILLING_SCHEMA);
3831
+ db.exec(ACP_CHECK_SCHEMA);
3832
+ for (const [table, columns] of Object.entries(V125_ADDED_COLUMNS)) {
3833
+ for (const [column, { definition }] of Object.entries(columns))
3834
+ addColumn(db, table, column, definition);
3835
+ }
3562
3836
  db.exec(REVIEW_SCHEMA);
3563
3837
  db.exec(RETENTION_SCHEMA);
3564
3838
  // 1-day evidence: an older file's retention_setting only allowed 7 days or more (no version bump: its rows carry over).
@@ -3589,6 +3863,7 @@ function initializeStore(db, file) {
3589
3863
  // What a person's lead knows about them, beside its name and persona (an older lead_config gains it).
3590
3864
  addColumn(db, "lead_config", "about_json", "TEXT NOT NULL DEFAULT '[]'");
3591
3865
  db.exec(LEAD_COMMITMENT_SCHEMA);
3866
+ db.exec(LEAD_SWEEP_SCHEMA);
3592
3867
  mergeCheckTables(db);
3593
3868
  // v110: the action ledger admits remote commands ('api', 'mcp').
3594
3869
  rebuildLedger(db);
@@ -3612,7 +3887,44 @@ function initializeStore(db, file) {
3612
3887
  addColumn(db, "workflow_preview", "flow_id", "INTEGER");
3613
3888
  // v115: the task each standing-order firing filed, kept on the firing itself (a card's task changes as it moves).
3614
3889
  addColumn(db, "flow_trigger_event", "task", "TEXT");
3890
+ // v126: signed webhooks into tasks (V126_ADDED_COLUMNS: saved rows read null or 0) and each task's outside source.
3891
+ for (const [table, columns] of Object.entries(V126_ADDED_COLUMNS)) {
3892
+ for (const [column, { definition }] of Object.entries(columns))
3893
+ addColumn(db, table, column, definition);
3894
+ }
3895
+ db.exec(TASK_SOURCE_SCHEMA);
3615
3896
  db.exec(PLAN_AUTO_SCHEMA);
3897
+ // v127 (cockpit B1): the running console's instance record (one row; a new table, additive only).
3898
+ db.exec(CONSOLE_INSTANCE_SCHEMA);
3899
+ // v128: the personal lead queue (a new table) and lead_message.pending, set once from each saved row's provenance
3900
+ // (exactly V128_ADDED_COLUMNS' `saved` expression), then the history index the model's reads use.
3901
+ if (!hasColumn(db, "lead_message", "pending")) {
3902
+ addColumn(db, "lead_message", "pending", V128_ADDED_COLUMNS.lead_message.pending.definition);
3903
+ db.exec(`UPDATE lead_message SET pending = ${V128_ADDED_COLUMNS.lead_message.pending.saved}`);
3904
+ }
3905
+ db.exec("CREATE INDEX IF NOT EXISTS lead_message_history ON lead_message (thread, pending, id)");
3906
+ db.exec(LEAD_QUEUE_SCHEMA);
3907
+ // v128: the memory search indexes a waiting message when a turn takes it, found by the turn that took it.
3908
+ db.exec("CREATE INDEX IF NOT EXISTS team_message_admitted ON team_message (turn_id) WHERE turn_id IS NOT NULL");
3909
+ // v129: how a run went — its turn count and a lead request's cache tokens (V129_ADDED_COLUMNS: saved rows read NULL),
3910
+ // and the derived How it ran row (a new table).
3911
+ for (const [table, columns] of Object.entries(V129_ADDED_COLUMNS)) {
3912
+ for (const [column, { definition }] of Object.entries(columns))
3913
+ addColumn(db, table, column, definition);
3914
+ }
3915
+ db.exec(RUN_EFFICIENCY_SCHEMA);
3916
+ // v130: a project's network and whether an agent may run unconfined (new tables only).
3917
+ db.exec(CONFINEMENT_SCHEMA);
3918
+ // v123: a flow that runs on its own (flow-unattended.ts): its grants and daily runs (new tables), and which authority
3919
+ // armed a plan's unchanged-plan approval (plan_authorization.basis, V123_ADDED_COLUMNS: saved rows read the mode).
3920
+ db.exec(FLOW_UNATTENDED_SCHEMA);
3921
+ // v123: whether a trigger's firing was the clock's own (flow_trigger_event.on_time, V123_ADDED_COLUMNS: a schedule
3922
+ // due, not Run now; only those can run on their own), and what code an on-time script firing a grant signed for ran
3923
+ // (flow_trigger_event.ran_script, V123_ADDED_COLUMNS: flow-unattended.ts scriptRanDigestOf; older firings read null).
3924
+ for (const [table, columns] of Object.entries(V123_ADDED_COLUMNS)) {
3925
+ for (const [column, { definition }] of Object.entries(columns))
3926
+ addColumn(db, table, column, definition);
3927
+ }
3616
3928
  // v119: planning generations, and why a question closed unanswered (additive only; saved rows read the defaults).
3617
3929
  for (const [table, columns] of Object.entries(V119_ADDED_COLUMNS)) {
3618
3930
  for (const [column, { definition }] of Object.entries(columns))
@@ -4099,7 +4411,11 @@ function rebuildExact(db, table, oldDdl, targetDdl, columns) {
4099
4411
  ? db.prepare("SELECT name, seq FROM sqlite_sequence ORDER BY rowid").all()
4100
4412
  : [];
4101
4413
  db.exec(targetDdl(`${table}_next`));
4102
- const names = columns.join(", ");
4414
+ // A table keyed by anything but an INTEGER PRIMARY KEY still has a hidden rowid, and a plain copy renumbers it;
4415
+ // the update's history check reads it (count and highest rowid), so it is carried whole like any id.
4416
+ const key = db.prepare(`PRAGMA table_info(${table})`).all().filter(one => Number(one["pk"]) > 0);
4417
+ const aliased = key.length === 1 && String(key[0]["type"]).toUpperCase() === "INTEGER";
4418
+ const names = (aliased || /WITHOUT\s+ROWID/i.test(stored) ? columns : ["rowid", ...columns]).join(", ");
4103
4419
  db.exec(`INSERT INTO ${table}_next (${names}) SELECT ${names} FROM ${table}`);
4104
4420
  db.exec(`DROP TABLE ${table}`);
4105
4421
  db.exec(`ALTER TABLE ${table}_next RENAME TO ${table}`);
@@ -4699,13 +5015,119 @@ CREATE TABLE IF NOT EXISTS phase_tier_alternate (
4699
5015
  scope TEXT NOT NULL,
4700
5016
  phase TEXT NOT NULL CHECK (phase IN ('plan','build')),
4701
5017
  tier TEXT NOT NULL CHECK (tier IN ('light','routine','strong')),
4702
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
5018
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
4703
5019
  model TEXT NOT NULL,
4704
5020
  updated_at TEXT NOT NULL,
4705
5021
  updated_by TEXT NOT NULL,
4706
5022
  PRIMARY KEY (scope, phase, tier, provider)
4707
5023
  );
4708
5024
  `;
5025
+ /**
5026
+ * v125: an ACP agent's passed conformance checks (acp-attest.ts): per provider and runner, how to launch the exact
5027
+ * binary that passed and every file that launch depends on, what the agent said it is, the models it offered and the
5028
+ * checks it passed. At most one live row per agent and runner; revoking keeps the row as history.
5029
+ */
5030
+ export const ACP_ATTESTATION_SCHEMA = `
5031
+ CREATE TABLE IF NOT EXISTS acp_attestation (
5032
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5033
+ provider TEXT NOT NULL CHECK (provider IN ('opencode','cursor','cline','qwen','goose')),
5034
+ runner TEXT NOT NULL,
5035
+ conformance_version INTEGER NOT NULL,
5036
+ launch_json TEXT NOT NULL,
5037
+ agent_name TEXT NOT NULL,
5038
+ agent_version TEXT NOT NULL,
5039
+ cli_version TEXT NOT NULL,
5040
+ models_json TEXT NOT NULL,
5041
+ checks_json TEXT NOT NULL,
5042
+ recorded_at TEXT NOT NULL,
5043
+ recorded_by TEXT NOT NULL,
5044
+ revoked_at TEXT,
5045
+ revoked_by TEXT
5046
+ );
5047
+ CREATE UNIQUE INDEX IF NOT EXISTS acp_attestation_live ON acp_attestation (provider, runner) WHERE revoked_at IS NULL;
5048
+ `;
5049
+ /**
5050
+ * v125: how an ACP agent's work on one model is billed, per runner (docs/plans/acp-adapter.md § Billing): metered
5051
+ * (the default, and what no row means — cost unknown, charged as unknown cost is) or the agent's own plan ($0,
5052
+ * counted as unmeasured). Each change is a new row; the newest is the declaration, its id the revision a run stamps.
5053
+ */
5054
+ /**
5055
+ * v125: a requested ACP agent check (docs/plans/acp-adapter.md § Layer E): asked for with a password, claimed and run
5056
+ * by one watch of the named runner — one running check per runner, which the partial unique index holds — through two
5057
+ * bounded steps, each bound to its run and graded on plane evidence; the attestation is recorded only in the same
5058
+ * transaction that marks the request passed.
5059
+ */
5060
+ export const ACP_CHECK_SCHEMA = `
5061
+ CREATE TABLE IF NOT EXISTS acp_check_request (
5062
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5063
+ provider TEXT NOT NULL CHECK (provider IN ('opencode','cursor','cline','qwen','goose')),
5064
+ runner TEXT NOT NULL,
5065
+ model TEXT NOT NULL,
5066
+ conformance_version INTEGER NOT NULL,
5067
+ requested_by TEXT NOT NULL,
5068
+ requested_at TEXT NOT NULL,
5069
+ expires_at TEXT NOT NULL,
5070
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','running','passed','failed','expired')),
5071
+ watch_repo TEXT,
5072
+ watch_owner TEXT,
5073
+ task_ref INTEGER REFERENCES task_ref(id),
5074
+ launch_json TEXT,
5075
+ lease_id TEXT,
5076
+ steps_used INTEGER NOT NULL DEFAULT 0 CHECK (steps_used BETWEEN 0 AND 2),
5077
+ step1_run INTEGER REFERENCES run(id),
5078
+ step2_run INTEGER REFERENCES run(id),
5079
+ step1_passed INTEGER NOT NULL DEFAULT 0 CHECK (step1_passed IN (0,1)),
5080
+ step2_passed INTEGER NOT NULL DEFAULT 0 CHECK (step2_passed IN (0,1)),
5081
+ result TEXT,
5082
+ finished_at TEXT,
5083
+ settled_at TEXT,
5084
+ settle_by TEXT,
5085
+ settle_until TEXT
5086
+ );
5087
+ CREATE UNIQUE INDEX IF NOT EXISTS acp_check_running ON acp_check_request (runner) WHERE state = 'running';
5088
+ `;
5089
+ export const ACP_BILLING_SCHEMA = `
5090
+ CREATE TABLE IF NOT EXISTS acp_billing (
5091
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5092
+ provider TEXT NOT NULL CHECK (provider IN ('opencode','cursor','cline','qwen','goose')),
5093
+ runner TEXT NOT NULL,
5094
+ model TEXT NOT NULL,
5095
+ kind TEXT NOT NULL CHECK (kind IN ('metered','plan')),
5096
+ set_at TEXT NOT NULL,
5097
+ set_by TEXT NOT NULL
5098
+ );
5099
+ CREATE INDEX IF NOT EXISTS acp_billing_by ON acp_billing (provider, runner, model, id);
5100
+ `;
5101
+ /** The provider CHECK before ACP agents joined (acp-agents.ts), and after: the same list, five ids longer. */
5102
+ const PROVIDERS_BEFORE_ACP = "CHECK (provider IN ('claude','codex','openrouter','gemini'))";
5103
+ const PROVIDERS_WITH_ACP = "CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose'))";
5104
+ /** The sign-in pause's auth mode before an ACP agent's own sign-in ('agent-login'), and after. */
5105
+ const AUTH_MODES_BEFORE_ACP = "CHECK (auth_mode IN ('subscription', 'api-key'))";
5106
+ const AUTH_MODES_WITH_ACP = "CHECK (auth_mode IN ('subscription', 'api-key', 'agent-login'))";
5107
+ /**
5108
+ * v125: ACP agents as providers (docs/plans/acp-adapter.md): every row carries over whole, the CHECKs only widen. Each table whose stored DDL still has the old list is rebuilt with the wider one, through rebuildExact — the
5109
+ * stored DDL must be exactly a known shape with the old clause appearing once, or the open refuses. Indexes the rebuild
5110
+ * drops are put back by their own schema's IF NOT EXISTS, which the caller runs again after.
5111
+ */
5112
+ function widenForAcp(db) {
5113
+ const widen = (table, before, after) => {
5114
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
5115
+ if (row === undefined)
5116
+ return;
5117
+ const stored = String(row["sql"]);
5118
+ if (stored.includes(after))
5119
+ return;
5120
+ if (stored.split(before).length !== 2)
5121
+ throw new Error(`the ${table} table's DDL is not a shape this migration knows — refusing to widen it`);
5122
+ const named = (sql) => (name) => sql.replace(new RegExp(`^CREATE TABLE (?:IF NOT EXISTS )?"?${table}"?`), `CREATE TABLE ${name}`);
5123
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(one => String(one["name"]));
5124
+ rebuildExact(db, table, named(stored), named(stored.replace(before, after)), columns);
5125
+ };
5126
+ for (const table of ["phase_config", "phase_tier_config", "phase_tier_alternate", "provider_readiness"])
5127
+ widen(table, PROVIDERS_BEFORE_ACP, PROVIDERS_WITH_ACP);
5128
+ widen("provider_auth_pause", AUTH_MODES_BEFORE_ACP, AUTH_MODES_WITH_ACP);
5129
+ widen("quota", "CHECK (auth_mode IN ('subscription','api-key'))", "CHECK (auth_mode IN ('subscription','api-key','agent-login'))");
5130
+ }
4709
5131
  /** A file from before the light tier keeps `tier IN ('strong')` in phase_tier_config's CHECK: rebuild the table with
4710
5132
  * the wider one, keeping every row, in one transaction. A file already widened is untouched. */
4711
5133
  function widenPhaseTierSchema(db) {
@@ -4718,13 +5140,13 @@ function widenPhaseTierSchema(db) {
4718
5140
  scope TEXT NOT NULL,
4719
5141
  phase TEXT NOT NULL CHECK (phase IN ('plan','build','repair','review')),
4720
5142
  tier TEXT NOT NULL CHECK (tier IN ('light','strong')),
4721
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
5143
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
4722
5144
  model TEXT,
4723
5145
  updated_at TEXT NOT NULL,
4724
5146
  updated_by TEXT NOT NULL,
4725
5147
  PRIMARY KEY (scope, phase, tier)
4726
5148
  )`);
4727
- db.exec("INSERT INTO phase_tier_config_next SELECT scope, phase, tier, provider, model, updated_at, updated_by FROM phase_tier_config");
5149
+ db.exec("INSERT INTO phase_tier_config_next (rowid, scope, phase, tier, provider, model, updated_at, updated_by) SELECT rowid, scope, phase, tier, provider, model, updated_at, updated_by FROM phase_tier_config");
4728
5150
  db.exec("DROP TABLE phase_tier_config");
4729
5151
  db.exec("ALTER TABLE phase_tier_config_next RENAME TO phase_tier_config");
4730
5152
  db.exec("COMMIT");
@@ -4818,6 +5240,36 @@ export class Store {
4818
5240
  close() {
4819
5241
  this.db.close();
4820
5242
  }
5243
+ /** The digest of the sealing key Toolroll made for its tool secrets; null before it made one (or in an older database). */
5244
+ toolSecretKeyDigest() {
5245
+ if (!tableExists(this.db, "tool_secret_key"))
5246
+ return null;
5247
+ const row = this.db.prepare("SELECT digest FROM tool_secret_key WHERE id = 1").get();
5248
+ return row === undefined ? null : String(row["digest"]);
5249
+ }
5250
+ /**
5251
+ * The folder secrets were kept in before Toolroll's own, as decided at this database's first start of a command that
5252
+ * can start builds (`record`): `probe` runs until then, and its answer (null: none) is kept for every later start.
5253
+ * Other commands never write, so a preview stays read-only and a busy database doesn't stop them.
5254
+ */
5255
+ toolSecretLegacyDir(probe, now, options) {
5256
+ const read = () => tableExists(this.db, "tool_secret_legacy") ? this.db.prepare("SELECT dir FROM tool_secret_legacy WHERE id = 1").get() : undefined;
5257
+ let row = read();
5258
+ if (row === undefined) {
5259
+ if (!options.record)
5260
+ return probe();
5261
+ this.db.prepare("INSERT OR IGNORE INTO tool_secret_legacy (id, dir, decided_at) VALUES (1, ?, ?)").run(probe(), now.toISOString());
5262
+ row = read();
5263
+ }
5264
+ return row?.["dir"] === null || row?.["dir"] === undefined ? null : String(row["dir"]);
5265
+ }
5266
+ /** Record a new sealing key's digest only if the one recorded is still `expected` (null: none yet). */
5267
+ swapToolSecretKeyDigest(expected, next, now) {
5268
+ const changed = expected === null
5269
+ ? this.db.prepare("INSERT OR IGNORE INTO tool_secret_key (id, digest, made_at) VALUES (1, ?, ?)").run(next, now.toISOString()).changes
5270
+ : this.db.prepare("UPDATE tool_secret_key SET digest = ?, made_at = ? WHERE id = 1 AND digest = ?").run(next, now.toISOString(), expected).changes;
5271
+ return Number(changed) === 1;
5272
+ }
4821
5273
  // ---- the built-in task store -------------------------------------------
4822
5274
  /**
4823
5275
  * Creating a task also creates its overlay reference, because a task with no
@@ -6154,7 +6606,8 @@ export class Store {
6154
6606
  ? { ...profile, permissionArgv: "bypassPermissions" }
6155
6607
  : profile.provider === "gemini"
6156
6608
  ? { ...profile, approvalArgv: "yolo" }
6157
- : { ...profile, sandboxMode: "danger-full-access" };
6609
+ : isAcpProfile(profile) ? profile
6610
+ : { ...profile, sandboxMode: "danger-full-access" };
6158
6611
  }
6159
6612
  // Anything still above the ceiling files at the highest level beneath it its provider has; a provider with none
6160
6613
  // keeps its terms, and approval refuses them in words.
@@ -6344,7 +6797,9 @@ export class Store {
6344
6797
  ? `Approved by ${currentActor()?.lead === true ? "the lead" : lifecycleWords(by, 40)}. A connected worker can take it next.`
6345
6798
  : basis.kind === "chat"
6346
6799
  ? `Approved by ${lifecycleWords(by, 40)} in chat. A connected worker can take it next.`
6347
- : "Approved automatically under the operating mode. A connected worker can take it next.",
6800
+ : basis.kind === "grant"
6801
+ ? "Approved automatically: its flow runs on its own. A connected worker can take it next."
6802
+ : "Approved automatically under the operating mode. A connected worker can take it next.",
6348
6803
  link: chatControlHref("task", taskId),
6349
6804
  }, now);
6350
6805
  }
@@ -6375,16 +6830,19 @@ export class Store {
6375
6830
  * scopes answer true: this belt only ever REMOVES mode authority.
6376
6831
  */
6377
6832
  modeApprovalLive(taskRef, now) {
6833
+ // A flow's "runs on its own" grant (flow-unattended.ts) is held to the same belt.
6378
6834
  const dead = this.db
6379
6835
  .prepare(`SELECT 1 AS dead FROM task_scope
6380
6836
  JOIN task_ref ON task_ref.id = ? AND task_ref.backend = 'built-in' AND task_scope.task_id = task_ref.external_id
6381
- WHERE task_scope.approval_basis = 'mode' AND task_scope.approved_at IS NOT NULL
6837
+ WHERE task_scope.approved_at IS NOT NULL
6838
+ AND ((task_scope.approval_basis = 'mode'
6382
6839
  AND NOT EXISTS (SELECT 1 FROM operating_mode om
6383
6840
  JOIN approver signer ON signer.name = om.signed_by
6384
6841
  WHERE om.repo = task_ref.repo AND om.revoked_at IS NULL
6385
6842
  AND om.absolute_expiry > ? AND om.digest = task_scope.mode_digest
6386
- AND signer.revoked_at IS NULL AND signer.role = 'approver')`)
6387
- .get(taskRef, now.toISOString());
6843
+ AND signer.revoked_at IS NULL AND signer.role = 'approver' AND ${accountReachesSql("signer", "task_ref.repo")}))
6844
+ OR (task_scope.approval_basis = 'grant' AND NOT ${grantStandsSql("task_scope.mode_digest", "?", "task_ref.repo")}))`)
6845
+ .get(taskRef, now.toISOString(), now.toISOString());
6388
6846
  return dead === undefined;
6389
6847
  }
6390
6848
  // ---- approvers ----------------------------------------------------------
@@ -6511,6 +6969,29 @@ export class Store {
6511
6969
  return true;
6512
6970
  });
6513
6971
  }
6972
+ /** A flow's "runs on its own" grant ended (revoked, expired, renewed): the undispatched work it sealed falls back
6973
+ * to a person, as a mode's does, and plans it armed lose their pre-authorization. Running and finished work keeps
6974
+ * its history. */
6975
+ withdrawGrantApprovals(digest, now) {
6976
+ const demotable = `WHERE approval_basis = 'grant' AND approved_at IS NOT NULL AND mode_digest = ?
6977
+ AND EXISTS (SELECT 1 FROM task_ref JOIN task ON task.id = task_ref.external_id
6978
+ WHERE task_ref.backend = 'built-in' AND task_ref.external_id = task_scope.task_id AND task.state = 'queued')
6979
+ AND NOT EXISTS (SELECT 1 FROM claim JOIN task_ref tr ON tr.id = claim.task_ref
6980
+ WHERE tr.backend = 'built-in' AND tr.external_id = task_scope.task_id
6981
+ AND claim.released_at IS NULL AND claim.expires_at > ?)`;
6982
+ const demoted = this.db.prepare(`SELECT task_id FROM task_scope ${demotable}`).all(digest, now.toISOString()).map(row => String(row["task_id"]));
6983
+ this.db
6984
+ .prepare(`UPDATE task_scope SET approved_at = NULL, approved_by = NULL, approved_digest = NULL,
6985
+ approved_profile_json = NULL, approval_basis = 'password', mode_digest = NULL ${demotable}`)
6986
+ .run(digest, now.toISOString());
6987
+ this.db.prepare("DELETE FROM plan_authorization WHERE basis = 'grant' AND mode_digest = ?").run(digest);
6988
+ for (const taskId of demoted) {
6989
+ const ref = this.lookupRef(taskId);
6990
+ if (ref === null)
6991
+ continue;
6992
+ this.noteLifecycle({ taskRef: ref.id, kind: "approval-withdrawn", identity: `t${ref.id}`, subject: "Approval needs renewing", body: "Its flow no longer runs on its own. Approve it before it runs.", link: chatControlHref("approval", taskId) }, now);
6993
+ }
6994
+ }
6514
6995
  /** F2/R-REVOKE: the ONE reconciliation road. newMode null = no live
6515
6996
  * automerge authority (revocation, expiry, or a notify posture):
6516
6997
  * mode-basis pending/claimed intents drop to waiting-human, leases
@@ -6735,6 +7216,7 @@ export class Store {
6735
7216
  for (const row of this.db.prepare("SELECT repo FROM operating_mode WHERE signed_by = ? AND revoked_at IS NULL").all(name)) {
6736
7217
  this.revokeMode(String(row["repo"]), by, "operator", now);
6737
7218
  }
7219
+ endUnattendedGrants(this, { signer: name }, by, "access changed", now);
6738
7220
  this.revokeDerivedAuthority(name, by, now);
6739
7221
  for (const repo of [...new Set([...(account.projects ?? []), ...(access ?? [])])]) {
6740
7222
  this.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null,
@@ -6762,6 +7244,10 @@ export class Store {
6762
7244
  ON CONFLICT(repo) DO UPDATE SET not_requester = excluded.not_requester, protect_project = excluded.protect_project, protected_paths = excluded.protected_paths, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
6763
7245
  .run(repo, next.notRequester ? 1 : 0, next.protectProject ? 1 : 0, JSON.stringify(next.protectedPaths), by, now.toISOString());
6764
7246
  this.recordPolicy(by, repo, "approval rules changed", rulesWords(before), rulesWords(next), now);
7247
+ // Protected work is always a person's: a flow here (or building here) stops running on its own, and its queued
7248
+ // approvals are withdrawn (flow-unattended.ts). The dispatch belts check the rules again for any race.
7249
+ if (isProtectedWork(next, []))
7250
+ endUnattendedGrants(this, { repo }, by, "project protected", now);
6765
7251
  });
6766
7252
  }
6767
7253
  /** Who filed a task, when anyone knows. */
@@ -9060,9 +9546,23 @@ export class Store {
9060
9546
  }
9061
9547
  /** Stamp a task as the revision it is: source task + immutable brief. */
9062
9548
  markRevision(taskRef, revisionOf, briefArtifact) {
9063
- this.db
9064
- .prepare("UPDATE task_ref SET revision_of = ?, revision_brief_artifact = ? WHERE id = ?")
9065
- .run(revisionOf, briefArtifact, taskRef);
9549
+ this.transact(() => {
9550
+ this.db
9551
+ .prepare("UPDATE task_ref SET revision_of = ?, revision_brief_artifact = ? WHERE id = ?")
9552
+ .run(revisionOf, briefArtifact, taskRef);
9553
+ // v126: a revision keeps where the work came from, copied whole from the task it revises.
9554
+ this.db.prepare(`INSERT OR IGNORE INTO task_source (task_id, flow, card, card_entry, source_json, at)
9555
+ SELECT r.external_id, s.flow, s.card, s.card_entry, s.source_json, s.at FROM task_ref r JOIN task_source s ON s.task_id = ? WHERE r.id = ?`).run(revisionOf, taskRef);
9556
+ });
9557
+ }
9558
+ /** v126: where a task came from, written once in its filing's transaction (task-source.ts), never changed. */
9559
+ recordTaskSource(taskId, from, sourceJson, now) {
9560
+ this.db.prepare("INSERT OR IGNORE INTO task_source (task_id, flow, card, card_entry, source_json, at) VALUES (?, ?, ?, ?, ?, ?)").run(taskId, from.flow, from.card, from.entry, sourceJson, now.toISOString());
9561
+ }
9562
+ /** v126: where a task came from, as kept when it (or the task it revises) was filed; null for every other task. */
9563
+ taskSourceOf(taskId) {
9564
+ const row = this.db.prepare("SELECT flow, card, card_entry, source_json FROM task_source WHERE task_id = ?").get(taskId);
9565
+ return row === undefined ? null : { flow: Number(row["flow"]), card: Number(row["card"]), entry: Number(row["card_entry"]), sourceJson: String(row["source_json"]) };
9066
9566
  }
9067
9567
  // ---- the revision boundary: ONE field-by-field policy -------------------
9068
9568
  /**
@@ -9832,7 +10332,10 @@ export class Store {
9832
10332
  return "published";
9833
10333
  return null;
9834
10334
  }
9835
- requeueTask(taskId, by, now) {
10335
+ requeueTask(taskId, by, now,
10336
+ /** Approve & build again: requeue only while this exact build is the finished task's latest result and the scope
10337
+ * is still the one just approved, read inside this transaction ("moved" otherwise). */
10338
+ expect) {
9836
10339
  return this.transact(() => {
9837
10340
  const ref = this.db
9838
10341
  .prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?")
@@ -9878,6 +10381,12 @@ export class Store {
9878
10381
  else if (incidents.length === 0 && state !== "failed") {
9879
10382
  return { ok: false, reason: "not-stalled" };
9880
10383
  }
10384
+ if (expect !== undefined) {
10385
+ const scope = this.db.prepare("SELECT digest, approved_digest FROM task_scope WHERE task_id = ?").get(taskId);
10386
+ if (state !== "done" || rejectedRun !== expect.run || scope === undefined || String(scope["digest"]) !== expect.scopeDigest ||
10387
+ scope["approved_digest"] !== scope["digest"])
10388
+ return { ok: false, reason: "moved" };
10389
+ }
9881
10390
  for (const incident of incidents)
9882
10391
  this.resolveIncidentLocked(incident, by, now);
9883
10392
  this.resetStrikes(taskRef);
@@ -11158,9 +11667,9 @@ export class Store {
11158
11667
  this.spendCache = null; // its spend counts toward the next budget check
11159
11668
  const changed = this.db
11160
11669
  .prepare(`UPDATE chat_turn SET state = ?, failure_reason = ?, tokens_in = ?, tokens_out = ?,
11161
- settled_microusd = ?, unknown_spend = ?, reply_bytes = ?, candidate_count = ?, finished_at = ?
11670
+ settled_microusd = ?, unknown_spend = ?, reply_bytes = ?, candidate_count = ?, cache_read_tokens = ?, cache_write_tokens = ?, finished_at = ?
11162
11671
  WHERE id = ? AND generation = ? AND state IN ('queued','running')`)
11163
- .run(outcome.state, outcome.failureReason ?? null, outcome.tokensIn ?? null, outcome.tokensOut ?? null, outcome.settledMicrousd, outcome.unknownSpend === true ? 1 : 0, outcome.replyBytes ?? null, outcome.candidateCount ?? null, now.toISOString(), id, generation);
11672
+ .run(outcome.state, outcome.failureReason ?? null, outcome.tokensIn ?? null, outcome.tokensOut ?? null, outcome.settledMicrousd, outcome.unknownSpend === true ? 1 : 0, outcome.replyBytes ?? null, outcome.candidateCount ?? null, outcome.cacheReadTokens ?? null, outcome.cacheWriteTokens ?? null, now.toISOString(), id, generation);
11164
11673
  return Number(changed.changes) === 1;
11165
11674
  }
11166
11675
  /**
@@ -11883,6 +12392,11 @@ export class Store {
11883
12392
  // duplicate before any request is consumed.
11884
12393
  reviewAttempt);
11885
12394
  const id = Number(inserted.lastInsertRowid);
12395
+ // An ACP run's billing is the declaration standing for its runner and model NOW, stamped with the row it bills:
12396
+ // settlement reads this stamp, never the live declaration (docs/plans/acp-adapter.md § Billing).
12397
+ if (provider !== undefined && provider !== null && isAcpProvider(provider) && model !== null) {
12398
+ this.db.prepare("UPDATE run SET acp_billing_json = ? WHERE id = ?").run(JSON.stringify(this.acpBillingFor(provider, run.runner, model)), id);
12399
+ }
11886
12400
  // The attempt key (Agents board): opaque, minted with the row, and the only key its stream state is recorded under.
11887
12401
  this.db.prepare("UPDATE run SET attempt_key = ? WHERE id = ?").run(mintAttemptKey(), id);
11888
12402
  // The provenance row lands in the same transaction as the run row —
@@ -12316,6 +12830,216 @@ export class Store {
12316
12830
  * structural signal is gone. `auth-expired` pauses its provider; every
12317
12831
  * other class is observational.
12318
12832
  */
12833
+ /**
12834
+ * Record an ACP agent's passed conformance check on a runner (acp-attest.ts), replacing the live one — one
12835
+ * transaction, so there is never a moment with two or with a gap that reads as the old one.
12836
+ */
12837
+ recordAcpAttestation(input, now) {
12838
+ return this.transact(() => {
12839
+ this.db.prepare("UPDATE acp_attestation SET revoked_at = ?, revoked_by = ? WHERE provider = ? AND runner = ? AND revoked_at IS NULL")
12840
+ .run(now.toISOString(), `${input.recordedBy} (checked again)`, input.provider, input.runner);
12841
+ const inserted = this.db.prepare(`INSERT INTO acp_attestation (provider, runner, conformance_version, launch_json, agent_name, agent_version, cli_version, models_json, checks_json, recorded_at, recorded_by)
12842
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.provider, input.runner, input.conformanceVersion ?? ACP_CONFORMANCE_VERSION, JSON.stringify(input.launch), input.agent.name, input.agent.version, input.cliVersion, JSON.stringify(input.models), JSON.stringify(input.checks), now.toISOString(), input.recordedBy);
12843
+ return Number(inserted.lastInsertRowid);
12844
+ });
12845
+ }
12846
+ /** An ACP agent's live attestation on a runner, under this build's conformance version; null when there is none. */
12847
+ acpAttestationFor(provider, runner) {
12848
+ const row = this.db.prepare("SELECT * FROM acp_attestation WHERE provider = ? AND runner = ? AND revoked_at IS NULL").get(provider, runner);
12849
+ if (row === undefined || Number(row["conformance_version"]) !== ACP_CONFORMANCE_VERSION)
12850
+ return null;
12851
+ const launch = launchFromJson(String(row["launch_json"]));
12852
+ const list = (json) => {
12853
+ try {
12854
+ const value = JSON.parse(String(json));
12855
+ return Array.isArray(value) && value.every(one => typeof one === "string") ? value : null;
12856
+ }
12857
+ catch {
12858
+ return null;
12859
+ }
12860
+ };
12861
+ const models = list(row["models_json"]);
12862
+ const checks = list(row["checks_json"]);
12863
+ if (launch === null || models === null || checks === null)
12864
+ return null;
12865
+ return {
12866
+ id: Number(row["id"]), provider: String(row["provider"]), runner: String(row["runner"]), conformanceVersion: Number(row["conformance_version"]),
12867
+ launch, agent: { name: String(row["agent_name"]), version: String(row["agent_version"]) }, cliVersion: String(row["cli_version"]),
12868
+ models, checks, recordedAt: String(row["recorded_at"]), recordedBy: String(row["recorded_by"]),
12869
+ };
12870
+ }
12871
+ /** Declare how an ACP agent's work on one model is billed on a runner; the new revision. */
12872
+ declareAcpBilling(input, now) {
12873
+ return Number(this.db.prepare("INSERT INTO acp_billing (provider, runner, model, kind, set_at, set_by) VALUES (?, ?, ?, ?, ?, ?)")
12874
+ .run(input.provider, input.runner, input.model, input.kind, now.toISOString(), input.by).lastInsertRowid);
12875
+ }
12876
+ /** The billing declaration standing for an ACP agent's model on a runner: metered, revision 0, when none was made. */
12877
+ acpBillingFor(provider, runner, model) {
12878
+ const row = this.db.prepare("SELECT id, kind FROM acp_billing WHERE provider = ? AND runner = ? AND model = ? ORDER BY id DESC LIMIT 1").get(provider, runner, model);
12879
+ return row === undefined ? { kind: "metered", revision: 0, model } : { kind: row["kind"] === "plan" ? "plan" : "metered", revision: Number(row["id"]), model };
12880
+ }
12881
+ /** The billing an ACP run was admitted under, or null (not an ACP run, or unreadable — then it is metered). */
12882
+ acpBillingOfRun(runId) {
12883
+ const raw = this.db.prepare("SELECT acp_billing_json FROM run WHERE id = ?").get(runId)?.["acp_billing_json"];
12884
+ if (typeof raw !== "string")
12885
+ return null;
12886
+ try {
12887
+ const value = JSON.parse(raw);
12888
+ if ((value["kind"] === "metered" || value["kind"] === "plan") && typeof value["revision"] === "number" && typeof value["model"] === "string") {
12889
+ return { kind: value["kind"], revision: value["revision"], model: value["model"] };
12890
+ }
12891
+ }
12892
+ catch { /* unreadable: metered */ }
12893
+ return null;
12894
+ }
12895
+ /** Ask for an ACP agent check on a runner (the caller proved the password); the request's id. Valid 30 minutes. */
12896
+ requestAcpCheck(input, now) {
12897
+ return Number(this.db.prepare(`INSERT INTO acp_check_request (provider, runner, model, conformance_version, requested_by, requested_at, expires_at)
12898
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(input.provider, input.runner, input.model, ACP_CONFORMANCE_VERSION, input.by, now.toISOString(), new Date(now.getTime() + 30 * 60_000).toISOString()).lastInsertRowid);
12899
+ }
12900
+ acpCheck(id) {
12901
+ const row = this.db.prepare("SELECT * FROM acp_check_request WHERE id = ?").get(id);
12902
+ return row === undefined ? null : readAcpCheck(row);
12903
+ }
12904
+ /** The checks asked for an agent, newest first (for the providers screen). */
12905
+ acpChecksFor(provider, limit = 10) {
12906
+ return this.db.prepare("SELECT * FROM acp_check_request WHERE provider = ? ORDER BY id DESC LIMIT ?").all(provider, limit).map(readAcpCheck);
12907
+ }
12908
+ /** The SQL that proves a watch still holds its lease (runner, repo, owner) at `?` — appended with its bindings. */
12909
+ static WATCH_LIVE = "EXISTS (SELECT 1 FROM watch_lease w WHERE w.runner = acp_check_request.runner AND w.repo = acp_check_request.watch_repo AND w.owner = acp_check_request.watch_owner AND w.expires_at > ?)";
12910
+ /**
12911
+ * Claim the oldest pending, unexpired check for this runner, for the watch that will run it — one statement; the
12912
+ * partial unique index refuses a second running check on the runner. Null when there is none (or another runs).
12913
+ */
12914
+ claimAcpCheck(runner, watch, now) {
12915
+ const at = now.toISOString();
12916
+ try {
12917
+ const claimed = this.db.prepare(`UPDATE acp_check_request SET state = 'running', watch_repo = ?, watch_owner = ?
12918
+ WHERE id = (SELECT id FROM acp_check_request WHERE runner = ? AND state = 'pending' AND expires_at > ? ORDER BY id LIMIT 1)
12919
+ AND EXISTS (SELECT 1 FROM watch_lease w WHERE w.runner = ? AND w.repo = ? AND w.owner = ? AND w.expires_at > ?)
12920
+ RETURNING id`).get(watch.repo, watch.owner, runner, at, runner, watch.repo, watch.owner, at);
12921
+ return claimed === undefined ? null : this.acpCheck(Number(claimed["id"]));
12922
+ }
12923
+ catch (error) {
12924
+ if (/UNIQUE constraint failed/.test(String(error)))
12925
+ return null;
12926
+ throw error;
12927
+ }
12928
+ }
12929
+ /** Bind a running check, still owned and unexpired, to its task and the launch it inspected. */
12930
+ bindAcpCheck(id, binding, now) {
12931
+ const at = now.toISOString();
12932
+ return Number(this.db.prepare(`UPDATE acp_check_request SET task_ref = ?, launch_json = ?, lease_id = ?
12933
+ WHERE id = ? AND state = 'running' AND expires_at > ? AND task_ref IS NULL AND ${Store.WATCH_LIVE}`).run(binding.taskRef, binding.launchJson, binding.leaseId, id, at, at).changes) === 1;
12934
+ }
12935
+ /** Consume step 1 or 2 for its run: in order, once, owned, unexpired. */
12936
+ consumeAcpCheckStep(id, step, runId, now) {
12937
+ const at = now.toISOString();
12938
+ const column = step === 1 ? "step1_run" : "step2_run";
12939
+ return Number(this.db.prepare(`UPDATE acp_check_request SET steps_used = ?, ${column} = ?
12940
+ WHERE id = ? AND state = 'running' AND steps_used = ? AND ${column} IS NULL AND expires_at > ? AND ${Store.WATCH_LIVE}
12941
+ ${step === 2 ? "AND step1_passed = 1" : ""}`).run(step, runId, id, step - 1, at, at).changes) === 1;
12942
+ }
12943
+ /** Record a step as passed, once its grading did: owned, unexpired, and only for the run the step was bound to. */
12944
+ passAcpCheckStep(id, step, runId, now) {
12945
+ const at = now.toISOString();
12946
+ const run = step === 1 ? "step1_run" : "step2_run";
12947
+ const passed = step === 1 ? "step1_passed" : "step2_passed";
12948
+ return Number(this.db.prepare(`UPDATE acp_check_request SET ${passed} = 1
12949
+ WHERE id = ? AND state = 'running' AND ${run} = ? AND ${passed} = 0 AND expires_at > ? AND ${Store.WATCH_LIVE}`).run(id, runId, at, at).changes) === 1;
12950
+ }
12951
+ /**
12952
+ * Publish a passed check: in one transaction, the request moves to passed — only if it is still running, unexpired,
12953
+ * owned by its live watch, with both steps used, bound and passed, and both their runs finished as agent-check
12954
+ * refusals — and the attestation is recorded. Anything else publishes nothing.
12955
+ */
12956
+ publishAcpCheck(id, attestation, now) {
12957
+ const at = now.toISOString();
12958
+ return this.transact(() => {
12959
+ // The attestation must be for this request's agent and runner, and offer the model it was checked on.
12960
+ const request = this.acpCheck(id);
12961
+ if (request === null || request.provider !== attestation.provider || request.runner !== attestation.runner || !attestation.models.includes(request.model))
12962
+ return false;
12963
+ const moved = Number(this.db.prepare(`UPDATE acp_check_request SET state = 'passed', finished_at = ?, result = 'passed'
12964
+ WHERE id = ? AND state = 'running' AND expires_at > ? AND ${Store.WATCH_LIVE}
12965
+ AND steps_used = 2 AND step1_passed = 1 AND step2_passed = 1 AND conformance_version = ?
12966
+ AND (SELECT COUNT(*) FROM run r WHERE r.id IN (step1_run, step2_run) AND r.task_ref = acp_check_request.task_ref
12967
+ AND r.outcome = 'refused' AND r.reason = 'agent-check' AND r.finished_at IS NOT NULL) = 2`)
12968
+ .run(at, id, at, at, ACP_CONFORMANCE_VERSION).changes);
12969
+ if (moved !== 1)
12970
+ return false;
12971
+ this.recordAcpAttestation(attestation, now);
12972
+ return true;
12973
+ });
12974
+ }
12975
+ /** The check request, when it still admits this run as its current step: running, unexpired, its watch's lease
12976
+ * live, and the step bound to exactly this run. Null otherwise. */
12977
+ acpCheckAdmitting(id, step, runId, now) {
12978
+ const at = now.toISOString();
12979
+ const row = this.db.prepare(`SELECT * FROM acp_check_request WHERE id = ? AND state = 'running' AND expires_at > ? AND ${Store.WATCH_LIVE}
12980
+ AND steps_used = ? AND ${step === 1 ? "step1_run" : "step2_run"} = ?`).get(id, at, at, step, runId);
12981
+ return row === undefined ? null : readAcpCheck(row);
12982
+ }
12983
+ /** Fail a check that hasn't finished, with its words. */
12984
+ failAcpCheck(id, words, now) {
12985
+ return Number(this.db.prepare("UPDATE acp_check_request SET state = 'failed', result = ?, finished_at = ? WHERE id = ? AND state IN ('pending','running')")
12986
+ .run(words.slice(0, 2000), now.toISOString(), id).changes) === 1;
12987
+ }
12988
+ /** Running checks on this runner no live watch holds any more, or past their time: the ones recovery may close. */
12989
+ abandonedAcpChecks(runner, now) {
12990
+ const at = now.toISOString();
12991
+ return this.db.prepare(`SELECT * FROM acp_check_request WHERE runner = ? AND state = 'running' AND (expires_at <= ? OR NOT ${Store.WATCH_LIVE})`)
12992
+ .all(runner, at, at).map(readAcpCheck);
12993
+ }
12994
+ /** Finished checks on this runner whose claim, checkout and task are not yet let go: cleanup's to-do, retried each pass. */
12995
+ unsettledAcpChecks(runner) {
12996
+ return this.db.prepare("SELECT * FROM acp_check_request WHERE runner = ? AND state IN ('passed','failed') AND settled_at IS NULL ORDER BY id").all(runner).map(readAcpCheck);
12997
+ }
12998
+ /** The claim a finished check's cleanup now holds on its task (its first one expired): the only one it may release. */
12999
+ setAcpCheckLease(id, leaseId) {
13000
+ return Number(this.db.prepare("UPDATE acp_check_request SET lease_id = ? WHERE id = ? AND state IN ('passed','failed') AND settled_at IS NULL").run(leaseId, id).changes) === 1;
13001
+ }
13002
+ /** A finished check holds something again (a checkout that landed after recovery settled it): cleanup reopens. */
13003
+ unsettleAcpCheck(id) {
13004
+ return Number(this.db.prepare("UPDATE acp_check_request SET settled_at = NULL WHERE id = ? AND state IN ('passed','failed') AND settled_at IS NOT NULL").run(id).changes) === 1;
13005
+ }
13006
+ /** Take — or renew — the one cleanup attempt a finished check may have at a time: free, lapsed, or already this one. */
13007
+ holdAcpSettle(id, attempt, now, ttlMs) {
13008
+ const at = now.toISOString();
13009
+ return Number(this.db.prepare(`UPDATE acp_check_request SET settle_by = ?, settle_until = ?
13010
+ WHERE id = ? AND state IN ('passed','failed') AND settled_at IS NULL AND (settle_by IS NULL OR settle_by = ? OR settle_until <= ?)`)
13011
+ .run(attempt, new Date(now.getTime() + ttlMs).toISOString(), id, attempt, at).changes) === 1;
13012
+ }
13013
+ /** Whether this cleanup attempt still holds the check. */
13014
+ holdsAcpSettle(id, attempt, now) {
13015
+ return this.db.prepare("SELECT 1 FROM acp_check_request WHERE id = ? AND settle_by = ? AND settle_until > ?").get(id, attempt, now.toISOString()) !== undefined;
13016
+ }
13017
+ /** Let go of a cleanup attempt (only this one). */
13018
+ endAcpSettle(id, attempt) {
13019
+ this.db.prepare("UPDATE acp_check_request SET settle_by = NULL, settle_until = NULL WHERE id = ? AND settle_by = ?").run(id, attempt);
13020
+ }
13021
+ /** Mark a finished check's holdings let go — by the attempt holding it, and only if no checkout of its branch is
13022
+ * left (one that landed late keeps it open, whatever the attempt last saw). */
13023
+ settleAcpCheck(id, attempt, branch, now) {
13024
+ return Number(this.db.prepare(`UPDATE acp_check_request SET settled_at = ?, settle_by = NULL, settle_until = NULL
13025
+ WHERE id = ? AND state IN ('passed','failed') AND settled_at IS NULL AND settle_by = ? AND settle_until > ?
13026
+ AND NOT EXISTS (SELECT 1 FROM worktree w WHERE w.task_ref = acp_check_request.task_ref AND w.branch = ?)`)
13027
+ .run(now.toISOString(), id, attempt, now.toISOString(), branch).changes) === 1;
13028
+ }
13029
+ /** Pending checks past their time are expired, untouched. */
13030
+ expireAcpChecks(now) {
13031
+ return Number(this.db.prepare("UPDATE acp_check_request SET state = 'expired', finished_at = ? WHERE state = 'pending' AND expires_at <= ?").run(now.toISOString(), now.toISOString()).changes);
13032
+ }
13033
+ /** Revoke an ACP agent's live attestation on a runner; whether there was one. */
13034
+ revokeAcpAttestation(provider, runner, by, now) {
13035
+ return Number(this.db.prepare("UPDATE acp_attestation SET revoked_at = ?, revoked_by = ? WHERE provider = ? AND runner = ? AND revoked_at IS NULL").run(now.toISOString(), by, provider, runner).changes) > 0;
13036
+ }
13037
+ /** What a run's ACP agent itself reported it used (never priced; acp-client.ts). */
13038
+ stampAgentReportedUsage(id, usage) {
13039
+ if (usage === null)
13040
+ return;
13041
+ this.db.prepare("UPDATE run SET agent_reported_usage_json = ? WHERE id = ?").run(usage.slice(0, 8192), id);
13042
+ }
12319
13043
  stampTerminalClass(id, authMode, terminalClass) {
12320
13044
  // FIRST-WRITE only (Codex E2/E3 review, finding 8): the gateway stamps
12321
13045
  // the disposal class exactly once, before disposition; nothing may
@@ -12325,6 +13049,12 @@ export class Store {
12325
13049
  .prepare("UPDATE run SET auth_mode = COALESCE(auth_mode, ?), terminal_class = ? WHERE id = ? AND terminal_class IS NULL")
12326
13050
  .run(authMode, terminalClass, id);
12327
13051
  }
13052
+ /** A launch refused before any model turn (an installed CLI too old for a
13053
+ * build's flags): the start stamp goes, its version and evidence stay. Only
13054
+ * while the run is open, so a settled run's account never changes. */
13055
+ withdrawProviderStart(id) {
13056
+ this.db.prepare("UPDATE run SET provider_started_at = NULL WHERE id = ? AND outcome IS NULL").run(id);
13057
+ }
12328
13058
  /** The race-refusal stamp (Phase 3 C5): the version that was REFUSED,
12329
13059
  * recorded without provider_started_at — no provider process started. */
12330
13060
  stampProviderVersion(id, providerVersion) {
@@ -12336,9 +13066,10 @@ export class Store {
12336
13066
  .prepare(`UPDATE run SET tokens_in = COALESCE(?, tokens_in),
12337
13067
  tokens_out = COALESCE(?, tokens_out),
12338
13068
  cost_usd = COALESCE(?, cost_usd),
12339
- usage_json = COALESCE(?, usage_json)
13069
+ usage_json = COALESCE(?, usage_json),
13070
+ agent_turns = COALESCE(?, agent_turns)
12340
13071
  WHERE id = ?`)
12341
- .run(usage.tokensIn ?? null, usage.tokensOut ?? null, usage.costUsd ?? null, usage.usageJson ?? null, id);
13072
+ .run(usage.tokensIn ?? null, usage.tokensOut ?? null, usage.costUsd ?? null, usage.usageJson ?? null, usage.agentTurns ?? null, id);
12342
13073
  this.settleRunSpend(id, new Date());
12343
13074
  }
12344
13075
  /** v105: price a run as it stands (reported cost, or tokens at the catalogue price, or unpriced), frozen at the
@@ -12348,8 +13079,11 @@ export class Store {
12348
13079
  if (row === undefined)
12349
13080
  return;
12350
13081
  // How the run is billed is fixed the first time it's settled (its chain entry's, or the provider's login or key).
13082
+ // An ACP run is billed as its stamp says: its agent's own plan reads as a plan ($0), anything else as metered.
13083
+ const acpStamp = isAcpProvider(String(row["provider"])) ? this.acpBillingOfRun(id) : null;
12351
13084
  const billing = row["billing"] === "subscription" || row["billing"] === "api-key" ? row["billing"]
12352
- : row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key" ? row["auth_mode"] : billingOf(String(row["provider"]), this.db);
13085
+ : isAcpProvider(String(row["provider"])) ? (acpStamp?.kind === "plan" ? "subscription" : "api-key")
13086
+ : row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key" ? row["auth_mode"] : billingOf(String(row["provider"]), this.db);
12353
13087
  const priced = priceWork(this.db, { provider: String(row["provider"]), model: row["model"] == null ? null : String(row["model"]), billing,
12354
13088
  costUsd: row["cost_usd"] == null ? null : Number(row["cost_usd"]), tokensIn: row["tokens_in"] == null ? null : Number(row["tokens_in"]), tokensOut: row["tokens_out"] == null ? null : Number(row["tokens_out"]) });
12355
13089
  this.db.prepare(`INSERT INTO run_spend (run, microusd, source, billing, price_model, input_usd, output_usd, priced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -12619,6 +13353,15 @@ export class Store {
12619
13353
  return { ...none, remainingMicrousd: remaining };
12620
13354
  };
12621
13355
  }
13356
+ /** How one agent would bill new work now: an ACP agent by its declaration for that exact model on that runner
13357
+ * (plan reads as a plan, anything else as metered), every other provider as agentsFor says. */
13358
+ agentFor(provider, model, runner) {
13359
+ if (isAcpProvider(provider)) {
13360
+ const declared = model === null || runner === null ? null : this.acpBillingFor(provider, runner, model);
13361
+ return { provider, billing: declared?.kind === "plan" ? "subscription" : "api-key" };
13362
+ }
13363
+ return this.agentsFor([provider])[0];
13364
+ }
12622
13365
  /** How each provider would bill new work now (see spend.billingOf). */
12623
13366
  agentsFor(providers) {
12624
13367
  return [...new Set(providers)].map(provider => ({ provider, billing: billingOf(provider, this.db) }));
@@ -13178,6 +13921,60 @@ export class Store {
13178
13921
  return { ok: true, id };
13179
13922
  }), result => result.ok);
13180
13923
  }
13924
+ /**
13925
+ * File a note for the exact run a person is watching (live-conversation.md
13926
+ * §6): in ONE transaction, the run must still be the task's live attempt
13927
+ * (or, `onward`, any attempt of this task: the note goes to the next one),
13928
+ * and a repeated `key` from the same author hands back the same note — only
13929
+ * for the same task, run and text, whichever of the two ways it was sent;
13930
+ * any other reuse is refused. Nothing is filed on a refusal.
13931
+ */
13932
+ fileSteerNoteForRun(taskId, author, note, now, expect) {
13933
+ return this.transact(() => {
13934
+ const valid = validateNote(note);
13935
+ if (!valid.ok)
13936
+ return { ok: false, reason: "invalid-note", problem: valid.problem };
13937
+ const task = this.db.prepare("SELECT state FROM task WHERE id = ?").get(taskId);
13938
+ const ref = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, taskId);
13939
+ if (task === undefined || ref === undefined)
13940
+ return { ok: false, reason: "unknown-task" };
13941
+ const taskRef = Number(ref["id"]);
13942
+ const key = expect.key === null ? null : `steer-note:${author}:${expect.key}`;
13943
+ const digest = createHash("sha256").update(JSON.stringify([taskId, expect.run, valid.note])).digest("hex");
13944
+ if (key !== null) {
13945
+ const seen = this.db.prepare("SELECT operation, result FROM mutation WHERE idempotency_key = ?").get(key);
13946
+ if (seen !== undefined) {
13947
+ const saved = parseStoreColumn("mutation.result", seen["result"]);
13948
+ if (seen["operation"] === "fileSteerNoteForRun" && saved !== null && saved.digest === digest && typeof saved.id === "number") {
13949
+ return { ok: true, id: saved.id, replayed: true };
13950
+ }
13951
+ return { ok: false, reason: "key-reused" };
13952
+ }
13953
+ }
13954
+ const state = String(task["state"]);
13955
+ if (state === "done" || state === "cancelled")
13956
+ return { ok: false, reason: "task-finished" };
13957
+ const shown = this.db.prepare("SELECT task_ref, outcome, lease_id FROM run WHERE id = ?").get(expect.run);
13958
+ if (shown === undefined || Number(shown["task_ref"]) !== taskRef)
13959
+ return { ok: false, reason: "attempt-ended" };
13960
+ // Sent on from an attempt that ended (`onward`), the note is for the task's next attempt. Otherwise the run on
13961
+ // screen must still be the task's live attempt: unfinished, under the current lease. How the note reaches it —
13962
+ // its brief, its steering folder, or stdin — is steering's own business, as for any note.
13963
+ if (expect.onward !== true) {
13964
+ const lease = this.currentLiveLease(taskRef, now);
13965
+ if (shown["outcome"] !== null || lease === null || shown["lease_id"] !== lease)
13966
+ return { ok: false, reason: "attempt-ended" };
13967
+ }
13968
+ const id = this.insertSteerNote(taskRef, author, valid.note, now);
13969
+ if (key !== null) {
13970
+ this.db
13971
+ .prepare("INSERT INTO mutation (idempotency_key, operation, result, actor, created_at) VALUES (?, ?, ?, ?, ?)")
13972
+ .run(key, "fileSteerNoteForRun", JSON.stringify({ id, digest }), author, now.toISOString());
13973
+ }
13974
+ this.bumpWake();
13975
+ return { ok: true, id, replayed: false };
13976
+ });
13977
+ }
13181
13978
  /** One verified note, with the identity a running build's stdin copy is sent under: filing it is also what queues
13182
13979
  * its live delivery, in the same transaction (the builder's dispatcher sends whatever is filed and unsent). */
13183
13980
  insertSteerNote(taskRef, author, note, now) {
@@ -13240,6 +14037,7 @@ export class Store {
13240
14037
  this.db
13241
14038
  .prepare("UPDATE task_steer SET attached_run = ?, attached_at = ?, live_sends = 0, live_sent_at = NULL, live_missed_at = NULL WHERE id = ?")
13242
14039
  .run(runId, stamp, Number(row["id"]));
14040
+ this.noteSteerReached(Number(row["id"]), runId, "attached", stamp);
13243
14041
  }
13244
14042
  return rows.map(row => readSteerNote({ ...row, attached_run: runId, attached_at: stamp, live_sends: 0, live_sent_at: null, live_missed_at: null }));
13245
14043
  });
@@ -13251,9 +14049,13 @@ export class Store {
13251
14049
  * first receipt wins, and a replay changes nothing.
13252
14050
  */
13253
14051
  settleSteerDelivered(runId, now) {
14052
+ const stamp = now.toISOString();
14053
+ const settled = this.db.prepare("SELECT id FROM task_steer WHERE attached_run = ? AND delivered_at IS NULL").all(runId);
13254
14054
  const { changes } = this.db
13255
14055
  .prepare("UPDATE task_steer SET delivered_at = ? WHERE attached_run = ? AND delivered_at IS NULL")
13256
- .run(now.toISOString(), runId);
14056
+ .run(stamp, runId);
14057
+ for (const row of settled)
14058
+ this.noteSteerReached(Number(row["id"]), runId, "read", stamp);
13257
14059
  return Number(changes);
13258
14060
  }
13259
14061
  /**
@@ -13307,8 +14109,24 @@ export class Store {
13307
14109
  live_missed_at = CASE WHEN mailed_run IS ? THEN live_missed_at ELSE NULL END
13308
14110
  WHERE id = ? AND delivered_at IS NULL AND superseded_at IS NULL`)
13309
14111
  .run(runId, now.toISOString(), runId, runId, runId, noteId);
14112
+ if (Number(changes) > 0)
14113
+ this.noteSteerReached(noteId, runId, "mailed", now.toISOString());
13310
14114
  return Number(changes) > 0;
13311
14115
  }
14116
+ /** One run a note reached, kept for its live conversation (live-notes.ts); display state, so a failure never blocks steering. */
14117
+ noteSteerReached(noteId, runId, how, at) {
14118
+ try {
14119
+ this.db.prepare("INSERT OR IGNORE INTO steer_note_run (note, run, how, at) VALUES (?, ?, ?, ?)").run(noteId, runId, how, at);
14120
+ }
14121
+ catch { /* display state */ }
14122
+ }
14123
+ /** The notes this run reached, filing order — attached to its brief, mailed into its folder, or read by it. */
14124
+ steerNotesReaching(runId) {
14125
+ return this.db
14126
+ .prepare("SELECT * FROM task_steer WHERE id IN (SELECT note FROM steer_note_run WHERE run = ?) ORDER BY id")
14127
+ .all(runId)
14128
+ .map(readSteerNote);
14129
+ }
13312
14130
  /**
13313
14131
  * The agent moved this note to handled/ (v120): it was read, by THIS run.
13314
14132
  * Acknowledgement is the only road from mailed to delivered — a hook firing
@@ -13321,6 +14139,8 @@ export class Store {
13321
14139
  .prepare(`UPDATE task_steer SET acked_at = ?, delivered_at = ?, attached_run = ?, attached_at = COALESCE(mailed_at, ?)
13322
14140
  WHERE id = ? AND mailed_run = ? AND acked_at IS NULL AND delivered_at IS NULL AND superseded_at IS NULL`)
13323
14141
  .run(stamp, stamp, runId, stamp, noteId, runId);
14142
+ if (Number(changes) > 0)
14143
+ this.noteSteerReached(noteId, runId, "read", stamp);
13324
14144
  if (Number(changes) > 0)
13325
14145
  this.bumpWake();
13326
14146
  return Number(changes) > 0;
@@ -14418,17 +15238,31 @@ export class Store {
14418
15238
  return row === undefined ? null : readLeadSession(row);
14419
15239
  }
14420
15240
  endLeadSession(id, by, now) {
14421
- const changed = this.db
14422
- .prepare("UPDATE lead_session SET ended_at = ?, ended_by = ? WHERE id = ? AND ended_at IS NULL")
14423
- .run(now.toISOString(), by, id);
14424
- return Number(changed.changes) === 1;
15241
+ return this.transact(() => {
15242
+ const changed = this.db
15243
+ .prepare("UPDATE lead_session SET ended_at = ?, ended_by = ? WHERE id = ? AND ended_at IS NULL")
15244
+ .run(now.toISOString(), by, id);
15245
+ // Its waiting messages can never run under it (each is bound to the session it was sent in): they end now, Not
15246
+ // sent, and keep no words (v128 lead queue).
15247
+ if (Number(changed.changes) === 1) {
15248
+ const waiting = this.db.prepare("SELECT message FROM lead_queue WHERE session = ? AND status = 'queued'").all(id).map(row => Number(row["message"]));
15249
+ for (const message of waiting) {
15250
+ this.db.prepare("UPDATE lead_queue SET status = 'cancelled', error = 'Not sent: this conversation ended before it ran.', revision = revision + 1, settled_at = ? WHERE message = ?").run(now.toISOString(), message);
15251
+ this.forgetQueuedLeadMessage(message);
15252
+ }
15253
+ }
15254
+ return Number(changed.changes) === 1;
15255
+ });
14425
15256
  }
14426
15257
  /** Revocation's companion (ruling 10): an approver's standing ends every session it minted. */
14427
15258
  endLeadSessionsFor(approver, by, now) {
14428
- const changed = this.db
14429
- .prepare("UPDATE lead_session SET ended_at = ?, ended_by = ? WHERE approver = ? AND ended_at IS NULL")
14430
- .run(now.toISOString(), by, approver);
14431
- return Number(changed.changes);
15259
+ return this.transact(() => {
15260
+ const ending = this.db.prepare("SELECT id FROM lead_session WHERE approver = ? AND ended_at IS NULL").all(approver).map(row => Number(row["id"]));
15261
+ // Each ends as endLeadSession ends one: its waiting messages too (v128 lead queue).
15262
+ for (const id of ending)
15263
+ this.endLeadSession(id, by, now);
15264
+ return ending.length;
15265
+ });
14432
15266
  }
14433
15267
  /** The live thread for this approver and scope under THIS ceiling; a
14434
15268
  * thread under another ceiling is closed first (ruling 9) — the surface
@@ -14514,11 +15348,14 @@ export class Store {
14514
15348
  const { changes } = this.db.prepare("UPDATE flow SET state = 'archived', updated_by = ?, updated_at = ? WHERE id = ? AND state = 'active'").run(by, now.toISOString(), id);
14515
15349
  return Number(changes) === 1;
14516
15350
  }
15351
+ /** A new card. `byKind` (v126) is decided where the card is made, never read from `by`: a delivery, a poll, a schedule
15352
+ * a public form or a chat channel's message is automation whatever its label says; a signed-in person's own card is a
15353
+ * person's. Every caller says which: filing passes it on exactly, and never reads it from `by`. */
14517
15354
  addFlowCard(card, now) {
14518
15355
  return this.transact(() => {
14519
15356
  const stamp = now.toISOString();
14520
- const id = Number(this.db.prepare("INSERT INTO flow_card (flow, title, description, stage, state, created_by, created_at, updated_at, source_json) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)")
14521
- .run(card.flow, card.title, card.description, card.stage, card.by, stamp, stamp, card.source === undefined ? null : JSON.stringify(card.source)).lastInsertRowid);
15357
+ const id = Number(this.db.prepare("INSERT INTO flow_card (flow, title, description, stage, state, created_by, created_at, updated_at, source_json, created_kind) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)")
15358
+ .run(card.flow, card.title, card.description, card.stage, card.by, stamp, stamp, card.source === undefined ? null : JSON.stringify(card.source), card.byKind).lastInsertRowid);
14522
15359
  this.db.prepare("INSERT INTO flow_event (card, from_stage, to_stage, outcome, actor, note, at) VALUES (?, NULL, ?, 'created', ?, NULL, ?)").run(id, card.stage, card.by, stamp);
14523
15360
  return id;
14524
15361
  });
@@ -14928,6 +15765,8 @@ export class Store {
14928
15765
  const version = (current?.version ?? Number(this.db.prepare("SELECT COALESCE(MAX(version), 0) AS v FROM flow_script WHERE repo = ? AND name = ?").get(script.repo, script.name)?.["v"] ?? 0)) + 1;
14929
15766
  if (current !== null)
14930
15767
  this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE id = ?").run(current.id);
15768
+ // A "runs on its own" grant signed this script's code: a new version ends it (flow-unattended.ts).
15769
+ endUnattendedGrants(this, { script: { repo: script.repo, name: script.name } }, script.by, "script changed", now);
14931
15770
  this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file, held)
14932
15771
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(script.repo, script.name, script.about, script.body, script.timeoutMinutes, version, script.digest, script.by, now.toISOString(), script.language ?? "shell", script.file ?? null, script.held ?? null);
14933
15772
  return version;
@@ -14938,9 +15777,14 @@ export class Store {
14938
15777
  const { changes } = this.db.prepare("UPDATE flow_script SET held = NULL WHERE repo = ? AND name = ? AND state = 'active' AND held IS NOT NULL").run(repo, name);
14939
15778
  return Number(changes) === 1;
14940
15779
  }
14941
- removeFlowScript(repo, name) {
14942
- const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
14943
- return Number(changes) === 1;
15780
+ removeFlowScript(repo, name, by = "flow", now = new Date()) {
15781
+ return this.transact(() => {
15782
+ const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
15783
+ // A grant that signed this script's code covers nothing without it: it ends (flow-unattended.ts).
15784
+ if (Number(changes) === 1)
15785
+ endUnattendedGrants(this, { script: { repo, name } }, by, "script changed", now);
15786
+ return Number(changes) === 1;
15787
+ });
14944
15788
  }
14945
15789
  /** Claim a card's visit to a step zone. False when another pass holds it or it already finished. */
14946
15790
  claimFlowStep(step, now) {
@@ -15029,6 +15873,13 @@ export class Store {
15029
15873
  // ---- flow triggers (v82) -----------------------------------------------------
15030
15874
  addFlowTrigger(trigger, now) {
15031
15875
  const stamp = now.toISOString();
15876
+ // A new schedule is one the flow's "runs on its own" grant never read: it ends (flow-unattended.ts).
15877
+ if (trigger.kind === "schedule")
15878
+ return this.transact(() => {
15879
+ endUnattendedGrants(this, { flow: trigger.flow }, trigger.by, "schedule changed", now);
15880
+ return Number(this.db.prepare("INSERT INTO flow_trigger (flow, kind, config_json, state, hook_hash, cursor, next_at, created_by, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)")
15881
+ .run(trigger.flow, trigger.kind, trigger.configJson, trigger.hookHash, trigger.cursor, trigger.nextAt, trigger.by, stamp, stamp).lastInsertRowid);
15882
+ });
15032
15883
  return Number(this.db.prepare("INSERT INTO flow_trigger (flow, kind, config_json, state, hook_hash, cursor, next_at, created_by, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)")
15033
15884
  .run(trigger.flow, trigger.kind, trigger.configJson, trigger.hookHash, trigger.cursor, trigger.nextAt, trigger.by, stamp, stamp).lastInsertRowid);
15034
15885
  }
@@ -15049,11 +15900,17 @@ export class Store {
15049
15900
  const row = this.db.prepare("SELECT t.* FROM flow_trigger t JOIN flow f ON f.id = t.flow WHERE t.hook_hash = ? AND t.state = 'active' AND f.state = 'active'").get(hash);
15050
15901
  return row === undefined ? null : readFlowTriggerRow(row);
15051
15902
  }
15052
- updateFlowTrigger(id, change, now) {
15903
+ updateFlowTrigger(id, change, now, by = "a person") {
15053
15904
  const trigger = this.getFlowTrigger(id);
15054
15905
  if (trigger === null)
15055
15906
  return;
15056
- this.db.prepare("UPDATE flow_trigger SET state = ?, cursor = ?, next_at = ?, last_at = ?, last_outcome = ?, failures = ?, hook_hash = ?, updated_at = ? WHERE id = ?").run(change.state ?? trigger.state, change.cursor === undefined ? trigger.cursor : change.cursor, change.nextAt === undefined ? trigger.nextAt : change.nextAt, change.lastAt ?? trigger.lastAt, change.lastOutcome ?? trigger.lastOutcome, change.failures ?? trigger.failures, change.hookHash === undefined ? trigger.hookHash : change.hookHash, now.toISOString(), id);
15907
+ // Pausing, resuming or removing a schedule ends the flow's "runs on its own" grant (flow-unattended.ts).
15908
+ const write = () => this.db.prepare("UPDATE flow_trigger SET state = ?, cursor = ?, next_at = ?, last_at = ?, last_outcome = ?, failures = ?, hook_hash = ?, updated_at = ? WHERE id = ?").run(change.state ?? trigger.state, change.cursor === undefined ? trigger.cursor : change.cursor, change.nextAt === undefined ? trigger.nextAt : change.nextAt, change.lastAt ?? trigger.lastAt, change.lastOutcome ?? trigger.lastOutcome, change.failures ?? trigger.failures, change.hookHash === undefined ? trigger.hookHash : change.hookHash, now.toISOString(), id);
15909
+ if (trigger.kind !== "schedule" || change.state === undefined || change.state === trigger.state) {
15910
+ write();
15911
+ return;
15912
+ }
15913
+ this.transact(() => { endUnattendedGrants(this, { flow: trigger.flow }, by, "schedule changed", now); write(); });
15057
15914
  }
15058
15915
  /** Whether a trigger already handled this outside thing (an issue, a run, a schedule slot). */
15059
15916
  flowTriggerSaw(trigger, key) {
@@ -15074,8 +15931,81 @@ export class Store {
15074
15931
  * Record what a trigger did with one outside thing: the card it made, or why it made none, and (a standing order's
15075
15932
  * firing) the task it filed. False when it was already recorded.
15076
15933
  */
15077
- recordFlowTriggerEvent(trigger, key, card, note, now, task = null) {
15078
- const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, task) VALUES (?, ?, ?, ?, ?, ?)").run(trigger, key, card, note, now.toISOString(), task);
15934
+ recordFlowTriggerEvent(trigger, key, card, note, now, task = null, onTime = false, ranScript = null) {
15935
+ const at = now.toISOString();
15936
+ const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, task, on_time, ran_script, state, accepted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
15937
+ .run(trigger, key, card, note, at, task, onTime ? 1 : 0, ranScript, card === null ? "skipped" : "accepted", card === null ? null : at);
15938
+ return Number(changes) === 1;
15939
+ }
15940
+ /**
15941
+ * v126: point a trigger at another saved secret ("" for none), and (when given) its new settings, in one write — only
15942
+ * from the secret and settings the caller read, so of two changes at once exactly one lands. False when it changed
15943
+ * meanwhile (or the trigger is gone): the caller then removes only the secret it wrote itself.
15944
+ */
15945
+ swapFlowHookCredential(id, from, to, configJson, now) {
15946
+ // The address moves with it when given: one write, from the very secret, settings and address the caller read.
15947
+ const { changes } = this.db.prepare(`UPDATE flow_trigger SET hook_credential = ?, hook_hash = COALESCE(?, hook_hash), config_json = COALESCE(?, config_json), updated_at = ?
15948
+ WHERE id = ? AND hook_credential = ? AND config_json = ? AND hook_hash IS ? AND state != 'removed'`)
15949
+ .run(to.credential, to.hookHash ?? null, configJson, now.toISOString(), id, from.credential, from.configJson, from.hookHash);
15950
+ return Number(changes) === 1;
15951
+ }
15952
+ /** v126: what a trigger did with one delivery, or null when it never received it. */
15953
+ flowReceipt(trigger, key) {
15954
+ const row = this.db.prepare("SELECT card, note, at, state, accepted_at, item_json FROM flow_trigger_event WHERE trigger = ? AND key = ?").get(trigger, key);
15955
+ return row === undefined ? null : readFlowReceipt(row);
15956
+ }
15957
+ /** v126: a delivery received but made no card for: ignored (not for this flow), or held until there's room (its
15958
+ * prepared item kept whole). False when the key was already received. */
15959
+ recordFlowReceipt(trigger, key, state, note, itemJson, now) {
15960
+ if ((state === "held") !== (itemJson !== null))
15961
+ throw new Error("a held receipt keeps its item, and only a held one");
15962
+ const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, state, item_json) VALUES (?, ?, NULL, ?, ?, ?, ?)")
15963
+ .run(trigger, key, note, now.toISOString(), state, itemJson);
15964
+ return Number(changes) === 1;
15965
+ }
15966
+ /** v126: a held delivery became its card. Only ever from held, in the card's own transaction: false (the caller rolls
15967
+ * the card back) when it is no longer held. */
15968
+ acceptHeldReceipt(trigger, key, card, now) {
15969
+ const { changes } = this.db.prepare("UPDATE flow_trigger_event SET state = 'accepted', card = ?, accepted_at = ?, item_json = NULL WHERE trigger = ? AND key = ? AND state = 'held'")
15970
+ .run(card, now.toISOString(), trigger, key);
15971
+ return Number(changes) === 1;
15972
+ }
15973
+ /** v126: the cards a trigger made since `since`, counted when each was made (a held delivery counts when it became
15974
+ * its card, not when it arrived). A receipt saved before v126 counts at its arrival. */
15975
+ flowCardsAcceptedSince(trigger, since) {
15976
+ const at = since.toISOString();
15977
+ const row = this.db.prepare(`SELECT
15978
+ (SELECT COUNT(*) FROM flow_trigger_event WHERE trigger = :trigger AND accepted_at > :at)
15979
+ + (SELECT COUNT(*) FROM flow_trigger_event WHERE trigger = :trigger AND accepted_at IS NULL AND state IS NULL AND card IS NOT NULL AND at > :at) AS n`).get({ trigger, at });
15980
+ return Number(row?.["n"] ?? 0);
15981
+ }
15982
+ /** v126: a trigger's held deliveries, oldest first. */
15983
+ heldFlowReceipts(trigger, limit) {
15984
+ return this.db.prepare("SELECT key, item_json, at FROM flow_trigger_event WHERE trigger = ? AND state = 'held' ORDER BY at, rowid LIMIT ?").all(trigger, limit)
15985
+ .map(row => ({ key: String(row["key"]), itemJson: String(row["item_json"]), at: String(row["at"]) }));
15986
+ }
15987
+ /** v126: a held delivery that can't become its card (its words no longer pass): left out, with why. */
15988
+ skipHeldReceipt(trigger, key, note) {
15989
+ const { changes } = this.db.prepare("UPDATE flow_trigger_event SET state = 'skipped', note = ?, item_json = NULL WHERE trigger = ? AND key = ? AND state = 'held'").run(note, trigger, key);
15990
+ return Number(changes) === 1;
15991
+ }
15992
+ /** v126: how many deliveries a trigger holds. */
15993
+ heldFlowReceiptCount(trigger) {
15994
+ return Number(this.db.prepare("SELECT COUNT(*) AS n FROM flow_trigger_event WHERE trigger = ? AND state = 'held'").get(trigger)?.["n"] ?? 0);
15995
+ }
15996
+ /** v126: one delivery turned away because the hold queue was full: counted, and since when, until a person clears it. */
15997
+ noteFlowDeliveryDropped(trigger, now) {
15998
+ this.db.prepare("UPDATE flow_trigger SET dropped_count = dropped_count + 1, dropped_since = COALESCE(dropped_since, ?) WHERE id = ?").run(now.toISOString(), trigger);
15999
+ }
16000
+ /** v126: a person has seen the turned-away deliveries. */
16001
+ clearFlowDeliveriesDropped(trigger) {
16002
+ this.db.prepare("UPDATE flow_trigger SET dropped_count = 0, dropped_since = NULL WHERE id = ?").run(trigger);
16003
+ }
16004
+ /** v126: whether this hour's "too many" note on a trigger is still to be written: true at most once an hour, claimed
16005
+ * atomically so two deliveries at once write it once. */
16006
+ claimFlowLimitNote(trigger, now) {
16007
+ const { changes } = this.db.prepare("UPDATE flow_trigger SET limit_noted_at = ? WHERE id = ? AND (limit_noted_at IS NULL OR limit_noted_at <= ?)")
16008
+ .run(now.toISOString(), trigger, new Date(now.getTime() - 3_600_000).toISOString());
15079
16009
  return Number(changes) === 1;
15080
16010
  }
15081
16011
  /** The last card a trigger made, if any. */
@@ -15174,6 +16104,25 @@ export class Store {
15174
16104
  this.db.prepare(`INSERT INTO run_tool (run, tools_json, created_at) VALUES (?, json_object('fence', json(?)), ?)
15175
16105
  ON CONFLICT (run) DO UPDATE SET tools_json = json_set(tools_json, '$.fence', json(?))`).run(run, JSON.stringify(fence), now.toISOString(), JSON.stringify(fence));
15176
16106
  }
16107
+ /** What one attempt's network was and what its proxy saw (egress.ts), kept beside its fence. */
16108
+ recordRunNetwork(run, network, now) {
16109
+ const json = JSON.stringify(network);
16110
+ this.db.prepare(`INSERT INTO run_tool (run, tools_json, created_at) VALUES (?, json_object('network', json(?)), ?)
16111
+ ON CONFLICT (run) DO UPDATE SET tools_json = json_set(tools_json, '$.network', json(?))`).run(run, json, now.toISOString(), json);
16112
+ }
16113
+ runNetwork(run) {
16114
+ const row = this.db.prepare("SELECT json_extract(tools_json, '$.network') AS network FROM run_tool WHERE run = ?").get(run);
16115
+ if (row === undefined || row["network"] === null)
16116
+ return null;
16117
+ let parsed;
16118
+ try {
16119
+ parsed = JSON.parse(String(row["network"]));
16120
+ }
16121
+ catch {
16122
+ return null;
16123
+ }
16124
+ return runNetworkFromValue(parsed);
16125
+ }
15177
16126
  runFence(run) {
15178
16127
  const row = this.db.prepare("SELECT json_extract(tools_json, '$.fence') AS fence FROM run_tool WHERE run = ?").get(run);
15179
16128
  if (row === undefined || row["fence"] === null)
@@ -15224,27 +16173,30 @@ export class Store {
15224
16173
  return rows.length;
15225
16174
  });
15226
16175
  }
16176
+ /** `pending`: a queued message (personal or team) that waits for its turn — out of the model's history until the
16177
+ * admission that binds it clears the flag in the same statement (v128). */
15227
16178
  appendLeadMessage(message, now) {
15228
16179
  const inserted = this.db
15229
- .prepare("INSERT INTO lead_message (thread, turn, role, text, activity, created_at) VALUES (?, ?, ?, ?, ?, ?)")
15230
- .run(message.thread, message.turn, message.role, message.text, message.activity ?? null, now.toISOString());
16180
+ .prepare("INSERT INTO lead_message (thread, turn, role, text, activity, created_at, pending) VALUES (?, ?, ?, ?, ?, ?, ?)")
16181
+ .run(message.thread, message.turn, message.role, message.text, message.activity ?? null, now.toISOString(), message.pending === true ? 1 : 0);
15231
16182
  this.db.prepare("UPDATE lead_thread SET last_turn_at = ? WHERE id = ?").run(now.toISOString(), message.thread);
15232
16183
  return Number(inserted.lastInsertRowid);
15233
16184
  }
16185
+ /** The model's history: the last `limit` ADMITTED messages, oldest first — a message still waiting for its turn (or
16186
+ * removed, expired or refused while waiting) is never read back to the lead. An index range scan whatever the backlog
16187
+ * (lead_message_history). People see the whole thread through listLeadMessages. */
16188
+ listLeadHistory(thread, limit) {
16189
+ return this.db
16190
+ .prepare("SELECT * FROM (SELECT * FROM lead_message WHERE thread = ? AND pending = 0 ORDER BY id DESC LIMIT ?) ORDER BY id ASC")
16191
+ .all(thread, limit)
16192
+ .map(readLeadMessageRow);
16193
+ }
15234
16194
  /** The last `limit` messages, oldest first. */
15235
16195
  listLeadMessages(thread, limit) {
15236
16196
  return this.db
15237
16197
  .prepare("SELECT * FROM (SELECT * FROM lead_message WHERE thread = ? ORDER BY id DESC LIMIT ?) ORDER BY id ASC")
15238
16198
  .all(thread, limit)
15239
- .map(row => ({
15240
- id: Number(row["id"]),
15241
- thread: Number(row["thread"]),
15242
- turn: row["turn"] === null ? null : Number(row["turn"]),
15243
- role: String(row["role"]),
15244
- text: String(row["text"]),
15245
- activity: row["activity"] === null ? null : String(row["activity"]),
15246
- createdAt: String(row["created_at"]),
15247
- }));
16199
+ .map(readLeadMessageRow);
15248
16200
  }
15249
16201
  /** A proposal is inert (`drafting`) until its turn finalizes. */
15250
16202
  draftLeadProposal(proposal, now) {
@@ -15425,6 +16377,18 @@ export class Store {
15425
16377
  const liveChat = this.db.prepare("SELECT 1 FROM chat_turn WHERE approver=? AND state IN ('queued','running') AND mate_turn IS NULL LIMIT 1").get(args.approver);
15426
16378
  if (live !== undefined || liveChat !== undefined)
15427
16379
  return { ok: false, reason: "concurrent" };
16380
+ // v128: while something of this person's waits in the personal queue (or is claimed but not yet bound), the only
16381
+ // personal turn admitted is the one carrying the exact claim of the backlog's oldest message — a follow pass, a
16382
+ // channel turn or another send waits behind it. Team turns are counted per thread and are not held.
16383
+ if (!teamBinding) {
16384
+ const oldest = this.db.prepare("SELECT message, runner, generation, status FROM lead_queue WHERE approver = ? AND (status = 'queued' OR (status = 'running' AND turn_id IS NULL)) ORDER BY message LIMIT 1").get(args.approver);
16385
+ if (oldest !== undefined) {
16386
+ const claim = args.queueClaim;
16387
+ const own = claim !== undefined && Number(oldest["message"]) === claim.message && oldest["status"] === "running" && oldest["runner"] === claim.runner && Number(oldest["generation"]) === claim.generation;
16388
+ if (!own)
16389
+ return { ok: false, reason: "backlog" };
16390
+ }
16391
+ }
15428
16392
  if (this.chatTurnsToday(args.approver, now) >= args.dailyTurns)
15429
16393
  return { ok: false, reason: "daily-cap" };
15430
16394
  if (session.endedAt !== null) {
@@ -15920,7 +16884,7 @@ export class Store {
15920
16884
  handoff: row["handoff"] === null ? null : String(row["handoff"]),
15921
16885
  costUsd: row["cost_usd"] === null ? null : Number(row["cost_usd"]),
15922
16886
  provider: row["provider"] === null || row["provider"] === undefined ? null : String(row["provider"]),
15923
- authMode: row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key"
16887
+ authMode: row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key" || row["auth_mode"] === "agent-login"
15924
16888
  ? row["auth_mode"]
15925
16889
  : null,
15926
16890
  ranMinutes: row["started_at"] === null || row["finished_at"] === null || row["started_at"] === undefined
@@ -15977,7 +16941,7 @@ export class Store {
15977
16941
  ? null
15978
16942
  : Math.max(1, Math.round((new Date(String(row["finished_at"])).getTime() - new Date(String(row["started_at"])).getTime()) / 60_000)),
15979
16943
  costUsd: row["cost_usd"] === null ? null : Number(row["cost_usd"]),
15980
- authMode: row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key"
16944
+ authMode: row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key" || row["auth_mode"] === "agent-login"
15981
16945
  ? row["auth_mode"]
15982
16946
  : null,
15983
16947
  prNumber: row["pr_number"] === null || row["pr_number"] === undefined ? null : Number(row["pr_number"]),
@@ -16799,6 +17763,11 @@ export class Store {
16799
17763
  return Number(this.db.prepare("INSERT INTO run_process (run,host,process_group,observed_at,boot_id) VALUES (?,?,?,?,?)")
16800
17764
  .run(runId, hostname(), group ? 1 : 0, now.toISOString(), currentBootId()).lastInsertRowid);
16801
17765
  }
17766
+ /** The run's process witnesses not yet proved exited — after first saving whatever exit evidence is available. */
17767
+ openRunProcessCount(runId, now) {
17768
+ this.recordRunProcessExits(runId, now);
17769
+ return Number(this.db.prepare("SELECT COUNT(*) AS n FROM run_process WHERE run = ? AND exited_at IS NULL").get(runId).n);
17770
+ }
16802
17771
  /** Save positive OS exit evidence while it is available, before a numeric
16803
17772
  * PID can be reused. Completion, missing ancestry and age prove nothing.
16804
17773
  * Existing rows stay intact; only a previously unknown exit is recorded.
@@ -17249,6 +18218,45 @@ export class Store {
17249
18218
  return counts;
17250
18219
  }
17251
18220
  /** A project's build concurrency changed (project-concurrency.ts): the ledger keeps before → after. */
18221
+ /** A project's network (egress.ts): its saved setting, or the default when none is saved or it doesn't read. */
18222
+ projectNetwork(repo) {
18223
+ const row = this.db.prepare("SELECT network_json FROM project_network WHERE repo = ?").get(repo);
18224
+ if (row === undefined)
18225
+ return PROJECT_NETWORK_DEFAULT;
18226
+ let parsed = null;
18227
+ try {
18228
+ parsed = JSON.parse(String(row["network_json"]));
18229
+ }
18230
+ catch {
18231
+ parsed = null;
18232
+ }
18233
+ // A damaged row reads as the narrowest setting, never a wider one.
18234
+ return projectNetworkFromValue(parsed) ?? { mode: "allowlist", hosts: [], loopback: "proxy-only" };
18235
+ }
18236
+ /** An approver changed a project's network: saved, and in the ledger as before → after. */
18237
+ setProjectNetwork(repo, network, by, now) {
18238
+ return this.transact(() => {
18239
+ const before = this.projectNetwork(repo);
18240
+ const json = JSON.stringify({ mode: network.mode, hosts: [...new Set(network.hosts)].sort(), loopback: network.loopback });
18241
+ this.db.prepare(`INSERT INTO project_network (repo, network_json, updated_at, updated_by) VALUES (?, ?, ?, ?)
18242
+ ON CONFLICT (repo) DO UPDATE SET network_json = excluded.network_json, updated_at = excluded.updated_at, updated_by = excluded.updated_by`).run(repo, json, now.toISOString(), by);
18243
+ const after = this.projectNetwork(repo);
18244
+ const words = (one) => `${one.mode}${one.hosts.length === 0 ? "" : ` +${one.hosts.join(",")}`}${one.loopback === "proxy-only" ? " no-local" : ""}`;
18245
+ this.recordPolicy(by, repo, "network", words(before), words(after), now);
18246
+ return { before, after };
18247
+ });
18248
+ }
18249
+ /** Whether this installation lets an agent run where no OS confinement exists (off unless an operator said so). */
18250
+ unfencedAgentsAllowed() {
18251
+ return Number(this.db.prepare("SELECT unfenced_allowed FROM confinement_setting WHERE id = 1").get()?.["unfenced_allowed"] ?? 0) === 1;
18252
+ }
18253
+ setUnfencedAgentsAllowed(allowed, by, now) {
18254
+ this.transact(() => {
18255
+ const before = this.unfencedAgentsAllowed();
18256
+ this.db.prepare("UPDATE confinement_setting SET unfenced_allowed = ?, updated_at = ?, updated_by = ? WHERE id = 1").run(allowed ? 1 : 0, now.toISOString(), by);
18257
+ this.recordPolicy(by, null, "unconfined agents", before ? "allowed" : "refused", allowed ? "allowed" : "refused", now);
18258
+ });
18259
+ }
17252
18260
  recordProjectConcurrency(by, repo, before, after, now) {
17253
18261
  this.recordPolicy(by, repo, "builds at once", String(before), String(after), now);
17254
18262
  }
@@ -17273,6 +18281,7 @@ export class Store {
17273
18281
  markTelegramUpdateApplied(...args) { return this.telegramChat().markUpdateApplied(...args); }
17274
18282
  // ---- the conversation queue (v62) ------------------------------------------
17275
18283
  enqueueTelegramConversation(...args) { return this.telegramChat().enqueueMessage(...args); }
18284
+ telegramMessagesWaiting(binding) { return this.telegramChat().messagesWaiting(binding); }
17276
18285
  telegramConversationWaitingOn(...args) { return this.telegramChat().messageWaitingOn(...args); }
17277
18286
  getTelegramConversation(...args) { return this.telegramChat().getMessage(...args); }
17278
18287
  listTelegramConversations(...args) { return this.telegramChat().listMessages(...args); }
@@ -17307,6 +18316,7 @@ export class Store {
17307
18316
  this.telegramChat().deferUntil(botId, until);
17308
18317
  }
17309
18318
  telegramDeliveryProblem(...args) { return this.telegramChat().deliveryProblem(...args); }
18319
+ telegramWatchFence(...args) { return this.telegramChat().watchFence(...args); }
17310
18320
  recordTelegramMessage(...args) { return this.telegramChat().recordMessage(...args); }
17311
18321
  telegramMessageOf(...args) { return this.telegramChat().messageOf(...args); }
17312
18322
  finalizeTelegramDelivery(...args) { return this.telegramChat().finalizeDelivery(...args); }
@@ -17899,7 +18909,7 @@ export class Store {
17899
18909
  * the subscription live under its enrolled generation, the notification
17900
18910
  * unresolved and still stamped. Null = do not transmit.
17901
18911
  */
17902
- pushSendFence(id, owner, generation) {
18912
+ pushSendFence(id, owner, generation, now = new Date()) {
17903
18913
  const pair = this.db
17904
18914
  .prepare(`SELECT * FROM push_delivery
17905
18915
  WHERE id = ? AND state = 'claimed' AND claim_owner = ? AND claim_generation = ?`)
@@ -17919,7 +18929,20 @@ export class Store {
17919
18929
  .get(Number(pair["notification"]));
17920
18930
  if (notification === undefined)
17921
18931
  return null;
17922
- return { ...readPushPair(pair), subscriptionRow: readPushSubscription(subscription), notificationRow: readNotification(notification) };
18932
+ // The watch, again at the last moment (cockpit §3): a person who opened the agent since the claim holds the ping
18933
+ // (back to pending, no attempt spent) or, for news they have now seen, retires it.
18934
+ const row = readNotification(notification), approverName = String(subscription["approver"]);
18935
+ const watched = watchedPing(this, row, approverName, now);
18936
+ if (watched === "skip") {
18937
+ this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = 'seen', claim_owner = NULL WHERE id = ? AND state = 'claimed' AND claim_owner = ? AND claim_generation = ?").run(id, owner, generation);
18938
+ return null;
18939
+ }
18940
+ if (watched !== "send") {
18941
+ this.db.prepare(`UPDATE push_delivery SET state = 'pending', claim_owner = NULL, claim_expires_at = NULL, next_attempt_at = ?
18942
+ WHERE id = ? AND state = 'claimed' AND claim_owner = ? AND claim_generation = ?`).run(watched.hold, id, owner, generation);
18943
+ return null;
18944
+ }
18945
+ return { ...readPushPair(pair), subscriptionRow: readPushSubscription(subscription), notificationRow: row };
17923
18946
  }
17924
18947
  /** Settle a pair, conditionally on owner + generation (finding 25). */
17925
18948
  settlePushPair(id, owner, generation, outcome, now) {
@@ -18012,6 +19035,87 @@ export class Store {
18012
19035
  .run(idempotencyKey, operation, JSON.stringify(result ?? null), actor, at.toISOString());
18013
19036
  return result;
18014
19037
  }
19038
+ /** A kept web session's changes: never inserts, so a session ended elsewhere is not brought back by a cached copy.
19039
+ * False when it is gone. (Kept last in the class: the freshness tripwire pins method positions.) */
19040
+ updateWebSession(session) {
19041
+ return Number(this.db.prepare("UPDATE web_session SET last_seen = ?, project = ?, project_revision = ?, sso_at = ? WHERE id_hash = ?")
19042
+ .run(session.lastSeen, session.project, session.projectRevision, session.ssoAt, session.idHash).changes) > 0;
19043
+ }
19044
+ // ---- lead sweeps (docs/plans/lead-fan-out.md). Kept last in the class: the freshness tripwire pins method positions.
19045
+ /** A new sweep record (inside the caller's filing transaction). */
19046
+ createSweep(input, now) {
19047
+ const row = this.db.prepare("INSERT INTO lead_sweep (owner, thread, repo, title, brief, max_parallel, proposal, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
19048
+ .run(input.owner, input.thread, input.repo, input.title, input.brief, input.maxParallel, input.proposal, now.toISOString());
19049
+ return Number(row.lastInsertRowid);
19050
+ }
19051
+ /** Link one filed child (its family root) to its sweep, in order. */
19052
+ addSweepTask(sweep, rootTask, ordinal, label) {
19053
+ this.db.prepare("INSERT INTO lead_sweep_task (sweep, root_task, ordinal, label) VALUES (?, ?, ?, ?)").run(sweep, rootTask, ordinal, label);
19054
+ }
19055
+ getSweep(id) {
19056
+ const row = this.db.prepare("SELECT * FROM lead_sweep WHERE id = ?").get(id);
19057
+ if (row === undefined)
19058
+ return null;
19059
+ return { id: Number(row["id"]), owner: String(row["owner"]), thread: row["thread"] === null ? null : Number(row["thread"]), repo: String(row["repo"]), title: String(row["title"]),
19060
+ brief: String(row["brief"]), maxParallel: Number(row["max_parallel"]), proposal: row["proposal"] === null ? null : Number(row["proposal"]), createdAt: String(row["created_at"]) };
19061
+ }
19062
+ /** The sweep a proposal filed, if it did (a replayed confirm finds it). */
19063
+ sweepOfProposal(proposal) {
19064
+ const row = this.db.prepare("SELECT id FROM lead_sweep WHERE proposal = ?").get(proposal);
19065
+ return row === undefined ? null : Number(row["id"]);
19066
+ }
19067
+ /** A sweep's children, in order: each one's family root and its label. */
19068
+ sweepTasks(sweep) {
19069
+ return this.db.prepare("SELECT root_task, ordinal, label FROM lead_sweep_task WHERE sweep = ? ORDER BY ordinal").all(sweep)
19070
+ .map(row => ({ rootTask: String(row["root_task"]), ordinal: Number(row["ordinal"]), label: String(row["label"]) }));
19071
+ }
19072
+ /** The sweep a task belongs to through its family root (a revision of a child belongs too), or null. */
19073
+ sweepOfTask(taskId) {
19074
+ const { chain } = this.revisionAncestryStatus(taskId);
19075
+ const root = chain.at(-1) ?? taskId;
19076
+ const row = this.db.prepare("SELECT s.id, s.max_parallel FROM lead_sweep_task t JOIN lead_sweep s ON s.id = t.sweep WHERE t.root_task = ?").get(root);
19077
+ return row === undefined ? null : { id: Number(row["id"]), maxParallel: Number(row["max_parallel"]) };
19078
+ }
19079
+ /**
19080
+ * How many of a sweep's families hold a live claim now (the latest lease of any task in the family, unreleased and
19081
+ * unexpired, across every runner) — claimed-but-not-started counts. `except` leaves one task_ref out (the one being
19082
+ * claimed). Read inside acquire's own write transaction, so two runners can't both take the last slot.
19083
+ */
19084
+ sweepLiveClaims(sweep, stamp, except = null) {
19085
+ const row = this.db.prepare(`WITH RECURSIVE fam(root, id, depth) AS (
19086
+ SELECT t.root_task, t.root_task, 0 FROM lead_sweep_task t WHERE t.sweep = ?
19087
+ UNION SELECT fam.root, r.external_id, fam.depth + 1 FROM task_ref r JOIN fam ON r.revision_of = fam.id WHERE r.backend = ? AND fam.depth < 64),
19088
+ refs AS (SELECT DISTINCT fam.root, r.id AS ref FROM fam JOIN task_ref r ON r.backend = ? AND r.external_id = fam.id),
19089
+ latest AS (SELECT refs.root, c.released_at, c.expires_at FROM refs JOIN claim c ON c.task_ref = refs.ref
19090
+ WHERE refs.ref IS NOT ? AND c.lease_generation = (SELECT MAX(lease_generation) FROM claim WHERE task_ref = refs.ref))
19091
+ SELECT COUNT(DISTINCT root) AS n FROM latest WHERE released_at IS NULL AND expires_at > ?`).get(sweep, BUILT_IN, BUILT_IN, except, stamp);
19092
+ return Number(row?.["n"] ?? 0);
19093
+ }
19094
+ /** Whether a request of this lead turn ended with its cost unknown and that cost is still unconfirmed — the turn is
19095
+ * what keeps chat paused on its credential. Once confirmed, it no longer is. */
19096
+ leadTurnCostUnconfirmed(leadTurn) {
19097
+ return this.db.prepare("SELECT 1 AS hit FROM chat_turn WHERE mate_turn = ? AND unknown_spend = 1 AND acknowledged_at IS NULL LIMIT 1").get(leadTurn) !== undefined;
19098
+ }
19099
+ /** The lead turn a chat step belongs to (a lead reply's request), or null for a standalone chat turn. */
19100
+ leadTurnOfStep(step) {
19101
+ const row = this.db.prepare("SELECT mate_turn FROM chat_turn WHERE id = ?").get(step);
19102
+ return row === undefined || row["mate_turn"] === null ? null : this.getLeadTurn(Number(row["mate_turn"]));
19103
+ }
19104
+ /** A waiting message that will never run keeps nothing of what it said (v128 lead queue): its words, the context it was
19105
+ * sent with, and any copy the memory search made of it. Only while it is still pending — a message a turn took stays. */
19106
+ /** Whether a send of this session reached the plane: the engine took it, or the lead queue holds it (waiting, run,
19107
+ * removed or expired — any of those means the composer's draft was accepted and must not be sent again). */
19108
+ leadRequestReceived(session, request) {
19109
+ return this.leadRequestReceipt(session, request) !== null
19110
+ || this.db.prepare("SELECT 1 FROM lead_queue WHERE session = ? AND request_id = ?").get(session, request) !== undefined;
19111
+ }
19112
+ forgetQueuedLeadMessage(message) {
19113
+ const blanked = this.db.prepare("UPDATE lead_message SET text = '' WHERE id = ? AND pending = 1").run(message);
19114
+ if (Number(blanked.changes) !== 1)
19115
+ return;
19116
+ this.db.prepare("UPDATE lead_queue SET context_json = NULL WHERE message = ?").run(message);
19117
+ this.db.prepare("DELETE FROM memory_search WHERE kind = 'conversation' AND ref = ?").run(`message:${message}`);
19118
+ }
18015
19119
  }
18016
19120
  /** A stored count or id that must be a whole, non-negative number — a
18017
19121
  * NULL, an empty string, a fraction, or text reads as null (v48 authority repair). */
@@ -18059,6 +19163,7 @@ function readRun(row) {
18059
19163
  : String(row["provider_started_at"]),
18060
19164
  tokensIn: row["tokens_in"] === null || row["tokens_in"] === undefined ? null : Number(row["tokens_in"]),
18061
19165
  tokensOut: row["tokens_out"] === null || row["tokens_out"] === undefined ? null : Number(row["tokens_out"]),
19166
+ agentTurns: row["agent_turns"] === null || row["agent_turns"] === undefined ? null : Number(row["agent_turns"]),
18062
19167
  costUsd: row["cost_usd"] === null || row["cost_usd"] === undefined ? null : Number(row["cost_usd"]),
18063
19168
  headRevision: row["head_revision"] === null || row["head_revision"] === undefined
18064
19169
  ? null
@@ -18070,7 +19175,7 @@ function readRun(row) {
18070
19175
  ? String(row["phase"])
18071
19176
  : null,
18072
19177
  // An auth mode is exactly one of the two words — anything else reads as unknown.
18073
- authMode: row["auth_mode"] === "api-key" ? "api-key" : row["auth_mode"] === "subscription" ? "subscription" : null,
19178
+ authMode: row["auth_mode"] === "api-key" ? "api-key" : row["auth_mode"] === "subscription" ? "subscription" : row["auth_mode"] === "agent-login" ? "agent-login" : null,
18074
19179
  terminalClass: row["terminal_class"] === null || row["terminal_class"] === undefined
18075
19180
  ? null
18076
19181
  : String(row["terminal_class"]),
@@ -18644,6 +19749,8 @@ export function permissionModeOfProfile(profile) {
18644
19749
  return profile.permissionArgv === "bypassPermissions" ? "bypassPermissions" : "auto";
18645
19750
  if (profile.provider === "gemini")
18646
19751
  return profile.approvalArgv === "yolo" ? "bypassPermissions" : "auto";
19752
+ if (isAcpProfile(profile))
19753
+ return "bypassPermissions";
18647
19754
  return profile.sandboxMode === "danger-full-access" ? "bypassPermissions" : "auto";
18648
19755
  }
18649
19756
  export function revisionTermsOf(sourceRef, sourceScope, coverage, permissionDefault, qualityDefault) {
@@ -18909,3 +20016,18 @@ function readGrant(row) {
18909
20016
  export function verifiedAuthor(name) {
18910
20017
  return name;
18911
20018
  }
20019
+ function readAcpCheck(row) {
20020
+ const text = (key) => row[key] == null ? null : String(row[key]);
20021
+ const num = (key) => row[key] == null ? null : Number(row[key]);
20022
+ const state = String(row["state"]);
20023
+ return {
20024
+ id: Number(row["id"]), provider: String(row["provider"]), runner: String(row["runner"]), model: String(row["model"]),
20025
+ conformanceVersion: Number(row["conformance_version"]), requestedBy: String(row["requested_by"]), requestedAt: String(row["requested_at"]),
20026
+ expiresAt: String(row["expires_at"]),
20027
+ state: state === "running" || state === "passed" || state === "failed" || state === "expired" ? state : "pending",
20028
+ watch: row["watch_repo"] == null || row["watch_owner"] == null ? null : { repo: String(row["watch_repo"]), owner: String(row["watch_owner"]) },
20029
+ taskRef: num("task_ref"), launchJson: text("launch_json"), leaseId: text("lease_id"), stepsUsed: Number(row["steps_used"]), step1Run: num("step1_run"), step2Run: num("step2_run"),
20030
+ step1Passed: Number(row["step1_passed"]) === 1, step2Passed: Number(row["step2_passed"]) === 1, result: text("result"), finishedAt: text("finished_at"),
20031
+ settledAt: text("settled_at"),
20032
+ };
20033
+ }