toolroll 0.9.63 → 0.9.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (292) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -5
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +50 -2
  16. package/dist/agent-fence.js +157 -10
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +36 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
  32. package/dist/browser/terminal.css +37 -0
  33. package/dist/browser/terminal.js +117 -0
  34. package/dist/browser/workspace.css +2 -2
  35. package/dist/browser/workspace.js +42 -38
  36. package/dist/browser-shell.js +6 -3
  37. package/dist/browser-workspace.d.ts +235 -1
  38. package/dist/browser-workspace.js +60 -0
  39. package/dist/builder.d.ts +6 -1
  40. package/dist/builder.js +34 -13
  41. package/dist/chat-actions.d.ts +13 -1
  42. package/dist/chat-actions.js +197 -15
  43. package/dist/chat-ask.d.ts +4 -0
  44. package/dist/chat-ask.js +14 -0
  45. package/dist/chat-channel.js +20 -0
  46. package/dist/chat-continuity.js +5 -4
  47. package/dist/chat-decide.d.ts +0 -14
  48. package/dist/chat-decide.js +4 -1
  49. package/dist/chat-delivery.d.ts +9 -0
  50. package/dist/chat-delivery.js +45 -6
  51. package/dist/chat-messages.d.ts +8 -0
  52. package/dist/chat-messages.js +36 -4
  53. package/dist/check-levels-ui.js +2 -2
  54. package/dist/checkout-proof.d.ts +28 -0
  55. package/dist/checkout-proof.js +62 -0
  56. package/dist/claim.d.ts +12 -0
  57. package/dist/claim.js +19 -0
  58. package/dist/cli-help.d.ts +1 -1
  59. package/dist/cli-help.js +4 -1
  60. package/dist/cli.d.ts +3 -0
  61. package/dist/cli.js +21 -2
  62. package/dist/coding-ui.js +6 -4
  63. package/dist/console-instance.d.ts +124 -0
  64. package/dist/console-instance.js +337 -0
  65. package/dist/contracts/chat-actions.d.ts +16 -0
  66. package/dist/contracts/chat-actions.js +4 -0
  67. package/dist/contracts/chat-content.d.ts +19 -0
  68. package/dist/contracts/chat-content.js +6 -0
  69. package/dist/contracts/console-api.d.ts +12 -4
  70. package/dist/contracts/console-api.js +12 -4
  71. package/dist/contracts/flow-card.d.ts +4 -0
  72. package/dist/contracts/flow-card.js +3 -0
  73. package/dist/contracts/flow.d.ts +46 -0
  74. package/dist/contracts/flow.js +11 -2
  75. package/dist/contracts/lead-tools.d.ts +92 -0
  76. package/dist/contracts/lead-tools.js +13 -0
  77. package/dist/contracts/provider.d.ts +11 -1
  78. package/dist/contracts/provider.js +1 -1
  79. package/dist/contracts/route.d.ts +76 -1
  80. package/dist/contracts/route.js +1 -1
  81. package/dist/contracts/store-json.d.ts +2 -0
  82. package/dist/control-setup.d.ts +1 -1
  83. package/dist/control-ui.js +2 -2
  84. package/dist/converse.d.ts +16 -0
  85. package/dist/converse.js +32 -4
  86. package/dist/demo-chat.js +4 -4
  87. package/dist/demo.js +2 -2
  88. package/dist/desktop-update-gate.js +6 -0
  89. package/dist/desktop-update.d.ts +1 -1
  90. package/dist/discord-chat.js +4 -1
  91. package/dist/doctor.js +1 -1
  92. package/dist/egress-bridge.d.ts +17 -0
  93. package/dist/egress-bridge.js +111 -0
  94. package/dist/egress.d.ts +119 -0
  95. package/dist/egress.js +423 -0
  96. package/dist/exec.d.ts +78 -0
  97. package/dist/exec.js +367 -9
  98. package/dist/export-ui.js +2 -2
  99. package/dist/flow-engine.js +24 -5
  100. package/dist/flow-gallery-ui.js +2 -2
  101. package/dist/flow-gallery.js +27 -8
  102. package/dist/flow-share.js +16 -5
  103. package/dist/flow-triggers.d.ts +77 -8
  104. package/dist/flow-triggers.js +517 -70
  105. package/dist/flows-cli.js +8 -2
  106. package/dist/flows-ui.d.ts +0 -2
  107. package/dist/flows-ui.js +52 -4
  108. package/dist/flows.d.ts +4 -0
  109. package/dist/flows.js +9 -1
  110. package/dist/html.d.ts +9 -0
  111. package/dist/html.js +11 -0
  112. package/dist/invoke.d.ts +17 -0
  113. package/dist/invoke.js +217 -21
  114. package/dist/keys.d.ts +18 -14
  115. package/dist/keys.js +5 -0
  116. package/dist/lead-capabilities.js +3 -2
  117. package/dist/lead-contract.js +2 -0
  118. package/dist/lead-doors.d.ts +2 -0
  119. package/dist/lead-doors.js +7 -0
  120. package/dist/lead-follow.d.ts +2 -0
  121. package/dist/lead-follow.js +58 -2
  122. package/dist/lead-progress.js +1 -0
  123. package/dist/lead-queue-runtime.d.ts +46 -0
  124. package/dist/lead-queue-runtime.js +191 -0
  125. package/dist/lead-queue.d.ts +104 -0
  126. package/dist/lead-queue.js +187 -0
  127. package/dist/lead-sweep.d.ts +67 -0
  128. package/dist/lead-sweep.js +99 -0
  129. package/dist/lead-tools.d.ts +22 -0
  130. package/dist/lead-tools.js +152 -1
  131. package/dist/lead.d.ts +13 -5
  132. package/dist/lead.js +89 -29
  133. package/dist/limits-ui.d.ts +8 -1
  134. package/dist/limits-ui.js +26 -0
  135. package/dist/live-bus.d.ts +8 -0
  136. package/dist/live-bus.js +40 -4
  137. package/dist/live-detail.d.ts +27 -0
  138. package/dist/live-detail.js +57 -0
  139. package/dist/live-notes.d.ts +20 -0
  140. package/dist/live-notes.js +47 -0
  141. package/dist/live-records.d.ts +140 -0
  142. package/dist/live-records.js +935 -0
  143. package/dist/live-terminal.d.ts +258 -0
  144. package/dist/live-terminal.js +649 -0
  145. package/dist/live.d.ts +38 -27
  146. package/dist/live.js +142 -124
  147. package/dist/mcp-oauth.d.ts +6 -3
  148. package/dist/mcp-oauth.js +8 -4
  149. package/dist/memory-pass.js +2 -2
  150. package/dist/mobile-viewport.d.ts +5 -1
  151. package/dist/mobile-viewport.js +18 -2
  152. package/dist/model-catalog.js +4 -0
  153. package/dist/monitoring-ui.js +2 -2
  154. package/dist/needs-you.d.ts +1 -1
  155. package/dist/needs-you.js +11 -4
  156. package/dist/operate.d.ts +2 -0
  157. package/dist/operate.js +275 -17
  158. package/dist/passwordless.d.ts +23 -0
  159. package/dist/passwordless.js +26 -0
  160. package/dist/peek-cli.d.ts +12 -4
  161. package/dist/peek-cli.js +19 -11
  162. package/dist/phase-routing.js +10 -3
  163. package/dist/planner-source.d.ts +3 -0
  164. package/dist/planner-source.js +14 -0
  165. package/dist/planner.d.ts +2 -1
  166. package/dist/planner.js +7 -1
  167. package/dist/policy-ui.js +2 -2
  168. package/dist/policy.d.ts +1 -20
  169. package/dist/policy.js +29 -2
  170. package/dist/project-delete-ui.d.ts +12 -0
  171. package/dist/project-delete-ui.js +41 -9
  172. package/dist/project-delete.js +2 -2
  173. package/dist/project-memory.d.ts +3 -1
  174. package/dist/project-memory.js +57 -25
  175. package/dist/project-tools.d.ts +5 -3
  176. package/dist/project-tools.js +12 -9
  177. package/dist/provider-auth.d.ts +4 -3
  178. package/dist/provider-auth.js +5 -4
  179. package/dist/provider-connection.d.ts +2 -1
  180. package/dist/provider-connection.js +5 -0
  181. package/dist/provider.d.ts +25 -2
  182. package/dist/provider.js +63 -9
  183. package/dist/pull-request-ui.js +2 -2
  184. package/dist/push.js +1 -1
  185. package/dist/quick-start.d.ts +24 -0
  186. package/dist/quick-start.js +127 -0
  187. package/dist/release.d.ts +3 -0
  188. package/dist/release.js +14 -8
  189. package/dist/request-budget-ui.js +2 -2
  190. package/dist/result-review.d.ts +1 -1
  191. package/dist/result-review.js +9 -3
  192. package/dist/retention-ui.js +2 -2
  193. package/dist/retention.js +10 -1
  194. package/dist/run-efficiency.d.ts +73 -0
  195. package/dist/run-efficiency.js +228 -0
  196. package/dist/runner.d.ts +5 -1
  197. package/dist/runner.js +22 -1
  198. package/dist/scope.d.ts +170 -5
  199. package/dist/scope.js +251 -31
  200. package/dist/scout-net.d.ts +4 -1
  201. package/dist/scout-net.js +37 -12
  202. package/dist/scout.d.ts +20 -6
  203. package/dist/scout.js +27 -3
  204. package/dist/serve.js +247 -40
  205. package/dist/server/chat.d.ts +1 -1
  206. package/dist/server/chat.js +122 -31
  207. package/dist/server/chrome.d.ts +6 -1
  208. package/dist/server/chrome.js +18 -7
  209. package/dist/server/console.css +52 -38
  210. package/dist/server/flows.js +38 -8
  211. package/dist/server/guards.d.ts +1 -0
  212. package/dist/server/guards.js +23 -9
  213. package/dist/server/http.d.ts +7 -0
  214. package/dist/server/http.js +3 -2
  215. package/dist/server/live.d.ts +34 -1
  216. package/dist/server/live.js +39 -17
  217. package/dist/server/pages-assets.js +5 -0
  218. package/dist/server/pages.js +90 -13
  219. package/dist/server/people-tokens.d.ts +3 -0
  220. package/dist/server/people-tokens.js +55 -17
  221. package/dist/server/remote-hooks.js +12 -6
  222. package/dist/server/remote.d.ts +2 -1
  223. package/dist/server/remote.js +14 -5
  224. package/dist/server/render-chat.d.ts +12 -3
  225. package/dist/server/render-chat.js +47 -16
  226. package/dist/server/render-pages.d.ts +4 -1
  227. package/dist/server/render-pages.js +5 -4
  228. package/dist/server/render-people.d.ts +3 -2
  229. package/dist/server/render-people.js +3 -2
  230. package/dist/server/render-results.d.ts +18 -2
  231. package/dist/server/render-results.js +37 -28
  232. package/dist/server/render-settings.js +6 -5
  233. package/dist/server/render-tasks.d.ts +35 -10
  234. package/dist/server/render-tasks.js +110 -27
  235. package/dist/server/request-context.d.ts +2 -0
  236. package/dist/server/request-context.js +2 -1
  237. package/dist/server/route-table.d.ts +3 -0
  238. package/dist/server/route-table.js +46 -5
  239. package/dist/server/runtime.d.ts +33 -2
  240. package/dist/server/session.d.ts +25 -1
  241. package/dist/server/session.js +18 -4
  242. package/dist/server/settings.js +121 -10
  243. package/dist/server/sweeps.d.ts +4 -0
  244. package/dist/server/sweeps.js +58 -0
  245. package/dist/server/task-views.d.ts +2 -1
  246. package/dist/server/task-views.js +96 -6
  247. package/dist/server/tasks.js +320 -74
  248. package/dist/server/terminals.d.ts +31 -0
  249. package/dist/server/terminals.js +365 -0
  250. package/dist/setup-guide.js +3 -2
  251. package/dist/slack-chat.js +4 -1
  252. package/dist/spend-ui.js +3 -3
  253. package/dist/spend.js +2 -1
  254. package/dist/steer-capabilities.d.ts +32 -0
  255. package/dist/steer-capabilities.js +60 -0
  256. package/dist/steer-controls.d.ts +14 -0
  257. package/dist/steer-controls.js +36 -0
  258. package/dist/steer-status.js +2 -2
  259. package/dist/storage-ui.js +4 -4
  260. package/dist/store-baseline.d.ts +1 -1
  261. package/dist/store-baseline.js +1 -1
  262. package/dist/store.d.ts +430 -15
  263. package/dist/store.js +1041 -52
  264. package/dist/subagent-desk.js +1 -1
  265. package/dist/summary.js +3 -0
  266. package/dist/task-source.d.ts +58 -0
  267. package/dist/task-source.js +93 -0
  268. package/dist/team-leads.js +1 -1
  269. package/dist/teams-chat.js +4 -1
  270. package/dist/telegram-lead.d.ts +3 -0
  271. package/dist/telegram-lead.js +44 -7
  272. package/dist/telegram.d.ts +11 -2
  273. package/dist/telegram.js +51 -17
  274. package/dist/terminal-catalog.d.ts +11 -0
  275. package/dist/terminal-catalog.js +129 -0
  276. package/dist/text-limits.d.ts +2 -0
  277. package/dist/text-limits.js +2 -0
  278. package/dist/tool-broker.d.ts +2 -0
  279. package/dist/tool-broker.js +1 -0
  280. package/dist/toolroll-update-ui.js +2 -2
  281. package/dist/toolroll-update.js +13 -3
  282. package/dist/tools-ui.js +2 -2
  283. package/dist/webhook-limits.d.ts +23 -0
  284. package/dist/webhook-limits.js +30 -0
  285. package/dist/webhook-readers.d.ts +56 -0
  286. package/dist/webhook-readers.js +138 -0
  287. package/dist/webhook-signing.d.ts +27 -0
  288. package/dist/webhook-signing.js +119 -0
  289. package/dist/work-index.d.ts +29 -0
  290. package/dist/work-index.js +16 -2
  291. package/dist/workspace-revision.js +3 -1
  292. package/package.json +11 -2
package/dist/store.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { PROJECT_NETWORK_DEFAULT, projectNetworkFromValue, runNetworkFromValue } from "./egress.js";
2
+ import { isAcpProfile, isAcpProvider } from "./acp-agents.js";
3
+ import { ACP_CONFORMANCE_VERSION, launchFromJson } from "./acp-attest.js";
1
4
  import { reclaimDatabase } from "./database-reclaim.js";
2
5
  import { BASELINE_SCHEMA, BASELINE_SCHEMA_VERSION } from "./store-baseline.js";
3
6
  import { CHAT_PROVIDERS, CHAT_SCHEMA } from "./contracts/chat-tables.js";
@@ -77,6 +80,7 @@ import { parseCheckProgressSnapshot } from "./check-progress.js";
77
80
  import { accountReachesSql, normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
78
81
  import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installLedgerTriggers } from "./action-ledger.js";
79
82
  import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
83
+ import { CONSOLE_INSTANCE_SCHEMA } from "./console-instance.js";
80
84
  import { endUnattendedGrants, FLOW_UNATTENDED_SCHEMA, grantStandsSql } from "./flow-unattended.js";
81
85
  import { RECIPE_SCHEMA } from "./recipes.js";
82
86
  import { POLICY_SCHEMA, agentRefusal, approvalRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
@@ -273,6 +277,29 @@ export function leadTurnKeepsProposals(turn) {
273
277
  /** What the lead promised to follow up on (lead-commitments.ts; no version bump: additive only, so a build that
274
278
  * predates it still opens the store). `condition_json` is a task, attempt, check or time; `check_at` is when to look
275
279
  * next; `channel` is where it reports (`chat`: the conversation it was promised in); a promise lapses at `expires_at`. */
280
+ /** Lead fan-out (docs/plans/lead-fan-out.md): a sweep is N ordinary tasks under one shared brief, started with one yes,
281
+ * at most `max_parallel` of them claimed at once. Membership is by task family root, so a revision of a child is that
282
+ * child. Additive (no schema version bump), like lead_commitment. */
283
+ export const LEAD_SWEEP_SCHEMA = `
284
+ CREATE TABLE IF NOT EXISTS lead_sweep (
285
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
286
+ owner TEXT NOT NULL,
287
+ thread INTEGER,
288
+ repo TEXT NOT NULL,
289
+ title TEXT NOT NULL,
290
+ brief TEXT NOT NULL,
291
+ max_parallel INTEGER NOT NULL CHECK (max_parallel BETWEEN 1 AND 16),
292
+ proposal INTEGER UNIQUE,
293
+ created_at TEXT NOT NULL
294
+ );
295
+ CREATE TABLE IF NOT EXISTS lead_sweep_task (
296
+ sweep INTEGER NOT NULL REFERENCES lead_sweep(id),
297
+ root_task TEXT NOT NULL UNIQUE,
298
+ ordinal INTEGER NOT NULL,
299
+ label TEXT NOT NULL,
300
+ PRIMARY KEY (sweep, ordinal)
301
+ );
302
+ `;
276
303
  export const LEAD_COMMITMENT_SCHEMA = `
277
304
  CREATE TABLE IF NOT EXISTS lead_commitment (
278
305
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -409,6 +436,25 @@ CREATE TABLE IF NOT EXISTS run_resume (
409
436
  );
410
437
  CREATE INDEX IF NOT EXISTS run_resume_by_run ON run_resume (run, id);
411
438
  `;
439
+ /** A project's "Show commands in the live view" (live-detail.ts): absent means off. */
440
+ export const LIVE_DETAIL_SCHEMA = `
441
+ CREATE TABLE IF NOT EXISTS live_detail_setting (
442
+ repo TEXT PRIMARY KEY,
443
+ commands INTEGER NOT NULL CHECK (commands IN (0,1)),
444
+ updated_by TEXT NOT NULL,
445
+ updated_at TEXT NOT NULL
446
+ );
447
+ -- Every run a steering note reached, kept as it happens (live-notes.ts): a
448
+ -- note's own attached/mailed fields move to the next attempt, this does not.
449
+ CREATE TABLE IF NOT EXISTS steer_note_run (
450
+ note INTEGER NOT NULL REFERENCES task_steer(id) ON DELETE CASCADE,
451
+ run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,
452
+ how TEXT NOT NULL CHECK (how IN ('attached','mailed','read')),
453
+ at TEXT NOT NULL,
454
+ PRIMARY KEY (note, run, how)
455
+ );
456
+ CREATE INDEX IF NOT EXISTS steer_note_run_by_run ON steer_note_run (run, note);
457
+ `;
412
458
  export const CHAT_APPROVAL_SCHEMA = `
413
459
  CREATE TABLE IF NOT EXISTS chat_approval_setting (
414
460
  approver TEXT NOT NULL,
@@ -645,7 +691,31 @@ const REGISTERED_AT_FILING = "again, as it was filed";
645
691
  // v123 adds, and changes no saved row: flows that run on their own (flow_script_provenance, flow_unattended_grant,
646
692
  // flow_unattended_run, plan_authorization.basis, flow_trigger_event.on_time and ran_script) and the tool-secret
647
693
  // sealing key's record (tool_secret_key, tool_secret_legacy).
648
- export const SCHEMA_VERSION = 123;
694
+ // v124 adds, and changes no saved row: the live conversation's project setting (live_detail_setting: "Show commands in
695
+ // the live view", absent means off) and the runs each steering note reached (steer_note_run; older notes have none).
696
+ // v125 changes no saved row: ACP agents become providers (the provider CHECKs of phase_config, phase_tier_config,
697
+ // phase_tier_alternate and provider_readiness widen, and provider_auth_pause's auth mode admits 'agent-login'; each
698
+ // table is rebuilt with every row and id carried over) and a run keeps what its agent itself reported it used
699
+ // (run.agent_reported_usage_json, never priced; saved runs read none).
700
+ // v126 adds, and changes no saved row: signed webhooks into tasks (docs/plans/webhook-tasks.md): a receipt's typed state,
701
+ // when its card was made and the item it holds while it waits for room (flow_trigger_event.state, accepted_at,
702
+ // item_json); which saved secret a trigger's deliveries are proved with and its turned-away deliveries (flow_trigger.hook_credential,
703
+ // dropped_count, dropped_since, limit_noted_at); who made a card (flow_card.created_kind); and the outside source a
704
+ // task was filed from (task_source). Saved receipts read their state from their card, saved cards read a person.
705
+ // v127 adds, and changes no saved row: the running console's instance record (console_instance, one row: its secret,
706
+ // pid, port and build) that bare `toolroll` proves a console with before attaching (docs/plans/agents-cockpit.md B1).
707
+ // v128 adds, and changes no saved row's existing columns: the personal lead queue (lead_queue; docs/plans/
708
+ // lead-message-queue.md) and whether a lead message is still waiting for its turn (lead_message.pending, indexed with
709
+ // its thread so the model's history reads only admitted messages). A saved message reads waiting exactly when it is an
710
+ // operator message of a team queue row never bound to a turn (V128_ADDED_COLUMNS).
711
+ // v129 adds, and changes no saved row: how a run went (docs/plans/run-efficiency.md) — the agent's turn count where its
712
+ // provider reports it (run.agent_turns), a lead request's cache read and write tokens (chat_turn.cache_read_tokens,
713
+ // cache_write_tokens), each NULL (unknown) on saved rows, and the derived, display-only run_efficiency row a result's
714
+ // How it ran reads (a cache of the run's own records, written on first view, never a verdict).
715
+ // v130 adds, and changes no saved row: what a project's builds may reach on the network (project_network; egress.ts,
716
+ // docs/plans/agent-baseline-hardening.md arc 1) and whether this installation lets an agent run with no OS confinement
717
+ // (confinement_setting, one row; off unless an instance operator turns it on).
718
+ export const SCHEMA_VERSION = 130;
649
719
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
650
720
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
651
721
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -773,9 +843,12 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
773
843
  * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
774
844
  * table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
775
845
  * 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.
846
+ * 0 or null), checked the same way. v124 only adds new tables (the live conversation's setting and note history).
847
+ * v125 rebuilds five tables with wider CHECKs, every row and id carried over (the rehearsal's history rules find them
848
+ * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
849
+ * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
777
850
  */
778
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123]);
851
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130]);
779
852
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
780
853
  export const V119_ADDED_COLUMNS = Object.freeze({
781
854
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -836,6 +909,131 @@ export const V123_ADDED_COLUMNS = Object.freeze({
836
909
  ran_script: { definition: "TEXT", saved: "NULL" },
837
910
  },
838
911
  });
912
+ /** v125: the column it adds, with the value every row saved before it reads: what a run's ACP agent itself reported it
913
+ * used (acp-client.ts; never priced). Runs saved before it read none. */
914
+ export const V125_ADDED_COLUMNS = Object.freeze({
915
+ run: {
916
+ agent_reported_usage_json: { definition: "TEXT", saved: "NULL" },
917
+ // An ACP run's billing, stamped when it is admitted (AcpBillingStamp); every other run, and every saved one, none.
918
+ acp_billing_json: { definition: "TEXT", saved: "NULL" },
919
+ },
920
+ });
921
+ /** v126: the columns it adds, with the value every row saved before it reads (docs/plans/webhook-tasks.md). A saved
922
+ * receipt reads no state (accepted when it has a card, skipped otherwise), no acceptance time (its arrival counts) and
923
+ * no held item; a saved trigger is on its first credential generation with nothing turned away; a saved card was made
924
+ * by a person. */
925
+ export const V126_ADDED_COLUMNS = Object.freeze({
926
+ flow_trigger_event: {
927
+ state: { definition: "TEXT CHECK (state IN ('accepted','skipped','held','ignored'))", saved: "NULL" },
928
+ accepted_at: { definition: "TEXT", saved: "NULL" },
929
+ item_json: { definition: "TEXT", saved: "NULL" },
930
+ },
931
+ flow_trigger: {
932
+ // Which saved secret proves its deliveries: 'legacy' (the address every secret saved before v126 has), '' (none:
933
+ // deliveries are refused until one is saved), or the id of one written whole before the trigger pointed at it.
934
+ hook_credential: { definition: "TEXT NOT NULL DEFAULT 'legacy'", saved: "'legacy'" },
935
+ dropped_count: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" },
936
+ dropped_since: { definition: "TEXT", saved: "NULL" },
937
+ limit_noted_at: { definition: "TEXT", saved: "NULL" },
938
+ },
939
+ flow_card: {
940
+ created_kind: { definition: "TEXT CHECK (created_kind IN ('person','automation'))", saved: "NULL" },
941
+ },
942
+ });
943
+ /** v128: whether a lead message waits for its turn. A message saved before v128 waits exactly when it is an operator
944
+ * message that a team queue row holds and no turn has taken (the team runtime's own unadmitted messages); every other
945
+ * saved message is history (`saved` is evaluated per row). */
946
+ export const V128_ADDED_COLUMNS = Object.freeze({
947
+ lead_message: {
948
+ pending: {
949
+ definition: "INTEGER NOT NULL DEFAULT 0 CHECK (pending IN (0, 1))",
950
+ saved: "(CASE WHEN role = 'operator' AND turn IS NULL AND EXISTS (SELECT 1 FROM team_message q WHERE q.message = lead_message.id) THEN 1 ELSE 0 END)",
951
+ },
952
+ },
953
+ });
954
+ /** v129: how a run went (docs/plans/run-efficiency.md). Recorded from now on; a saved row reads unknown (NULL), never 0. */
955
+ export const V129_ADDED_COLUMNS = Object.freeze({
956
+ run: { agent_turns: { definition: "INTEGER CHECK (agent_turns IS NULL OR agent_turns >= 0)", saved: "NULL" } },
957
+ chat_turn: {
958
+ cache_read_tokens: { definition: "INTEGER CHECK (cache_read_tokens IS NULL OR cache_read_tokens >= 0)", saved: "NULL" },
959
+ cache_write_tokens: { definition: "INTEGER CHECK (cache_write_tokens IS NULL OR cache_write_tokens >= 0)", saved: "NULL" },
960
+ },
961
+ });
962
+ /** v130: a project's network (an approver's setting, egress.ts ProjectNetwork as JSON) and the installation's choice
963
+ * whether an agent may run where no OS confinement exists (one row; 0 until an instance operator turns it on). */
964
+ export const CONFINEMENT_SCHEMA = `
965
+ CREATE TABLE IF NOT EXISTS project_network (
966
+ repo TEXT PRIMARY KEY,
967
+ network_json TEXT NOT NULL CHECK (length(network_json) <= 16384),
968
+ updated_at TEXT NOT NULL,
969
+ updated_by TEXT NOT NULL
970
+ );
971
+ CREATE TABLE IF NOT EXISTS confinement_setting (
972
+ id INTEGER PRIMARY KEY CHECK (id = 1),
973
+ unfenced_allowed INTEGER NOT NULL DEFAULT 0 CHECK (unfenced_allowed IN (0, 1)),
974
+ updated_at TEXT,
975
+ updated_by TEXT
976
+ );
977
+ INSERT OR IGNORE INTO confinement_setting (id, unfenced_allowed) VALUES (1, 0);
978
+ `;
979
+ /** v129: a finished run's How it ran, derived from its own records on first view (display only: never a verdict). Its
980
+ * parser version says when to derive again; `steps` says how complete the step detail was, and why not. */
981
+ export const RUN_EFFICIENCY_SCHEMA = `
982
+ CREATE TABLE IF NOT EXISTS run_efficiency (
983
+ run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,
984
+ parser_version INTEGER NOT NULL,
985
+ steps TEXT NOT NULL CHECK (steps IN ('complete','truncated','expired','absent')),
986
+ steps_reason TEXT,
987
+ payload_json TEXT NOT NULL CHECK (length(payload_json) <= 16384),
988
+ derived_at TEXT NOT NULL
989
+ );
990
+ `;
991
+ /** v128: the personal lead queue (docs/plans/lead-message-queue.md). A message sent while one of its author's personal
992
+ * lead turns runs (or while earlier ones wait) waits here, one turn per message, in order. Its words are the
993
+ * lead_message row (pending until admitted); terminal rows keep only their request identity (a tombstone). */
994
+ export const LEAD_QUEUE_SCHEMA = `
995
+ CREATE TABLE IF NOT EXISTS lead_queue (
996
+ message INTEGER PRIMARY KEY REFERENCES lead_message(id) ON DELETE CASCADE,
997
+ thread INTEGER NOT NULL REFERENCES lead_thread(id) ON DELETE CASCADE,
998
+ approver TEXT NOT NULL,
999
+ approver_generation INTEGER NOT NULL,
1000
+ session INTEGER NOT NULL,
1001
+ ceiling_digest TEXT NOT NULL,
1002
+ request_id TEXT NOT NULL,
1003
+ payload_hash TEXT NOT NULL,
1004
+ context_json TEXT,
1005
+ status TEXT NOT NULL CHECK (status IN ('queued','running','answered','failed','cancelled','uncertain')),
1006
+ revision INTEGER NOT NULL DEFAULT 1,
1007
+ generation INTEGER NOT NULL DEFAULT 0,
1008
+ runner TEXT,
1009
+ claimed_at TEXT,
1010
+ turn_id INTEGER REFERENCES mate_turn(id),
1011
+ error TEXT,
1012
+ created_at TEXT NOT NULL,
1013
+ settled_at TEXT,
1014
+ UNIQUE (session, request_id)
1015
+ );
1016
+ CREATE INDEX IF NOT EXISTS lead_queue_backlog ON lead_queue (approver, status, message);
1017
+ CREATE UNIQUE INDEX IF NOT EXISTS lead_queue_one_running ON lead_queue (approver) WHERE status = 'running';
1018
+ CREATE INDEX IF NOT EXISTS lead_queue_admitted ON lead_queue (turn_id) WHERE turn_id IS NOT NULL;
1019
+ `;
1020
+ /** v126: the outside source a task was filed from, kept with the task (a card's own task link moves on; this never
1021
+ * does). Written in the filing's transaction, copied whole to a revision, never changed. */
1022
+ export const TASK_SOURCE_SCHEMA = `
1023
+ CREATE TABLE IF NOT EXISTS task_source (
1024
+ task_id TEXT PRIMARY KEY,
1025
+ flow INTEGER NOT NULL,
1026
+ card INTEGER NOT NULL,
1027
+ card_entry INTEGER NOT NULL,
1028
+ source_json TEXT NOT NULL,
1029
+ at TEXT NOT NULL
1030
+ );
1031
+ CREATE TRIGGER IF NOT EXISTS task_source_immutable BEFORE UPDATE ON task_source
1032
+ BEGIN
1033
+ SELECT RAISE(ABORT, 'a task source is immutable');
1034
+ END;
1035
+ CREATE INDEX IF NOT EXISTS flow_trigger_event_accepted ON flow_trigger_event (trigger, accepted_at);
1036
+ `;
839
1037
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
840
1038
  export function updateSafeSchema(version) {
841
1039
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -970,6 +1168,19 @@ export const BUILT_IN = "built-in";
970
1168
  /** States from which no further work is dispatched. */
971
1169
  export const TERMINAL_STATES = ["done", "failed", "cancelled"];
972
1170
  export const LEAD_THREAD = { kind: "lead" };
1171
+ /** A lead_message row as a LeadMessage. */
1172
+ function readLeadMessageRow(row) {
1173
+ return {
1174
+ id: Number(row["id"]),
1175
+ thread: Number(row["thread"]),
1176
+ turn: row["turn"] === null ? null : Number(row["turn"]),
1177
+ role: String(row["role"]),
1178
+ text: String(row["text"]),
1179
+ activity: row["activity"] === null ? null : String(row["activity"]),
1180
+ createdAt: String(row["created_at"]),
1181
+ ...(Number(row["pending"] ?? 0) === 1 ? { pending: true } : {}),
1182
+ };
1183
+ }
973
1184
  function readSubagentMemory(row) {
974
1185
  return { id: Number(row["id"]), subagent: Number(row["subagent"]), text: String(row["text"]), source: String(row["source"]), card: row["card"] === null ? null : Number(row["card"]),
975
1186
  createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedBy: String(row["updated_by"]), updatedAt: String(row["updated_at"]), usedAt: row["used_at"] === null ? null : String(row["used_at"]) };
@@ -1032,7 +1243,9 @@ function readFlowCardRow(row) {
1032
1243
  const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
1033
1244
  return { id: Number(row["id"]), flow: Number(row["flow"]), title: String(row["title"]), description: optional("description"), stage: String(row["stage"]), entry: Number(row["entry"]),
1034
1245
  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") };
1246
+ createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedAt: String(row["updated_at"]), source: readFlowCardSource(row["source_json"]), owner: optional("owner"),
1247
+ // v126: a card saved before it was a person's (what filing always assumed).
1248
+ createdKind: row["created_kind"] === "automation" ? "automation" : "person" };
1036
1249
  }
1037
1250
  function readFlowCardSource(value) {
1038
1251
  if (typeof value !== "string")
@@ -1044,12 +1257,17 @@ function readFlowCardSource(value) {
1044
1257
  const parsed = read.value;
1045
1258
  const mail = parsed["mail"] !== null && typeof parsed["mail"] === "object" ? parsed["mail"] : null;
1046
1259
  const chat = parsed["chat"] !== null && typeof parsed["chat"] === "object" ? parsed["chat"] : null;
1260
+ const external = parsed["external"] !== null && typeof parsed["external"] === "object" ? parsed["external"] : null;
1047
1261
  return { kind: String(parsed["kind"] ?? ""), label: parsed["label"], url: typeof parsed["url"] === "string" ? parsed["url"] : null,
1048
1262
  ...(mail === null || typeof mail["from"] !== "string" ? {} : { mail: { id: typeof mail["id"] === "string" ? mail["id"] : null, references: Array.isArray(mail["references"]) ? mail["references"].filter((one) => typeof one === "string") : [],
1049
1263
  subject: typeof mail["subject"] === "string" ? mail["subject"] : "", from: mail["from"] } }),
1050
1264
  ...(chat === null || typeof chat["app"] !== "string" || typeof chat["chat"] !== "string" || typeof chat["thread"] !== "string" || typeof chat["binding"] !== "number" ? {} : { chat: {
1051
1265
  app: chat["app"], installation: String(chat["installation"] ?? ""), chat: chat["chat"], conversation: typeof chat["conversation"] === "string" ? chat["conversation"] : chat["chat"], thread: chat["thread"], binding: chat["binding"]
1052
- } }) };
1266
+ } }),
1267
+ // v126: kept as they were written at receipt; a card saved before them has none.
1268
+ ...(typeof parsed["verified"] === "string" ? { verified: parsed["verified"] } : {}),
1269
+ ...(external === null ? {} : { external: { id: typeof external["id"] === "string" ? external["id"] : null, url: typeof external["url"] === "string" ? external["url"] : null } }),
1270
+ ...(typeof parsed["receipt"] === "string" ? { receipt: parsed["receipt"] } : {}) };
1053
1271
  }
1054
1272
  catch {
1055
1273
  return null;
@@ -1059,7 +1277,14 @@ function readFlowTriggerRow(row) {
1059
1277
  const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
1060
1278
  return { id: Number(row["id"]), flow: Number(row["flow"]), kind: String(row["kind"]), configJson: String(row["config_json"]), state: String(row["state"]),
1061
1279
  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"]) };
1280
+ failures: Number(row["failures"] ?? 0), createdBy: String(row["created_by"]), createdAt: String(row["created_at"]), updatedAt: String(row["updated_at"]),
1281
+ hookCredential: row["hook_credential"] === null || row["hook_credential"] === undefined ? "legacy" : String(row["hook_credential"]), droppedCount: Number(row["dropped_count"] ?? 0), droppedSince: optional("dropped_since") };
1282
+ }
1283
+ function readFlowReceipt(row) {
1284
+ const card = row["card"] === null || row["card"] === undefined ? null : Number(row["card"]);
1285
+ const state = row["state"] === null || row["state"] === undefined ? (card === null ? "skipped" : "accepted") : String(row["state"]);
1286
+ const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
1287
+ return { state, card, note: optional("note"), at: String(row["at"]), acceptedAt: optional("accepted_at"), itemJson: optional("item_json") };
1063
1288
  }
1064
1289
  /** `kind:name`, the unambiguous way a requirement names a capability. */
1065
1290
  export function capabilityKey(capability) {
@@ -1313,7 +1538,7 @@ CREATE TABLE IF NOT EXISTS mcp_idempotency (
1313
1538
  CREATE TABLE IF NOT EXISTS phase_config (
1314
1539
  scope TEXT NOT NULL,
1315
1540
  phase TEXT NOT NULL CHECK (phase IN ('plan','build','repair','review')),
1316
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
1541
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
1317
1542
  model TEXT,
1318
1543
  updated_at TEXT NOT NULL,
1319
1544
  updated_by TEXT NOT NULL,
@@ -1329,7 +1554,7 @@ CREATE TABLE IF NOT EXISTS phase_tier_config (
1329
1554
  scope TEXT NOT NULL,
1330
1555
  phase TEXT NOT NULL CHECK (phase IN ('plan','build','repair','review')),
1331
1556
  tier TEXT NOT NULL CHECK (tier IN ('light','strong')),
1332
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
1557
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
1333
1558
  model TEXT,
1334
1559
  updated_at TEXT NOT NULL,
1335
1560
  updated_by TEXT NOT NULL,
@@ -1345,7 +1570,7 @@ CREATE TABLE IF NOT EXISTS phase_tier_config (
1345
1570
  -- substitutes for it.
1346
1571
  CREATE TABLE IF NOT EXISTS provider_readiness (
1347
1572
  runner TEXT NOT NULL REFERENCES runner(name) ON DELETE CASCADE,
1348
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
1573
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
1349
1574
  state TEXT NOT NULL CHECK (state IN ('ready','unavailable','unknown')),
1350
1575
  reason TEXT NOT NULL,
1351
1576
  probe TEXT NOT NULL,
@@ -2635,7 +2860,7 @@ CREATE TABLE IF NOT EXISTS quota (
2635
2860
  -- key. auth_mode + a stable NON-SECRET
2636
2861
  -- credential fingerprint join the key. Defaults keep every pre-v30 row
2637
2862
  -- 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')),
2863
+ auth_mode TEXT NOT NULL DEFAULT 'subscription' CHECK (auth_mode IN ('subscription','api-key','agent-login')),
2639
2864
  credential_fp TEXT NOT NULL DEFAULT '',
2640
2865
  state TEXT NOT NULL CHECK (state IN ('exhausted','half-open')),
2641
2866
  reason TEXT NOT NULL,
@@ -3566,6 +3791,9 @@ function initializeStore(db, file) {
3566
3791
  db.exec(CHAT_DECIDE_SCHEMA);
3567
3792
  // An owner's lasting chat-approval setting (no version bump: additive only; absent means off).
3568
3793
  db.exec(CHAT_APPROVAL_SCHEMA);
3794
+ // v124: the live conversation: a project's "Show commands in the live view" (absent means off) and every run a
3795
+ // steering note reached (older notes simply have no rows). New tables only.
3796
+ db.exec(LIVE_DETAIL_SCHEMA);
3569
3797
  // What a running agent did last (no version bump: additive only; absent means no word yet).
3570
3798
  db.exec(RUN_ACTIVITY_SCHEMA);
3571
3799
  // v122: a task branch moved to current main before an attempt (fresh base): a new table, nothing carried over.
@@ -3593,6 +3821,18 @@ function initializeStore(db, file) {
3593
3821
  addColumn(db, "task_ref", "route_tier", "TEXT");
3594
3822
  widenPhaseTierSchema(db);
3595
3823
  db.exec(PHASE_TIER_ALTERNATE_SCHEMA);
3824
+ // v125: ACP agents as providers, their own sign-in as an auth mode (rows carry over, CHECKs widen; the auth schema
3825
+ // runs again to put back the open-pause index the rebuild dropped), and what an agent itself reported it used
3826
+ // (V125_ADDED_COLUMNS: never priced, saved runs read none).
3827
+ widenForAcp(db);
3828
+ db.exec(PROVIDER_AUTH_SCHEMA);
3829
+ db.exec(ACP_ATTESTATION_SCHEMA);
3830
+ db.exec(ACP_BILLING_SCHEMA);
3831
+ db.exec(ACP_CHECK_SCHEMA);
3832
+ for (const [table, columns] of Object.entries(V125_ADDED_COLUMNS)) {
3833
+ for (const [column, { definition }] of Object.entries(columns))
3834
+ addColumn(db, table, column, definition);
3835
+ }
3596
3836
  db.exec(REVIEW_SCHEMA);
3597
3837
  db.exec(RETENTION_SCHEMA);
3598
3838
  // 1-day evidence: an older file's retention_setting only allowed 7 days or more (no version bump: its rows carry over).
@@ -3623,6 +3863,7 @@ function initializeStore(db, file) {
3623
3863
  // What a person's lead knows about them, beside its name and persona (an older lead_config gains it).
3624
3864
  addColumn(db, "lead_config", "about_json", "TEXT NOT NULL DEFAULT '[]'");
3625
3865
  db.exec(LEAD_COMMITMENT_SCHEMA);
3866
+ db.exec(LEAD_SWEEP_SCHEMA);
3626
3867
  mergeCheckTables(db);
3627
3868
  // v110: the action ledger admits remote commands ('api', 'mcp').
3628
3869
  rebuildLedger(db);
@@ -3646,7 +3887,34 @@ function initializeStore(db, file) {
3646
3887
  addColumn(db, "workflow_preview", "flow_id", "INTEGER");
3647
3888
  // v115: the task each standing-order firing filed, kept on the firing itself (a card's task changes as it moves).
3648
3889
  addColumn(db, "flow_trigger_event", "task", "TEXT");
3890
+ // v126: signed webhooks into tasks (V126_ADDED_COLUMNS: saved rows read null or 0) and each task's outside source.
3891
+ for (const [table, columns] of Object.entries(V126_ADDED_COLUMNS)) {
3892
+ for (const [column, { definition }] of Object.entries(columns))
3893
+ addColumn(db, table, column, definition);
3894
+ }
3895
+ db.exec(TASK_SOURCE_SCHEMA);
3649
3896
  db.exec(PLAN_AUTO_SCHEMA);
3897
+ // v127 (cockpit B1): the running console's instance record (one row; a new table, additive only).
3898
+ db.exec(CONSOLE_INSTANCE_SCHEMA);
3899
+ // v128: the personal lead queue (a new table) and lead_message.pending, set once from each saved row's provenance
3900
+ // (exactly V128_ADDED_COLUMNS' `saved` expression), then the history index the model's reads use.
3901
+ if (!hasColumn(db, "lead_message", "pending")) {
3902
+ addColumn(db, "lead_message", "pending", V128_ADDED_COLUMNS.lead_message.pending.definition);
3903
+ db.exec(`UPDATE lead_message SET pending = ${V128_ADDED_COLUMNS.lead_message.pending.saved}`);
3904
+ }
3905
+ db.exec("CREATE INDEX IF NOT EXISTS lead_message_history ON lead_message (thread, pending, id)");
3906
+ db.exec(LEAD_QUEUE_SCHEMA);
3907
+ // v128: the memory search indexes a waiting message when a turn takes it, found by the turn that took it.
3908
+ db.exec("CREATE INDEX IF NOT EXISTS team_message_admitted ON team_message (turn_id) WHERE turn_id IS NOT NULL");
3909
+ // v129: how a run went — its turn count and a lead request's cache tokens (V129_ADDED_COLUMNS: saved rows read NULL),
3910
+ // and the derived How it ran row (a new table).
3911
+ for (const [table, columns] of Object.entries(V129_ADDED_COLUMNS)) {
3912
+ for (const [column, { definition }] of Object.entries(columns))
3913
+ addColumn(db, table, column, definition);
3914
+ }
3915
+ db.exec(RUN_EFFICIENCY_SCHEMA);
3916
+ // v130: a project's network and whether an agent may run unconfined (new tables only).
3917
+ db.exec(CONFINEMENT_SCHEMA);
3650
3918
  // v123: a flow that runs on its own (flow-unattended.ts): its grants and daily runs (new tables), and which authority
3651
3919
  // armed a plan's unchanged-plan approval (plan_authorization.basis, V123_ADDED_COLUMNS: saved rows read the mode).
3652
3920
  db.exec(FLOW_UNATTENDED_SCHEMA);
@@ -4143,7 +4411,11 @@ function rebuildExact(db, table, oldDdl, targetDdl, columns) {
4143
4411
  ? db.prepare("SELECT name, seq FROM sqlite_sequence ORDER BY rowid").all()
4144
4412
  : [];
4145
4413
  db.exec(targetDdl(`${table}_next`));
4146
- const names = columns.join(", ");
4414
+ // A table keyed by anything but an INTEGER PRIMARY KEY still has a hidden rowid, and a plain copy renumbers it;
4415
+ // the update's history check reads it (count and highest rowid), so it is carried whole like any id.
4416
+ const key = db.prepare(`PRAGMA table_info(${table})`).all().filter(one => Number(one["pk"]) > 0);
4417
+ const aliased = key.length === 1 && String(key[0]["type"]).toUpperCase() === "INTEGER";
4418
+ const names = (aliased || /WITHOUT\s+ROWID/i.test(stored) ? columns : ["rowid", ...columns]).join(", ");
4147
4419
  db.exec(`INSERT INTO ${table}_next (${names}) SELECT ${names} FROM ${table}`);
4148
4420
  db.exec(`DROP TABLE ${table}`);
4149
4421
  db.exec(`ALTER TABLE ${table}_next RENAME TO ${table}`);
@@ -4743,13 +5015,119 @@ CREATE TABLE IF NOT EXISTS phase_tier_alternate (
4743
5015
  scope TEXT NOT NULL,
4744
5016
  phase TEXT NOT NULL CHECK (phase IN ('plan','build')),
4745
5017
  tier TEXT NOT NULL CHECK (tier IN ('light','routine','strong')),
4746
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
5018
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
4747
5019
  model TEXT NOT NULL,
4748
5020
  updated_at TEXT NOT NULL,
4749
5021
  updated_by TEXT NOT NULL,
4750
5022
  PRIMARY KEY (scope, phase, tier, provider)
4751
5023
  );
4752
5024
  `;
5025
+ /**
5026
+ * v125: an ACP agent's passed conformance checks (acp-attest.ts): per provider and runner, how to launch the exact
5027
+ * binary that passed and every file that launch depends on, what the agent said it is, the models it offered and the
5028
+ * checks it passed. At most one live row per agent and runner; revoking keeps the row as history.
5029
+ */
5030
+ export const ACP_ATTESTATION_SCHEMA = `
5031
+ CREATE TABLE IF NOT EXISTS acp_attestation (
5032
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5033
+ provider TEXT NOT NULL CHECK (provider IN ('opencode','cursor','cline','qwen','goose')),
5034
+ runner TEXT NOT NULL,
5035
+ conformance_version INTEGER NOT NULL,
5036
+ launch_json TEXT NOT NULL,
5037
+ agent_name TEXT NOT NULL,
5038
+ agent_version TEXT NOT NULL,
5039
+ cli_version TEXT NOT NULL,
5040
+ models_json TEXT NOT NULL,
5041
+ checks_json TEXT NOT NULL,
5042
+ recorded_at TEXT NOT NULL,
5043
+ recorded_by TEXT NOT NULL,
5044
+ revoked_at TEXT,
5045
+ revoked_by TEXT
5046
+ );
5047
+ CREATE UNIQUE INDEX IF NOT EXISTS acp_attestation_live ON acp_attestation (provider, runner) WHERE revoked_at IS NULL;
5048
+ `;
5049
+ /**
5050
+ * v125: how an ACP agent's work on one model is billed, per runner (docs/plans/acp-adapter.md § Billing): metered
5051
+ * (the default, and what no row means — cost unknown, charged as unknown cost is) or the agent's own plan ($0,
5052
+ * counted as unmeasured). Each change is a new row; the newest is the declaration, its id the revision a run stamps.
5053
+ */
5054
+ /**
5055
+ * v125: a requested ACP agent check (docs/plans/acp-adapter.md § Layer E): asked for with a password, claimed and run
5056
+ * by one watch of the named runner — one running check per runner, which the partial unique index holds — through two
5057
+ * bounded steps, each bound to its run and graded on plane evidence; the attestation is recorded only in the same
5058
+ * transaction that marks the request passed.
5059
+ */
5060
+ export const ACP_CHECK_SCHEMA = `
5061
+ CREATE TABLE IF NOT EXISTS acp_check_request (
5062
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5063
+ provider TEXT NOT NULL CHECK (provider IN ('opencode','cursor','cline','qwen','goose')),
5064
+ runner TEXT NOT NULL,
5065
+ model TEXT NOT NULL,
5066
+ conformance_version INTEGER NOT NULL,
5067
+ requested_by TEXT NOT NULL,
5068
+ requested_at TEXT NOT NULL,
5069
+ expires_at TEXT NOT NULL,
5070
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','running','passed','failed','expired')),
5071
+ watch_repo TEXT,
5072
+ watch_owner TEXT,
5073
+ task_ref INTEGER REFERENCES task_ref(id),
5074
+ launch_json TEXT,
5075
+ lease_id TEXT,
5076
+ steps_used INTEGER NOT NULL DEFAULT 0 CHECK (steps_used BETWEEN 0 AND 2),
5077
+ step1_run INTEGER REFERENCES run(id),
5078
+ step2_run INTEGER REFERENCES run(id),
5079
+ step1_passed INTEGER NOT NULL DEFAULT 0 CHECK (step1_passed IN (0,1)),
5080
+ step2_passed INTEGER NOT NULL DEFAULT 0 CHECK (step2_passed IN (0,1)),
5081
+ result TEXT,
5082
+ finished_at TEXT,
5083
+ settled_at TEXT,
5084
+ settle_by TEXT,
5085
+ settle_until TEXT
5086
+ );
5087
+ CREATE UNIQUE INDEX IF NOT EXISTS acp_check_running ON acp_check_request (runner) WHERE state = 'running';
5088
+ `;
5089
+ export const ACP_BILLING_SCHEMA = `
5090
+ CREATE TABLE IF NOT EXISTS acp_billing (
5091
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5092
+ provider TEXT NOT NULL CHECK (provider IN ('opencode','cursor','cline','qwen','goose')),
5093
+ runner TEXT NOT NULL,
5094
+ model TEXT NOT NULL,
5095
+ kind TEXT NOT NULL CHECK (kind IN ('metered','plan')),
5096
+ set_at TEXT NOT NULL,
5097
+ set_by TEXT NOT NULL
5098
+ );
5099
+ CREATE INDEX IF NOT EXISTS acp_billing_by ON acp_billing (provider, runner, model, id);
5100
+ `;
5101
+ /** The provider CHECK before ACP agents joined (acp-agents.ts), and after: the same list, five ids longer. */
5102
+ const PROVIDERS_BEFORE_ACP = "CHECK (provider IN ('claude','codex','openrouter','gemini'))";
5103
+ const PROVIDERS_WITH_ACP = "CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose'))";
5104
+ /** The sign-in pause's auth mode before an ACP agent's own sign-in ('agent-login'), and after. */
5105
+ const AUTH_MODES_BEFORE_ACP = "CHECK (auth_mode IN ('subscription', 'api-key'))";
5106
+ const AUTH_MODES_WITH_ACP = "CHECK (auth_mode IN ('subscription', 'api-key', 'agent-login'))";
5107
+ /**
5108
+ * v125: ACP agents as providers (docs/plans/acp-adapter.md): every row carries over whole, the CHECKs only widen. Each table whose stored DDL still has the old list is rebuilt with the wider one, through rebuildExact — the
5109
+ * stored DDL must be exactly a known shape with the old clause appearing once, or the open refuses. Indexes the rebuild
5110
+ * drops are put back by their own schema's IF NOT EXISTS, which the caller runs again after.
5111
+ */
5112
+ function widenForAcp(db) {
5113
+ const widen = (table, before, after) => {
5114
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
5115
+ if (row === undefined)
5116
+ return;
5117
+ const stored = String(row["sql"]);
5118
+ if (stored.includes(after))
5119
+ return;
5120
+ if (stored.split(before).length !== 2)
5121
+ throw new Error(`the ${table} table's DDL is not a shape this migration knows — refusing to widen it`);
5122
+ const named = (sql) => (name) => sql.replace(new RegExp(`^CREATE TABLE (?:IF NOT EXISTS )?"?${table}"?`), `CREATE TABLE ${name}`);
5123
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(one => String(one["name"]));
5124
+ rebuildExact(db, table, named(stored), named(stored.replace(before, after)), columns);
5125
+ };
5126
+ for (const table of ["phase_config", "phase_tier_config", "phase_tier_alternate", "provider_readiness"])
5127
+ widen(table, PROVIDERS_BEFORE_ACP, PROVIDERS_WITH_ACP);
5128
+ widen("provider_auth_pause", AUTH_MODES_BEFORE_ACP, AUTH_MODES_WITH_ACP);
5129
+ widen("quota", "CHECK (auth_mode IN ('subscription','api-key'))", "CHECK (auth_mode IN ('subscription','api-key','agent-login'))");
5130
+ }
4753
5131
  /** A file from before the light tier keeps `tier IN ('strong')` in phase_tier_config's CHECK: rebuild the table with
4754
5132
  * the wider one, keeping every row, in one transaction. A file already widened is untouched. */
4755
5133
  function widenPhaseTierSchema(db) {
@@ -4762,13 +5140,13 @@ function widenPhaseTierSchema(db) {
4762
5140
  scope TEXT NOT NULL,
4763
5141
  phase TEXT NOT NULL CHECK (phase IN ('plan','build','repair','review')),
4764
5142
  tier TEXT NOT NULL CHECK (tier IN ('light','strong')),
4765
- provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini')),
5143
+ provider TEXT NOT NULL CHECK (provider IN ('claude','codex','openrouter','gemini','opencode','cursor','cline','qwen','goose')),
4766
5144
  model TEXT,
4767
5145
  updated_at TEXT NOT NULL,
4768
5146
  updated_by TEXT NOT NULL,
4769
5147
  PRIMARY KEY (scope, phase, tier)
4770
5148
  )`);
4771
- db.exec("INSERT INTO phase_tier_config_next SELECT scope, phase, tier, provider, model, updated_at, updated_by FROM phase_tier_config");
5149
+ db.exec("INSERT INTO phase_tier_config_next (rowid, scope, phase, tier, provider, model, updated_at, updated_by) SELECT rowid, scope, phase, tier, provider, model, updated_at, updated_by FROM phase_tier_config");
4772
5150
  db.exec("DROP TABLE phase_tier_config");
4773
5151
  db.exec("ALTER TABLE phase_tier_config_next RENAME TO phase_tier_config");
4774
5152
  db.exec("COMMIT");
@@ -6228,7 +6606,8 @@ export class Store {
6228
6606
  ? { ...profile, permissionArgv: "bypassPermissions" }
6229
6607
  : profile.provider === "gemini"
6230
6608
  ? { ...profile, approvalArgv: "yolo" }
6231
- : { ...profile, sandboxMode: "danger-full-access" };
6609
+ : isAcpProfile(profile) ? profile
6610
+ : { ...profile, sandboxMode: "danger-full-access" };
6232
6611
  }
6233
6612
  // Anything still above the ceiling files at the highest level beneath it its provider has; a provider with none
6234
6613
  // keeps its terms, and approval refuses them in words.
@@ -9167,9 +9546,23 @@ export class Store {
9167
9546
  }
9168
9547
  /** Stamp a task as the revision it is: source task + immutable brief. */
9169
9548
  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);
9549
+ this.transact(() => {
9550
+ this.db
9551
+ .prepare("UPDATE task_ref SET revision_of = ?, revision_brief_artifact = ? WHERE id = ?")
9552
+ .run(revisionOf, briefArtifact, taskRef);
9553
+ // v126: a revision keeps where the work came from, copied whole from the task it revises.
9554
+ this.db.prepare(`INSERT OR IGNORE INTO task_source (task_id, flow, card, card_entry, source_json, at)
9555
+ SELECT r.external_id, s.flow, s.card, s.card_entry, s.source_json, s.at FROM task_ref r JOIN task_source s ON s.task_id = ? WHERE r.id = ?`).run(revisionOf, taskRef);
9556
+ });
9557
+ }
9558
+ /** v126: where a task came from, written once in its filing's transaction (task-source.ts), never changed. */
9559
+ recordTaskSource(taskId, from, sourceJson, now) {
9560
+ this.db.prepare("INSERT OR IGNORE INTO task_source (task_id, flow, card, card_entry, source_json, at) VALUES (?, ?, ?, ?, ?, ?)").run(taskId, from.flow, from.card, from.entry, sourceJson, now.toISOString());
9561
+ }
9562
+ /** v126: where a task came from, as kept when it (or the task it revises) was filed; null for every other task. */
9563
+ taskSourceOf(taskId) {
9564
+ const row = this.db.prepare("SELECT flow, card, card_entry, source_json FROM task_source WHERE task_id = ?").get(taskId);
9565
+ return row === undefined ? null : { flow: Number(row["flow"]), card: Number(row["card"]), entry: Number(row["card_entry"]), sourceJson: String(row["source_json"]) };
9173
9566
  }
9174
9567
  // ---- the revision boundary: ONE field-by-field policy -------------------
9175
9568
  /**
@@ -9939,7 +10332,10 @@ export class Store {
9939
10332
  return "published";
9940
10333
  return null;
9941
10334
  }
9942
- requeueTask(taskId, by, now) {
10335
+ requeueTask(taskId, by, now,
10336
+ /** Approve & build again: requeue only while this exact build is the finished task's latest result and the scope
10337
+ * is still the one just approved, read inside this transaction ("moved" otherwise). */
10338
+ expect) {
9943
10339
  return this.transact(() => {
9944
10340
  const ref = this.db
9945
10341
  .prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?")
@@ -9985,6 +10381,12 @@ export class Store {
9985
10381
  else if (incidents.length === 0 && state !== "failed") {
9986
10382
  return { ok: false, reason: "not-stalled" };
9987
10383
  }
10384
+ if (expect !== undefined) {
10385
+ const scope = this.db.prepare("SELECT digest, approved_digest FROM task_scope WHERE task_id = ?").get(taskId);
10386
+ if (state !== "done" || rejectedRun !== expect.run || scope === undefined || String(scope["digest"]) !== expect.scopeDigest ||
10387
+ scope["approved_digest"] !== scope["digest"])
10388
+ return { ok: false, reason: "moved" };
10389
+ }
9988
10390
  for (const incident of incidents)
9989
10391
  this.resolveIncidentLocked(incident, by, now);
9990
10392
  this.resetStrikes(taskRef);
@@ -11265,9 +11667,9 @@ export class Store {
11265
11667
  this.spendCache = null; // its spend counts toward the next budget check
11266
11668
  const changed = this.db
11267
11669
  .prepare(`UPDATE chat_turn SET state = ?, failure_reason = ?, tokens_in = ?, tokens_out = ?,
11268
- settled_microusd = ?, unknown_spend = ?, reply_bytes = ?, candidate_count = ?, finished_at = ?
11670
+ settled_microusd = ?, unknown_spend = ?, reply_bytes = ?, candidate_count = ?, cache_read_tokens = ?, cache_write_tokens = ?, finished_at = ?
11269
11671
  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);
11672
+ .run(outcome.state, outcome.failureReason ?? null, outcome.tokensIn ?? null, outcome.tokensOut ?? null, outcome.settledMicrousd, outcome.unknownSpend === true ? 1 : 0, outcome.replyBytes ?? null, outcome.candidateCount ?? null, outcome.cacheReadTokens ?? null, outcome.cacheWriteTokens ?? null, now.toISOString(), id, generation);
11271
11673
  return Number(changed.changes) === 1;
11272
11674
  }
11273
11675
  /**
@@ -11990,6 +12392,11 @@ export class Store {
11990
12392
  // duplicate before any request is consumed.
11991
12393
  reviewAttempt);
11992
12394
  const id = Number(inserted.lastInsertRowid);
12395
+ // An ACP run's billing is the declaration standing for its runner and model NOW, stamped with the row it bills:
12396
+ // settlement reads this stamp, never the live declaration (docs/plans/acp-adapter.md § Billing).
12397
+ if (provider !== undefined && provider !== null && isAcpProvider(provider) && model !== null) {
12398
+ this.db.prepare("UPDATE run SET acp_billing_json = ? WHERE id = ?").run(JSON.stringify(this.acpBillingFor(provider, run.runner, model)), id);
12399
+ }
11993
12400
  // The attempt key (Agents board): opaque, minted with the row, and the only key its stream state is recorded under.
11994
12401
  this.db.prepare("UPDATE run SET attempt_key = ? WHERE id = ?").run(mintAttemptKey(), id);
11995
12402
  // The provenance row lands in the same transaction as the run row —
@@ -12423,6 +12830,216 @@ export class Store {
12423
12830
  * structural signal is gone. `auth-expired` pauses its provider; every
12424
12831
  * other class is observational.
12425
12832
  */
12833
+ /**
12834
+ * Record an ACP agent's passed conformance check on a runner (acp-attest.ts), replacing the live one — one
12835
+ * transaction, so there is never a moment with two or with a gap that reads as the old one.
12836
+ */
12837
+ recordAcpAttestation(input, now) {
12838
+ return this.transact(() => {
12839
+ this.db.prepare("UPDATE acp_attestation SET revoked_at = ?, revoked_by = ? WHERE provider = ? AND runner = ? AND revoked_at IS NULL")
12840
+ .run(now.toISOString(), `${input.recordedBy} (checked again)`, input.provider, input.runner);
12841
+ const inserted = this.db.prepare(`INSERT INTO acp_attestation (provider, runner, conformance_version, launch_json, agent_name, agent_version, cli_version, models_json, checks_json, recorded_at, recorded_by)
12842
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.provider, input.runner, input.conformanceVersion ?? ACP_CONFORMANCE_VERSION, JSON.stringify(input.launch), input.agent.name, input.agent.version, input.cliVersion, JSON.stringify(input.models), JSON.stringify(input.checks), now.toISOString(), input.recordedBy);
12843
+ return Number(inserted.lastInsertRowid);
12844
+ });
12845
+ }
12846
+ /** An ACP agent's live attestation on a runner, under this build's conformance version; null when there is none. */
12847
+ acpAttestationFor(provider, runner) {
12848
+ const row = this.db.prepare("SELECT * FROM acp_attestation WHERE provider = ? AND runner = ? AND revoked_at IS NULL").get(provider, runner);
12849
+ if (row === undefined || Number(row["conformance_version"]) !== ACP_CONFORMANCE_VERSION)
12850
+ return null;
12851
+ const launch = launchFromJson(String(row["launch_json"]));
12852
+ const list = (json) => {
12853
+ try {
12854
+ const value = JSON.parse(String(json));
12855
+ return Array.isArray(value) && value.every(one => typeof one === "string") ? value : null;
12856
+ }
12857
+ catch {
12858
+ return null;
12859
+ }
12860
+ };
12861
+ const models = list(row["models_json"]);
12862
+ const checks = list(row["checks_json"]);
12863
+ if (launch === null || models === null || checks === null)
12864
+ return null;
12865
+ return {
12866
+ id: Number(row["id"]), provider: String(row["provider"]), runner: String(row["runner"]), conformanceVersion: Number(row["conformance_version"]),
12867
+ launch, agent: { name: String(row["agent_name"]), version: String(row["agent_version"]) }, cliVersion: String(row["cli_version"]),
12868
+ models, checks, recordedAt: String(row["recorded_at"]), recordedBy: String(row["recorded_by"]),
12869
+ };
12870
+ }
12871
+ /** Declare how an ACP agent's work on one model is billed on a runner; the new revision. */
12872
+ declareAcpBilling(input, now) {
12873
+ return Number(this.db.prepare("INSERT INTO acp_billing (provider, runner, model, kind, set_at, set_by) VALUES (?, ?, ?, ?, ?, ?)")
12874
+ .run(input.provider, input.runner, input.model, input.kind, now.toISOString(), input.by).lastInsertRowid);
12875
+ }
12876
+ /** The billing declaration standing for an ACP agent's model on a runner: metered, revision 0, when none was made. */
12877
+ acpBillingFor(provider, runner, model) {
12878
+ const row = this.db.prepare("SELECT id, kind FROM acp_billing WHERE provider = ? AND runner = ? AND model = ? ORDER BY id DESC LIMIT 1").get(provider, runner, model);
12879
+ return row === undefined ? { kind: "metered", revision: 0, model } : { kind: row["kind"] === "plan" ? "plan" : "metered", revision: Number(row["id"]), model };
12880
+ }
12881
+ /** The billing an ACP run was admitted under, or null (not an ACP run, or unreadable — then it is metered). */
12882
+ acpBillingOfRun(runId) {
12883
+ const raw = this.db.prepare("SELECT acp_billing_json FROM run WHERE id = ?").get(runId)?.["acp_billing_json"];
12884
+ if (typeof raw !== "string")
12885
+ return null;
12886
+ try {
12887
+ const value = JSON.parse(raw);
12888
+ if ((value["kind"] === "metered" || value["kind"] === "plan") && typeof value["revision"] === "number" && typeof value["model"] === "string") {
12889
+ return { kind: value["kind"], revision: value["revision"], model: value["model"] };
12890
+ }
12891
+ }
12892
+ catch { /* unreadable: metered */ }
12893
+ return null;
12894
+ }
12895
+ /** Ask for an ACP agent check on a runner (the caller proved the password); the request's id. Valid 30 minutes. */
12896
+ requestAcpCheck(input, now) {
12897
+ return Number(this.db.prepare(`INSERT INTO acp_check_request (provider, runner, model, conformance_version, requested_by, requested_at, expires_at)
12898
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(input.provider, input.runner, input.model, ACP_CONFORMANCE_VERSION, input.by, now.toISOString(), new Date(now.getTime() + 30 * 60_000).toISOString()).lastInsertRowid);
12899
+ }
12900
+ acpCheck(id) {
12901
+ const row = this.db.prepare("SELECT * FROM acp_check_request WHERE id = ?").get(id);
12902
+ return row === undefined ? null : readAcpCheck(row);
12903
+ }
12904
+ /** The checks asked for an agent, newest first (for the providers screen). */
12905
+ acpChecksFor(provider, limit = 10) {
12906
+ return this.db.prepare("SELECT * FROM acp_check_request WHERE provider = ? ORDER BY id DESC LIMIT ?").all(provider, limit).map(readAcpCheck);
12907
+ }
12908
+ /** The SQL that proves a watch still holds its lease (runner, repo, owner) at `?` — appended with its bindings. */
12909
+ static WATCH_LIVE = "EXISTS (SELECT 1 FROM watch_lease w WHERE w.runner = acp_check_request.runner AND w.repo = acp_check_request.watch_repo AND w.owner = acp_check_request.watch_owner AND w.expires_at > ?)";
12910
+ /**
12911
+ * Claim the oldest pending, unexpired check for this runner, for the watch that will run it — one statement; the
12912
+ * partial unique index refuses a second running check on the runner. Null when there is none (or another runs).
12913
+ */
12914
+ claimAcpCheck(runner, watch, now) {
12915
+ const at = now.toISOString();
12916
+ try {
12917
+ const claimed = this.db.prepare(`UPDATE acp_check_request SET state = 'running', watch_repo = ?, watch_owner = ?
12918
+ WHERE id = (SELECT id FROM acp_check_request WHERE runner = ? AND state = 'pending' AND expires_at > ? ORDER BY id LIMIT 1)
12919
+ AND EXISTS (SELECT 1 FROM watch_lease w WHERE w.runner = ? AND w.repo = ? AND w.owner = ? AND w.expires_at > ?)
12920
+ RETURNING id`).get(watch.repo, watch.owner, runner, at, runner, watch.repo, watch.owner, at);
12921
+ return claimed === undefined ? null : this.acpCheck(Number(claimed["id"]));
12922
+ }
12923
+ catch (error) {
12924
+ if (/UNIQUE constraint failed/.test(String(error)))
12925
+ return null;
12926
+ throw error;
12927
+ }
12928
+ }
12929
+ /** Bind a running check, still owned and unexpired, to its task and the launch it inspected. */
12930
+ bindAcpCheck(id, binding, now) {
12931
+ const at = now.toISOString();
12932
+ return Number(this.db.prepare(`UPDATE acp_check_request SET task_ref = ?, launch_json = ?, lease_id = ?
12933
+ WHERE id = ? AND state = 'running' AND expires_at > ? AND task_ref IS NULL AND ${Store.WATCH_LIVE}`).run(binding.taskRef, binding.launchJson, binding.leaseId, id, at, at).changes) === 1;
12934
+ }
12935
+ /** Consume step 1 or 2 for its run: in order, once, owned, unexpired. */
12936
+ consumeAcpCheckStep(id, step, runId, now) {
12937
+ const at = now.toISOString();
12938
+ const column = step === 1 ? "step1_run" : "step2_run";
12939
+ return Number(this.db.prepare(`UPDATE acp_check_request SET steps_used = ?, ${column} = ?
12940
+ WHERE id = ? AND state = 'running' AND steps_used = ? AND ${column} IS NULL AND expires_at > ? AND ${Store.WATCH_LIVE}
12941
+ ${step === 2 ? "AND step1_passed = 1" : ""}`).run(step, runId, id, step - 1, at, at).changes) === 1;
12942
+ }
12943
+ /** Record a step as passed, once its grading did: owned, unexpired, and only for the run the step was bound to. */
12944
+ passAcpCheckStep(id, step, runId, now) {
12945
+ const at = now.toISOString();
12946
+ const run = step === 1 ? "step1_run" : "step2_run";
12947
+ const passed = step === 1 ? "step1_passed" : "step2_passed";
12948
+ return Number(this.db.prepare(`UPDATE acp_check_request SET ${passed} = 1
12949
+ WHERE id = ? AND state = 'running' AND ${run} = ? AND ${passed} = 0 AND expires_at > ? AND ${Store.WATCH_LIVE}`).run(id, runId, at, at).changes) === 1;
12950
+ }
12951
+ /**
12952
+ * Publish a passed check: in one transaction, the request moves to passed — only if it is still running, unexpired,
12953
+ * owned by its live watch, with both steps used, bound and passed, and both their runs finished as agent-check
12954
+ * refusals — and the attestation is recorded. Anything else publishes nothing.
12955
+ */
12956
+ publishAcpCheck(id, attestation, now) {
12957
+ const at = now.toISOString();
12958
+ return this.transact(() => {
12959
+ // The attestation must be for this request's agent and runner, and offer the model it was checked on.
12960
+ const request = this.acpCheck(id);
12961
+ if (request === null || request.provider !== attestation.provider || request.runner !== attestation.runner || !attestation.models.includes(request.model))
12962
+ return false;
12963
+ const moved = Number(this.db.prepare(`UPDATE acp_check_request SET state = 'passed', finished_at = ?, result = 'passed'
12964
+ WHERE id = ? AND state = 'running' AND expires_at > ? AND ${Store.WATCH_LIVE}
12965
+ AND steps_used = 2 AND step1_passed = 1 AND step2_passed = 1 AND conformance_version = ?
12966
+ AND (SELECT COUNT(*) FROM run r WHERE r.id IN (step1_run, step2_run) AND r.task_ref = acp_check_request.task_ref
12967
+ AND r.outcome = 'refused' AND r.reason = 'agent-check' AND r.finished_at IS NOT NULL) = 2`)
12968
+ .run(at, id, at, at, ACP_CONFORMANCE_VERSION).changes);
12969
+ if (moved !== 1)
12970
+ return false;
12971
+ this.recordAcpAttestation(attestation, now);
12972
+ return true;
12973
+ });
12974
+ }
12975
+ /** The check request, when it still admits this run as its current step: running, unexpired, its watch's lease
12976
+ * live, and the step bound to exactly this run. Null otherwise. */
12977
+ acpCheckAdmitting(id, step, runId, now) {
12978
+ const at = now.toISOString();
12979
+ const row = this.db.prepare(`SELECT * FROM acp_check_request WHERE id = ? AND state = 'running' AND expires_at > ? AND ${Store.WATCH_LIVE}
12980
+ AND steps_used = ? AND ${step === 1 ? "step1_run" : "step2_run"} = ?`).get(id, at, at, step, runId);
12981
+ return row === undefined ? null : readAcpCheck(row);
12982
+ }
12983
+ /** Fail a check that hasn't finished, with its words. */
12984
+ failAcpCheck(id, words, now) {
12985
+ return Number(this.db.prepare("UPDATE acp_check_request SET state = 'failed', result = ?, finished_at = ? WHERE id = ? AND state IN ('pending','running')")
12986
+ .run(words.slice(0, 2000), now.toISOString(), id).changes) === 1;
12987
+ }
12988
+ /** Running checks on this runner no live watch holds any more, or past their time: the ones recovery may close. */
12989
+ abandonedAcpChecks(runner, now) {
12990
+ const at = now.toISOString();
12991
+ return this.db.prepare(`SELECT * FROM acp_check_request WHERE runner = ? AND state = 'running' AND (expires_at <= ? OR NOT ${Store.WATCH_LIVE})`)
12992
+ .all(runner, at, at).map(readAcpCheck);
12993
+ }
12994
+ /** Finished checks on this runner whose claim, checkout and task are not yet let go: cleanup's to-do, retried each pass. */
12995
+ unsettledAcpChecks(runner) {
12996
+ return this.db.prepare("SELECT * FROM acp_check_request WHERE runner = ? AND state IN ('passed','failed') AND settled_at IS NULL ORDER BY id").all(runner).map(readAcpCheck);
12997
+ }
12998
+ /** The claim a finished check's cleanup now holds on its task (its first one expired): the only one it may release. */
12999
+ setAcpCheckLease(id, leaseId) {
13000
+ return Number(this.db.prepare("UPDATE acp_check_request SET lease_id = ? WHERE id = ? AND state IN ('passed','failed') AND settled_at IS NULL").run(leaseId, id).changes) === 1;
13001
+ }
13002
+ /** A finished check holds something again (a checkout that landed after recovery settled it): cleanup reopens. */
13003
+ unsettleAcpCheck(id) {
13004
+ return Number(this.db.prepare("UPDATE acp_check_request SET settled_at = NULL WHERE id = ? AND state IN ('passed','failed') AND settled_at IS NOT NULL").run(id).changes) === 1;
13005
+ }
13006
+ /** Take — or renew — the one cleanup attempt a finished check may have at a time: free, lapsed, or already this one. */
13007
+ holdAcpSettle(id, attempt, now, ttlMs) {
13008
+ const at = now.toISOString();
13009
+ return Number(this.db.prepare(`UPDATE acp_check_request SET settle_by = ?, settle_until = ?
13010
+ WHERE id = ? AND state IN ('passed','failed') AND settled_at IS NULL AND (settle_by IS NULL OR settle_by = ? OR settle_until <= ?)`)
13011
+ .run(attempt, new Date(now.getTime() + ttlMs).toISOString(), id, attempt, at).changes) === 1;
13012
+ }
13013
+ /** Whether this cleanup attempt still holds the check. */
13014
+ holdsAcpSettle(id, attempt, now) {
13015
+ return this.db.prepare("SELECT 1 FROM acp_check_request WHERE id = ? AND settle_by = ? AND settle_until > ?").get(id, attempt, now.toISOString()) !== undefined;
13016
+ }
13017
+ /** Let go of a cleanup attempt (only this one). */
13018
+ endAcpSettle(id, attempt) {
13019
+ this.db.prepare("UPDATE acp_check_request SET settle_by = NULL, settle_until = NULL WHERE id = ? AND settle_by = ?").run(id, attempt);
13020
+ }
13021
+ /** Mark a finished check's holdings let go — by the attempt holding it, and only if no checkout of its branch is
13022
+ * left (one that landed late keeps it open, whatever the attempt last saw). */
13023
+ settleAcpCheck(id, attempt, branch, now) {
13024
+ return Number(this.db.prepare(`UPDATE acp_check_request SET settled_at = ?, settle_by = NULL, settle_until = NULL
13025
+ WHERE id = ? AND state IN ('passed','failed') AND settled_at IS NULL AND settle_by = ? AND settle_until > ?
13026
+ AND NOT EXISTS (SELECT 1 FROM worktree w WHERE w.task_ref = acp_check_request.task_ref AND w.branch = ?)`)
13027
+ .run(now.toISOString(), id, attempt, now.toISOString(), branch).changes) === 1;
13028
+ }
13029
+ /** Pending checks past their time are expired, untouched. */
13030
+ expireAcpChecks(now) {
13031
+ return Number(this.db.prepare("UPDATE acp_check_request SET state = 'expired', finished_at = ? WHERE state = 'pending' AND expires_at <= ?").run(now.toISOString(), now.toISOString()).changes);
13032
+ }
13033
+ /** Revoke an ACP agent's live attestation on a runner; whether there was one. */
13034
+ revokeAcpAttestation(provider, runner, by, now) {
13035
+ return Number(this.db.prepare("UPDATE acp_attestation SET revoked_at = ?, revoked_by = ? WHERE provider = ? AND runner = ? AND revoked_at IS NULL").run(now.toISOString(), by, provider, runner).changes) > 0;
13036
+ }
13037
+ /** What a run's ACP agent itself reported it used (never priced; acp-client.ts). */
13038
+ stampAgentReportedUsage(id, usage) {
13039
+ if (usage === null)
13040
+ return;
13041
+ this.db.prepare("UPDATE run SET agent_reported_usage_json = ? WHERE id = ?").run(usage.slice(0, 8192), id);
13042
+ }
12426
13043
  stampTerminalClass(id, authMode, terminalClass) {
12427
13044
  // FIRST-WRITE only (Codex E2/E3 review, finding 8): the gateway stamps
12428
13045
  // the disposal class exactly once, before disposition; nothing may
@@ -12449,9 +13066,10 @@ export class Store {
12449
13066
  .prepare(`UPDATE run SET tokens_in = COALESCE(?, tokens_in),
12450
13067
  tokens_out = COALESCE(?, tokens_out),
12451
13068
  cost_usd = COALESCE(?, cost_usd),
12452
- usage_json = COALESCE(?, usage_json)
13069
+ usage_json = COALESCE(?, usage_json),
13070
+ agent_turns = COALESCE(?, agent_turns)
12453
13071
  WHERE id = ?`)
12454
- .run(usage.tokensIn ?? null, usage.tokensOut ?? null, usage.costUsd ?? null, usage.usageJson ?? null, id);
13072
+ .run(usage.tokensIn ?? null, usage.tokensOut ?? null, usage.costUsd ?? null, usage.usageJson ?? null, usage.agentTurns ?? null, id);
12455
13073
  this.settleRunSpend(id, new Date());
12456
13074
  }
12457
13075
  /** v105: price a run as it stands (reported cost, or tokens at the catalogue price, or unpriced), frozen at the
@@ -12461,8 +13079,11 @@ export class Store {
12461
13079
  if (row === undefined)
12462
13080
  return;
12463
13081
  // How the run is billed is fixed the first time it's settled (its chain entry's, or the provider's login or key).
13082
+ // An ACP run is billed as its stamp says: its agent's own plan reads as a plan ($0), anything else as metered.
13083
+ const acpStamp = isAcpProvider(String(row["provider"])) ? this.acpBillingOfRun(id) : null;
12464
13084
  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);
13085
+ : isAcpProvider(String(row["provider"])) ? (acpStamp?.kind === "plan" ? "subscription" : "api-key")
13086
+ : row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key" ? row["auth_mode"] : billingOf(String(row["provider"]), this.db);
12466
13087
  const priced = priceWork(this.db, { provider: String(row["provider"]), model: row["model"] == null ? null : String(row["model"]), billing,
12467
13088
  costUsd: row["cost_usd"] == null ? null : Number(row["cost_usd"]), tokensIn: row["tokens_in"] == null ? null : Number(row["tokens_in"]), tokensOut: row["tokens_out"] == null ? null : Number(row["tokens_out"]) });
12468
13089
  this.db.prepare(`INSERT INTO run_spend (run, microusd, source, billing, price_model, input_usd, output_usd, priced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -12732,6 +13353,15 @@ export class Store {
12732
13353
  return { ...none, remainingMicrousd: remaining };
12733
13354
  };
12734
13355
  }
13356
+ /** How one agent would bill new work now: an ACP agent by its declaration for that exact model on that runner
13357
+ * (plan reads as a plan, anything else as metered), every other provider as agentsFor says. */
13358
+ agentFor(provider, model, runner) {
13359
+ if (isAcpProvider(provider)) {
13360
+ const declared = model === null || runner === null ? null : this.acpBillingFor(provider, runner, model);
13361
+ return { provider, billing: declared?.kind === "plan" ? "subscription" : "api-key" };
13362
+ }
13363
+ return this.agentsFor([provider])[0];
13364
+ }
12735
13365
  /** How each provider would bill new work now (see spend.billingOf). */
12736
13366
  agentsFor(providers) {
12737
13367
  return [...new Set(providers)].map(provider => ({ provider, billing: billingOf(provider, this.db) }));
@@ -13291,6 +13921,60 @@ export class Store {
13291
13921
  return { ok: true, id };
13292
13922
  }), result => result.ok);
13293
13923
  }
13924
+ /**
13925
+ * File a note for the exact run a person is watching (live-conversation.md
13926
+ * §6): in ONE transaction, the run must still be the task's live attempt
13927
+ * (or, `onward`, any attempt of this task: the note goes to the next one),
13928
+ * and a repeated `key` from the same author hands back the same note — only
13929
+ * for the same task, run and text, whichever of the two ways it was sent;
13930
+ * any other reuse is refused. Nothing is filed on a refusal.
13931
+ */
13932
+ fileSteerNoteForRun(taskId, author, note, now, expect) {
13933
+ return this.transact(() => {
13934
+ const valid = validateNote(note);
13935
+ if (!valid.ok)
13936
+ return { ok: false, reason: "invalid-note", problem: valid.problem };
13937
+ const task = this.db.prepare("SELECT state FROM task WHERE id = ?").get(taskId);
13938
+ const ref = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, taskId);
13939
+ if (task === undefined || ref === undefined)
13940
+ return { ok: false, reason: "unknown-task" };
13941
+ const taskRef = Number(ref["id"]);
13942
+ const key = expect.key === null ? null : `steer-note:${author}:${expect.key}`;
13943
+ const digest = createHash("sha256").update(JSON.stringify([taskId, expect.run, valid.note])).digest("hex");
13944
+ if (key !== null) {
13945
+ const seen = this.db.prepare("SELECT operation, result FROM mutation WHERE idempotency_key = ?").get(key);
13946
+ if (seen !== undefined) {
13947
+ const saved = parseStoreColumn("mutation.result", seen["result"]);
13948
+ if (seen["operation"] === "fileSteerNoteForRun" && saved !== null && saved.digest === digest && typeof saved.id === "number") {
13949
+ return { ok: true, id: saved.id, replayed: true };
13950
+ }
13951
+ return { ok: false, reason: "key-reused" };
13952
+ }
13953
+ }
13954
+ const state = String(task["state"]);
13955
+ if (state === "done" || state === "cancelled")
13956
+ return { ok: false, reason: "task-finished" };
13957
+ const shown = this.db.prepare("SELECT task_ref, outcome, lease_id FROM run WHERE id = ?").get(expect.run);
13958
+ if (shown === undefined || Number(shown["task_ref"]) !== taskRef)
13959
+ return { ok: false, reason: "attempt-ended" };
13960
+ // Sent on from an attempt that ended (`onward`), the note is for the task's next attempt. Otherwise the run on
13961
+ // screen must still be the task's live attempt: unfinished, under the current lease. How the note reaches it —
13962
+ // its brief, its steering folder, or stdin — is steering's own business, as for any note.
13963
+ if (expect.onward !== true) {
13964
+ const lease = this.currentLiveLease(taskRef, now);
13965
+ if (shown["outcome"] !== null || lease === null || shown["lease_id"] !== lease)
13966
+ return { ok: false, reason: "attempt-ended" };
13967
+ }
13968
+ const id = this.insertSteerNote(taskRef, author, valid.note, now);
13969
+ if (key !== null) {
13970
+ this.db
13971
+ .prepare("INSERT INTO mutation (idempotency_key, operation, result, actor, created_at) VALUES (?, ?, ?, ?, ?)")
13972
+ .run(key, "fileSteerNoteForRun", JSON.stringify({ id, digest }), author, now.toISOString());
13973
+ }
13974
+ this.bumpWake();
13975
+ return { ok: true, id, replayed: false };
13976
+ });
13977
+ }
13294
13978
  /** One verified note, with the identity a running build's stdin copy is sent under: filing it is also what queues
13295
13979
  * its live delivery, in the same transaction (the builder's dispatcher sends whatever is filed and unsent). */
13296
13980
  insertSteerNote(taskRef, author, note, now) {
@@ -13353,6 +14037,7 @@ export class Store {
13353
14037
  this.db
13354
14038
  .prepare("UPDATE task_steer SET attached_run = ?, attached_at = ?, live_sends = 0, live_sent_at = NULL, live_missed_at = NULL WHERE id = ?")
13355
14039
  .run(runId, stamp, Number(row["id"]));
14040
+ this.noteSteerReached(Number(row["id"]), runId, "attached", stamp);
13356
14041
  }
13357
14042
  return rows.map(row => readSteerNote({ ...row, attached_run: runId, attached_at: stamp, live_sends: 0, live_sent_at: null, live_missed_at: null }));
13358
14043
  });
@@ -13364,9 +14049,13 @@ export class Store {
13364
14049
  * first receipt wins, and a replay changes nothing.
13365
14050
  */
13366
14051
  settleSteerDelivered(runId, now) {
14052
+ const stamp = now.toISOString();
14053
+ const settled = this.db.prepare("SELECT id FROM task_steer WHERE attached_run = ? AND delivered_at IS NULL").all(runId);
13367
14054
  const { changes } = this.db
13368
14055
  .prepare("UPDATE task_steer SET delivered_at = ? WHERE attached_run = ? AND delivered_at IS NULL")
13369
- .run(now.toISOString(), runId);
14056
+ .run(stamp, runId);
14057
+ for (const row of settled)
14058
+ this.noteSteerReached(Number(row["id"]), runId, "read", stamp);
13370
14059
  return Number(changes);
13371
14060
  }
13372
14061
  /**
@@ -13420,8 +14109,24 @@ export class Store {
13420
14109
  live_missed_at = CASE WHEN mailed_run IS ? THEN live_missed_at ELSE NULL END
13421
14110
  WHERE id = ? AND delivered_at IS NULL AND superseded_at IS NULL`)
13422
14111
  .run(runId, now.toISOString(), runId, runId, runId, noteId);
14112
+ if (Number(changes) > 0)
14113
+ this.noteSteerReached(noteId, runId, "mailed", now.toISOString());
13423
14114
  return Number(changes) > 0;
13424
14115
  }
14116
+ /** One run a note reached, kept for its live conversation (live-notes.ts); display state, so a failure never blocks steering. */
14117
+ noteSteerReached(noteId, runId, how, at) {
14118
+ try {
14119
+ this.db.prepare("INSERT OR IGNORE INTO steer_note_run (note, run, how, at) VALUES (?, ?, ?, ?)").run(noteId, runId, how, at);
14120
+ }
14121
+ catch { /* display state */ }
14122
+ }
14123
+ /** The notes this run reached, filing order — attached to its brief, mailed into its folder, or read by it. */
14124
+ steerNotesReaching(runId) {
14125
+ return this.db
14126
+ .prepare("SELECT * FROM task_steer WHERE id IN (SELECT note FROM steer_note_run WHERE run = ?) ORDER BY id")
14127
+ .all(runId)
14128
+ .map(readSteerNote);
14129
+ }
13425
14130
  /**
13426
14131
  * The agent moved this note to handled/ (v120): it was read, by THIS run.
13427
14132
  * Acknowledgement is the only road from mailed to delivered — a hook firing
@@ -13434,6 +14139,8 @@ export class Store {
13434
14139
  .prepare(`UPDATE task_steer SET acked_at = ?, delivered_at = ?, attached_run = ?, attached_at = COALESCE(mailed_at, ?)
13435
14140
  WHERE id = ? AND mailed_run = ? AND acked_at IS NULL AND delivered_at IS NULL AND superseded_at IS NULL`)
13436
14141
  .run(stamp, stamp, runId, stamp, noteId, runId);
14142
+ if (Number(changes) > 0)
14143
+ this.noteSteerReached(noteId, runId, "read", stamp);
13437
14144
  if (Number(changes) > 0)
13438
14145
  this.bumpWake();
13439
14146
  return Number(changes) > 0;
@@ -14531,17 +15238,31 @@ export class Store {
14531
15238
  return row === undefined ? null : readLeadSession(row);
14532
15239
  }
14533
15240
  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;
15241
+ return this.transact(() => {
15242
+ const changed = this.db
15243
+ .prepare("UPDATE lead_session SET ended_at = ?, ended_by = ? WHERE id = ? AND ended_at IS NULL")
15244
+ .run(now.toISOString(), by, id);
15245
+ // Its waiting messages can never run under it (each is bound to the session it was sent in): they end now, Not
15246
+ // sent, and keep no words (v128 lead queue).
15247
+ if (Number(changed.changes) === 1) {
15248
+ const waiting = this.db.prepare("SELECT message FROM lead_queue WHERE session = ? AND status = 'queued'").all(id).map(row => Number(row["message"]));
15249
+ for (const message of waiting) {
15250
+ this.db.prepare("UPDATE lead_queue SET status = 'cancelled', error = 'Not sent: this conversation ended before it ran.', revision = revision + 1, settled_at = ? WHERE message = ?").run(now.toISOString(), message);
15251
+ this.forgetQueuedLeadMessage(message);
15252
+ }
15253
+ }
15254
+ return Number(changed.changes) === 1;
15255
+ });
14538
15256
  }
14539
15257
  /** Revocation's companion (ruling 10): an approver's standing ends every session it minted. */
14540
15258
  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);
15259
+ return this.transact(() => {
15260
+ const ending = this.db.prepare("SELECT id FROM lead_session WHERE approver = ? AND ended_at IS NULL").all(approver).map(row => Number(row["id"]));
15261
+ // Each ends as endLeadSession ends one: its waiting messages too (v128 lead queue).
15262
+ for (const id of ending)
15263
+ this.endLeadSession(id, by, now);
15264
+ return ending.length;
15265
+ });
14545
15266
  }
14546
15267
  /** The live thread for this approver and scope under THIS ceiling; a
14547
15268
  * thread under another ceiling is closed first (ruling 9) — the surface
@@ -14627,11 +15348,14 @@ export class Store {
14627
15348
  const { changes } = this.db.prepare("UPDATE flow SET state = 'archived', updated_by = ?, updated_at = ? WHERE id = ? AND state = 'active'").run(by, now.toISOString(), id);
14628
15349
  return Number(changes) === 1;
14629
15350
  }
15351
+ /** A new card. `byKind` (v126) is decided where the card is made, never read from `by`: a delivery, a poll, a schedule
15352
+ * a public form or a chat channel's message is automation whatever its label says; a signed-in person's own card is a
15353
+ * person's. Every caller says which: filing passes it on exactly, and never reads it from `by`. */
14630
15354
  addFlowCard(card, now) {
14631
15355
  return this.transact(() => {
14632
15356
  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);
15357
+ const id = Number(this.db.prepare("INSERT INTO flow_card (flow, title, description, stage, state, created_by, created_at, updated_at, source_json, created_kind) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)")
15358
+ .run(card.flow, card.title, card.description, card.stage, card.by, stamp, stamp, card.source === undefined ? null : JSON.stringify(card.source), card.byKind).lastInsertRowid);
14635
15359
  this.db.prepare("INSERT INTO flow_event (card, from_stage, to_stage, outcome, actor, note, at) VALUES (?, NULL, ?, 'created', ?, NULL, ?)").run(id, card.stage, card.by, stamp);
14636
15360
  return id;
14637
15361
  });
@@ -15208,7 +15932,80 @@ export class Store {
15208
15932
  * firing) the task it filed. False when it was already recorded.
15209
15933
  */
15210
15934
  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);
15935
+ const at = now.toISOString();
15936
+ const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, task, on_time, ran_script, state, accepted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
15937
+ .run(trigger, key, card, note, at, task, onTime ? 1 : 0, ranScript, card === null ? "skipped" : "accepted", card === null ? null : at);
15938
+ return Number(changes) === 1;
15939
+ }
15940
+ /**
15941
+ * v126: point a trigger at another saved secret ("" for none), and (when given) its new settings, in one write — only
15942
+ * from the secret and settings the caller read, so of two changes at once exactly one lands. False when it changed
15943
+ * meanwhile (or the trigger is gone): the caller then removes only the secret it wrote itself.
15944
+ */
15945
+ swapFlowHookCredential(id, from, to, configJson, now) {
15946
+ // The address moves with it when given: one write, from the very secret, settings and address the caller read.
15947
+ const { changes } = this.db.prepare(`UPDATE flow_trigger SET hook_credential = ?, hook_hash = COALESCE(?, hook_hash), config_json = COALESCE(?, config_json), updated_at = ?
15948
+ WHERE id = ? AND hook_credential = ? AND config_json = ? AND hook_hash IS ? AND state != 'removed'`)
15949
+ .run(to.credential, to.hookHash ?? null, configJson, now.toISOString(), id, from.credential, from.configJson, from.hookHash);
15950
+ return Number(changes) === 1;
15951
+ }
15952
+ /** v126: what a trigger did with one delivery, or null when it never received it. */
15953
+ flowReceipt(trigger, key) {
15954
+ const row = this.db.prepare("SELECT card, note, at, state, accepted_at, item_json FROM flow_trigger_event WHERE trigger = ? AND key = ?").get(trigger, key);
15955
+ return row === undefined ? null : readFlowReceipt(row);
15956
+ }
15957
+ /** v126: a delivery received but made no card for: ignored (not for this flow), or held until there's room (its
15958
+ * prepared item kept whole). False when the key was already received. */
15959
+ recordFlowReceipt(trigger, key, state, note, itemJson, now) {
15960
+ if ((state === "held") !== (itemJson !== null))
15961
+ throw new Error("a held receipt keeps its item, and only a held one");
15962
+ const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, state, item_json) VALUES (?, ?, NULL, ?, ?, ?, ?)")
15963
+ .run(trigger, key, note, now.toISOString(), state, itemJson);
15964
+ return Number(changes) === 1;
15965
+ }
15966
+ /** v126: a held delivery became its card. Only ever from held, in the card's own transaction: false (the caller rolls
15967
+ * the card back) when it is no longer held. */
15968
+ acceptHeldReceipt(trigger, key, card, now) {
15969
+ const { changes } = this.db.prepare("UPDATE flow_trigger_event SET state = 'accepted', card = ?, accepted_at = ?, item_json = NULL WHERE trigger = ? AND key = ? AND state = 'held'")
15970
+ .run(card, now.toISOString(), trigger, key);
15971
+ return Number(changes) === 1;
15972
+ }
15973
+ /** v126: the cards a trigger made since `since`, counted when each was made (a held delivery counts when it became
15974
+ * its card, not when it arrived). A receipt saved before v126 counts at its arrival. */
15975
+ flowCardsAcceptedSince(trigger, since) {
15976
+ const at = since.toISOString();
15977
+ const row = this.db.prepare(`SELECT
15978
+ (SELECT COUNT(*) FROM flow_trigger_event WHERE trigger = :trigger AND accepted_at > :at)
15979
+ + (SELECT COUNT(*) FROM flow_trigger_event WHERE trigger = :trigger AND accepted_at IS NULL AND state IS NULL AND card IS NOT NULL AND at > :at) AS n`).get({ trigger, at });
15980
+ return Number(row?.["n"] ?? 0);
15981
+ }
15982
+ /** v126: a trigger's held deliveries, oldest first. */
15983
+ heldFlowReceipts(trigger, limit) {
15984
+ return this.db.prepare("SELECT key, item_json, at FROM flow_trigger_event WHERE trigger = ? AND state = 'held' ORDER BY at, rowid LIMIT ?").all(trigger, limit)
15985
+ .map(row => ({ key: String(row["key"]), itemJson: String(row["item_json"]), at: String(row["at"]) }));
15986
+ }
15987
+ /** v126: a held delivery that can't become its card (its words no longer pass): left out, with why. */
15988
+ skipHeldReceipt(trigger, key, note) {
15989
+ const { changes } = this.db.prepare("UPDATE flow_trigger_event SET state = 'skipped', note = ?, item_json = NULL WHERE trigger = ? AND key = ? AND state = 'held'").run(note, trigger, key);
15990
+ return Number(changes) === 1;
15991
+ }
15992
+ /** v126: how many deliveries a trigger holds. */
15993
+ heldFlowReceiptCount(trigger) {
15994
+ return Number(this.db.prepare("SELECT COUNT(*) AS n FROM flow_trigger_event WHERE trigger = ? AND state = 'held'").get(trigger)?.["n"] ?? 0);
15995
+ }
15996
+ /** v126: one delivery turned away because the hold queue was full: counted, and since when, until a person clears it. */
15997
+ noteFlowDeliveryDropped(trigger, now) {
15998
+ this.db.prepare("UPDATE flow_trigger SET dropped_count = dropped_count + 1, dropped_since = COALESCE(dropped_since, ?) WHERE id = ?").run(now.toISOString(), trigger);
15999
+ }
16000
+ /** v126: a person has seen the turned-away deliveries. */
16001
+ clearFlowDeliveriesDropped(trigger) {
16002
+ this.db.prepare("UPDATE flow_trigger SET dropped_count = 0, dropped_since = NULL WHERE id = ?").run(trigger);
16003
+ }
16004
+ /** v126: whether this hour's "too many" note on a trigger is still to be written: true at most once an hour, claimed
16005
+ * atomically so two deliveries at once write it once. */
16006
+ claimFlowLimitNote(trigger, now) {
16007
+ const { changes } = this.db.prepare("UPDATE flow_trigger SET limit_noted_at = ? WHERE id = ? AND (limit_noted_at IS NULL OR limit_noted_at <= ?)")
16008
+ .run(now.toISOString(), trigger, new Date(now.getTime() - 3_600_000).toISOString());
15212
16009
  return Number(changes) === 1;
15213
16010
  }
15214
16011
  /** The last card a trigger made, if any. */
@@ -15307,6 +16104,25 @@ export class Store {
15307
16104
  this.db.prepare(`INSERT INTO run_tool (run, tools_json, created_at) VALUES (?, json_object('fence', json(?)), ?)
15308
16105
  ON CONFLICT (run) DO UPDATE SET tools_json = json_set(tools_json, '$.fence', json(?))`).run(run, JSON.stringify(fence), now.toISOString(), JSON.stringify(fence));
15309
16106
  }
16107
+ /** What one attempt's network was and what its proxy saw (egress.ts), kept beside its fence. */
16108
+ recordRunNetwork(run, network, now) {
16109
+ const json = JSON.stringify(network);
16110
+ this.db.prepare(`INSERT INTO run_tool (run, tools_json, created_at) VALUES (?, json_object('network', json(?)), ?)
16111
+ ON CONFLICT (run) DO UPDATE SET tools_json = json_set(tools_json, '$.network', json(?))`).run(run, json, now.toISOString(), json);
16112
+ }
16113
+ runNetwork(run) {
16114
+ const row = this.db.prepare("SELECT json_extract(tools_json, '$.network') AS network FROM run_tool WHERE run = ?").get(run);
16115
+ if (row === undefined || row["network"] === null)
16116
+ return null;
16117
+ let parsed;
16118
+ try {
16119
+ parsed = JSON.parse(String(row["network"]));
16120
+ }
16121
+ catch {
16122
+ return null;
16123
+ }
16124
+ return runNetworkFromValue(parsed);
16125
+ }
15310
16126
  runFence(run) {
15311
16127
  const row = this.db.prepare("SELECT json_extract(tools_json, '$.fence') AS fence FROM run_tool WHERE run = ?").get(run);
15312
16128
  if (row === undefined || row["fence"] === null)
@@ -15357,27 +16173,30 @@ export class Store {
15357
16173
  return rows.length;
15358
16174
  });
15359
16175
  }
16176
+ /** `pending`: a queued message (personal or team) that waits for its turn — out of the model's history until the
16177
+ * admission that binds it clears the flag in the same statement (v128). */
15360
16178
  appendLeadMessage(message, now) {
15361
16179
  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());
16180
+ .prepare("INSERT INTO lead_message (thread, turn, role, text, activity, created_at, pending) VALUES (?, ?, ?, ?, ?, ?, ?)")
16181
+ .run(message.thread, message.turn, message.role, message.text, message.activity ?? null, now.toISOString(), message.pending === true ? 1 : 0);
15364
16182
  this.db.prepare("UPDATE lead_thread SET last_turn_at = ? WHERE id = ?").run(now.toISOString(), message.thread);
15365
16183
  return Number(inserted.lastInsertRowid);
15366
16184
  }
16185
+ /** The model's history: the last `limit` ADMITTED messages, oldest first — a message still waiting for its turn (or
16186
+ * removed, expired or refused while waiting) is never read back to the lead. An index range scan whatever the backlog
16187
+ * (lead_message_history). People see the whole thread through listLeadMessages. */
16188
+ listLeadHistory(thread, limit) {
16189
+ return this.db
16190
+ .prepare("SELECT * FROM (SELECT * FROM lead_message WHERE thread = ? AND pending = 0 ORDER BY id DESC LIMIT ?) ORDER BY id ASC")
16191
+ .all(thread, limit)
16192
+ .map(readLeadMessageRow);
16193
+ }
15367
16194
  /** The last `limit` messages, oldest first. */
15368
16195
  listLeadMessages(thread, limit) {
15369
16196
  return this.db
15370
16197
  .prepare("SELECT * FROM (SELECT * FROM lead_message WHERE thread = ? ORDER BY id DESC LIMIT ?) ORDER BY id ASC")
15371
16198
  .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
- }));
16199
+ .map(readLeadMessageRow);
15381
16200
  }
15382
16201
  /** A proposal is inert (`drafting`) until its turn finalizes. */
15383
16202
  draftLeadProposal(proposal, now) {
@@ -15558,6 +16377,18 @@ export class Store {
15558
16377
  const liveChat = this.db.prepare("SELECT 1 FROM chat_turn WHERE approver=? AND state IN ('queued','running') AND mate_turn IS NULL LIMIT 1").get(args.approver);
15559
16378
  if (live !== undefined || liveChat !== undefined)
15560
16379
  return { ok: false, reason: "concurrent" };
16380
+ // v128: while something of this person's waits in the personal queue (or is claimed but not yet bound), the only
16381
+ // personal turn admitted is the one carrying the exact claim of the backlog's oldest message — a follow pass, a
16382
+ // channel turn or another send waits behind it. Team turns are counted per thread and are not held.
16383
+ if (!teamBinding) {
16384
+ const oldest = this.db.prepare("SELECT message, runner, generation, status FROM lead_queue WHERE approver = ? AND (status = 'queued' OR (status = 'running' AND turn_id IS NULL)) ORDER BY message LIMIT 1").get(args.approver);
16385
+ if (oldest !== undefined) {
16386
+ const claim = args.queueClaim;
16387
+ const own = claim !== undefined && Number(oldest["message"]) === claim.message && oldest["status"] === "running" && oldest["runner"] === claim.runner && Number(oldest["generation"]) === claim.generation;
16388
+ if (!own)
16389
+ return { ok: false, reason: "backlog" };
16390
+ }
16391
+ }
15561
16392
  if (this.chatTurnsToday(args.approver, now) >= args.dailyTurns)
15562
16393
  return { ok: false, reason: "daily-cap" };
15563
16394
  if (session.endedAt !== null) {
@@ -16053,7 +16884,7 @@ export class Store {
16053
16884
  handoff: row["handoff"] === null ? null : String(row["handoff"]),
16054
16885
  costUsd: row["cost_usd"] === null ? null : Number(row["cost_usd"]),
16055
16886
  provider: row["provider"] === null || row["provider"] === undefined ? null : String(row["provider"]),
16056
- authMode: row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key"
16887
+ authMode: row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key" || row["auth_mode"] === "agent-login"
16057
16888
  ? row["auth_mode"]
16058
16889
  : null,
16059
16890
  ranMinutes: row["started_at"] === null || row["finished_at"] === null || row["started_at"] === undefined
@@ -16110,7 +16941,7 @@ export class Store {
16110
16941
  ? null
16111
16942
  : Math.max(1, Math.round((new Date(String(row["finished_at"])).getTime() - new Date(String(row["started_at"])).getTime()) / 60_000)),
16112
16943
  costUsd: row["cost_usd"] === null ? null : Number(row["cost_usd"]),
16113
- authMode: row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key"
16944
+ authMode: row["auth_mode"] === "subscription" || row["auth_mode"] === "api-key" || row["auth_mode"] === "agent-login"
16114
16945
  ? row["auth_mode"]
16115
16946
  : null,
16116
16947
  prNumber: row["pr_number"] === null || row["pr_number"] === undefined ? null : Number(row["pr_number"]),
@@ -16932,6 +17763,11 @@ export class Store {
16932
17763
  return Number(this.db.prepare("INSERT INTO run_process (run,host,process_group,observed_at,boot_id) VALUES (?,?,?,?,?)")
16933
17764
  .run(runId, hostname(), group ? 1 : 0, now.toISOString(), currentBootId()).lastInsertRowid);
16934
17765
  }
17766
+ /** The run's process witnesses not yet proved exited — after first saving whatever exit evidence is available. */
17767
+ openRunProcessCount(runId, now) {
17768
+ this.recordRunProcessExits(runId, now);
17769
+ return Number(this.db.prepare("SELECT COUNT(*) AS n FROM run_process WHERE run = ? AND exited_at IS NULL").get(runId).n);
17770
+ }
16935
17771
  /** Save positive OS exit evidence while it is available, before a numeric
16936
17772
  * PID can be reused. Completion, missing ancestry and age prove nothing.
16937
17773
  * Existing rows stay intact; only a previously unknown exit is recorded.
@@ -17382,6 +18218,45 @@ export class Store {
17382
18218
  return counts;
17383
18219
  }
17384
18220
  /** A project's build concurrency changed (project-concurrency.ts): the ledger keeps before → after. */
18221
+ /** A project's network (egress.ts): its saved setting, or the default when none is saved or it doesn't read. */
18222
+ projectNetwork(repo) {
18223
+ const row = this.db.prepare("SELECT network_json FROM project_network WHERE repo = ?").get(repo);
18224
+ if (row === undefined)
18225
+ return PROJECT_NETWORK_DEFAULT;
18226
+ let parsed = null;
18227
+ try {
18228
+ parsed = JSON.parse(String(row["network_json"]));
18229
+ }
18230
+ catch {
18231
+ parsed = null;
18232
+ }
18233
+ // A damaged row reads as the narrowest setting, never a wider one.
18234
+ return projectNetworkFromValue(parsed) ?? { mode: "allowlist", hosts: [], loopback: "proxy-only" };
18235
+ }
18236
+ /** An approver changed a project's network: saved, and in the ledger as before → after. */
18237
+ setProjectNetwork(repo, network, by, now) {
18238
+ return this.transact(() => {
18239
+ const before = this.projectNetwork(repo);
18240
+ const json = JSON.stringify({ mode: network.mode, hosts: [...new Set(network.hosts)].sort(), loopback: network.loopback });
18241
+ this.db.prepare(`INSERT INTO project_network (repo, network_json, updated_at, updated_by) VALUES (?, ?, ?, ?)
18242
+ ON CONFLICT (repo) DO UPDATE SET network_json = excluded.network_json, updated_at = excluded.updated_at, updated_by = excluded.updated_by`).run(repo, json, now.toISOString(), by);
18243
+ const after = this.projectNetwork(repo);
18244
+ const words = (one) => `${one.mode}${one.hosts.length === 0 ? "" : ` +${one.hosts.join(",")}`}${one.loopback === "proxy-only" ? " no-local" : ""}`;
18245
+ this.recordPolicy(by, repo, "network", words(before), words(after), now);
18246
+ return { before, after };
18247
+ });
18248
+ }
18249
+ /** Whether this installation lets an agent run where no OS confinement exists (off unless an operator said so). */
18250
+ unfencedAgentsAllowed() {
18251
+ return Number(this.db.prepare("SELECT unfenced_allowed FROM confinement_setting WHERE id = 1").get()?.["unfenced_allowed"] ?? 0) === 1;
18252
+ }
18253
+ setUnfencedAgentsAllowed(allowed, by, now) {
18254
+ this.transact(() => {
18255
+ const before = this.unfencedAgentsAllowed();
18256
+ this.db.prepare("UPDATE confinement_setting SET unfenced_allowed = ?, updated_at = ?, updated_by = ? WHERE id = 1").run(allowed ? 1 : 0, now.toISOString(), by);
18257
+ this.recordPolicy(by, null, "unconfined agents", before ? "allowed" : "refused", allowed ? "allowed" : "refused", now);
18258
+ });
18259
+ }
17385
18260
  recordProjectConcurrency(by, repo, before, after, now) {
17386
18261
  this.recordPolicy(by, repo, "builds at once", String(before), String(after), now);
17387
18262
  }
@@ -17406,6 +18281,7 @@ export class Store {
17406
18281
  markTelegramUpdateApplied(...args) { return this.telegramChat().markUpdateApplied(...args); }
17407
18282
  // ---- the conversation queue (v62) ------------------------------------------
17408
18283
  enqueueTelegramConversation(...args) { return this.telegramChat().enqueueMessage(...args); }
18284
+ telegramMessagesWaiting(binding) { return this.telegramChat().messagesWaiting(binding); }
17409
18285
  telegramConversationWaitingOn(...args) { return this.telegramChat().messageWaitingOn(...args); }
17410
18286
  getTelegramConversation(...args) { return this.telegramChat().getMessage(...args); }
17411
18287
  listTelegramConversations(...args) { return this.telegramChat().listMessages(...args); }
@@ -17440,6 +18316,7 @@ export class Store {
17440
18316
  this.telegramChat().deferUntil(botId, until);
17441
18317
  }
17442
18318
  telegramDeliveryProblem(...args) { return this.telegramChat().deliveryProblem(...args); }
18319
+ telegramWatchFence(...args) { return this.telegramChat().watchFence(...args); }
17443
18320
  recordTelegramMessage(...args) { return this.telegramChat().recordMessage(...args); }
17444
18321
  telegramMessageOf(...args) { return this.telegramChat().messageOf(...args); }
17445
18322
  finalizeTelegramDelivery(...args) { return this.telegramChat().finalizeDelivery(...args); }
@@ -18032,7 +18909,7 @@ export class Store {
18032
18909
  * the subscription live under its enrolled generation, the notification
18033
18910
  * unresolved and still stamped. Null = do not transmit.
18034
18911
  */
18035
- pushSendFence(id, owner, generation) {
18912
+ pushSendFence(id, owner, generation, now = new Date()) {
18036
18913
  const pair = this.db
18037
18914
  .prepare(`SELECT * FROM push_delivery
18038
18915
  WHERE id = ? AND state = 'claimed' AND claim_owner = ? AND claim_generation = ?`)
@@ -18052,7 +18929,20 @@ export class Store {
18052
18929
  .get(Number(pair["notification"]));
18053
18930
  if (notification === undefined)
18054
18931
  return null;
18055
- return { ...readPushPair(pair), subscriptionRow: readPushSubscription(subscription), notificationRow: readNotification(notification) };
18932
+ // The watch, again at the last moment (cockpit §3): a person who opened the agent since the claim holds the ping
18933
+ // (back to pending, no attempt spent) or, for news they have now seen, retires it.
18934
+ const row = readNotification(notification), approverName = String(subscription["approver"]);
18935
+ const watched = watchedPing(this, row, approverName, now);
18936
+ if (watched === "skip") {
18937
+ this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = 'seen', claim_owner = NULL WHERE id = ? AND state = 'claimed' AND claim_owner = ? AND claim_generation = ?").run(id, owner, generation);
18938
+ return null;
18939
+ }
18940
+ if (watched !== "send") {
18941
+ this.db.prepare(`UPDATE push_delivery SET state = 'pending', claim_owner = NULL, claim_expires_at = NULL, next_attempt_at = ?
18942
+ WHERE id = ? AND state = 'claimed' AND claim_owner = ? AND claim_generation = ?`).run(watched.hold, id, owner, generation);
18943
+ return null;
18944
+ }
18945
+ return { ...readPushPair(pair), subscriptionRow: readPushSubscription(subscription), notificationRow: row };
18056
18946
  }
18057
18947
  /** Settle a pair, conditionally on owner + generation (finding 25). */
18058
18948
  settlePushPair(id, owner, generation, outcome, now) {
@@ -18145,6 +19035,87 @@ export class Store {
18145
19035
  .run(idempotencyKey, operation, JSON.stringify(result ?? null), actor, at.toISOString());
18146
19036
  return result;
18147
19037
  }
19038
+ /** A kept web session's changes: never inserts, so a session ended elsewhere is not brought back by a cached copy.
19039
+ * False when it is gone. (Kept last in the class: the freshness tripwire pins method positions.) */
19040
+ updateWebSession(session) {
19041
+ return Number(this.db.prepare("UPDATE web_session SET last_seen = ?, project = ?, project_revision = ?, sso_at = ? WHERE id_hash = ?")
19042
+ .run(session.lastSeen, session.project, session.projectRevision, session.ssoAt, session.idHash).changes) > 0;
19043
+ }
19044
+ // ---- lead sweeps (docs/plans/lead-fan-out.md). Kept last in the class: the freshness tripwire pins method positions.
19045
+ /** A new sweep record (inside the caller's filing transaction). */
19046
+ createSweep(input, now) {
19047
+ const row = this.db.prepare("INSERT INTO lead_sweep (owner, thread, repo, title, brief, max_parallel, proposal, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
19048
+ .run(input.owner, input.thread, input.repo, input.title, input.brief, input.maxParallel, input.proposal, now.toISOString());
19049
+ return Number(row.lastInsertRowid);
19050
+ }
19051
+ /** Link one filed child (its family root) to its sweep, in order. */
19052
+ addSweepTask(sweep, rootTask, ordinal, label) {
19053
+ this.db.prepare("INSERT INTO lead_sweep_task (sweep, root_task, ordinal, label) VALUES (?, ?, ?, ?)").run(sweep, rootTask, ordinal, label);
19054
+ }
19055
+ getSweep(id) {
19056
+ const row = this.db.prepare("SELECT * FROM lead_sweep WHERE id = ?").get(id);
19057
+ if (row === undefined)
19058
+ return null;
19059
+ return { id: Number(row["id"]), owner: String(row["owner"]), thread: row["thread"] === null ? null : Number(row["thread"]), repo: String(row["repo"]), title: String(row["title"]),
19060
+ brief: String(row["brief"]), maxParallel: Number(row["max_parallel"]), proposal: row["proposal"] === null ? null : Number(row["proposal"]), createdAt: String(row["created_at"]) };
19061
+ }
19062
+ /** The sweep a proposal filed, if it did (a replayed confirm finds it). */
19063
+ sweepOfProposal(proposal) {
19064
+ const row = this.db.prepare("SELECT id FROM lead_sweep WHERE proposal = ?").get(proposal);
19065
+ return row === undefined ? null : Number(row["id"]);
19066
+ }
19067
+ /** A sweep's children, in order: each one's family root and its label. */
19068
+ sweepTasks(sweep) {
19069
+ return this.db.prepare("SELECT root_task, ordinal, label FROM lead_sweep_task WHERE sweep = ? ORDER BY ordinal").all(sweep)
19070
+ .map(row => ({ rootTask: String(row["root_task"]), ordinal: Number(row["ordinal"]), label: String(row["label"]) }));
19071
+ }
19072
+ /** The sweep a task belongs to through its family root (a revision of a child belongs too), or null. */
19073
+ sweepOfTask(taskId) {
19074
+ const { chain } = this.revisionAncestryStatus(taskId);
19075
+ const root = chain.at(-1) ?? taskId;
19076
+ const row = this.db.prepare("SELECT s.id, s.max_parallel FROM lead_sweep_task t JOIN lead_sweep s ON s.id = t.sweep WHERE t.root_task = ?").get(root);
19077
+ return row === undefined ? null : { id: Number(row["id"]), maxParallel: Number(row["max_parallel"]) };
19078
+ }
19079
+ /**
19080
+ * How many of a sweep's families hold a live claim now (the latest lease of any task in the family, unreleased and
19081
+ * unexpired, across every runner) — claimed-but-not-started counts. `except` leaves one task_ref out (the one being
19082
+ * claimed). Read inside acquire's own write transaction, so two runners can't both take the last slot.
19083
+ */
19084
+ sweepLiveClaims(sweep, stamp, except = null) {
19085
+ const row = this.db.prepare(`WITH RECURSIVE fam(root, id, depth) AS (
19086
+ SELECT t.root_task, t.root_task, 0 FROM lead_sweep_task t WHERE t.sweep = ?
19087
+ UNION SELECT fam.root, r.external_id, fam.depth + 1 FROM task_ref r JOIN fam ON r.revision_of = fam.id WHERE r.backend = ? AND fam.depth < 64),
19088
+ refs AS (SELECT DISTINCT fam.root, r.id AS ref FROM fam JOIN task_ref r ON r.backend = ? AND r.external_id = fam.id),
19089
+ latest AS (SELECT refs.root, c.released_at, c.expires_at FROM refs JOIN claim c ON c.task_ref = refs.ref
19090
+ WHERE refs.ref IS NOT ? AND c.lease_generation = (SELECT MAX(lease_generation) FROM claim WHERE task_ref = refs.ref))
19091
+ SELECT COUNT(DISTINCT root) AS n FROM latest WHERE released_at IS NULL AND expires_at > ?`).get(sweep, BUILT_IN, BUILT_IN, except, stamp);
19092
+ return Number(row?.["n"] ?? 0);
19093
+ }
19094
+ /** Whether a request of this lead turn ended with its cost unknown and that cost is still unconfirmed — the turn is
19095
+ * what keeps chat paused on its credential. Once confirmed, it no longer is. */
19096
+ leadTurnCostUnconfirmed(leadTurn) {
19097
+ return this.db.prepare("SELECT 1 AS hit FROM chat_turn WHERE mate_turn = ? AND unknown_spend = 1 AND acknowledged_at IS NULL LIMIT 1").get(leadTurn) !== undefined;
19098
+ }
19099
+ /** The lead turn a chat step belongs to (a lead reply's request), or null for a standalone chat turn. */
19100
+ leadTurnOfStep(step) {
19101
+ const row = this.db.prepare("SELECT mate_turn FROM chat_turn WHERE id = ?").get(step);
19102
+ return row === undefined || row["mate_turn"] === null ? null : this.getLeadTurn(Number(row["mate_turn"]));
19103
+ }
19104
+ /** A waiting message that will never run keeps nothing of what it said (v128 lead queue): its words, the context it was
19105
+ * sent with, and any copy the memory search made of it. Only while it is still pending — a message a turn took stays. */
19106
+ /** Whether a send of this session reached the plane: the engine took it, or the lead queue holds it (waiting, run,
19107
+ * removed or expired — any of those means the composer's draft was accepted and must not be sent again). */
19108
+ leadRequestReceived(session, request) {
19109
+ return this.leadRequestReceipt(session, request) !== null
19110
+ || this.db.prepare("SELECT 1 FROM lead_queue WHERE session = ? AND request_id = ?").get(session, request) !== undefined;
19111
+ }
19112
+ forgetQueuedLeadMessage(message) {
19113
+ const blanked = this.db.prepare("UPDATE lead_message SET text = '' WHERE id = ? AND pending = 1").run(message);
19114
+ if (Number(blanked.changes) !== 1)
19115
+ return;
19116
+ this.db.prepare("UPDATE lead_queue SET context_json = NULL WHERE message = ?").run(message);
19117
+ this.db.prepare("DELETE FROM memory_search WHERE kind = 'conversation' AND ref = ?").run(`message:${message}`);
19118
+ }
18148
19119
  }
18149
19120
  /** A stored count or id that must be a whole, non-negative number — a
18150
19121
  * NULL, an empty string, a fraction, or text reads as null (v48 authority repair). */
@@ -18192,6 +19163,7 @@ function readRun(row) {
18192
19163
  : String(row["provider_started_at"]),
18193
19164
  tokensIn: row["tokens_in"] === null || row["tokens_in"] === undefined ? null : Number(row["tokens_in"]),
18194
19165
  tokensOut: row["tokens_out"] === null || row["tokens_out"] === undefined ? null : Number(row["tokens_out"]),
19166
+ agentTurns: row["agent_turns"] === null || row["agent_turns"] === undefined ? null : Number(row["agent_turns"]),
18195
19167
  costUsd: row["cost_usd"] === null || row["cost_usd"] === undefined ? null : Number(row["cost_usd"]),
18196
19168
  headRevision: row["head_revision"] === null || row["head_revision"] === undefined
18197
19169
  ? null
@@ -18203,7 +19175,7 @@ function readRun(row) {
18203
19175
  ? String(row["phase"])
18204
19176
  : null,
18205
19177
  // 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,
19178
+ authMode: row["auth_mode"] === "api-key" ? "api-key" : row["auth_mode"] === "subscription" ? "subscription" : row["auth_mode"] === "agent-login" ? "agent-login" : null,
18207
19179
  terminalClass: row["terminal_class"] === null || row["terminal_class"] === undefined
18208
19180
  ? null
18209
19181
  : String(row["terminal_class"]),
@@ -18777,6 +19749,8 @@ export function permissionModeOfProfile(profile) {
18777
19749
  return profile.permissionArgv === "bypassPermissions" ? "bypassPermissions" : "auto";
18778
19750
  if (profile.provider === "gemini")
18779
19751
  return profile.approvalArgv === "yolo" ? "bypassPermissions" : "auto";
19752
+ if (isAcpProfile(profile))
19753
+ return "bypassPermissions";
18780
19754
  return profile.sandboxMode === "danger-full-access" ? "bypassPermissions" : "auto";
18781
19755
  }
18782
19756
  export function revisionTermsOf(sourceRef, sourceScope, coverage, permissionDefault, qualityDefault) {
@@ -19042,3 +20016,18 @@ function readGrant(row) {
19042
20016
  export function verifiedAuthor(name) {
19043
20017
  return name;
19044
20018
  }
20019
+ function readAcpCheck(row) {
20020
+ const text = (key) => row[key] == null ? null : String(row[key]);
20021
+ const num = (key) => row[key] == null ? null : Number(row[key]);
20022
+ const state = String(row["state"]);
20023
+ return {
20024
+ id: Number(row["id"]), provider: String(row["provider"]), runner: String(row["runner"]), model: String(row["model"]),
20025
+ conformanceVersion: Number(row["conformance_version"]), requestedBy: String(row["requested_by"]), requestedAt: String(row["requested_at"]),
20026
+ expiresAt: String(row["expires_at"]),
20027
+ state: state === "running" || state === "passed" || state === "failed" || state === "expired" ? state : "pending",
20028
+ watch: row["watch_repo"] == null || row["watch_owner"] == null ? null : { repo: String(row["watch_repo"]), owner: String(row["watch_owner"]) },
20029
+ taskRef: num("task_ref"), launchJson: text("launch_json"), leaseId: text("lease_id"), stepsUsed: Number(row["steps_used"]), step1Run: num("step1_run"), step2Run: num("step2_run"),
20030
+ step1Passed: Number(row["step1_passed"]) === 1, step2Passed: Number(row["step2_passed"]) === 1, result: text("result"), finishedAt: text("finished_at"),
20031
+ settledAt: text("settled_at"),
20032
+ };
20033
+ }