toolroll 0.9.63 → 0.9.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +33 -5
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +117 -2
  16. package/dist/agent-fence.js +439 -12
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +39 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/batch-checks.js +3 -2
  32. package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
  33. package/dist/browser/terminal.css +37 -0
  34. package/dist/browser/terminal.js +228 -0
  35. package/dist/browser/workspace.css +2 -2
  36. package/dist/browser/workspace.js +42 -38
  37. package/dist/browser-shell.js +6 -3
  38. package/dist/browser-workspace.d.ts +235 -1
  39. package/dist/browser-workspace.js +60 -0
  40. package/dist/builder.d.ts +10 -1
  41. package/dist/builder.js +87 -24
  42. package/dist/chat-actions.d.ts +13 -1
  43. package/dist/chat-actions.js +197 -15
  44. package/dist/chat-ask.d.ts +4 -0
  45. package/dist/chat-ask.js +14 -0
  46. package/dist/chat-channel.js +20 -0
  47. package/dist/chat-continuity.js +5 -4
  48. package/dist/chat-decide.d.ts +0 -14
  49. package/dist/chat-decide.js +4 -1
  50. package/dist/chat-delivery.d.ts +9 -0
  51. package/dist/chat-delivery.js +45 -6
  52. package/dist/chat-messages.d.ts +8 -0
  53. package/dist/chat-messages.js +36 -4
  54. package/dist/check-levels-ui.js +2 -2
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +19 -0
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +4 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +21 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/console-api.d.ts +13 -4
  71. package/dist/contracts/console-api.js +13 -4
  72. package/dist/contracts/flow-card.d.ts +4 -0
  73. package/dist/contracts/flow-card.js +3 -0
  74. package/dist/contracts/flow.d.ts +46 -0
  75. package/dist/contracts/flow.js +11 -2
  76. package/dist/contracts/lead-tools.d.ts +92 -0
  77. package/dist/contracts/lead-tools.js +13 -0
  78. package/dist/contracts/provider.d.ts +11 -1
  79. package/dist/contracts/provider.js +1 -1
  80. package/dist/contracts/route.d.ts +76 -1
  81. package/dist/contracts/route.js +1 -1
  82. package/dist/contracts/store-json.d.ts +2 -0
  83. package/dist/control-setup.d.ts +1 -1
  84. package/dist/control-ui.js +2 -2
  85. package/dist/converse.d.ts +16 -0
  86. package/dist/converse.js +32 -4
  87. package/dist/demo-chat.js +4 -4
  88. package/dist/demo.js +2 -2
  89. package/dist/desktop-update-gate.js +6 -0
  90. package/dist/desktop-update.d.ts +1 -1
  91. package/dist/discord-chat.js +4 -1
  92. package/dist/doctor.js +1 -1
  93. package/dist/egress-bridge.d.ts +17 -0
  94. package/dist/egress-bridge.js +111 -0
  95. package/dist/egress.d.ts +119 -0
  96. package/dist/egress.js +423 -0
  97. package/dist/exec.d.ts +78 -0
  98. package/dist/exec.js +367 -9
  99. package/dist/export-ui.js +2 -2
  100. package/dist/flow-engine.js +24 -5
  101. package/dist/flow-gallery-ui.js +2 -2
  102. package/dist/flow-gallery.js +27 -8
  103. package/dist/flow-share.js +16 -5
  104. package/dist/flow-triggers.d.ts +77 -8
  105. package/dist/flow-triggers.js +517 -70
  106. package/dist/flows-cli.js +8 -2
  107. package/dist/flows-ui.d.ts +0 -2
  108. package/dist/flows-ui.js +52 -4
  109. package/dist/flows.d.ts +4 -0
  110. package/dist/flows.js +9 -1
  111. package/dist/html.d.ts +9 -0
  112. package/dist/html.js +11 -0
  113. package/dist/invoke.d.ts +35 -0
  114. package/dist/invoke.js +347 -28
  115. package/dist/keys.d.ts +18 -14
  116. package/dist/keys.js +5 -0
  117. package/dist/lead-capabilities.js +3 -2
  118. package/dist/lead-contract.js +2 -0
  119. package/dist/lead-doors.d.ts +2 -0
  120. package/dist/lead-doors.js +7 -0
  121. package/dist/lead-follow.d.ts +2 -0
  122. package/dist/lead-follow.js +58 -2
  123. package/dist/lead-progress.js +1 -0
  124. package/dist/lead-queue-runtime.d.ts +46 -0
  125. package/dist/lead-queue-runtime.js +191 -0
  126. package/dist/lead-queue.d.ts +104 -0
  127. package/dist/lead-queue.js +187 -0
  128. package/dist/lead-sweep.d.ts +67 -0
  129. package/dist/lead-sweep.js +99 -0
  130. package/dist/lead-tools.d.ts +22 -0
  131. package/dist/lead-tools.js +152 -1
  132. package/dist/lead.d.ts +13 -5
  133. package/dist/lead.js +89 -29
  134. package/dist/limits-ui.d.ts +8 -1
  135. package/dist/limits-ui.js +26 -0
  136. package/dist/live-bus.d.ts +8 -0
  137. package/dist/live-bus.js +40 -4
  138. package/dist/live-detail.d.ts +27 -0
  139. package/dist/live-detail.js +57 -0
  140. package/dist/live-notes.d.ts +20 -0
  141. package/dist/live-notes.js +47 -0
  142. package/dist/live-records.d.ts +140 -0
  143. package/dist/live-records.js +935 -0
  144. package/dist/live-terminal.d.ts +269 -0
  145. package/dist/live-terminal.js +686 -0
  146. package/dist/live.d.ts +38 -27
  147. package/dist/live.js +142 -124
  148. package/dist/mcp-oauth.d.ts +6 -3
  149. package/dist/mcp-oauth.js +8 -4
  150. package/dist/memory-pass.js +2 -2
  151. package/dist/mobile-viewport.d.ts +5 -1
  152. package/dist/mobile-viewport.js +18 -2
  153. package/dist/model-catalog.js +4 -0
  154. package/dist/monitoring-ui.js +2 -2
  155. package/dist/needs-you.d.ts +1 -1
  156. package/dist/needs-you.js +11 -4
  157. package/dist/operate.d.ts +4 -0
  158. package/dist/operate.js +335 -19
  159. package/dist/passwordless.d.ts +23 -0
  160. package/dist/passwordless.js +26 -0
  161. package/dist/peek-cli.d.ts +12 -4
  162. package/dist/peek-cli.js +19 -11
  163. package/dist/phase-routing.js +10 -3
  164. package/dist/planner-source.d.ts +3 -0
  165. package/dist/planner-source.js +14 -0
  166. package/dist/planner.d.ts +2 -1
  167. package/dist/planner.js +7 -1
  168. package/dist/policy-ui.js +2 -2
  169. package/dist/policy.d.ts +1 -20
  170. package/dist/policy.js +29 -2
  171. package/dist/project-delete-ui.d.ts +18 -0
  172. package/dist/project-delete-ui.js +60 -9
  173. package/dist/project-delete.js +2 -2
  174. package/dist/project-memory.d.ts +3 -1
  175. package/dist/project-memory.js +57 -25
  176. package/dist/project-tools.d.ts +5 -3
  177. package/dist/project-tools.js +12 -9
  178. package/dist/provider-auth.d.ts +4 -3
  179. package/dist/provider-auth.js +5 -4
  180. package/dist/provider-connection.d.ts +2 -1
  181. package/dist/provider-connection.js +5 -0
  182. package/dist/provider.d.ts +25 -2
  183. package/dist/provider.js +63 -9
  184. package/dist/pull-request-ui.js +2 -2
  185. package/dist/push.js +1 -1
  186. package/dist/quick-start.d.ts +24 -0
  187. package/dist/quick-start.js +127 -0
  188. package/dist/release-adapters.d.ts +6 -0
  189. package/dist/release-adapters.js +70 -3
  190. package/dist/release.d.ts +10 -1
  191. package/dist/release.js +34 -15
  192. package/dist/request-budget-ui.js +2 -2
  193. package/dist/result-follow-ups.js +3 -2
  194. package/dist/result-review.d.ts +1 -1
  195. package/dist/result-review.js +9 -3
  196. package/dist/retention-ui.js +2 -2
  197. package/dist/retention.js +10 -1
  198. package/dist/run-efficiency.d.ts +73 -0
  199. package/dist/run-efficiency.js +228 -0
  200. package/dist/runner.d.ts +5 -1
  201. package/dist/runner.js +22 -1
  202. package/dist/scope.d.ts +183 -5
  203. package/dist/scope.js +256 -31
  204. package/dist/scout-net.d.ts +4 -1
  205. package/dist/scout-net.js +37 -12
  206. package/dist/scout.d.ts +20 -6
  207. package/dist/scout.js +27 -3
  208. package/dist/serve.js +248 -40
  209. package/dist/server/chat.d.ts +1 -1
  210. package/dist/server/chat.js +122 -31
  211. package/dist/server/chrome.d.ts +16 -2
  212. package/dist/server/chrome.js +63 -11
  213. package/dist/server/console.css +107 -42
  214. package/dist/server/flows.js +38 -8
  215. package/dist/server/guards.d.ts +1 -0
  216. package/dist/server/guards.js +23 -9
  217. package/dist/server/http.d.ts +7 -0
  218. package/dist/server/http.js +3 -2
  219. package/dist/server/live.d.ts +34 -1
  220. package/dist/server/live.js +39 -17
  221. package/dist/server/pages-assets.js +5 -0
  222. package/dist/server/pages.js +90 -13
  223. package/dist/server/people-tokens.d.ts +3 -0
  224. package/dist/server/people-tokens.js +55 -17
  225. package/dist/server/remote-hooks.js +12 -6
  226. package/dist/server/remote.d.ts +2 -1
  227. package/dist/server/remote.js +14 -5
  228. package/dist/server/render-chat.d.ts +12 -3
  229. package/dist/server/render-chat.js +47 -16
  230. package/dist/server/render-pages.d.ts +32 -4
  231. package/dist/server/render-pages.js +83 -13
  232. package/dist/server/render-people.d.ts +3 -2
  233. package/dist/server/render-people.js +3 -2
  234. package/dist/server/render-results.d.ts +18 -2
  235. package/dist/server/render-results.js +37 -28
  236. package/dist/server/render-settings.js +6 -5
  237. package/dist/server/render-tasks.d.ts +35 -10
  238. package/dist/server/render-tasks.js +111 -27
  239. package/dist/server/request-context.d.ts +2 -0
  240. package/dist/server/request-context.js +2 -1
  241. package/dist/server/route-table.d.ts +3 -0
  242. package/dist/server/route-table.js +47 -5
  243. package/dist/server/runtime.d.ts +33 -2
  244. package/dist/server/session.d.ts +25 -1
  245. package/dist/server/session.js +18 -4
  246. package/dist/server/settings.js +142 -10
  247. package/dist/server/sweeps.d.ts +4 -0
  248. package/dist/server/sweeps.js +58 -0
  249. package/dist/server/task-views.d.ts +2 -1
  250. package/dist/server/task-views.js +96 -6
  251. package/dist/server/tasks.js +327 -78
  252. package/dist/server/terminals.d.ts +31 -0
  253. package/dist/server/terminals.js +365 -0
  254. package/dist/setup-guide.js +3 -2
  255. package/dist/slack-chat.js +4 -1
  256. package/dist/spend-ui.js +3 -3
  257. package/dist/spend.js +2 -1
  258. package/dist/steer-capabilities.d.ts +32 -0
  259. package/dist/steer-capabilities.js +60 -0
  260. package/dist/steer-controls.d.ts +14 -0
  261. package/dist/steer-controls.js +36 -0
  262. package/dist/steer-status.js +2 -2
  263. package/dist/storage-ui.js +4 -4
  264. package/dist/store-baseline.d.ts +1 -1
  265. package/dist/store-baseline.js +1 -1
  266. package/dist/store.d.ts +440 -15
  267. package/dist/store.js +1072 -52
  268. package/dist/subagent-desk.js +1 -1
  269. package/dist/summary.js +3 -0
  270. package/dist/task-source.d.ts +58 -0
  271. package/dist/task-source.js +93 -0
  272. package/dist/team-leads.js +1 -1
  273. package/dist/teams-chat.js +4 -1
  274. package/dist/teardown-git.d.ts +22 -0
  275. package/dist/teardown-git.js +136 -2
  276. package/dist/telegram-lead.d.ts +3 -0
  277. package/dist/telegram-lead.js +44 -7
  278. package/dist/telegram.d.ts +11 -2
  279. package/dist/telegram.js +51 -17
  280. package/dist/terminal-catalog.d.ts +11 -0
  281. package/dist/terminal-catalog.js +129 -0
  282. package/dist/text-limits.d.ts +2 -0
  283. package/dist/text-limits.js +2 -0
  284. package/dist/tool-broker.d.ts +14 -0
  285. package/dist/tool-broker.js +67 -2
  286. package/dist/toolroll-update-ui.js +2 -2
  287. package/dist/toolroll-update.js +13 -3
  288. package/dist/tools-ui.js +2 -2
  289. package/dist/unfenced-test-support.d.ts +2 -0
  290. package/dist/unfenced-test-support.js +11 -0
  291. package/dist/webhook-limits.d.ts +23 -0
  292. package/dist/webhook-limits.js +30 -0
  293. package/dist/webhook-readers.d.ts +56 -0
  294. package/dist/webhook-readers.js +138 -0
  295. package/dist/webhook-signing.d.ts +27 -0
  296. package/dist/webhook-signing.js +119 -0
  297. package/dist/work-index.d.ts +29 -0
  298. package/dist/work-index.js +16 -2
  299. package/dist/workspace-revision.js +3 -1
  300. package/dist/writes-mode.d.ts +9 -0
  301. package/dist/writes-mode.js +8 -0
  302. package/package.json +15 -2
package/dist/store.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { WRITES_DEFAULT } from "./writes-mode.js";
2
+ import { PROJECT_NETWORK_DEFAULT, projectNetworkFromValue, runNetworkFromValue } from "./egress.js";
3
+ import { isAcpProfile, isAcpProvider } from "./acp-agents.js";
4
+ import { ACP_CONFORMANCE_VERSION, launchFromJson } from "./acp-attest.js";
1
5
  import { reclaimDatabase } from "./database-reclaim.js";
2
6
  import { BASELINE_SCHEMA, BASELINE_SCHEMA_VERSION } from "./store-baseline.js";
3
7
  import { CHAT_PROVIDERS, CHAT_SCHEMA } from "./contracts/chat-tables.js";
@@ -77,6 +81,7 @@ import { parseCheckProgressSnapshot } from "./check-progress.js";
77
81
  import { accountReachesSql, normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
78
82
  import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installLedgerTriggers } from "./action-ledger.js";
79
83
  import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
84
+ import { CONSOLE_INSTANCE_SCHEMA } from "./console-instance.js";
80
85
  import { endUnattendedGrants, FLOW_UNATTENDED_SCHEMA, grantStandsSql } from "./flow-unattended.js";
81
86
  import { RECIPE_SCHEMA } from "./recipes.js";
82
87
  import { POLICY_SCHEMA, agentRefusal, approvalRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
@@ -273,6 +278,29 @@ export function leadTurnKeepsProposals(turn) {
273
278
  /** What the lead promised to follow up on (lead-commitments.ts; no version bump: additive only, so a build that
274
279
  * predates it still opens the store). `condition_json` is a task, attempt, check or time; `check_at` is when to look
275
280
  * next; `channel` is where it reports (`chat`: the conversation it was promised in); a promise lapses at `expires_at`. */
281
+ /** Lead fan-out (docs/plans/lead-fan-out.md): a sweep is N ordinary tasks under one shared brief, started with one yes,
282
+ * at most `max_parallel` of them claimed at once. Membership is by task family root, so a revision of a child is that
283
+ * child. Additive (no schema version bump), like lead_commitment. */
284
+ export const LEAD_SWEEP_SCHEMA = `
285
+ CREATE TABLE IF NOT EXISTS lead_sweep (
286
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
287
+ owner TEXT NOT NULL,
288
+ thread INTEGER,
289
+ repo TEXT NOT NULL,
290
+ title TEXT NOT NULL,
291
+ brief TEXT NOT NULL,
292
+ max_parallel INTEGER NOT NULL CHECK (max_parallel BETWEEN 1 AND 16),
293
+ proposal INTEGER UNIQUE,
294
+ created_at TEXT NOT NULL
295
+ );
296
+ CREATE TABLE IF NOT EXISTS lead_sweep_task (
297
+ sweep INTEGER NOT NULL REFERENCES lead_sweep(id),
298
+ root_task TEXT NOT NULL UNIQUE,
299
+ ordinal INTEGER NOT NULL,
300
+ label TEXT NOT NULL,
301
+ PRIMARY KEY (sweep, ordinal)
302
+ );
303
+ `;
276
304
  export const LEAD_COMMITMENT_SCHEMA = `
277
305
  CREATE TABLE IF NOT EXISTS lead_commitment (
278
306
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -409,6 +437,25 @@ CREATE TABLE IF NOT EXISTS run_resume (
409
437
  );
410
438
  CREATE INDEX IF NOT EXISTS run_resume_by_run ON run_resume (run, id);
411
439
  `;
440
+ /** A project's "Show commands in the live view" (live-detail.ts): absent means off. */
441
+ export const LIVE_DETAIL_SCHEMA = `
442
+ CREATE TABLE IF NOT EXISTS live_detail_setting (
443
+ repo TEXT PRIMARY KEY,
444
+ commands INTEGER NOT NULL CHECK (commands IN (0,1)),
445
+ updated_by TEXT NOT NULL,
446
+ updated_at TEXT NOT NULL
447
+ );
448
+ -- Every run a steering note reached, kept as it happens (live-notes.ts): a
449
+ -- note's own attached/mailed fields move to the next attempt, this does not.
450
+ CREATE TABLE IF NOT EXISTS steer_note_run (
451
+ note INTEGER NOT NULL REFERENCES task_steer(id) ON DELETE CASCADE,
452
+ run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,
453
+ how TEXT NOT NULL CHECK (how IN ('attached','mailed','read')),
454
+ at TEXT NOT NULL,
455
+ PRIMARY KEY (note, run, how)
456
+ );
457
+ CREATE INDEX IF NOT EXISTS steer_note_run_by_run ON steer_note_run (run, note);
458
+ `;
412
459
  export const CHAT_APPROVAL_SCHEMA = `
413
460
  CREATE TABLE IF NOT EXISTS chat_approval_setting (
414
461
  approver TEXT NOT NULL,
@@ -645,7 +692,33 @@ const REGISTERED_AT_FILING = "again, as it was filed";
645
692
  // v123 adds, and changes no saved row: flows that run on their own (flow_script_provenance, flow_unattended_grant,
646
693
  // flow_unattended_run, plan_authorization.basis, flow_trigger_event.on_time and ran_script) and the tool-secret
647
694
  // sealing key's record (tool_secret_key, tool_secret_legacy).
648
- export const SCHEMA_VERSION = 123;
695
+ // v124 adds, and changes no saved row: the live conversation's project setting (live_detail_setting: "Show commands in
696
+ // the live view", absent means off) and the runs each steering note reached (steer_note_run; older notes have none).
697
+ // v125 changes no saved row: ACP agents become providers (the provider CHECKs of phase_config, phase_tier_config,
698
+ // phase_tier_alternate and provider_readiness widen, and provider_auth_pause's auth mode admits 'agent-login'; each
699
+ // table is rebuilt with every row and id carried over) and a run keeps what its agent itself reported it used
700
+ // (run.agent_reported_usage_json, never priced; saved runs read none).
701
+ // v126 adds, and changes no saved row: signed webhooks into tasks (docs/plans/webhook-tasks.md): a receipt's typed state,
702
+ // when its card was made and the item it holds while it waits for room (flow_trigger_event.state, accepted_at,
703
+ // item_json); which saved secret a trigger's deliveries are proved with and its turned-away deliveries (flow_trigger.hook_credential,
704
+ // dropped_count, dropped_since, limit_noted_at); who made a card (flow_card.created_kind); and the outside source a
705
+ // task was filed from (task_source). Saved receipts read their state from their card, saved cards read a person.
706
+ // v127 adds, and changes no saved row: the running console's instance record (console_instance, one row: its secret,
707
+ // pid, port and build) that bare `toolroll` proves a console with before attaching (docs/plans/agents-cockpit.md B1).
708
+ // v128 adds, and changes no saved row's existing columns: the personal lead queue (lead_queue; docs/plans/
709
+ // lead-message-queue.md) and whether a lead message is still waiting for its turn (lead_message.pending, indexed with
710
+ // its thread so the model's history reads only admitted messages). A saved message reads waiting exactly when it is an
711
+ // operator message of a team queue row never bound to a turn (V128_ADDED_COLUMNS).
712
+ // v129 adds, and changes no saved row: how a run went (docs/plans/run-efficiency.md) — the agent's turn count where its
713
+ // provider reports it (run.agent_turns), a lead request's cache read and write tokens (chat_turn.cache_read_tokens,
714
+ // cache_write_tokens), each NULL (unknown) on saved rows, and the derived, display-only run_efficiency row a result's
715
+ // How it ran reads (a cache of the run's own records, written on first view, never a verdict).
716
+ // v130 adds, and changes no saved row: what a project's builds may reach on the network (project_network; egress.ts,
717
+ // docs/plans/agent-baseline-hardening.md arc 1) and whether this installation lets an agent run with no OS confinement
718
+ // (confinement_setting, one row; off unless an instance operator turns it on).
719
+ // v131 adds, and changes no saved row: where a project's builds may write (project_writes; agent-fence.ts, docs/plans/
720
+ // agent-baseline-hardening.md arc 2) — only their workspace unless an approver chose anywhere.
721
+ export const SCHEMA_VERSION = 131;
649
722
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
650
723
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
651
724
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -773,9 +846,12 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
773
846
  * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
774
847
  * table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
775
848
  * same way. v123 only adds new tables (unattended flows, tool secrets) and the columns in V123_ADDED_COLUMNS ('mode',
776
- * 0 or null), checked the same way.
849
+ * 0 or null), checked the same way. v124 only adds new tables (the live conversation's setting and note history).
850
+ * v125 rebuilds five tables with wider CHECKs, every row and id carried over (the rehearsal's history rules find them
851
+ * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
852
+ * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
777
853
  */
778
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123]);
854
+ 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, 131]);
779
855
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
780
856
  export const V119_ADDED_COLUMNS = Object.freeze({
781
857
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -836,6 +912,140 @@ export const V123_ADDED_COLUMNS = Object.freeze({
836
912
  ran_script: { definition: "TEXT", saved: "NULL" },
837
913
  },
838
914
  });
915
+ /** v125: the column it adds, with the value every row saved before it reads: what a run's ACP agent itself reported it
916
+ * used (acp-client.ts; never priced). Runs saved before it read none. */
917
+ export const V125_ADDED_COLUMNS = Object.freeze({
918
+ run: {
919
+ agent_reported_usage_json: { definition: "TEXT", saved: "NULL" },
920
+ // An ACP run's billing, stamped when it is admitted (AcpBillingStamp); every other run, and every saved one, none.
921
+ acp_billing_json: { definition: "TEXT", saved: "NULL" },
922
+ },
923
+ });
924
+ /** v126: the columns it adds, with the value every row saved before it reads (docs/plans/webhook-tasks.md). A saved
925
+ * receipt reads no state (accepted when it has a card, skipped otherwise), no acceptance time (its arrival counts) and
926
+ * no held item; a saved trigger is on its first credential generation with nothing turned away; a saved card was made
927
+ * by a person. */
928
+ export const V126_ADDED_COLUMNS = Object.freeze({
929
+ flow_trigger_event: {
930
+ state: { definition: "TEXT CHECK (state IN ('accepted','skipped','held','ignored'))", saved: "NULL" },
931
+ accepted_at: { definition: "TEXT", saved: "NULL" },
932
+ item_json: { definition: "TEXT", saved: "NULL" },
933
+ },
934
+ flow_trigger: {
935
+ // Which saved secret proves its deliveries: 'legacy' (the address every secret saved before v126 has), '' (none:
936
+ // deliveries are refused until one is saved), or the id of one written whole before the trigger pointed at it.
937
+ hook_credential: { definition: "TEXT NOT NULL DEFAULT 'legacy'", saved: "'legacy'" },
938
+ dropped_count: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" },
939
+ dropped_since: { definition: "TEXT", saved: "NULL" },
940
+ limit_noted_at: { definition: "TEXT", saved: "NULL" },
941
+ },
942
+ flow_card: {
943
+ created_kind: { definition: "TEXT CHECK (created_kind IN ('person','automation'))", saved: "NULL" },
944
+ },
945
+ });
946
+ /** v128: whether a lead message waits for its turn. A message saved before v128 waits exactly when it is an operator
947
+ * message that a team queue row holds and no turn has taken (the team runtime's own unadmitted messages); every other
948
+ * saved message is history (`saved` is evaluated per row). */
949
+ export const V128_ADDED_COLUMNS = Object.freeze({
950
+ lead_message: {
951
+ pending: {
952
+ definition: "INTEGER NOT NULL DEFAULT 0 CHECK (pending IN (0, 1))",
953
+ 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)",
954
+ },
955
+ },
956
+ });
957
+ /** v129: how a run went (docs/plans/run-efficiency.md). Recorded from now on; a saved row reads unknown (NULL), never 0. */
958
+ export const V129_ADDED_COLUMNS = Object.freeze({
959
+ run: { agent_turns: { definition: "INTEGER CHECK (agent_turns IS NULL OR agent_turns >= 0)", saved: "NULL" } },
960
+ chat_turn: {
961
+ cache_read_tokens: { definition: "INTEGER CHECK (cache_read_tokens IS NULL OR cache_read_tokens >= 0)", saved: "NULL" },
962
+ cache_write_tokens: { definition: "INTEGER CHECK (cache_write_tokens IS NULL OR cache_write_tokens >= 0)", saved: "NULL" },
963
+ },
964
+ });
965
+ /** v130: a project's network (an approver's setting, egress.ts ProjectNetwork as JSON) and the installation's choice
966
+ * whether an agent may run where no OS confinement exists (one row; 0 until an instance operator turns it on). */
967
+ export const CONFINEMENT_SCHEMA = `
968
+ CREATE TABLE IF NOT EXISTS project_network (
969
+ repo TEXT PRIMARY KEY,
970
+ network_json TEXT NOT NULL CHECK (length(network_json) <= 16384),
971
+ updated_at TEXT NOT NULL,
972
+ updated_by TEXT NOT NULL
973
+ );
974
+ CREATE TABLE IF NOT EXISTS confinement_setting (
975
+ id INTEGER PRIMARY KEY CHECK (id = 1),
976
+ unfenced_allowed INTEGER NOT NULL DEFAULT 0 CHECK (unfenced_allowed IN (0, 1)),
977
+ updated_at TEXT,
978
+ updated_by TEXT
979
+ );
980
+ INSERT OR IGNORE INTO confinement_setting (id, unfenced_allowed) VALUES (1, 0);
981
+ `;
982
+ /** v131: where a project's builds may write (an approver's setting, agent-fence.ts WritesMode). */
983
+ export const PROJECT_WRITES_SCHEMA = `
984
+ CREATE TABLE IF NOT EXISTS project_writes (
985
+ repo TEXT PRIMARY KEY,
986
+ mode TEXT NOT NULL CHECK (mode IN ('workspace', 'anywhere')),
987
+ updated_at TEXT NOT NULL,
988
+ updated_by TEXT NOT NULL
989
+ );
990
+ `;
991
+ /** v129: a finished run's How it ran, derived from its own records on first view (display only: never a verdict). Its
992
+ * parser version says when to derive again; `steps` says how complete the step detail was, and why not. */
993
+ export const RUN_EFFICIENCY_SCHEMA = `
994
+ CREATE TABLE IF NOT EXISTS run_efficiency (
995
+ run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,
996
+ parser_version INTEGER NOT NULL,
997
+ steps TEXT NOT NULL CHECK (steps IN ('complete','truncated','expired','absent')),
998
+ steps_reason TEXT,
999
+ payload_json TEXT NOT NULL CHECK (length(payload_json) <= 16384),
1000
+ derived_at TEXT NOT NULL
1001
+ );
1002
+ `;
1003
+ /** v128: the personal lead queue (docs/plans/lead-message-queue.md). A message sent while one of its author's personal
1004
+ * lead turns runs (or while earlier ones wait) waits here, one turn per message, in order. Its words are the
1005
+ * lead_message row (pending until admitted); terminal rows keep only their request identity (a tombstone). */
1006
+ export const LEAD_QUEUE_SCHEMA = `
1007
+ CREATE TABLE IF NOT EXISTS lead_queue (
1008
+ message INTEGER PRIMARY KEY REFERENCES lead_message(id) ON DELETE CASCADE,
1009
+ thread INTEGER NOT NULL REFERENCES lead_thread(id) ON DELETE CASCADE,
1010
+ approver TEXT NOT NULL,
1011
+ approver_generation INTEGER NOT NULL,
1012
+ session INTEGER NOT NULL,
1013
+ ceiling_digest TEXT NOT NULL,
1014
+ request_id TEXT NOT NULL,
1015
+ payload_hash TEXT NOT NULL,
1016
+ context_json TEXT,
1017
+ status TEXT NOT NULL CHECK (status IN ('queued','running','answered','failed','cancelled','uncertain')),
1018
+ revision INTEGER NOT NULL DEFAULT 1,
1019
+ generation INTEGER NOT NULL DEFAULT 0,
1020
+ runner TEXT,
1021
+ claimed_at TEXT,
1022
+ turn_id INTEGER REFERENCES mate_turn(id),
1023
+ error TEXT,
1024
+ created_at TEXT NOT NULL,
1025
+ settled_at TEXT,
1026
+ UNIQUE (session, request_id)
1027
+ );
1028
+ CREATE INDEX IF NOT EXISTS lead_queue_backlog ON lead_queue (approver, status, message);
1029
+ CREATE UNIQUE INDEX IF NOT EXISTS lead_queue_one_running ON lead_queue (approver) WHERE status = 'running';
1030
+ CREATE INDEX IF NOT EXISTS lead_queue_admitted ON lead_queue (turn_id) WHERE turn_id IS NOT NULL;
1031
+ `;
1032
+ /** v126: the outside source a task was filed from, kept with the task (a card's own task link moves on; this never
1033
+ * does). Written in the filing's transaction, copied whole to a revision, never changed. */
1034
+ export const TASK_SOURCE_SCHEMA = `
1035
+ CREATE TABLE IF NOT EXISTS task_source (
1036
+ task_id TEXT PRIMARY KEY,
1037
+ flow INTEGER NOT NULL,
1038
+ card INTEGER NOT NULL,
1039
+ card_entry INTEGER NOT NULL,
1040
+ source_json TEXT NOT NULL,
1041
+ at TEXT NOT NULL
1042
+ );
1043
+ CREATE TRIGGER IF NOT EXISTS task_source_immutable BEFORE UPDATE ON task_source
1044
+ BEGIN
1045
+ SELECT RAISE(ABORT, 'a task source is immutable');
1046
+ END;
1047
+ CREATE INDEX IF NOT EXISTS flow_trigger_event_accepted ON flow_trigger_event (trigger, accepted_at);
1048
+ `;
839
1049
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
840
1050
  export function updateSafeSchema(version) {
841
1051
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -970,6 +1180,19 @@ export const BUILT_IN = "built-in";
970
1180
  /** States from which no further work is dispatched. */
971
1181
  export const TERMINAL_STATES = ["done", "failed", "cancelled"];
972
1182
  export const LEAD_THREAD = { kind: "lead" };
1183
+ /** A lead_message row as a LeadMessage. */
1184
+ function readLeadMessageRow(row) {
1185
+ return {
1186
+ id: Number(row["id"]),
1187
+ thread: Number(row["thread"]),
1188
+ turn: row["turn"] === null ? null : Number(row["turn"]),
1189
+ role: String(row["role"]),
1190
+ text: String(row["text"]),
1191
+ activity: row["activity"] === null ? null : String(row["activity"]),
1192
+ createdAt: String(row["created_at"]),
1193
+ ...(Number(row["pending"] ?? 0) === 1 ? { pending: true } : {}),
1194
+ };
1195
+ }
973
1196
  function readSubagentMemory(row) {
974
1197
  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"]),
975
1198
  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"]) };
@@ -1032,7 +1255,9 @@ function readFlowCardRow(row) {
1032
1255
  const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
1033
1256
  return { id: Number(row["id"]), flow: Number(row["flow"]), title: String(row["title"]), description: optional("description"), stage: String(row["stage"]), entry: Number(row["entry"]),
1034
1257
  state: String(row["state"]), task: optional("task"), primaryTask: optional("primary_task"), note: optional("note"), waiting: optional("waiting"), outputs, attached,
1035
- createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedAt: String(row["updated_at"]), source: readFlowCardSource(row["source_json"]), owner: optional("owner") };
1258
+ createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedAt: String(row["updated_at"]), source: readFlowCardSource(row["source_json"]), owner: optional("owner"),
1259
+ // v126: a card saved before it was a person's (what filing always assumed).
1260
+ createdKind: row["created_kind"] === "automation" ? "automation" : "person" };
1036
1261
  }
1037
1262
  function readFlowCardSource(value) {
1038
1263
  if (typeof value !== "string")
@@ -1044,12 +1269,17 @@ function readFlowCardSource(value) {
1044
1269
  const parsed = read.value;
1045
1270
  const mail = parsed["mail"] !== null && typeof parsed["mail"] === "object" ? parsed["mail"] : null;
1046
1271
  const chat = parsed["chat"] !== null && typeof parsed["chat"] === "object" ? parsed["chat"] : null;
1272
+ const external = parsed["external"] !== null && typeof parsed["external"] === "object" ? parsed["external"] : null;
1047
1273
  return { kind: String(parsed["kind"] ?? ""), label: parsed["label"], url: typeof parsed["url"] === "string" ? parsed["url"] : null,
1048
1274
  ...(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") : [],
1049
1275
  subject: typeof mail["subject"] === "string" ? mail["subject"] : "", from: mail["from"] } }),
1050
1276
  ...(chat === null || typeof chat["app"] !== "string" || typeof chat["chat"] !== "string" || typeof chat["thread"] !== "string" || typeof chat["binding"] !== "number" ? {} : { chat: {
1051
1277
  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"]
1052
- } }) };
1278
+ } }),
1279
+ // v126: kept as they were written at receipt; a card saved before them has none.
1280
+ ...(typeof parsed["verified"] === "string" ? { verified: parsed["verified"] } : {}),
1281
+ ...(external === null ? {} : { external: { id: typeof external["id"] === "string" ? external["id"] : null, url: typeof external["url"] === "string" ? external["url"] : null } }),
1282
+ ...(typeof parsed["receipt"] === "string" ? { receipt: parsed["receipt"] } : {}) };
1053
1283
  }
1054
1284
  catch {
1055
1285
  return null;
@@ -1059,7 +1289,14 @@ function readFlowTriggerRow(row) {
1059
1289
  const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
1060
1290
  return { id: Number(row["id"]), flow: Number(row["flow"]), kind: String(row["kind"]), configJson: String(row["config_json"]), state: String(row["state"]),
1061
1291
  hookHash: optional("hook_hash"), cursor: optional("cursor"), nextAt: optional("next_at"), lastAt: optional("last_at"), lastOutcome: optional("last_outcome"),
1062
- failures: Number(row["failures"] ?? 0), createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedAt: String(row["updated_at"]) };
1292
+ failures: Number(row["failures"] ?? 0), createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedAt: String(row["updated_at"]),
1293
+ hookCredential: row["hook_credential"] === null || row["hook_credential"] === undefined ? "legacy" : String(row["hook_credential"]), droppedCount: Number(row["dropped_count"] ?? 0), droppedSince: optional("dropped_since") };
1294
+ }
1295
+ function readFlowReceipt(row) {
1296
+ const card = row["card"] === null || row["card"] === undefined ? null : Number(row["card"]);
1297
+ const state = row["state"] === null || row["state"] === undefined ? (card === null ? "skipped" : "accepted") : String(row["state"]);
1298
+ const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
1299
+ return { state, card, note: optional("note"), at: String(row["at"]), acceptedAt: optional("accepted_at"), itemJson: optional("item_json") };
1063
1300
  }
1064
1301
  /** `kind:name`, the unambiguous way a requirement names a capability. */
1065
1302
  export function capabilityKey(capability) {
@@ -1313,7 +1550,7 @@ CREATE TABLE IF NOT EXISTS mcp_idempotency (
1313
1550
  CREATE TABLE IF NOT EXISTS phase_config (
1314
1551
  scope TEXT NOT NULL,
1315
1552
  phase TEXT NOT NULL CHECK (phase IN ('plan','build','repair','review')),
1316
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
1553
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
1317
1554
  model TEXT,
1318
1555
  updated_at TEXT NOT NULL,
1319
1556
  updated_by TEXT NOT NULL,
@@ -1329,7 +1566,7 @@ CREATE TABLE IF NOT EXISTS phase_tier_config (
1329
1566
  scope TEXT NOT NULL,
1330
1567
  phase TEXT NOT NULL CHECK (phase IN ('plan','build','repair','review')),
1331
1568
  tier TEXT NOT NULL CHECK (tier IN ('light','strong')),
1332
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
1569
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
1333
1570
  model TEXT,
1334
1571
  updated_at TEXT NOT NULL,
1335
1572
  updated_by TEXT NOT NULL,
@@ -1345,7 +1582,7 @@ CREATE TABLE IF NOT EXISTS phase_tier_config (
1345
1582
  -- substitutes for it.
1346
1583
  CREATE TABLE IF NOT EXISTS provider_readiness (
1347
1584
  runner TEXT NOT NULL REFERENCES runner(name) ON DELETE CASCADE,
1348
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
1585
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
1349
1586
  state TEXT NOT NULL CHECK (state IN ('ready','unavailable','unknown')),
1350
1587
  reason TEXT NOT NULL,
1351
1588
  probe TEXT NOT NULL,
@@ -2635,7 +2872,7 @@ CREATE TABLE IF NOT EXISTS quota (
2635
2872
  -- key. auth_mode + a stable NON-SECRET
2636
2873
  -- credential fingerprint join the key. Defaults keep every pre-v30 row
2637
2874
  -- identical (mode 'subscription', empty fp) since that is what they were.
2638
- auth_mode TEXT NOT NULL DEFAULT 'subscription' CHECK (auth_mode IN ('subscription','api-key')),
2875
+ auth_mode TEXT NOT NULL DEFAULT 'subscription' CHECK (auth_mode IN ('subscription','api-key','agent-login')),
2639
2876
  credential_fp TEXT NOT NULL DEFAULT '',
2640
2877
  state TEXT NOT NULL CHECK (state IN ('exhausted','half-open')),
2641
2878
  reason TEXT NOT NULL,
@@ -3566,6 +3803,9 @@ function initializeStore(db, file) {
3566
3803
  db.exec(CHAT_DECIDE_SCHEMA);
3567
3804
  // An owner's lasting chat-approval setting (no version bump: additive only; absent means off).
3568
3805
  db.exec(CHAT_APPROVAL_SCHEMA);
3806
+ // v124: the live conversation: a project's "Show commands in the live view" (absent means off) and every run a
3807
+ // steering note reached (older notes simply have no rows). New tables only.
3808
+ db.exec(LIVE_DETAIL_SCHEMA);
3569
3809
  // What a running agent did last (no version bump: additive only; absent means no word yet).
3570
3810
  db.exec(RUN_ACTIVITY_SCHEMA);
3571
3811
  // v122: a task branch moved to current main before an attempt (fresh base): a new table, nothing carried over.
@@ -3593,6 +3833,18 @@ function initializeStore(db, file) {
3593
3833
  addColumn(db, "task_ref", "route_tier", "TEXT");
3594
3834
  widenPhaseTierSchema(db);
3595
3835
  db.exec(PHASE_TIER_ALTERNATE_SCHEMA);
3836
+ // v125: ACP agents as providers, their own sign-in as an auth mode (rows carry over, CHECKs widen; the auth schema
3837
+ // runs again to put back the open-pause index the rebuild dropped), and what an agent itself reported it used
3838
+ // (V125_ADDED_COLUMNS: never priced, saved runs read none).
3839
+ widenForAcp(db);
3840
+ db.exec(PROVIDER_AUTH_SCHEMA);
3841
+ db.exec(ACP_ATTESTATION_SCHEMA);
3842
+ db.exec(ACP_BILLING_SCHEMA);
3843
+ db.exec(ACP_CHECK_SCHEMA);
3844
+ for (const [table, columns] of Object.entries(V125_ADDED_COLUMNS)) {
3845
+ for (const [column, { definition }] of Object.entries(columns))
3846
+ addColumn(db, table, column, definition);
3847
+ }
3596
3848
  db.exec(REVIEW_SCHEMA);
3597
3849
  db.exec(RETENTION_SCHEMA);
3598
3850
  // 1-day evidence: an older file's retention_setting only allowed 7 days or more (no version bump: its rows carry over).
@@ -3623,6 +3875,7 @@ function initializeStore(db, file) {
3623
3875
  // What a person's lead knows about them, beside its name and persona (an older lead_config gains it).
3624
3876
  addColumn(db, "lead_config", "about_json", "TEXT NOT NULL DEFAULT '[]'");
3625
3877
  db.exec(LEAD_COMMITMENT_SCHEMA);
3878
+ db.exec(LEAD_SWEEP_SCHEMA);
3626
3879
  mergeCheckTables(db);
3627
3880
  // v110: the action ledger admits remote commands ('api', 'mcp').
3628
3881
  rebuildLedger(db);
@@ -3646,7 +3899,36 @@ function initializeStore(db, file) {
3646
3899
  addColumn(db, "workflow_preview", "flow_id", "INTEGER");
3647
3900
  // v115: the task each standing-order firing filed, kept on the firing itself (a card's task changes as it moves).
3648
3901
  addColumn(db, "flow_trigger_event", "task", "TEXT");
3902
+ // v126: signed webhooks into tasks (V126_ADDED_COLUMNS: saved rows read null or 0) and each task's outside source.
3903
+ for (const [table, columns] of Object.entries(V126_ADDED_COLUMNS)) {
3904
+ for (const [column, { definition }] of Object.entries(columns))
3905
+ addColumn(db, table, column, definition);
3906
+ }
3907
+ db.exec(TASK_SOURCE_SCHEMA);
3649
3908
  db.exec(PLAN_AUTO_SCHEMA);
3909
+ // v127 (cockpit B1): the running console's instance record (one row; a new table, additive only).
3910
+ db.exec(CONSOLE_INSTANCE_SCHEMA);
3911
+ // v128: the personal lead queue (a new table) and lead_message.pending, set once from each saved row's provenance
3912
+ // (exactly V128_ADDED_COLUMNS' `saved` expression), then the history index the model's reads use.
3913
+ if (!hasColumn(db, "lead_message", "pending")) {
3914
+ addColumn(db, "lead_message", "pending", V128_ADDED_COLUMNS.lead_message.pending.definition);
3915
+ db.exec(`UPDATE lead_message SET pending = ${V128_ADDED_COLUMNS.lead_message.pending.saved}`);
3916
+ }
3917
+ db.exec("CREATE INDEX IF NOT EXISTS lead_message_history ON lead_message (thread, pending, id)");
3918
+ db.exec(LEAD_QUEUE_SCHEMA);
3919
+ // v128: the memory search indexes a waiting message when a turn takes it, found by the turn that took it.
3920
+ db.exec("CREATE INDEX IF NOT EXISTS team_message_admitted ON team_message (turn_id) WHERE turn_id IS NOT NULL");
3921
+ // v129: how a run went — its turn count and a lead request's cache tokens (V129_ADDED_COLUMNS: saved rows read NULL),
3922
+ // and the derived How it ran row (a new table).
3923
+ for (const [table, columns] of Object.entries(V129_ADDED_COLUMNS)) {
3924
+ for (const [column, { definition }] of Object.entries(columns))
3925
+ addColumn(db, table, column, definition);
3926
+ }
3927
+ db.exec(RUN_EFFICIENCY_SCHEMA);
3928
+ // v130: a project's network and whether an agent may run unconfined (new tables only).
3929
+ db.exec(CONFINEMENT_SCHEMA);
3930
+ // v131: where a project's builds may write (a new table only).
3931
+ db.exec(PROJECT_WRITES_SCHEMA);
3650
3932
  // v123: a flow that runs on its own (flow-unattended.ts): its grants and daily runs (new tables), and which authority
3651
3933
  // armed a plan's unchanged-plan approval (plan_authorization.basis, V123_ADDED_COLUMNS: saved rows read the mode).
3652
3934
  db.exec(FLOW_UNATTENDED_SCHEMA);
@@ -4143,7 +4425,11 @@ function rebuildExact(db, table, oldDdl, targetDdl, columns) {
4143
4425
  ? db.prepare("SELECT name, seq FROM sqlite_sequence ORDER BY rowid").all()
4144
4426
  : [];
4145
4427
  db.exec(targetDdl(`${table}_next`));
4146
- const names = columns.join(", ");
4428
+ // A table keyed by anything but an INTEGER PRIMARY KEY still has a hidden rowid, and a plain copy renumbers it;
4429
+ // the update's history check reads it (count and highest rowid), so it is carried whole like any id.
4430
+ const key = db.prepare(`PRAGMA table_info(${table})`).all().filter(one => Number(one["pk"]) > 0);
4431
+ const aliased = key.length === 1 && String(key[0]["type"]).toUpperCase() === "INTEGER";
4432
+ const names = (aliased || /WITHOUT\s+ROWID/i.test(stored) ? columns : ["rowid", ...columns]).join(", ");
4147
4433
  db.exec(`INSERT INTO ${table}_next (${names}) SELECT ${names} FROM ${table}`);
4148
4434
  db.exec(`DROP TABLE ${table}`);
4149
4435
  db.exec(`ALTER TABLE ${table}_next RENAME TO ${table}`);
@@ -4743,13 +5029,119 @@ CREATE TABLE IF NOT EXISTS phase_tier_alternate (
4743
5029
  scope TEXT NOT NULL,
4744
5030
  phase TEXT NOT NULL CHECK (phase IN ('plan','build')),
4745
5031
  tier TEXT NOT NULL CHECK (tier IN ('light','routine','strong')),
4746
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
5032
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
4747
5033
  model TEXT NOT NULL,
4748
5034
  updated_at TEXT NOT NULL,
4749
5035
  updated_by TEXT NOT NULL,
4750
5036
  PRIMARY KEY (scope, phase, tier, provider)
4751
5037
  );
4752
5038
  `;
5039
+ /**
5040
+ * v125: an ACP agent's passed conformance checks (acp-attest.ts): per provider and runner, how to launch the exact
5041
+ * binary that passed and every file that launch depends on, what the agent said it is, the models it offered and the
5042
+ * checks it passed. At most one live row per agent and runner; revoking keeps the row as history.
5043
+ */
5044
+ export const ACP_ATTESTATION_SCHEMA = `
5045
+ CREATE TABLE IF NOT EXISTS acp_attestation (
5046
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5047
+ provider TEXT NOT NULL CHECK (provider IN ('opencode','cursor','cline','qwen','goose')),
5048
+ runner TEXT NOT NULL,
5049
+ conformance_version INTEGER NOT NULL,
5050
+ launch_json TEXT NOT NULL,
5051
+ agent_name TEXT NOT NULL,
5052
+ agent_version TEXT NOT NULL,
5053
+ cli_version TEXT NOT NULL,
5054
+ models_json TEXT NOT NULL,
5055
+ checks_json TEXT NOT NULL,
5056
+ recorded_at TEXT NOT NULL,
5057
+ recorded_by TEXT NOT NULL,
5058
+ revoked_at TEXT,
5059
+ revoked_by TEXT
5060
+ );
5061
+ CREATE UNIQUE INDEX IF NOT EXISTS acp_attestation_live ON acp_attestation (provider, runner) WHERE revoked_at IS NULL;
5062
+ `;
5063
+ /**
5064
+ * v125: how an ACP agent's work on one model is billed, per runner (docs/plans/acp-adapter.md § Billing): metered
5065
+ * (the default, and what no row means — cost unknown, charged as unknown cost is) or the agent's own plan ($0,
5066
+ * counted as unmeasured). Each change is a new row; the newest is the declaration, its id the revision a run stamps.
5067
+ */
5068
+ /**
5069
+ * v125: a requested ACP agent check (docs/plans/acp-adapter.md § Layer E): asked for with a password, claimed and run
5070
+ * by one watch of the named runner — one running check per runner, which the partial unique index holds — through two
5071
+ * bounded steps, each bound to its run and graded on plane evidence; the attestation is recorded only in the same
5072
+ * transaction that marks the request passed.
5073
+ */
5074
+ export const ACP_CHECK_SCHEMA = `
5075
+ CREATE TABLE IF NOT EXISTS acp_check_request (
5076
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5077
+ provider TEXT NOT NULL CHECK (provider IN ('opencode','cursor','cline','qwen','goose')),
5078
+ runner TEXT NOT NULL,
5079
+ model TEXT NOT NULL,
5080
+ conformance_version INTEGER NOT NULL,
5081
+ requested_by TEXT NOT NULL,
5082
+ requested_at TEXT NOT NULL,
5083
+ expires_at TEXT NOT NULL,
5084
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','running','passed','failed','expired')),
5085
+ watch_repo TEXT,
5086
+ watch_owner TEXT,
5087
+ task_ref INTEGER REFERENCES task_ref(id),
5088
+ launch_json TEXT,
5089
+ lease_id TEXT,
5090
+ steps_used INTEGER NOT NULL DEFAULT 0 CHECK (steps_used BETWEEN 0 AND 2),
5091
+ step1_run INTEGER REFERENCES run(id),
5092
+ step2_run INTEGER REFERENCES run(id),
5093
+ step1_passed INTEGER NOT NULL DEFAULT 0 CHECK (step1_passed IN (0,1)),
5094
+ step2_passed INTEGER NOT NULL DEFAULT 0 CHECK (step2_passed IN (0,1)),
5095
+ result TEXT,
5096
+ finished_at TEXT,
5097
+ settled_at TEXT,
5098
+ settle_by TEXT,
5099
+ settle_until TEXT
5100
+ );
5101
+ CREATE UNIQUE INDEX IF NOT EXISTS acp_check_running ON acp_check_request (runner) WHERE state = 'running';
5102
+ `;
5103
+ export const ACP_BILLING_SCHEMA = `
5104
+ CREATE TABLE IF NOT EXISTS acp_billing (
5105
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5106
+ provider TEXT NOT NULL CHECK (provider IN ('opencode','cursor','cline','qwen','goose')),
5107
+ runner TEXT NOT NULL,
5108
+ model TEXT NOT NULL,
5109
+ kind TEXT NOT NULL CHECK (kind IN ('metered','plan')),
5110
+ set_at TEXT NOT NULL,
5111
+ set_by TEXT NOT NULL
5112
+ );
5113
+ CREATE INDEX IF NOT EXISTS acp_billing_by ON acp_billing (provider, runner, model, id);
5114
+ `;
5115
+ /** The provider CHECK before ACP agents joined (acp-agents.ts), and after: the same list, five ids longer. */
5116
+ const PROVIDERS_BEFORE_ACP = "CHECK (provider IN ('claude','codex','openrouter','gemini'))";
5117
+ const PROVIDERS_WITH_ACP = "CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose'))";
5118
+ /** The sign-in pause's auth mode before an ACP agent's own sign-in ('agent-login'), and after. */
5119
+ const AUTH_MODES_BEFORE_ACP = "CHECK (auth_mode IN ('subscription', 'api-key'))";
5120
+ const AUTH_MODES_WITH_ACP = "CHECK (auth_mode IN ('subscription', 'api-key', 'agent-login'))";
5121
+ /**
5122
+ * 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
5123
+ * stored DDL must be exactly a known shape with the old clause appearing once, or the open refuses. Indexes the rebuild
5124
+ * drops are put back by their own schema's IF NOT EXISTS, which the caller runs again after.
5125
+ */
5126
+ function widenForAcp(db) {
5127
+ const widen = (table, before, after) => {
5128
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
5129
+ if (row === undefined)
5130
+ return;
5131
+ const stored = String(row["sql"]);
5132
+ if (stored.includes(after))
5133
+ return;
5134
+ if (stored.split(before).length !== 2)
5135
+ throw new Error(`the ${table} table's DDL is not a shape this migration knows — refusing to widen it`);
5136
+ const named = (sql) => (name) => sql.replace(new RegExp(`^CREATE TABLE (?:IF NOT EXISTS )?"?${table}"?`), `CREATE TABLE ${name}`);
5137
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(one => String(one["name"]));
5138
+ rebuildExact(db, table, named(stored), named(stored.replace(before, after)), columns);
5139
+ };
5140
+ for (const table of ["phase_config", "phase_tier_config", "phase_tier_alternate", "provider_readiness"])
5141
+ widen(table, PROVIDERS_BEFORE_ACP, PROVIDERS_WITH_ACP);
5142
+ widen("provider_auth_pause", AUTH_MODES_BEFORE_ACP, AUTH_MODES_WITH_ACP);
5143
+ widen("quota", "CHECK (auth_mode IN ('subscription','api-key'))", "CHECK (auth_mode IN ('subscription','api-key','agent-login'))");
5144
+ }
4753
5145
  /** A file from before the light tier keeps `tier IN ('strong')` in phase_tier_config's CHECK: rebuild the table with
4754
5146
  * the wider one, keeping every row, in one transaction. A file already widened is untouched. */
4755
5147
  function widenPhaseTierSchema(db) {
@@ -4762,13 +5154,13 @@ function widenPhaseTierSchema(db) {
4762
5154
  scope TEXT NOT NULL,
4763
5155
  phase TEXT NOT NULL CHECK (phase IN ('plan','build','repair','review')),
4764
5156
  tier TEXT NOT NULL CHECK (tier IN ('light','strong')),
4765
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
5157
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
4766
5158
  model TEXT,
4767
5159
  updated_at TEXT NOT NULL,
4768
5160
  updated_by TEXT NOT NULL,
4769
5161
  PRIMARY KEY (scope, phase, tier)
4770
5162
  )`);
4771
- db.exec("INSERT INTO phase_tier_config_next SELECT scope, phase, tier, provider, model, updated_at, updated_by FROM phase_tier_config");
5163
+ 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");
4772
5164
  db.exec("DROP TABLE phase_tier_config");
4773
5165
  db.exec("ALTER TABLE phase_tier_config_next RENAME TO phase_tier_config");
4774
5166
  db.exec("COMMIT");
@@ -6228,7 +6620,8 @@ export class Store {
6228
6620
  ? { ...profile, permissionArgv: "bypassPermissions" }
6229
6621
  : profile.provider === "gemini"
6230
6622
  ? { ...profile, approvalArgv: "yolo" }
6231
- : { ...profile, sandboxMode: "danger-full-access" };
6623
+ : isAcpProfile(profile) ? profile
6624
+ : { ...profile, sandboxMode: "danger-full-access" };
6232
6625
  }
6233
6626
  // Anything still above the ceiling files at the highest level beneath it its provider has; a provider with none
6234
6627
  // keeps its terms, and approval refuses them in words.
@@ -9167,9 +9560,23 @@ export class Store {
9167
9560
  }
9168
9561
  /** Stamp a task as the revision it is: source task + immutable brief. */
9169
9562
  markRevision(taskRef, revisionOf, briefArtifact) {
9170
- this.db
9171
- .prepare("UPDATE task_ref SET revision_of = ?, revision_brief_artifact = ? WHERE id = ?")
9172
- .run(revisionOf, briefArtifact, taskRef);
9563
+ this.transact(() => {
9564
+ this.db
9565
+ .prepare("UPDATE task_ref SET revision_of = ?, revision_brief_artifact = ? WHERE id = ?")
9566
+ .run(revisionOf, briefArtifact, taskRef);
9567
+ // v126: a revision keeps where the work came from, copied whole from the task it revises.
9568
+ this.db.prepare(`INSERT OR IGNORE INTO task_source (task_id, flow, card, card_entry, source_json, at)
9569
+ 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);
9570
+ });
9571
+ }
9572
+ /** v126: where a task came from, written once in its filing's transaction (task-source.ts), never changed. */
9573
+ recordTaskSource(taskId, from, sourceJson, now) {
9574
+ 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());
9575
+ }
9576
+ /** v126: where a task came from, as kept when it (or the task it revises) was filed; null for every other task. */
9577
+ taskSourceOf(taskId) {
9578
+ const row = this.db.prepare("SELECT flow, card, card_entry, source_json FROM task_source WHERE task_id = ?").get(taskId);
9579
+ return row === undefined ? null : { flow: Number(row["flow"]), card: Number(row["card"]), entry: Number(row["card_entry"]), sourceJson: String(row["source_json"]) };
9173
9580
  }
9174
9581
  // ---- the revision boundary: ONE field-by-field policy -------------------
9175
9582
  /**
@@ -9939,7 +10346,10 @@ export class Store {
9939
10346
  return "published";
9940
10347
  return null;
9941
10348
  }
9942
- requeueTask(taskId, by, now) {
10349
+ requeueTask(taskId, by, now,
10350
+ /** Approve & build again: requeue only while this exact build is the finished task's latest result and the scope
10351
+ * is still the one just approved, read inside this transaction ("moved" otherwise). */
10352
+ expect) {
9943
10353
  return this.transact(() => {
9944
10354
  const ref = this.db
9945
10355
  .prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?")
@@ -9985,6 +10395,12 @@ export class Store {
9985
10395
  else if (incidents.length === 0 && state !== "failed") {
9986
10396
  return { ok: false, reason: "not-stalled" };
9987
10397
  }
10398
+ if (expect !== undefined) {
10399
+ const scope = this.db.prepare("SELECT digest, approved_digest FROM task_scope WHERE task_id = ?").get(taskId);
10400
+ if (state !== "done" || rejectedRun !== expect.run || scope === undefined || String(scope["digest"]) !== expect.scopeDigest ||
10401
+ scope["approved_digest"] !== scope["digest"])
10402
+ return { ok: false, reason: "moved" };
10403
+ }
9988
10404
  for (const incident of incidents)
9989
10405
  this.resolveIncidentLocked(incident, by, now);
9990
10406
  this.resetStrikes(taskRef);
@@ -11265,9 +11681,9 @@ export class Store {
11265
11681
  this.spendCache = null; // its spend counts toward the next budget check
11266
11682
  const changed = this.db
11267
11683
  .prepare(`UPDATE chat_turn SET state = ?, failure_reason = ?, tokens_in = ?, tokens_out = ?,
11268
- settled_microusd = ?, unknown_spend = ?, reply_bytes = ?, candidate_count = ?, finished_at = ?
11684
+ settled_microusd = ?, unknown_spend = ?, reply_bytes = ?, candidate_count = ?, cache_read_tokens = ?, cache_write_tokens = ?, finished_at = ?
11269
11685
  WHERE id = ? AND generation = ? AND state IN ('queued','running')`)
11270
- .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);
11686
+ .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);
11271
11687
  return Number(changed.changes) === 1;
11272
11688
  }
11273
11689
  /**
@@ -11990,6 +12406,11 @@ export class Store {
11990
12406
  // duplicate before any request is consumed.
11991
12407
  reviewAttempt);
11992
12408
  const id = Number(inserted.lastInsertRowid);
12409
+ // An ACP run's billing is the declaration standing for its runner and model NOW, stamped with the row it bills:
12410
+ // settlement reads this stamp, never the live declaration (docs/plans/acp-adapter.md § Billing).
12411
+ if (provider !== undefined && provider !== null && isAcpProvider(provider) && model !== null) {
12412
+ this.db.prepare("UPDATE run SET acp_billing_json = ? WHERE id = ?").run(JSON.stringify(this.acpBillingFor(provider, run.runner, model)), id);
12413
+ }
11993
12414
  // The attempt key (Agents board): opaque, minted with the row, and the only key its stream state is recorded under.
11994
12415
  this.db.prepare("UPDATE run SET attempt_key = ? WHERE id = ?").run(mintAttemptKey(), id);
11995
12416
  // The provenance row lands in the same transaction as the run row —
@@ -12423,6 +12844,216 @@ export class Store {
12423
12844
  * structural signal is gone. `auth-expired` pauses its provider; every
12424
12845
  * other class is observational.
12425
12846
  */
12847
+ /**
12848
+ * Record an ACP agent's passed conformance check on a runner (acp-attest.ts), replacing the live one — one
12849
+ * transaction, so there is never a moment with two or with a gap that reads as the old one.
12850
+ */
12851
+ recordAcpAttestation(input, now) {
12852
+ return this.transact(() => {
12853
+ this.db.prepare("UPDATE acp_attestation SET revoked_at = ?, revoked_by = ? WHERE provider = ? AND runner = ? AND revoked_at IS NULL")
12854
+ .run(now.toISOString(), `${input.recordedBy} (checked again)`, input.provider, input.runner);
12855
+ 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)
12856
+ 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);
12857
+ return Number(inserted.lastInsertRowid);
12858
+ });
12859
+ }
12860
+ /** An ACP agent's live attestation on a runner, under this build's conformance version; null when there is none. */
12861
+ acpAttestationFor(provider, runner) {
12862
+ const row = this.db.prepare("SELECT * FROM acp_attestation WHERE provider = ? AND runner = ? AND revoked_at IS NULL").get(provider, runner);
12863
+ if (row === undefined || Number(row["conformance_version"]) !== ACP_CONFORMANCE_VERSION)
12864
+ return null;
12865
+ const launch = launchFromJson(String(row["launch_json"]));
12866
+ const list = (json) => {
12867
+ try {
12868
+ const value = JSON.parse(String(json));
12869
+ return Array.isArray(value) && value.every(one => typeof one === "string") ? value : null;
12870
+ }
12871
+ catch {
12872
+ return null;
12873
+ }
12874
+ };
12875
+ const models = list(row["models_json"]);
12876
+ const checks = list(row["checks_json"]);
12877
+ if (launch === null || models === null || checks === null)
12878
+ return null;
12879
+ return {
12880
+ id: Number(row["id"]), provider: String(row["provider"]), runner: String(row["runner"]), conformanceVersion: Number(row["conformance_version"]),
12881
+ launch, agent: { name: String(row["agent_name"]), version: String(row["agent_version"]) }, cliVersion: String(row["cli_version"]),
12882
+ models, checks, recordedAt: String(row["recorded_at"]), recordedBy: String(row["recorded_by"]),
12883
+ };
12884
+ }
12885
+ /** Declare how an ACP agent's work on one model is billed on a runner; the new revision. */
12886
+ declareAcpBilling(input, now) {
12887
+ return Number(this.db.prepare("INSERT INTO acp_billing (provider, runner, model, kind, set_at, set_by) VALUES (?, ?, ?, ?, ?, ?)")
12888
+ .run(input.provider, input.runner, input.model, input.kind, now.toISOString(), input.by).lastInsertRowid);
12889
+ }
12890
+ /** The billing declaration standing for an ACP agent's model on a runner: metered, revision 0, when none was made. */
12891
+ acpBillingFor(provider, runner, model) {
12892
+ 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);
12893
+ return row === undefined ? { kind: "metered", revision: 0, model } : { kind: row["kind"] === "plan" ? "plan" : "metered", revision: Number(row["id"]), model };
12894
+ }
12895
+ /** The billing an ACP run was admitted under, or null (not an ACP run, or unreadable — then it is metered). */
12896
+ acpBillingOfRun(runId) {
12897
+ const raw = this.db.prepare("SELECT acp_billing_json FROM run WHERE id = ?").get(runId)?.["acp_billing_json"];
12898
+ if (typeof raw !== "string")
12899
+ return null;
12900
+ try {
12901
+ const value = JSON.parse(raw);
12902
+ if ((value["kind"] === "metered" || value["kind"] === "plan") && typeof value["revision"] === "number" && typeof value["model"] === "string") {
12903
+ return { kind: value["kind"], revision: value["revision"], model: value["model"] };
12904
+ }
12905
+ }
12906
+ catch { /* unreadable: metered */ }
12907
+ return null;
12908
+ }
12909
+ /** Ask for an ACP agent check on a runner (the caller proved the password); the request's id. Valid 30 minutes. */
12910
+ requestAcpCheck(input, now) {
12911
+ return Number(this.db.prepare(`INSERT INTO acp_check_request (provider, runner, model, conformance_version, requested_by, requested_at, expires_at)
12912
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(input.provider, input.runner, input.model, ACP_CONFORMANCE_VERSION, input.by, now.toISOString(), new Date(now.getTime() + 30 * 60_000).toISOString()).lastInsertRowid);
12913
+ }
12914
+ acpCheck(id) {
12915
+ const row = this.db.prepare("SELECT * FROM acp_check_request WHERE id = ?").get(id);
12916
+ return row === undefined ? null : readAcpCheck(row);
12917
+ }
12918
+ /** The checks asked for an agent, newest first (for the providers screen). */
12919
+ acpChecksFor(provider, limit = 10) {
12920
+ return this.db.prepare("SELECT * FROM acp_check_request WHERE provider = ? ORDER BY id DESC LIMIT ?").all(provider, limit).map(readAcpCheck);
12921
+ }
12922
+ /** The SQL that proves a watch still holds its lease (runner, repo, owner) at `?` — appended with its bindings. */
12923
+ 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 > ?)";
12924
+ /**
12925
+ * Claim the oldest pending, unexpired check for this runner, for the watch that will run it — one statement; the
12926
+ * partial unique index refuses a second running check on the runner. Null when there is none (or another runs).
12927
+ */
12928
+ claimAcpCheck(runner, watch, now) {
12929
+ const at = now.toISOString();
12930
+ try {
12931
+ const claimed = this.db.prepare(`UPDATE acp_check_request SET state = 'running', watch_repo = ?, watch_owner = ?
12932
+ WHERE id = (SELECT id FROM acp_check_request WHERE runner = ? AND state = 'pending' AND expires_at > ? ORDER BY id LIMIT 1)
12933
+ AND EXISTS (SELECT 1 FROM watch_lease w WHERE w.runner = ? AND w.repo = ? AND w.owner = ? AND w.expires_at > ?)
12934
+ RETURNING id`).get(watch.repo, watch.owner, runner, at, runner, watch.repo, watch.owner, at);
12935
+ return claimed === undefined ? null : this.acpCheck(Number(claimed["id"]));
12936
+ }
12937
+ catch (error) {
12938
+ if (/UNIQUE constraint failed/.test(String(error)))
12939
+ return null;
12940
+ throw error;
12941
+ }
12942
+ }
12943
+ /** Bind a running check, still owned and unexpired, to its task and the launch it inspected. */
12944
+ bindAcpCheck(id, binding, now) {
12945
+ const at = now.toISOString();
12946
+ return Number(this.db.prepare(`UPDATE acp_check_request SET task_ref = ?, launch_json = ?, lease_id = ?
12947
+ 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;
12948
+ }
12949
+ /** Consume step 1 or 2 for its run: in order, once, owned, unexpired. */
12950
+ consumeAcpCheckStep(id, step, runId, now) {
12951
+ const at = now.toISOString();
12952
+ const column = step === 1 ? "step1_run" : "step2_run";
12953
+ return Number(this.db.prepare(`UPDATE acp_check_request SET steps_used = ?, ${column} = ?
12954
+ WHERE id = ? AND state = 'running' AND steps_used = ? AND ${column} IS NULL AND expires_at > ? AND ${Store.WATCH_LIVE}
12955
+ ${step === 2 ? "AND step1_passed = 1" : ""}`).run(step, runId, id, step - 1, at, at).changes) === 1;
12956
+ }
12957
+ /** Record a step as passed, once its grading did: owned, unexpired, and only for the run the step was bound to. */
12958
+ passAcpCheckStep(id, step, runId, now) {
12959
+ const at = now.toISOString();
12960
+ const run = step === 1 ? "step1_run" : "step2_run";
12961
+ const passed = step === 1 ? "step1_passed" : "step2_passed";
12962
+ return Number(this.db.prepare(`UPDATE acp_check_request SET ${passed} = 1
12963
+ WHERE id = ? AND state = 'running' AND ${run} = ? AND ${passed} = 0 AND expires_at > ? AND ${Store.WATCH_LIVE}`).run(id, runId, at, at).changes) === 1;
12964
+ }
12965
+ /**
12966
+ * Publish a passed check: in one transaction, the request moves to passed — only if it is still running, unexpired,
12967
+ * owned by its live watch, with both steps used, bound and passed, and both their runs finished as agent-check
12968
+ * refusals — and the attestation is recorded. Anything else publishes nothing.
12969
+ */
12970
+ publishAcpCheck(id, attestation, now) {
12971
+ const at = now.toISOString();
12972
+ return this.transact(() => {
12973
+ // The attestation must be for this request's agent and runner, and offer the model it was checked on.
12974
+ const request = this.acpCheck(id);
12975
+ if (request === null || request.provider !== attestation.provider || request.runner !== attestation.runner || !attestation.models.includes(request.model))
12976
+ return false;
12977
+ const moved = Number(this.db.prepare(`UPDATE acp_check_request SET state = 'passed', finished_at = ?, result = 'passed'
12978
+ WHERE id = ? AND state = 'running' AND expires_at > ? AND ${Store.WATCH_LIVE}
12979
+ AND steps_used = 2 AND step1_passed = 1 AND step2_passed = 1 AND conformance_version = ?
12980
+ AND (SELECT COUNT(*) FROM run r WHERE r.id IN (step1_run, step2_run) AND r.task_ref = acp_check_request.task_ref
12981
+ AND r.outcome = 'refused' AND r.reason = 'agent-check' AND r.finished_at IS NOT NULL) = 2`)
12982
+ .run(at, id, at, at, ACP_CONFORMANCE_VERSION).changes);
12983
+ if (moved !== 1)
12984
+ return false;
12985
+ this.recordAcpAttestation(attestation, now);
12986
+ return true;
12987
+ });
12988
+ }
12989
+ /** The check request, when it still admits this run as its current step: running, unexpired, its watch's lease
12990
+ * live, and the step bound to exactly this run. Null otherwise. */
12991
+ acpCheckAdmitting(id, step, runId, now) {
12992
+ const at = now.toISOString();
12993
+ const row = this.db.prepare(`SELECT * FROM acp_check_request WHERE id = ? AND state = 'running' AND expires_at > ? AND ${Store.WATCH_LIVE}
12994
+ AND steps_used = ? AND ${step === 1 ? "step1_run" : "step2_run"} = ?`).get(id, at, at, step, runId);
12995
+ return row === undefined ? null : readAcpCheck(row);
12996
+ }
12997
+ /** Fail a check that hasn't finished, with its words. */
12998
+ failAcpCheck(id, words, now) {
12999
+ return Number(this.db.prepare("UPDATE acp_check_request SET state = 'failed', result = ?, finished_at = ? WHERE id = ? AND state IN ('pending','running')")
13000
+ .run(words.slice(0, 2000), now.toISOString(), id).changes) === 1;
13001
+ }
13002
+ /** Running checks on this runner no live watch holds any more, or past their time: the ones recovery may close. */
13003
+ abandonedAcpChecks(runner, now) {
13004
+ const at = now.toISOString();
13005
+ return this.db.prepare(`SELECT * FROM acp_check_request WHERE runner = ? AND state = 'running' AND (expires_at <= ? OR NOT ${Store.WATCH_LIVE})`)
13006
+ .all(runner, at, at).map(readAcpCheck);
13007
+ }
13008
+ /** Finished checks on this runner whose claim, checkout and task are not yet let go: cleanup's to-do, retried each pass. */
13009
+ unsettledAcpChecks(runner) {
13010
+ 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);
13011
+ }
13012
+ /** The claim a finished check's cleanup now holds on its task (its first one expired): the only one it may release. */
13013
+ setAcpCheckLease(id, leaseId) {
13014
+ 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;
13015
+ }
13016
+ /** A finished check holds something again (a checkout that landed after recovery settled it): cleanup reopens. */
13017
+ unsettleAcpCheck(id) {
13018
+ 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;
13019
+ }
13020
+ /** Take — or renew — the one cleanup attempt a finished check may have at a time: free, lapsed, or already this one. */
13021
+ holdAcpSettle(id, attempt, now, ttlMs) {
13022
+ const at = now.toISOString();
13023
+ return Number(this.db.prepare(`UPDATE acp_check_request SET settle_by = ?, settle_until = ?
13024
+ WHERE id = ? AND state IN ('passed','failed') AND settled_at IS NULL AND (settle_by IS NULL OR settle_by = ? OR settle_until <= ?)`)
13025
+ .run(attempt, new Date(now.getTime() + ttlMs).toISOString(), id, attempt, at).changes) === 1;
13026
+ }
13027
+ /** Whether this cleanup attempt still holds the check. */
13028
+ holdsAcpSettle(id, attempt, now) {
13029
+ return this.db.prepare("SELECT 1 FROM acp_check_request WHERE id = ? AND settle_by = ? AND settle_until > ?").get(id, attempt, now.toISOString()) !== undefined;
13030
+ }
13031
+ /** Let go of a cleanup attempt (only this one). */
13032
+ endAcpSettle(id, attempt) {
13033
+ this.db.prepare("UPDATE acp_check_request SET settle_by = NULL, settle_until = NULL WHERE id = ? AND settle_by = ?").run(id, attempt);
13034
+ }
13035
+ /** Mark a finished check's holdings let go — by the attempt holding it, and only if no checkout of its branch is
13036
+ * left (one that landed late keeps it open, whatever the attempt last saw). */
13037
+ settleAcpCheck(id, attempt, branch, now) {
13038
+ return Number(this.db.prepare(`UPDATE acp_check_request SET settled_at = ?, settle_by = NULL, settle_until = NULL
13039
+ WHERE id = ? AND state IN ('passed','failed') AND settled_at IS NULL AND settle_by = ? AND settle_until > ?
13040
+ AND NOT EXISTS (SELECT 1 FROM worktree w WHERE w.task_ref = acp_check_request.task_ref AND w.branch = ?)`)
13041
+ .run(now.toISOString(), id, attempt, now.toISOString(), branch).changes) === 1;
13042
+ }
13043
+ /** Pending checks past their time are expired, untouched. */
13044
+ expireAcpChecks(now) {
13045
+ 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);
13046
+ }
13047
+ /** Revoke an ACP agent's live attestation on a runner; whether there was one. */
13048
+ revokeAcpAttestation(provider, runner, by, now) {
13049
+ 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;
13050
+ }
13051
+ /** What a run's ACP agent itself reported it used (never priced; acp-client.ts). */
13052
+ stampAgentReportedUsage(id, usage) {
13053
+ if (usage === null)
13054
+ return;
13055
+ this.db.prepare("UPDATE run SET agent_reported_usage_json = ? WHERE id = ?").run(usage.slice(0, 8192), id);
13056
+ }
12426
13057
  stampTerminalClass(id, authMode, terminalClass) {
12427
13058
  // FIRST-WRITE only (Codex E2/E3 review, finding 8): the gateway stamps
12428
13059
  // the disposal class exactly once, before disposition; nothing may
@@ -12449,9 +13080,10 @@ export class Store {
12449
13080
  .prepare(`UPDATE run SET tokens_in = COALESCE(?, tokens_in),
12450
13081
  tokens_out = COALESCE(?, tokens_out),
12451
13082
  cost_usd = COALESCE(?, cost_usd),
12452
- usage_json = COALESCE(?, usage_json)
13083
+ usage_json = COALESCE(?, usage_json),
13084
+ agent_turns = COALESCE(?, agent_turns)
12453
13085
  WHERE id = ?`)
12454
- .run(usage.tokensIn ?? null, usage.tokensOut ?? null, usage.costUsd ?? null, usage.usageJson ?? null, id);
13086
+ .run(usage.tokensIn ?? null, usage.tokensOut ?? null, usage.costUsd ?? null, usage.usageJson ?? null, usage.agentTurns ?? null, id);
12455
13087
  this.settleRunSpend(id, new Date());
12456
13088
  }
12457
13089
  /** v105: price a run as it stands (reported cost, or tokens at the catalogue price, or unpriced), frozen at the
@@ -12461,8 +13093,11 @@ export class Store {
12461
13093
  if (row === undefined)
12462
13094
  return;
12463
13095
  // How the run is billed is fixed the first time it's settled (its chain entry's, or the provider's login or key).
13096
+ // An ACP run is billed as its stamp says: its agent's own plan reads as a plan ($0), anything else as metered.
13097
+ const acpStamp = isAcpProvider(String(row["provider"])) ? this.acpBillingOfRun(id) : null;
12464
13098
  const billing = row["billing"] === "subscription" || row["billing"] === "api-key" ? row["billing"]
12465
- : row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key" ? row["auth_mode"] : billingOf(String(row["provider"]), this.db);
13099
+ : isAcpProvider(String(row["provider"])) ? (acpStamp?.kind === "plan" ? "subscription" : "api-key")
13100
+ : row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key" ? row["auth_mode"] : billingOf(String(row["provider"]), this.db);
12466
13101
  const priced = priceWork(this.db, { provider: String(row["provider"]), model: row["model"] == null ? null : String(row["model"]), billing,
12467
13102
  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"]) });
12468
13103
  this.db.prepare(`INSERT INTO run_spend (run, microusd, source, billing, price_model, input_usd, output_usd, priced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -12732,6 +13367,15 @@ export class Store {
12732
13367
  return { ...none, remainingMicrousd: remaining };
12733
13368
  };
12734
13369
  }
13370
+ /** How one agent would bill new work now: an ACP agent by its declaration for that exact model on that runner
13371
+ * (plan reads as a plan, anything else as metered), every other provider as agentsFor says. */
13372
+ agentFor(provider, model, runner) {
13373
+ if (isAcpProvider(provider)) {
13374
+ const declared = model === null || runner === null ? null : this.acpBillingFor(provider, runner, model);
13375
+ return { provider, billing: declared?.kind === "plan" ? "subscription" : "api-key" };
13376
+ }
13377
+ return this.agentsFor([provider])[0];
13378
+ }
12735
13379
  /** How each provider would bill new work now (see spend.billingOf). */
12736
13380
  agentsFor(providers) {
12737
13381
  return [...new Set(providers)].map(provider => ({ provider, billing: billingOf(provider, this.db) }));
@@ -13291,6 +13935,60 @@ export class Store {
13291
13935
  return { ok: true, id };
13292
13936
  }), result => result.ok);
13293
13937
  }
13938
+ /**
13939
+ * File a note for the exact run a person is watching (live-conversation.md
13940
+ * §6): in ONE transaction, the run must still be the task's live attempt
13941
+ * (or, `onward`, any attempt of this task: the note goes to the next one),
13942
+ * and a repeated `key` from the same author hands back the same note — only
13943
+ * for the same task, run and text, whichever of the two ways it was sent;
13944
+ * any other reuse is refused. Nothing is filed on a refusal.
13945
+ */
13946
+ fileSteerNoteForRun(taskId, author, note, now, expect) {
13947
+ return this.transact(() => {
13948
+ const valid = validateNote(note);
13949
+ if (!valid.ok)
13950
+ return { ok: false, reason: "invalid-note", problem: valid.problem };
13951
+ const task = this.db.prepare("SELECT state FROM task WHERE id = ?").get(taskId);
13952
+ const ref = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, taskId);
13953
+ if (task === undefined || ref === undefined)
13954
+ return { ok: false, reason: "unknown-task" };
13955
+ const taskRef = Number(ref["id"]);
13956
+ const key = expect.key === null ? null : `steer-note:${author}:${expect.key}`;
13957
+ const digest = createHash("sha256").update(JSON.stringify([taskId, expect.run, valid.note])).digest("hex");
13958
+ if (key !== null) {
13959
+ const seen = this.db.prepare("SELECT operation, result FROM mutation WHERE idempotency_key = ?").get(key);
13960
+ if (seen !== undefined) {
13961
+ const saved = parseStoreColumn("mutation.result", seen["result"]);
13962
+ if (seen["operation"] === "fileSteerNoteForRun" && saved !== null && saved.digest === digest && typeof saved.id === "number") {
13963
+ return { ok: true, id: saved.id, replayed: true };
13964
+ }
13965
+ return { ok: false, reason: "key-reused" };
13966
+ }
13967
+ }
13968
+ const state = String(task["state"]);
13969
+ if (state === "done" || state === "cancelled")
13970
+ return { ok: false, reason: "task-finished" };
13971
+ const shown = this.db.prepare("SELECT task_ref, outcome, lease_id FROM run WHERE id = ?").get(expect.run);
13972
+ if (shown === undefined || Number(shown["task_ref"]) !== taskRef)
13973
+ return { ok: false, reason: "attempt-ended" };
13974
+ // Sent on from an attempt that ended (`onward`), the note is for the task's next attempt. Otherwise the run on
13975
+ // screen must still be the task's live attempt: unfinished, under the current lease. How the note reaches it —
13976
+ // its brief, its steering folder, or stdin — is steering's own business, as for any note.
13977
+ if (expect.onward !== true) {
13978
+ const lease = this.currentLiveLease(taskRef, now);
13979
+ if (shown["outcome"] !== null || lease === null || shown["lease_id"] !== lease)
13980
+ return { ok: false, reason: "attempt-ended" };
13981
+ }
13982
+ const id = this.insertSteerNote(taskRef, author, valid.note, now);
13983
+ if (key !== null) {
13984
+ this.db
13985
+ .prepare("INSERT INTO mutation (idempotency_key, operation, result, actor, created_at) VALUES (?, ?, ?, ?, ?)")
13986
+ .run(key, "fileSteerNoteForRun", JSON.stringify({ id, digest }), author, now.toISOString());
13987
+ }
13988
+ this.bumpWake();
13989
+ return { ok: true, id, replayed: false };
13990
+ });
13991
+ }
13294
13992
  /** One verified note, with the identity a running build's stdin copy is sent under: filing it is also what queues
13295
13993
  * its live delivery, in the same transaction (the builder's dispatcher sends whatever is filed and unsent). */
13296
13994
  insertSteerNote(taskRef, author, note, now) {
@@ -13353,6 +14051,7 @@ export class Store {
13353
14051
  this.db
13354
14052
  .prepare("UPDATE task_steer SET attached_run = ?, attached_at = ?, live_sends = 0, live_sent_at = NULL, live_missed_at = NULL WHERE id = ?")
13355
14053
  .run(runId, stamp, Number(row["id"]));
14054
+ this.noteSteerReached(Number(row["id"]), runId, "attached", stamp);
13356
14055
  }
13357
14056
  return rows.map(row => readSteerNote({ ...row, attached_run: runId, attached_at: stamp, live_sends: 0, live_sent_at: null, live_missed_at: null }));
13358
14057
  });
@@ -13364,9 +14063,13 @@ export class Store {
13364
14063
  * first receipt wins, and a replay changes nothing.
13365
14064
  */
13366
14065
  settleSteerDelivered(runId, now) {
14066
+ const stamp = now.toISOString();
14067
+ const settled = this.db.prepare("SELECT id FROM task_steer WHERE attached_run = ? AND delivered_at IS NULL").all(runId);
13367
14068
  const { changes } = this.db
13368
14069
  .prepare("UPDATE task_steer SET delivered_at = ? WHERE attached_run = ? AND delivered_at IS NULL")
13369
- .run(now.toISOString(), runId);
14070
+ .run(stamp, runId);
14071
+ for (const row of settled)
14072
+ this.noteSteerReached(Number(row["id"]), runId, "read", stamp);
13370
14073
  return Number(changes);
13371
14074
  }
13372
14075
  /**
@@ -13420,8 +14123,24 @@ export class Store {
13420
14123
  live_missed_at = CASE WHEN mailed_run IS ? THEN live_missed_at ELSE NULL END
13421
14124
  WHERE id = ? AND delivered_at IS NULL AND superseded_at IS NULL`)
13422
14125
  .run(runId, now.toISOString(), runId, runId, runId, noteId);
14126
+ if (Number(changes) > 0)
14127
+ this.noteSteerReached(noteId, runId, "mailed", now.toISOString());
13423
14128
  return Number(changes) > 0;
13424
14129
  }
14130
+ /** One run a note reached, kept for its live conversation (live-notes.ts); display state, so a failure never blocks steering. */
14131
+ noteSteerReached(noteId, runId, how, at) {
14132
+ try {
14133
+ this.db.prepare("INSERT OR IGNORE INTO steer_note_run (note, run, how, at) VALUES (?, ?, ?, ?)").run(noteId, runId, how, at);
14134
+ }
14135
+ catch { /* display state */ }
14136
+ }
14137
+ /** The notes this run reached, filing order — attached to its brief, mailed into its folder, or read by it. */
14138
+ steerNotesReaching(runId) {
14139
+ return this.db
14140
+ .prepare("SELECT * FROM task_steer WHERE id IN (SELECT note FROM steer_note_run WHERE run = ?) ORDER BY id")
14141
+ .all(runId)
14142
+ .map(readSteerNote);
14143
+ }
13425
14144
  /**
13426
14145
  * The agent moved this note to handled/ (v120): it was read, by THIS run.
13427
14146
  * Acknowledgement is the only road from mailed to delivered — a hook firing
@@ -13434,6 +14153,8 @@ export class Store {
13434
14153
  .prepare(`UPDATE task_steer SET acked_at = ?, delivered_at = ?, attached_run = ?, attached_at = COALESCE(mailed_at, ?)
13435
14154
  WHERE id = ? AND mailed_run = ? AND acked_at IS NULL AND delivered_at IS NULL AND superseded_at IS NULL`)
13436
14155
  .run(stamp, stamp, runId, stamp, noteId, runId);
14156
+ if (Number(changes) > 0)
14157
+ this.noteSteerReached(noteId, runId, "read", stamp);
13437
14158
  if (Number(changes) > 0)
13438
14159
  this.bumpWake();
13439
14160
  return Number(changes) > 0;
@@ -14531,17 +15252,31 @@ export class Store {
14531
15252
  return row === undefined ? null : readLeadSession(row);
14532
15253
  }
14533
15254
  endLeadSession(id, by, now) {
14534
- const changed = this.db
14535
- .prepare("UPDATE lead_session SET ended_at = ?, ended_by = ? WHERE id = ? AND ended_at IS NULL")
14536
- .run(now.toISOString(), by, id);
14537
- return Number(changed.changes) === 1;
15255
+ return this.transact(() => {
15256
+ const changed = this.db
15257
+ .prepare("UPDATE lead_session SET ended_at = ?, ended_by = ? WHERE id = ? AND ended_at IS NULL")
15258
+ .run(now.toISOString(), by, id);
15259
+ // Its waiting messages can never run under it (each is bound to the session it was sent in): they end now, Not
15260
+ // sent, and keep no words (v128 lead queue).
15261
+ if (Number(changed.changes) === 1) {
15262
+ const waiting = this.db.prepare("SELECT message FROM lead_queue WHERE session = ? AND status = 'queued'").all(id).map(row => Number(row["message"]));
15263
+ for (const message of waiting) {
15264
+ 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);
15265
+ this.forgetQueuedLeadMessage(message);
15266
+ }
15267
+ }
15268
+ return Number(changed.changes) === 1;
15269
+ });
14538
15270
  }
14539
15271
  /** Revocation's companion (ruling 10): an approver's standing ends every session it minted. */
14540
15272
  endLeadSessionsFor(approver, by, now) {
14541
- const changed = this.db
14542
- .prepare("UPDATE lead_session SET ended_at = ?, ended_by = ? WHERE approver = ? AND ended_at IS NULL")
14543
- .run(now.toISOString(), by, approver);
14544
- return Number(changed.changes);
15273
+ return this.transact(() => {
15274
+ const ending = this.db.prepare("SELECT id FROM lead_session WHERE approver = ? AND ended_at IS NULL").all(approver).map(row => Number(row["id"]));
15275
+ // Each ends as endLeadSession ends one: its waiting messages too (v128 lead queue).
15276
+ for (const id of ending)
15277
+ this.endLeadSession(id, by, now);
15278
+ return ending.length;
15279
+ });
14545
15280
  }
14546
15281
  /** The live thread for this approver and scope under THIS ceiling; a
14547
15282
  * thread under another ceiling is closed first (ruling 9) — the surface
@@ -14627,11 +15362,14 @@ export class Store {
14627
15362
  const { changes } = this.db.prepare("UPDATE flow SET state = 'archived', updated_by = ?, updated_at = ? WHERE id = ? AND state = 'active'").run(by, now.toISOString(), id);
14628
15363
  return Number(changes) === 1;
14629
15364
  }
15365
+ /** A new card. `byKind` (v126) is decided where the card is made, never read from `by`: a delivery, a poll, a schedule
15366
+ * a public form or a chat channel's message is automation whatever its label says; a signed-in person's own card is a
15367
+ * person's. Every caller says which: filing passes it on exactly, and never reads it from `by`. */
14630
15368
  addFlowCard(card, now) {
14631
15369
  return this.transact(() => {
14632
15370
  const stamp = now.toISOString();
14633
- 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', ?, ?, ?, ?)")
14634
- .run(card.flow, card.title, card.description, card.stage, card.by, stamp, stamp, card.source === undefined ? null : JSON.stringify(card.source)).lastInsertRowid);
15371
+ 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', ?, ?, ?, ?, ?)")
15372
+ .run(card.flow, card.title, card.description, card.stage, card.by, stamp, stamp, card.source === undefined ? null : JSON.stringify(card.source), card.byKind).lastInsertRowid);
14635
15373
  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);
14636
15374
  return id;
14637
15375
  });
@@ -15208,7 +15946,80 @@ export class Store {
15208
15946
  * firing) the task it filed. False when it was already recorded.
15209
15947
  */
15210
15948
  recordFlowTriggerEvent(trigger, key, card, note, now, task = null, onTime = false, ranScript = null) {
15211
- const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, task, on_time, ran_script) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(trigger, key, card, note, now.toISOString(), task, onTime ? 1 : 0, ranScript);
15949
+ const at = now.toISOString();
15950
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
15951
+ .run(trigger, key, card, note, at, task, onTime ? 1 : 0, ranScript, card === null ? "skipped" : "accepted", card === null ? null : at);
15952
+ return Number(changes) === 1;
15953
+ }
15954
+ /**
15955
+ * v126: point a trigger at another saved secret ("" for none), and (when given) its new settings, in one write — only
15956
+ * from the secret and settings the caller read, so of two changes at once exactly one lands. False when it changed
15957
+ * meanwhile (or the trigger is gone): the caller then removes only the secret it wrote itself.
15958
+ */
15959
+ swapFlowHookCredential(id, from, to, configJson, now) {
15960
+ // The address moves with it when given: one write, from the very secret, settings and address the caller read.
15961
+ const { changes } = this.db.prepare(`UPDATE flow_trigger SET hook_credential = ?, hook_hash = COALESCE(?, hook_hash), config_json = COALESCE(?, config_json), updated_at = ?
15962
+ WHERE id = ? AND hook_credential = ? AND config_json = ? AND hook_hash IS ? AND state != 'removed'`)
15963
+ .run(to.credential, to.hookHash ?? null, configJson, now.toISOString(), id, from.credential, from.configJson, from.hookHash);
15964
+ return Number(changes) === 1;
15965
+ }
15966
+ /** v126: what a trigger did with one delivery, or null when it never received it. */
15967
+ flowReceipt(trigger, key) {
15968
+ 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);
15969
+ return row === undefined ? null : readFlowReceipt(row);
15970
+ }
15971
+ /** v126: a delivery received but made no card for: ignored (not for this flow), or held until there's room (its
15972
+ * prepared item kept whole). False when the key was already received. */
15973
+ recordFlowReceipt(trigger, key, state, note, itemJson, now) {
15974
+ if ((state === "held") !== (itemJson !== null))
15975
+ throw new Error("a held receipt keeps its item, and only a held one");
15976
+ const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, state, item_json) VALUES (?, ?, NULL, ?, ?, ?, ?)")
15977
+ .run(trigger, key, note, now.toISOString(), state, itemJson);
15978
+ return Number(changes) === 1;
15979
+ }
15980
+ /** v126: a held delivery became its card. Only ever from held, in the card's own transaction: false (the caller rolls
15981
+ * the card back) when it is no longer held. */
15982
+ acceptHeldReceipt(trigger, key, card, now) {
15983
+ 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'")
15984
+ .run(card, now.toISOString(), trigger, key);
15985
+ return Number(changes) === 1;
15986
+ }
15987
+ /** v126: the cards a trigger made since `since`, counted when each was made (a held delivery counts when it became
15988
+ * its card, not when it arrived). A receipt saved before v126 counts at its arrival. */
15989
+ flowCardsAcceptedSince(trigger, since) {
15990
+ const at = since.toISOString();
15991
+ const row = this.db.prepare(`SELECT
15992
+ (SELECT COUNT(*) FROM flow_trigger_event WHERE trigger = :trigger AND accepted_at > :at)
15993
+ + (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 });
15994
+ return Number(row?.["n"] ?? 0);
15995
+ }
15996
+ /** v126: a trigger's held deliveries, oldest first. */
15997
+ heldFlowReceipts(trigger, limit) {
15998
+ 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)
15999
+ .map(row => ({ key: String(row["key"]), itemJson: String(row["item_json"]), at: String(row["at"]) }));
16000
+ }
16001
+ /** v126: a held delivery that can't become its card (its words no longer pass): left out, with why. */
16002
+ skipHeldReceipt(trigger, key, note) {
16003
+ 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);
16004
+ return Number(changes) === 1;
16005
+ }
16006
+ /** v126: how many deliveries a trigger holds. */
16007
+ heldFlowReceiptCount(trigger) {
16008
+ return Number(this.db.prepare("SELECT COUNT(*) AS n FROM flow_trigger_event WHERE trigger = ? AND state = 'held'").get(trigger)?.["n"] ?? 0);
16009
+ }
16010
+ /** v126: one delivery turned away because the hold queue was full: counted, and since when, until a person clears it. */
16011
+ noteFlowDeliveryDropped(trigger, now) {
16012
+ this.db.prepare("UPDATE flow_trigger SET dropped_count = dropped_count + 1, dropped_since = COALESCE(dropped_since, ?) WHERE id = ?").run(now.toISOString(), trigger);
16013
+ }
16014
+ /** v126: a person has seen the turned-away deliveries. */
16015
+ clearFlowDeliveriesDropped(trigger) {
16016
+ this.db.prepare("UPDATE flow_trigger SET dropped_count = 0, dropped_since = NULL WHERE id = ?").run(trigger);
16017
+ }
16018
+ /** v126: whether this hour's "too many" note on a trigger is still to be written: true at most once an hour, claimed
16019
+ * atomically so two deliveries at once write it once. */
16020
+ claimFlowLimitNote(trigger, now) {
16021
+ const { changes } = this.db.prepare("UPDATE flow_trigger SET limit_noted_at = ? WHERE id = ? AND (limit_noted_at IS NULL OR limit_noted_at <= ?)")
16022
+ .run(now.toISOString(), trigger, new Date(now.getTime() - 3_600_000).toISOString());
15212
16023
  return Number(changes) === 1;
15213
16024
  }
15214
16025
  /** The last card a trigger made, if any. */
@@ -15307,6 +16118,25 @@ export class Store {
15307
16118
  this.db.prepare(`INSERT INTO run_tool (run, tools_json, created_at) VALUES (?, json_object('fence', json(?)), ?)
15308
16119
  ON CONFLICT (run) DO UPDATE SET tools_json = json_set(tools_json, '$.fence', json(?))`).run(run, JSON.stringify(fence), now.toISOString(), JSON.stringify(fence));
15309
16120
  }
16121
+ /** What one attempt's network was and what its proxy saw (egress.ts), kept beside its fence. */
16122
+ recordRunNetwork(run, network, now) {
16123
+ const json = JSON.stringify(network);
16124
+ this.db.prepare(`INSERT INTO run_tool (run, tools_json, created_at) VALUES (?, json_object('network', json(?)), ?)
16125
+ ON CONFLICT (run) DO UPDATE SET tools_json = json_set(tools_json, '$.network', json(?))`).run(run, json, now.toISOString(), json);
16126
+ }
16127
+ runNetwork(run) {
16128
+ const row = this.db.prepare("SELECT json_extract(tools_json, '$.network') AS network FROM run_tool WHERE run = ?").get(run);
16129
+ if (row === undefined || row["network"] === null)
16130
+ return null;
16131
+ let parsed;
16132
+ try {
16133
+ parsed = JSON.parse(String(row["network"]));
16134
+ }
16135
+ catch {
16136
+ return null;
16137
+ }
16138
+ return runNetworkFromValue(parsed);
16139
+ }
15310
16140
  runFence(run) {
15311
16141
  const row = this.db.prepare("SELECT json_extract(tools_json, '$.fence') AS fence FROM run_tool WHERE run = ?").get(run);
15312
16142
  if (row === undefined || row["fence"] === null)
@@ -15357,27 +16187,30 @@ export class Store {
15357
16187
  return rows.length;
15358
16188
  });
15359
16189
  }
16190
+ /** `pending`: a queued message (personal or team) that waits for its turn — out of the model's history until the
16191
+ * admission that binds it clears the flag in the same statement (v128). */
15360
16192
  appendLeadMessage(message, now) {
15361
16193
  const inserted = this.db
15362
- .prepare("INSERT INTO lead_message (thread, turn, role, text, activity, created_at) VALUES (?, ?, ?, ?, ?, ?)")
15363
- .run(message.thread, message.turn, message.role, message.text, message.activity ?? null, now.toISOString());
16194
+ .prepare("INSERT INTO lead_message (thread, turn, role, text, activity, created_at, pending) VALUES (?, ?, ?, ?, ?, ?, ?)")
16195
+ .run(message.thread, message.turn, message.role, message.text, message.activity ?? null, now.toISOString(), message.pending === true ? 1 : 0);
15364
16196
  this.db.prepare("UPDATE lead_thread SET last_turn_at = ? WHERE id = ?").run(now.toISOString(), message.thread);
15365
16197
  return Number(inserted.lastInsertRowid);
15366
16198
  }
16199
+ /** The model's history: the last `limit` ADMITTED messages, oldest first — a message still waiting for its turn (or
16200
+ * removed, expired or refused while waiting) is never read back to the lead. An index range scan whatever the backlog
16201
+ * (lead_message_history). People see the whole thread through listLeadMessages. */
16202
+ listLeadHistory(thread, limit) {
16203
+ return this.db
16204
+ .prepare("SELECT * FROM (SELECT * FROM lead_message WHERE thread = ? AND pending = 0 ORDER BY id DESC LIMIT ?) ORDER BY id ASC")
16205
+ .all(thread, limit)
16206
+ .map(readLeadMessageRow);
16207
+ }
15367
16208
  /** The last `limit` messages, oldest first. */
15368
16209
  listLeadMessages(thread, limit) {
15369
16210
  return this.db
15370
16211
  .prepare("SELECT * FROM (SELECT * FROM lead_message WHERE thread = ? ORDER BY id DESC LIMIT ?) ORDER BY id ASC")
15371
16212
  .all(thread, limit)
15372
- .map(row => ({
15373
- id: Number(row["id"]),
15374
- thread: Number(row["thread"]),
15375
- turn: row["turn"] === null ? null : Number(row["turn"]),
15376
- role: String(row["role"]),
15377
- text: String(row["text"]),
15378
- activity: row["activity"] === null ? null : String(row["activity"]),
15379
- createdAt: String(row["created_at"]),
15380
- }));
16213
+ .map(readLeadMessageRow);
15381
16214
  }
15382
16215
  /** A proposal is inert (`drafting`) until its turn finalizes. */
15383
16216
  draftLeadProposal(proposal, now) {
@@ -15558,6 +16391,18 @@ export class Store {
15558
16391
  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);
15559
16392
  if (live !== undefined || liveChat !== undefined)
15560
16393
  return { ok: false, reason: "concurrent" };
16394
+ // v128: while something of this person's waits in the personal queue (or is claimed but not yet bound), the only
16395
+ // personal turn admitted is the one carrying the exact claim of the backlog's oldest message — a follow pass, a
16396
+ // channel turn or another send waits behind it. Team turns are counted per thread and are not held.
16397
+ if (!teamBinding) {
16398
+ 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);
16399
+ if (oldest !== undefined) {
16400
+ const claim = args.queueClaim;
16401
+ const own = claim !== undefined && Number(oldest["message"]) === claim.message && oldest["status"] === "running" && oldest["runner"] === claim.runner && Number(oldest["generation"]) === claim.generation;
16402
+ if (!own)
16403
+ return { ok: false, reason: "backlog" };
16404
+ }
16405
+ }
15561
16406
  if (this.chatTurnsToday(args.approver, now) >= args.dailyTurns)
15562
16407
  return { ok: false, reason: "daily-cap" };
15563
16408
  if (session.endedAt !== null) {
@@ -16053,7 +16898,7 @@ export class Store {
16053
16898
  handoff: row["handoff"] === null ? null : String(row["handoff"]),
16054
16899
  costUsd: row["cost_usd"] === null ? null : Number(row["cost_usd"]),
16055
16900
  provider: row["provider"] === null || row["provider"] === undefined ? null : String(row["provider"]),
16056
- authMode: row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key"
16901
+ authMode: row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key" || row["auth_mode"] === "agent-login"
16057
16902
  ? row["auth_mode"]
16058
16903
  : null,
16059
16904
  ranMinutes: row["started_at"] === null || row["finished_at"] === null || row["started_at"] === undefined
@@ -16110,7 +16955,7 @@ export class Store {
16110
16955
  ? null
16111
16956
  : Math.max(1, Math.round((new Date(String(row["finished_at"])).getTime() - new Date(String(row["started_at"])).getTime()) / 60_000)),
16112
16957
  costUsd: row["cost_usd"] === null ? null : Number(row["cost_usd"]),
16113
- authMode: row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key"
16958
+ authMode: row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key" || row["auth_mode"] === "agent-login"
16114
16959
  ? row["auth_mode"]
16115
16960
  : null,
16116
16961
  prNumber: row["pr_number"] === null || row["pr_number"] === undefined ? null : Number(row["pr_number"]),
@@ -16932,6 +17777,11 @@ export class Store {
16932
17777
  return Number(this.db.prepare("INSERT INTO run_process (run,host,process_group,observed_at,boot_id) VALUES (?,?,?,?,?)")
16933
17778
  .run(runId, hostname(), group ? 1 : 0, now.toISOString(), currentBootId()).lastInsertRowid);
16934
17779
  }
17780
+ /** The run's process witnesses not yet proved exited — after first saving whatever exit evidence is available. */
17781
+ openRunProcessCount(runId, now) {
17782
+ this.recordRunProcessExits(runId, now);
17783
+ return Number(this.db.prepare("SELECT COUNT(*) AS n FROM run_process WHERE run = ? AND exited_at IS NULL").get(runId).n);
17784
+ }
16935
17785
  /** Save positive OS exit evidence while it is available, before a numeric
16936
17786
  * PID can be reused. Completion, missing ancestry and age prove nothing.
16937
17787
  * Existing rows stay intact; only a previously unknown exit is recorded.
@@ -17382,6 +18232,62 @@ export class Store {
17382
18232
  return counts;
17383
18233
  }
17384
18234
  /** A project's build concurrency changed (project-concurrency.ts): the ledger keeps before → after. */
18235
+ /** A project's network (egress.ts): its saved setting, or the default when none is saved or it doesn't read. */
18236
+ projectNetwork(repo) {
18237
+ const row = this.db.prepare("SELECT network_json FROM project_network WHERE repo = ?").get(repo);
18238
+ if (row === undefined)
18239
+ return PROJECT_NETWORK_DEFAULT;
18240
+ let parsed = null;
18241
+ try {
18242
+ parsed = JSON.parse(String(row["network_json"]));
18243
+ }
18244
+ catch {
18245
+ parsed = null;
18246
+ }
18247
+ // A damaged row reads as the narrowest setting, never a wider one.
18248
+ return projectNetworkFromValue(parsed) ?? { mode: "allowlist", hosts: [], loopback: "proxy-only" };
18249
+ }
18250
+ /** An approver changed a project's network: saved, and in the ledger as before → after. */
18251
+ setProjectNetwork(repo, network, by, now) {
18252
+ return this.transact(() => {
18253
+ const before = this.projectNetwork(repo);
18254
+ const json = JSON.stringify({ mode: network.mode, hosts: [...new Set(network.hosts)].sort(), loopback: network.loopback });
18255
+ this.db.prepare(`INSERT INTO project_network (repo, network_json, updated_at, updated_by) VALUES (?, ?, ?, ?)
18256
+ 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);
18257
+ const after = this.projectNetwork(repo);
18258
+ const words = (one) => `${one.mode}${one.hosts.length === 0 ? "" : ` +${one.hosts.join(",")}`}${one.loopback === "proxy-only" ? " no-local" : ""}`;
18259
+ this.recordPolicy(by, repo, "network", words(before), words(after), now);
18260
+ return { before, after };
18261
+ });
18262
+ }
18263
+ /** Where a project's builds may write (agent-fence.ts): its saved setting, or only their workspace. */
18264
+ projectWrites(repo) {
18265
+ const mode = this.db.prepare("SELECT mode FROM project_writes WHERE repo = ?").get(repo)?.["mode"];
18266
+ // A damaged row reads as the narrower setting, never the wider one.
18267
+ return mode === "anywhere" ? "anywhere" : WRITES_DEFAULT;
18268
+ }
18269
+ /** An approver changed where a project's builds may write: saved, and in the ledger as before → after. */
18270
+ setProjectWrites(repo, mode, by, now) {
18271
+ return this.transact(() => {
18272
+ const before = this.projectWrites(repo);
18273
+ this.db.prepare(`INSERT INTO project_writes (repo, mode, updated_at, updated_by) VALUES (?, ?, ?, ?)
18274
+ ON CONFLICT (repo) DO UPDATE SET mode = excluded.mode, updated_at = excluded.updated_at, updated_by = excluded.updated_by`).run(repo, mode, now.toISOString(), by);
18275
+ const after = this.projectWrites(repo);
18276
+ this.recordPolicy(by, repo, "build writes", before, after, now);
18277
+ return { before, after };
18278
+ });
18279
+ }
18280
+ /** Whether this installation lets an agent run where no OS confinement exists (off unless an operator said so). */
18281
+ unfencedAgentsAllowed() {
18282
+ return Number(this.db.prepare("SELECT unfenced_allowed FROM confinement_setting WHERE id = 1").get()?.["unfenced_allowed"] ?? 0) === 1;
18283
+ }
18284
+ setUnfencedAgentsAllowed(allowed, by, now) {
18285
+ this.transact(() => {
18286
+ const before = this.unfencedAgentsAllowed();
18287
+ this.db.prepare("UPDATE confinement_setting SET unfenced_allowed = ?, updated_at = ?, updated_by = ? WHERE id = 1").run(allowed ? 1 : 0, now.toISOString(), by);
18288
+ this.recordPolicy(by, null, "unconfined agents", before ? "allowed" : "refused", allowed ? "allowed" : "refused", now);
18289
+ });
18290
+ }
17385
18291
  recordProjectConcurrency(by, repo, before, after, now) {
17386
18292
  this.recordPolicy(by, repo, "builds at once", String(before), String(after), now);
17387
18293
  }
@@ -17406,6 +18312,7 @@ export class Store {
17406
18312
  markTelegramUpdateApplied(...args) { return this.telegramChat().markUpdateApplied(...args); }
17407
18313
  // ---- the conversation queue (v62) ------------------------------------------
17408
18314
  enqueueTelegramConversation(...args) { return this.telegramChat().enqueueMessage(...args); }
18315
+ telegramMessagesWaiting(binding) { return this.telegramChat().messagesWaiting(binding); }
17409
18316
  telegramConversationWaitingOn(...args) { return this.telegramChat().messageWaitingOn(...args); }
17410
18317
  getTelegramConversation(...args) { return this.telegramChat().getMessage(...args); }
17411
18318
  listTelegramConversations(...args) { return this.telegramChat().listMessages(...args); }
@@ -17440,6 +18347,7 @@ export class Store {
17440
18347
  this.telegramChat().deferUntil(botId, until);
17441
18348
  }
17442
18349
  telegramDeliveryProblem(...args) { return this.telegramChat().deliveryProblem(...args); }
18350
+ telegramWatchFence(...args) { return this.telegramChat().watchFence(...args); }
17443
18351
  recordTelegramMessage(...args) { return this.telegramChat().recordMessage(...args); }
17444
18352
  telegramMessageOf(...args) { return this.telegramChat().messageOf(...args); }
17445
18353
  finalizeTelegramDelivery(...args) { return this.telegramChat().finalizeDelivery(...args); }
@@ -18032,7 +18940,7 @@ export class Store {
18032
18940
  * the subscription live under its enrolled generation, the notification
18033
18941
  * unresolved and still stamped. Null = do not transmit.
18034
18942
  */
18035
- pushSendFence(id, owner, generation) {
18943
+ pushSendFence(id, owner, generation, now = new Date()) {
18036
18944
  const pair = this.db
18037
18945
  .prepare(`SELECT * FROM push_delivery
18038
18946
  WHERE id = ? AND state = 'claimed' AND claim_owner = ? AND claim_generation = ?`)
@@ -18052,7 +18960,20 @@ export class Store {
18052
18960
  .get(Number(pair["notification"]));
18053
18961
  if (notification === undefined)
18054
18962
  return null;
18055
- return { ...readPushPair(pair), subscriptionRow: readPushSubscription(subscription), notificationRow: readNotification(notification) };
18963
+ // The watch, again at the last moment (cockpit §3): a person who opened the agent since the claim holds the ping
18964
+ // (back to pending, no attempt spent) or, for news they have now seen, retires it.
18965
+ const row = readNotification(notification), approverName = String(subscription["approver"]);
18966
+ const watched = watchedPing(this, row, approverName, now);
18967
+ if (watched === "skip") {
18968
+ 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);
18969
+ return null;
18970
+ }
18971
+ if (watched !== "send") {
18972
+ this.db.prepare(`UPDATE push_delivery SET state = 'pending', claim_owner = NULL, claim_expires_at = NULL, next_attempt_at = ?
18973
+ WHERE id = ? AND state = 'claimed' AND claim_owner = ? AND claim_generation = ?`).run(watched.hold, id, owner, generation);
18974
+ return null;
18975
+ }
18976
+ return { ...readPushPair(pair), subscriptionRow: readPushSubscription(subscription), notificationRow: row };
18056
18977
  }
18057
18978
  /** Settle a pair, conditionally on owner + generation (finding 25). */
18058
18979
  settlePushPair(id, owner, generation, outcome, now) {
@@ -18145,6 +19066,87 @@ export class Store {
18145
19066
  .run(idempotencyKey, operation, JSON.stringify(result ?? null), actor, at.toISOString());
18146
19067
  return result;
18147
19068
  }
19069
+ /** A kept web session's changes: never inserts, so a session ended elsewhere is not brought back by a cached copy.
19070
+ * False when it is gone. (Kept last in the class: the freshness tripwire pins method positions.) */
19071
+ updateWebSession(session) {
19072
+ return Number(this.db.prepare("UPDATE web_session SET last_seen = ?, project = ?, project_revision = ?, sso_at = ? WHERE id_hash = ?")
19073
+ .run(session.lastSeen, session.project, session.projectRevision, session.ssoAt, session.idHash).changes) > 0;
19074
+ }
19075
+ // ---- lead sweeps (docs/plans/lead-fan-out.md). Kept last in the class: the freshness tripwire pins method positions.
19076
+ /** A new sweep record (inside the caller's filing transaction). */
19077
+ createSweep(input, now) {
19078
+ const row = this.db.prepare("INSERT INTO lead_sweep (owner, thread, repo, title, brief, max_parallel, proposal, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
19079
+ .run(input.owner, input.thread, input.repo, input.title, input.brief, input.maxParallel, input.proposal, now.toISOString());
19080
+ return Number(row.lastInsertRowid);
19081
+ }
19082
+ /** Link one filed child (its family root) to its sweep, in order. */
19083
+ addSweepTask(sweep, rootTask, ordinal, label) {
19084
+ this.db.prepare("INSERT INTO lead_sweep_task (sweep, root_task, ordinal, label) VALUES (?, ?, ?, ?)").run(sweep, rootTask, ordinal, label);
19085
+ }
19086
+ getSweep(id) {
19087
+ const row = this.db.prepare("SELECT * FROM lead_sweep WHERE id = ?").get(id);
19088
+ if (row === undefined)
19089
+ return null;
19090
+ 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"]),
19091
+ brief: String(row["brief"]), maxParallel: Number(row["max_parallel"]), proposal: row["proposal"] === null ? null : Number(row["proposal"]), createdAt: String(row["created_at"]) };
19092
+ }
19093
+ /** The sweep a proposal filed, if it did (a replayed confirm finds it). */
19094
+ sweepOfProposal(proposal) {
19095
+ const row = this.db.prepare("SELECT id FROM lead_sweep WHERE proposal = ?").get(proposal);
19096
+ return row === undefined ? null : Number(row["id"]);
19097
+ }
19098
+ /** A sweep's children, in order: each one's family root and its label. */
19099
+ sweepTasks(sweep) {
19100
+ return this.db.prepare("SELECT root_task, ordinal, label FROM lead_sweep_task WHERE sweep = ? ORDER BY ordinal").all(sweep)
19101
+ .map(row => ({ rootTask: String(row["root_task"]), ordinal: Number(row["ordinal"]), label: String(row["label"]) }));
19102
+ }
19103
+ /** The sweep a task belongs to through its family root (a revision of a child belongs too), or null. */
19104
+ sweepOfTask(taskId) {
19105
+ const { chain } = this.revisionAncestryStatus(taskId);
19106
+ const root = chain.at(-1) ?? taskId;
19107
+ 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);
19108
+ return row === undefined ? null : { id: Number(row["id"]), maxParallel: Number(row["max_parallel"]) };
19109
+ }
19110
+ /**
19111
+ * How many of a sweep's families hold a live claim now (the latest lease of any task in the family, unreleased and
19112
+ * unexpired, across every runner) — claimed-but-not-started counts. `except` leaves one task_ref out (the one being
19113
+ * claimed). Read inside acquire's own write transaction, so two runners can't both take the last slot.
19114
+ */
19115
+ sweepLiveClaims(sweep, stamp, except = null) {
19116
+ const row = this.db.prepare(`WITH RECURSIVE fam(root, id, depth) AS (
19117
+ SELECT t.root_task, t.root_task, 0 FROM lead_sweep_task t WHERE t.sweep = ?
19118
+ 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),
19119
+ 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),
19120
+ latest AS (SELECT refs.root, c.released_at, c.expires_at FROM refs JOIN claim c ON c.task_ref = refs.ref
19121
+ WHERE refs.ref IS NOT ? AND c.lease_generation = (SELECT MAX(lease_generation) FROM claim WHERE task_ref = refs.ref))
19122
+ SELECT COUNT(DISTINCT root) AS n FROM latest WHERE released_at IS NULL AND expires_at > ?`).get(sweep, BUILT_IN, BUILT_IN, except, stamp);
19123
+ return Number(row?.["n"] ?? 0);
19124
+ }
19125
+ /** Whether a request of this lead turn ended with its cost unknown and that cost is still unconfirmed — the turn is
19126
+ * what keeps chat paused on its credential. Once confirmed, it no longer is. */
19127
+ leadTurnCostUnconfirmed(leadTurn) {
19128
+ 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;
19129
+ }
19130
+ /** The lead turn a chat step belongs to (a lead reply's request), or null for a standalone chat turn. */
19131
+ leadTurnOfStep(step) {
19132
+ const row = this.db.prepare("SELECT mate_turn FROM chat_turn WHERE id = ?").get(step);
19133
+ return row === undefined || row["mate_turn"] === null ? null : this.getLeadTurn(Number(row["mate_turn"]));
19134
+ }
19135
+ /** A waiting message that will never run keeps nothing of what it said (v128 lead queue): its words, the context it was
19136
+ * sent with, and any copy the memory search made of it. Only while it is still pending — a message a turn took stays. */
19137
+ /** Whether a send of this session reached the plane: the engine took it, or the lead queue holds it (waiting, run,
19138
+ * removed or expired — any of those means the composer's draft was accepted and must not be sent again). */
19139
+ leadRequestReceived(session, request) {
19140
+ return this.leadRequestReceipt(session, request) !== null
19141
+ || this.db.prepare("SELECT 1 FROM lead_queue WHERE session = ? AND request_id = ?").get(session, request) !== undefined;
19142
+ }
19143
+ forgetQueuedLeadMessage(message) {
19144
+ const blanked = this.db.prepare("UPDATE lead_message SET text = '' WHERE id = ? AND pending = 1").run(message);
19145
+ if (Number(blanked.changes) !== 1)
19146
+ return;
19147
+ this.db.prepare("UPDATE lead_queue SET context_json = NULL WHERE message = ?").run(message);
19148
+ this.db.prepare("DELETE FROM memory_search WHERE kind = 'conversation' AND ref = ?").run(`message:${message}`);
19149
+ }
18148
19150
  }
18149
19151
  /** A stored count or id that must be a whole, non-negative number — a
18150
19152
  * NULL, an empty string, a fraction, or text reads as null (v48 authority repair). */
@@ -18192,6 +19194,7 @@ function readRun(row) {
18192
19194
  : String(row["provider_started_at"]),
18193
19195
  tokensIn: row["tokens_in"] === null || row["tokens_in"] === undefined ? null : Number(row["tokens_in"]),
18194
19196
  tokensOut: row["tokens_out"] === null || row["tokens_out"] === undefined ? null : Number(row["tokens_out"]),
19197
+ agentTurns: row["agent_turns"] === null || row["agent_turns"] === undefined ? null : Number(row["agent_turns"]),
18195
19198
  costUsd: row["cost_usd"] === null || row["cost_usd"] === undefined ? null : Number(row["cost_usd"]),
18196
19199
  headRevision: row["head_revision"] === null || row["head_revision"] === undefined
18197
19200
  ? null
@@ -18203,7 +19206,7 @@ function readRun(row) {
18203
19206
  ? String(row["phase"])
18204
19207
  : null,
18205
19208
  // An auth mode is exactly one of the two words — anything else reads as unknown.
18206
- authMode: row["auth_mode"] === "api-key" ? "api-key" : row["auth_mode"] === "subscription" ? "subscription" : null,
19209
+ authMode: row["auth_mode"] === "api-key" ? "api-key" : row["auth_mode"] === "subscription" ? "subscription" : row["auth_mode"] === "agent-login" ? "agent-login" : null,
18207
19210
  terminalClass: row["terminal_class"] === null || row["terminal_class"] === undefined
18208
19211
  ? null
18209
19212
  : String(row["terminal_class"]),
@@ -18777,6 +19780,8 @@ export function permissionModeOfProfile(profile) {
18777
19780
  return profile.permissionArgv === "bypassPermissions" ? "bypassPermissions" : "auto";
18778
19781
  if (profile.provider === "gemini")
18779
19782
  return profile.approvalArgv === "yolo" ? "bypassPermissions" : "auto";
19783
+ if (isAcpProfile(profile))
19784
+ return "bypassPermissions";
18780
19785
  return profile.sandboxMode === "danger-full-access" ? "bypassPermissions" : "auto";
18781
19786
  }
18782
19787
  export function revisionTermsOf(sourceRef, sourceScope, coverage, permissionDefault, qualityDefault) {
@@ -19042,3 +20047,18 @@ function readGrant(row) {
19042
20047
  export function verifiedAuthor(name) {
19043
20048
  return name;
19044
20049
  }
20050
+ function readAcpCheck(row) {
20051
+ const text = (key) => row[key] == null ? null : String(row[key]);
20052
+ const num = (key) => row[key] == null ? null : Number(row[key]);
20053
+ const state = String(row["state"]);
20054
+ return {
20055
+ id: Number(row["id"]), provider: String(row["provider"]), runner: String(row["runner"]), model: String(row["model"]),
20056
+ conformanceVersion: Number(row["conformance_version"]), requestedBy: String(row["requested_by"]), requestedAt: String(row["requested_at"]),
20057
+ expiresAt: String(row["expires_at"]),
20058
+ state: state === "running" || state === "passed" || state === "failed" || state === "expired" ? state : "pending",
20059
+ watch: row["watch_repo"] == null || row["watch_owner"] == null ? null : { repo: String(row["watch_repo"]), owner: String(row["watch_owner"]) },
20060
+ 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"),
20061
+ step1Passed: Number(row["step1_passed"]) === 1, step2Passed: Number(row["step2_passed"]) === 1, result: text("result"), finishedAt: text("finished_at"),
20062
+ settledAt: text("settled_at"),
20063
+ };
20064
+ }