@desplega.ai/agent-swarm 2.7.4 → 2.7.5

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 (253) hide show
  1. package/README.md +1 -1
  2. package/dist/acp-adapter-g3ads55s.js +15 -0
  3. package/dist/{actions-gfrr9vb7.js → actions-zwfykqqk.js} +18 -18
  4. package/dist/{amp-adapter-0s58gm46.js → amp-adapter-z3s0ssdt.js} +3 -3
  5. package/dist/{app-stvq692g.js → app-vmp4fgep.js} +3 -3
  6. package/dist/{assistant-5s37fr9n.js → assistant-vwde653f.js} +28 -21
  7. package/dist/{boot-reembed-yzy1841n.js → boot-reembed-375t7tbk.js} +1 -1
  8. package/dist/{boot-reembed-cv82gz7y.js → boot-reembed-gq9rcpnj.js} +1 -1
  9. package/dist/{boot-scrub-sweep-8k4q95ap.js → boot-scrub-sweep-nd938kyf.js} +4 -4
  10. package/dist/{boot-seeds-ftt9htah.js → boot-seeds-aykx51v3.js} +4 -4
  11. package/dist/{boxd-515v1t8z.js → boxd-k1j26g3h.js} +1 -1
  12. package/dist/{claude-adapter-t00wrzeh.js → claude-adapter-8j4hm099.js} +7 -7
  13. package/dist/{claude-managed-adapter-g6vmjccb.js → claude-managed-adapter-dvs2xw5n.js} +2 -2
  14. package/dist/{claude-managed-setup-afpa82f7.js → claude-managed-setup-0wyynpn1.js} +3 -3
  15. package/dist/{claude-sdk-session-vj0c2azx.js → claude-sdk-session-q6wj5hmy.js} +9 -9
  16. package/dist/{cli-wndv9p7g.js → cli-0731hvbw.js} +6 -6
  17. package/dist/{cli-d2pzqej1.js → cli-0bxqnvq9.js} +719 -7
  18. package/dist/{cli-jkt52mgt.js → cli-0gey5fzy.js} +2 -2
  19. package/dist/{cli-1yekbyqm.js → cli-1338796d.js} +205 -48
  20. package/dist/{cli-1s1dqn1j.js → cli-1ayk1eas.js} +171 -123
  21. package/dist/{cli-a4btqqft.js → cli-1b7btd38.js} +1 -1
  22. package/dist/{cli-a2jw6ee5.js → cli-1p89xny0.js} +1 -1
  23. package/dist/{cli-v726afdm.js → cli-2pnswgh8.js} +1 -1
  24. package/dist/{cli-kvtc7aez.js → cli-3h4nhjn8.js} +42 -14
  25. package/dist/{cli-655xm9ms.js → cli-583frbyr.js} +1 -1
  26. package/dist/{cli-4r12t6w6.js → cli-5c4hjzne.js} +101 -4
  27. package/dist/{cli-az0bpf8p.js → cli-5pag6rmn.js} +3 -3
  28. package/dist/{cli-36g23z21.js → cli-6s7vdcta.js} +4 -4
  29. package/dist/{cli-rtk9g7av.js → cli-73jzwksr.js} +6 -2
  30. package/dist/{cli-jg0nfda6.js → cli-75gkpjvw.js} +52 -21
  31. package/dist/{cli-a81qevyg.js → cli-7a42nfy0.js} +20 -36
  32. package/dist/{cli-0sbkss7h.js → cli-7wcyk2s5.js} +2 -2
  33. package/dist/{cli-41z8xwj0.js → cli-7wspm5gm.js} +14 -4
  34. package/dist/{cli-byqfbnjw.js → cli-82ae98pz.js} +9 -9
  35. package/dist/{cli-bq2pcphn.js → cli-8hs41zy2.js} +1 -1
  36. package/dist/{cli-vqgnyh76.js → cli-98rw5dt5.js} +15 -3
  37. package/dist/{cli-9cctx1tn.js → cli-a22h291q.js} +1 -1
  38. package/dist/{cli-j4kt1mpb.js → cli-a47xy8eq.js} +3 -3
  39. package/dist/{cli-ycm6180s.js → cli-a7v854rf.js} +5 -3
  40. package/dist/{cli-98pke4vk.js → cli-ae7mrdf9.js} +2 -2
  41. package/dist/{cli-ahd90bwt.js → cli-afrrppsw.js} +6 -6
  42. package/dist/{cli-8yy525ad.js → cli-bhjt02p8.js} +2 -2
  43. package/dist/{cli-v1yw3wha.js → cli-bnz7a7qf.js} +4 -4
  44. package/dist/{cli-j4kw3rsx.js → cli-cm5yqn35.js} +18 -0
  45. package/dist/{cli-h55y2bjc.js → cli-dhar9qay.js} +2 -2
  46. package/dist/{cli-5gr1g711.js → cli-fqzx7gwa.js} +2 -2
  47. package/dist/{cli-tzrpv5sc.js → cli-ftyxyspd.js} +1 -1
  48. package/dist/cli-fw2w0kva.js +47 -0
  49. package/dist/{cli-2dfbv8qr.js → cli-fy6x31ps.js} +1 -0
  50. package/dist/{cli-mvckssyq.js → cli-g61qxmge.js} +5 -5
  51. package/dist/{cli-aq58fk5m.js → cli-g83s9ewh.js} +7 -1
  52. package/dist/{cli-224epwwf.js → cli-hn9hxmz9.js} +3 -3
  53. package/dist/{cli-5hjced2v.js → cli-j3jvnpe8.js} +6 -6
  54. package/dist/{cli-dae4sbgy.js → cli-jm2vfpfd.js} +2 -2
  55. package/dist/{cli-zn15c46z.js → cli-mjzynrse.js} +1 -1
  56. package/dist/{cli-a1fhj4n9.js → cli-mmwm7zbf.js} +2 -2
  57. package/dist/{cli-cq1pf336.js → cli-mry0qzym.js} +5 -5
  58. package/dist/{cli-bwty35gx.js → cli-mthwvjre.js} +1 -1
  59. package/dist/{cli-s0exq74r.js → cli-nmbmy64z.js} +3 -3
  60. package/dist/{cli-wcmnan2g.js → cli-nn4d8dne.js} +5 -5
  61. package/dist/{cli-jg4p5t36.js → cli-nzxr556s.js} +121 -51
  62. package/dist/{cli-bcz074zx.js → cli-p599f1qm.js} +1 -1
  63. package/dist/{cli-k76tgq03.js → cli-pywf3eh5.js} +2 -2
  64. package/dist/{cli-wk9md5px.js → cli-qxnf8z2z.js} +2 -2
  65. package/dist/{cli-je8yeyc5.js → cli-rhw40jgb.js} +5 -5
  66. package/dist/{cli-avgcz4ra.js → cli-rm6hqj3z.js} +406 -35
  67. package/dist/{cli-0fgr1w9b.js → cli-rpm2sgv0.js} +2 -2
  68. package/dist/{cli-t282jgab.js → cli-rqkxrmgw.js} +12 -12
  69. package/dist/{cli-2qpvmmkw.js → cli-s0qnd4wv.js} +1 -1
  70. package/dist/{cli-pwyevs29.js → cli-spknvxbq.js} +22 -21
  71. package/dist/{cli-9y354g3j.js → cli-srye3vdt.js} +1 -1
  72. package/dist/{cli-pe09p6dv.js → cli-tqgg40ek.js} +1 -1
  73. package/dist/{cli-f255yaqn.js → cli-vbyfwh92.js} +3 -3
  74. package/dist/{cli-gcbdjt47.js → cli-vfym7wmz.js} +6 -6
  75. package/dist/{cli-edmm9d9y.js → cli-vqypxzh4.js} +7 -7
  76. package/dist/{cli-1gm8jcyr.js → cli-wvaawacd.js} +13 -13
  77. package/dist/{cli-eny54typ.js → cli-wx3rhnm0.js} +1 -1
  78. package/dist/{cli-b1afgtj3.js → cli-xbkdm710.js} +3 -3
  79. package/dist/{cli-mc97xy2s.js → cli-xtjhfmpc.js} +16 -4
  80. package/dist/{cli-eqx6k993.js → cli-y24sc29k.js} +25 -13
  81. package/dist/{cli-ktmvb20s.js → cli-z6jkx23q.js} +12 -12
  82. package/dist/{cli-7n5sbw0z.js → cli-z8eh3ex8.js} +5 -5
  83. package/dist/cli-zh3fp8v2.js +151 -0
  84. package/dist/{cli-13jvmew3.js → cli-zppwg9p9.js} +7 -7
  85. package/dist/cli.js +18 -18
  86. package/dist/{codex-adapter-g0gctw29.js → codex-adapter-q6k3gnyg.js} +5 -5
  87. package/dist/{codex-hook-vrzz2xhr.js → codex-hook-9efm2x1m.js} +1 -1
  88. package/dist/{codex-login-0gg46ygn.js → codex-login-y0pmff1c.js} +1 -1
  89. package/dist/{codex-session-runner-dr2cxe1c.js → codex-session-runner-3mwmk3d9.js} +6 -6
  90. package/dist/{commands-1p07cvff.js → commands-evrenjn9.js} +2 -2
  91. package/dist/{controller-z0s7sv6r.js → controller-xjp8vswh.js} +4 -4
  92. package/dist/{controllers-connect-x9kvvhyk.js → controllers-connect-pp5jnxvc.js} +20 -20
  93. package/dist/{cursor-adapter-m5b98pvk.js → cursor-adapter-yf47w4t0.js} +2 -2
  94. package/dist/{db-rsh04kh3.js → db-rgfsh91f.js} +2 -2
  95. package/dist/{db-capk2raj.js → db-zxpw6aht.js} +3 -3
  96. package/dist/{devin-adapter-yxm87fe9.js → devin-adapter-r9phbnc9.js} +1 -1
  97. package/dist/{dispatcher-304q6fwa.js → dispatcher-smasgws7.js} +17 -17
  98. package/dist/{docker-7rxt0rry.js → docker-rrjhaqt9.js} +2 -2
  99. package/dist/{dsh-adapter-btewem0y.js → dsh-adapter-ftyy8dnp.js} +2 -2
  100. package/dist/{e2b-renc0k8q.js → e2b-qcaj675y.js} +2 -2
  101. package/dist/{e2b-7fc9b9bd.js → e2b-w1p0bnpm.js} +2 -2
  102. package/dist/{easter-egg-3d-fm1p3n4d.js → easter-egg-3d-yyts7p16.js} +1 -1
  103. package/dist/{embedding-space-s68v49ct.js → embedding-space-1z0mbg95.js} +6 -6
  104. package/dist/{embeddings-4ms3604w.js → embeddings-ygeg7tm7.js} +1 -1
  105. package/dist/{event-bus-dfjx2qdh.js → event-bus-g0ka3pbw.js} +1 -1
  106. package/dist/{execute-mcdgccjs.js → execute-14qhqp4d.js} +1 -1
  107. package/dist/{external-channel-9dxwfztn.js → external-channel-ttgrs3sx.js} +2 -2
  108. package/dist/fx-adapter-gt6pncj2.js +18 -0
  109. package/dist/{grok-adapter-05ecv4h0.js → grok-adapter-sm4m5k4g.js} +8 -8
  110. package/dist/handlers-vwmba1m2.js +31 -0
  111. package/dist/{heartbeat-3n94heh3.js → heartbeat-qahz4p62.js} +20 -20
  112. package/dist/{hook-zxwje86c.js → hook-4fd5wq5w.js} +4 -4
  113. package/dist/{hook-aadrn69x.js → hook-kj3d398m.js} +6 -6
  114. package/dist/{http-xzxk9mst.js → http-1ted0j1k.js} +100 -64
  115. package/dist/index-5r71rf98.js +32 -0
  116. package/dist/{index-d34f6r87.js → index-6rq3r5zb.js} +1 -1
  117. package/dist/{index-9j4zwcht.js → index-7v51awbn.js} +158 -672
  118. package/dist/index-anmwxs87.js +29 -0
  119. package/dist/{index-qeb82z37.js → index-q4b917zs.js} +1 -1
  120. package/dist/index-spp0ra5c.js +36 -0
  121. package/dist/{keepalive-w4aakbyk.js → keepalive-6cf311t5.js} +4 -4
  122. package/dist/lead-fjy418cw.js +69 -0
  123. package/dist/{maintenance-hbptpk6p.js → maintenance-a4bm1bmt.js} +1 -1
  124. package/dist/{oauth-refresh-sweep-217semrv.js → oauth-refresh-sweep-3h0dfw1k.js} +3 -3
  125. package/dist/{onboard-vz2s5df3.js → onboard-myn5hraa.js} +3 -3
  126. package/dist/{opencode-adapter-gtfd7d7v.js → opencode-adapter-p2nkb5am.js} +5 -5
  127. package/dist/{otel-impl-aefave37.js → otel-impl-a1a0ef6b.js} +2 -2
  128. package/dist/{pi-mono-adapter-f8h5yseh.js → pi-mono-adapter-rzmpb72a.js} +117 -23
  129. package/dist/{pricing-refresh-m1yhh9ya.js → pricing-refresh-83drrnxj.js} +3 -3
  130. package/dist/{rbac-roles-svj72cdf.js → rbac-roles-0fb6e7pj.js} +2 -2
  131. package/dist/{rbac-roles-rrw8twdz.js → rbac-roles-s8y6ecb5.js} +1 -1
  132. package/dist/{render-v2-amwbnfxh.js → render-v2-twmbgrz4.js} +3 -3
  133. package/dist/{resume-9cd6wf6z.js → resume-zfqc36xj.js} +7 -7
  134. package/dist/{runtime-fp4vrx7m.js → runtime-qwggdpqr.js} +1 -1
  135. package/dist/{session-access-21t6yjyg.js → session-access-sc9pn9da.js} +4 -4
  136. package/dist/{session-source-backfill-pxbzcsc6.js → session-source-backfill-qh07xr8g.js} +3 -3
  137. package/dist/{setup-4592tqz4.js → setup-4fr0m7a1.js} +1 -1
  138. package/dist/{virtual-modules-gzfnpmnz.js → virtual-modules-fh4ha15b.js} +2 -2
  139. package/dist/{work-objects-t9tcf174.js → work-objects-0wr8zxct.js} +20 -20
  140. package/dist/worker-0c5tyzqe.js +69 -0
  141. package/dist/{worker-follow-up-q32jh4yk.js → worker-follow-up-ktyyazyp.js} +17 -17
  142. package/dist/{x-b6wxntdt.js → x-vmmye1kb.js} +2 -2
  143. package/openapi.json +97 -32
  144. package/package.json +1 -1
  145. package/src/be/db/session-summaries.ts +8 -2
  146. package/src/be/db/session-tree.ts +174 -61
  147. package/src/be/db/session-visibility.ts +5 -4
  148. package/src/be/db.ts +8 -4
  149. package/src/be/migrations/259_session_summaries_open_tasks.sql +111 -0
  150. package/src/be/model-validation.ts +5 -1
  151. package/src/be/pricing-normalize.ts +7 -2
  152. package/src/be/scripts/typecheck.ts +1 -0
  153. package/src/be/seed/schedules-seeder.ts +135 -9
  154. package/src/be/seed/workflows-seeder.ts +65 -1
  155. package/src/be/seed-pricing.ts +23 -0
  156. package/src/be/steering.ts +50 -3
  157. package/src/be/swarm-config-guard.ts +1 -0
  158. package/src/commands/provider-credentials.ts +29 -2
  159. package/src/commands/runner.ts +86 -5
  160. package/src/extensions/contract-types.generated.ts +1 -1
  161. package/src/extensions/contract.ts +27 -1
  162. package/src/extensions/dispatcher.ts +4 -0
  163. package/src/http/agents.ts +1 -0
  164. package/src/http/mcp-bridge.ts +4 -0
  165. package/src/integrations/chat-sdk/inbound.ts +15 -5
  166. package/src/integrations/chat-sdk/steering.ts +3 -1
  167. package/src/kv-reserved-namespaces.ts +2 -0
  168. package/src/prompts/base-prompt.ts +4 -1
  169. package/src/prompts/session-templates.ts +26 -0
  170. package/src/providers/acp-adapter.ts +190 -26
  171. package/src/providers/acp-targets.ts +306 -6
  172. package/src/providers/amp-adapter.ts +1 -1
  173. package/src/providers/codex-adapter.ts +9 -44
  174. package/src/providers/codex-oauth/flow.ts +24 -3
  175. package/src/providers/codex-oauth/storage.ts +56 -17
  176. package/src/providers/codex-oauth/subscription.ts +67 -0
  177. package/src/providers/fx-adapter.ts +211 -0
  178. package/src/providers/index.ts +5 -1
  179. package/src/providers/mcp-loopback-proxy.ts +94 -0
  180. package/src/providers/pi-mono-adapter.ts +98 -13
  181. package/src/providers/pricing-sources.md +8 -0
  182. package/src/providers/reasoning-effort.ts +17 -1
  183. package/src/providers/types.ts +2 -1
  184. package/src/scheduler/scheduler.ts +36 -3
  185. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  186. package/src/scripts-runtime/types/stdlib.d.ts +5 -0
  187. package/src/scripts-runtime/types/swarm-extension.d.ts +33 -0
  188. package/src/scripts-runtime/types/swarm-sdk.d.ts +5 -0
  189. package/src/server.ts +7 -0
  190. package/src/slack/assistant.ts +17 -1
  191. package/src/slack/handlers.ts +18 -5
  192. package/src/slack/left-threads.ts +65 -0
  193. package/src/slack/steering.ts +3 -0
  194. package/src/slack/thread-buffer.ts +40 -16
  195. package/src/tests/acp-adapter.test.ts +79 -0
  196. package/src/tests/base-prompt.test.ts +12 -0
  197. package/src/tests/chat-sdk-inbound.test.ts +30 -0
  198. package/src/tests/chatgpt-subscription-model-groups.test.ts +62 -0
  199. package/src/tests/chatgpt-subscription-pi.test.ts +170 -0
  200. package/src/tests/chatgpt-subscription-session-bench.test.ts +198 -0
  201. package/src/tests/codex-oauth-credential-info.test.ts +40 -0
  202. package/src/tests/codex-oauth-refresh-egress.test.ts +194 -0
  203. package/src/tests/context-window.test.ts +7 -0
  204. package/src/tests/explicit-model-validation.test.ts +99 -0
  205. package/src/tests/extensions-run-context.test.ts +11 -0
  206. package/src/tests/extensions-steering.test.ts +353 -0
  207. package/src/tests/extensions-validate.test.ts +5 -0
  208. package/src/tests/fixtures/extensions/steering-policy/hooks.ts +39 -0
  209. package/src/tests/fixtures/extensions/steering-policy/manifest.json +7 -0
  210. package/src/tests/fixtures/fake-fx.ts +167 -0
  211. package/src/tests/fixtures/fx/acp-responses-recorded.json +157 -0
  212. package/src/tests/fixtures/fx/acp-updates-recorded.jsonl +15 -0
  213. package/src/tests/fx-adapter.test.ts +771 -0
  214. package/src/tests/grok-adapter.test.ts +36 -0
  215. package/src/tests/mcp-loopback-proxy.test.ts +193 -0
  216. package/src/tests/model-tier-resolution.test.ts +3 -3
  217. package/src/tests/pricing-refresh.test.ts +5 -5
  218. package/src/tests/prompt-template-session.test.ts +5 -3
  219. package/src/tests/provider-registration.test.ts +2 -1
  220. package/src/tests/query-audit-plans.test.ts +3 -2
  221. package/src/tests/script-connections.test.ts +2 -0
  222. package/src/tests/seed-model-profiles.test.ts +2 -2
  223. package/src/tests/seed-schedules.test.ts +138 -0
  224. package/src/tests/session-cost-recompute-unit.test.ts +39 -0
  225. package/src/tests/session-costs-golden.test.ts +26 -0
  226. package/src/tests/session-summaries-plans.test.ts +120 -0
  227. package/src/tests/session-summaries.test.ts +72 -12
  228. package/src/tests/sessions-tree.test.ts +11 -3
  229. package/src/tests/slack-leave-thread.test.ts +431 -0
  230. package/src/tests/tool-annotations.test.ts +2 -1
  231. package/src/tests/workflow-engine-v2.test.ts +42 -0
  232. package/src/tests/workflow-script-principal.test.ts +207 -7
  233. package/src/tools/slack-leave-thread.ts +104 -0
  234. package/src/tools/steer-task.ts +3 -2
  235. package/src/tools/tool-config.ts +2 -1
  236. package/src/types.ts +19 -0
  237. package/src/utils/codex-auth-failure.ts +30 -0
  238. package/src/utils/context-window.ts +4 -1
  239. package/src/utils/credentials.ts +7 -0
  240. package/src/utils/fx-models.ts +43 -0
  241. package/src/workflows/cooldown.ts +3 -2
  242. package/src/workflows/engine.ts +24 -9
  243. package/templates/schedules/dreaming-daily/config.json +1 -1
  244. package/templates/schedules/dreaming-daily/content.md +6 -14
  245. package/templates/skills/swarm-docs/content.md +1 -0
  246. package/templates/workflows/dreaming/content.md +5 -1
  247. package/dist/acp-adapter-96dc64m2.js +0 -15
  248. package/dist/handlers-jg7v74ty.js +0 -31
  249. package/dist/index-8c1zbz59.js +0 -29
  250. package/dist/index-8k8tmfxd.js +0 -32
  251. package/dist/index-hq617xdj.js +0 -36
  252. package/dist/lead-567kw7x3.js +0 -68
  253. package/dist/worker-fjdqra6q.js +0 -68
@@ -1,3 +1,4 @@
1
+ import { TERMINAL_TASK_STATUSES } from "../../types";
1
2
  import { getDbClient } from "./runtime";
2
3
 
3
4
  /**
@@ -37,18 +38,20 @@ export interface SessionSummaryDrift {
37
38
  | "lastActivityAt"
38
39
  | "latestStatus"
39
40
  | "pendingApproval"
41
+ | "openTasks"
40
42
  | "lastHumanAt"
41
43
  | "member";
42
44
  expected: string | number | null;
43
45
  stored: string | number | null;
44
46
  }
45
47
 
48
+ const OPEN_TASK_SQL = `t.status NOT IN (${TERMINAL_TASK_STATUSES.map((s) => `'${s}'`).join(", ")})`;
46
49
  const HUMAN_TASK_SQL = `t.requestedByUserId IS NOT NULL AND t.requestedByUserIdInherited = 0
47
50
  AND COALESCE(t.source, '') NOT IN ('mcp', 'system', 'schedule', 'workflow')`;
48
51
 
49
52
  /**
50
53
  * Read-only drift check: compares the stored summaries and members with a
51
- * fresh chain walk (the rule `sessionSummaryQuery` applies today). Returns one
54
+ * fresh chain walk (the rule `legacySessionSummaryQuery` applies). Returns one
52
55
  * row per mismatch, so an empty result means the tables are exact. With
53
56
  * `since`, only roots whose chain activity is at or after it are compared.
54
57
  * `latestStatus` is skipped when two chain tasks tie on the newest
@@ -64,7 +67,7 @@ export function sessionSummaryDriftQuery(since: string | null = null): {
64
67
  UNION ALL SELECT c.root_id, t.id FROM chain c JOIN agent_tasks t ON t.parentTaskId = c.id
65
68
  ), walked AS (
66
69
  SELECT c.root_id, COUNT(*) AS chainCount, t.status AS latestStatus,
67
- MAX(t.lastUpdatedAt) AS lastActivityAt
70
+ MAX(t.lastUpdatedAt) AS lastActivityAt, SUM(${OPEN_TASK_SQL}) AS openTasks
68
71
  FROM chain c JOIN agent_tasks t ON t.id = c.id GROUP BY c.root_id
69
72
  ), expected AS (
70
73
  SELECT w.*,
@@ -98,6 +101,9 @@ export function sessionSummaryDriftQuery(since: string | null = null): {
98
101
  UNION ALL SELECT e.root_id, 'pendingApproval', e.pendingApproval, s.pendingApprovalCount > 0
99
102
  FROM expected e JOIN session_summaries s ON s.rootId = e.root_id
100
103
  WHERE (s.pendingApprovalCount > 0) IS NOT e.pendingApproval
104
+ UNION ALL SELECT e.root_id, 'openTasks', e.openTasks, s.openTaskCount
105
+ FROM expected e JOIN session_summaries s ON s.rootId = e.root_id
106
+ WHERE s.openTaskCount IS NOT e.openTasks
101
107
  UNION ALL SELECT e.root_id, 'lastHumanAt', e.lastHumanAt, s.lastHumanAt
102
108
  FROM expected e JOIN session_summaries s ON s.rootId = e.root_id
103
109
  WHERE s.lastHumanAt IS NOT e.lastHumanAt
@@ -70,10 +70,27 @@ type SqlFragment = { sql: string; params: (string | number | null)[] };
70
70
  /** How long a finished, read Inbox session stays quiet before it moves to Done on its own. */
71
71
  const AUTO_DONE_QUIET_MS = 24 * 3600000;
72
72
 
73
+ /** The chain aggregates the read-state rule reads, as SQL expressions. */
74
+ interface ReadStateColumns {
75
+ lastActivityAt: string;
76
+ openTasks: string;
77
+ pendingApproval: string;
78
+ /** Whether the chain changed in a way that reopens a Done session; read only for a Done row `s`. */
79
+ reopensDone: string;
80
+ }
81
+
82
+ /** The columns of the chain walk's `agg` rows (`legacySessionSummaryQuery`). */
83
+ const LEGACY_READ_STATE_COLUMNS: ReadStateColumns = {
84
+ lastActivityAt: "a.lastActivityAt",
85
+ openTasks: "a.openTasks",
86
+ pendingApproval: "a.pendingApproval",
87
+ reopensDone: "a.reopensDone > 0",
88
+ };
89
+
73
90
  /**
74
91
  * Shared read-state rule with bound ISO timestamps for the list and tree.
75
92
  * Done holds through agent activity (progress, status changes, child tasks):
76
- * only `a.reopensDone`, counted in `sessionSummaryQuery`, brings it back.
93
+ * only `reopensDone` brings it back.
77
94
  *
78
95
  * Auto-Done: an Inbox session (`inboxScopeCondition` on its root `r`) reads
79
96
  * as Done once it reads as read, every chain task is finished, no approval
@@ -85,21 +102,22 @@ const AUTO_DONE_QUIET_MS = 24 * 3600000;
85
102
  function sessionReadStateSql(
86
103
  userId: string | null | undefined,
87
104
  now = new Date().toISOString(),
105
+ a: ReadStateColumns = LEGACY_READ_STATE_COLUMNS,
88
106
  ): SqlFragment {
89
107
  const quiet = new Date(Date.parse(now) - AUTO_DONE_QUIET_MS).toISOString();
90
108
  const scope = inboxScopeCondition(userId);
91
- const autoDone = `(a.openTasks = 0 AND a.pendingApproval = 0 AND a.lastActivityAt <= ? AND ${scope.sql})`;
109
+ const autoDone = `(${a.openTasks} = 0 AND ${a.pendingApproval} = 0 AND ${a.lastActivityAt} <= ? AND ${scope.sql})`;
92
110
  const autoDoneParams = [quiet, ...scope.params];
93
111
  return {
94
112
  sql: `CASE
95
113
  WHEN s.id IS NULL THEN CASE
96
- WHEN a.lastActivityAt > ? THEN 'unread'
114
+ WHEN ${a.lastActivityAt} > ? THEN 'unread'
97
115
  WHEN ${autoDone} THEN 'done'
98
116
  ELSE 'read' END
99
117
  WHEN s.status = 'dismissed' THEN 'done'
100
- WHEN s.status = 'done' THEN CASE WHEN a.reopensDone > 0 THEN 'unread' ELSE 'done' END
118
+ WHEN s.status = 'done' THEN CASE WHEN ${a.reopensDone} THEN 'unread' ELSE 'done' END
101
119
  WHEN s.status = 'snoozed' THEN CASE WHEN s.snoozeUntil > ? THEN 'later' ELSE 'unread' END
102
- WHEN a.lastActivityAt > COALESCE(s.lastReadAt, s.lastUpdatedAt) THEN 'unread'
120
+ WHEN ${a.lastActivityAt} > COALESCE(s.lastReadAt, s.lastUpdatedAt) THEN 'unread'
103
121
  WHEN ${autoDone} AND s.lastUpdatedAt <= ? THEN 'done'
104
122
  ELSE 'read'
105
123
  END`,
@@ -117,22 +135,42 @@ function sourceKeyRanges() {
117
135
  return SESSION_SOURCES.map((source) => ({ source, ...keyPrefixRange(`shared/${source}/`) }));
118
136
  }
119
137
 
120
- /** Count every owned root at the exact source thread key, independent of viewer filters. */
121
- export function threadRootsSql(): SqlFragment {
138
+ /** Whether `r."key"` is a source thread key (`isSourceThreadKey`). */
139
+ function sourceThreadKeySql(): SqlFragment {
122
140
  const key = 'r."key"';
123
141
  // Trim the final slash, then all non-slash characters to locate the last segment.
124
142
  // For example, shared/slack/c1/thread:1.2/ gives thread:1.2/.
125
143
  const leaf = `substr(${key}, length(rtrim(rtrim(${key}, '/'), replace(${key}, '/', ''))) + 1)`;
126
144
  const ranges = sourceKeyRanges();
127
145
  return {
128
- sql: `(CASE WHEN (${ranges.map(() => `(${key} >= ? AND ${key} < ?)`).join(" OR ")}) AND ${leaf} GLOB 'thread:?*/'
129
- THEN (SELECT COUNT(*) FROM agent_tasks session_thread_root WHERE session_thread_root."key" = ${key}
130
- AND session_thread_root.parentTaskId IS NULL AND session_thread_root.requestedByUserId IS NOT NULL)
131
- ELSE 0 END)`,
146
+ sql: `(${ranges.map(() => `(${key} >= ? AND ${key} < ?)`).join(" OR ")}) AND ${leaf} GLOB 'thread:?*/'`,
132
147
  params: ranges.flatMap(({ lo, hi }) => [lo, hi]),
133
148
  };
134
149
  }
135
150
 
151
+ /** Count every owned root at the exact source thread key, independent of viewer filters. */
152
+ export function threadRootsSql(): SqlFragment {
153
+ const thread = sourceThreadKeySql();
154
+ return {
155
+ sql: `(CASE WHEN ${thread.sql}
156
+ THEN (SELECT COUNT(*) FROM session_summaries session_thread_root
157
+ WHERE session_thread_root."key" = r."key" AND session_thread_root.requestedByUserId IS NOT NULL)
158
+ ELSE 0 END)`,
159
+ params: thread.params,
160
+ };
161
+ }
162
+
163
+ /** {@link threadRootsSql} for every source thread key at once: one grouped read for the tree. */
164
+ async function threadRootCounts(): Promise<Map<string, number>> {
165
+ const thread = sourceThreadKeySql();
166
+ const rows = await getDbClient().query<{ key: string; threadRoots: number }>(
167
+ `SELECT r."key", COUNT(*) AS threadRoots FROM session_summaries r
168
+ WHERE r.requestedByUserId IS NOT NULL AND ${thread.sql} GROUP BY r."key"`,
169
+ thread.params,
170
+ );
171
+ return new Map(rows.map((row) => [row.key, row.threadRoots]));
172
+ }
173
+
136
174
  /** A task's group with the list row's rule (`groupKey` on `GET /api/sessions`). */
137
175
  export async function getSessionGroupKey(task: { id: string; key: string }): Promise<string> {
138
176
  const threadRoots = threadRootsSql();
@@ -143,14 +181,19 @@ export async function getSessionGroupKey(task: { id: string; key: string }): Pro
143
181
  return sessionThreadGroupKey(task.key, row?.threadRoots ?? 0);
144
182
  }
145
183
 
146
- function groupCondition(group: string): SqlFragment {
184
+ /**
185
+ * `scan` keeps the key index out of the range (unary `+`). A broad prefix such
186
+ * as `shared/` holds most roots, where one pass over the narrow summaries
187
+ * beats an index lookup per row; without planner statistics SQLite cannot tell.
188
+ */
189
+ function groupCondition(group: string, scan = false): SqlFragment {
147
190
  const { lo, hi } = keyPrefixRange(group);
148
191
  // The remaining key must be one resource leaf, with no nested path.
149
192
  // SQLite substr/length count code points, so count them here too (not UTF-16 units).
150
193
  const start = [...lo].length + 1;
151
194
  const threadRoots = threadRootsSql();
152
195
  return {
153
- sql: `(r."key" >= ? AND r."key" < ? AND (r."key" = ? OR (
196
+ sql: `(${scan ? "+" : ""}r."key" >= ? AND ${scan ? "+" : ""}r."key" < ? AND (r."key" = ? OR (
154
197
  instr(substr(r."key", ?, length(r."key") - ?), '/') = 0 AND
155
198
  (${SESSION_LEAF_KINDS.map(() => 'substr(r."key", ?) GLOB ?').join(" OR ")}) AND
156
199
  ${threadRoots.sql} < ?
@@ -170,8 +213,8 @@ function groupCondition(group: string): SqlFragment {
170
213
 
171
214
  /** No group: roots at `shared/` and at the viewer's `personal/<me>/`, with their direct leaves. */
172
215
  function noGroupCondition(userId: string | null | undefined): SqlFragment {
173
- const groups = [groupCondition("shared/")];
174
- if (userId) groups.push(groupCondition(`personal/${userId}/`));
216
+ const groups = [groupCondition("shared/", true)];
217
+ if (userId) groups.push(groupCondition(`personal/${userId}/`, true));
175
218
  return {
176
219
  sql: `(${groups.map((g) => g.sql).join(" OR ")})`,
177
220
  params: groups.flatMap((g) => g.params),
@@ -187,7 +230,7 @@ function inboxScopeCondition(userId: string | null | undefined): SqlFragment {
187
230
  const ranges = sourceKeyRanges();
188
231
  const noGroup = noGroupCondition(userId);
189
232
  return {
190
- sql: `(${ranges.map(() => '(r."key" >= ? AND r."key" < ?)').join(" OR ")} OR ${noGroup.sql})`,
233
+ sql: `(${ranges.map(() => '(+r."key" >= ? AND +r."key" < ?)').join(" OR ")} OR ${noGroup.sql})`,
191
234
  params: [...ranges.flatMap(({ lo, hi }) => [lo, hi]), ...noGroup.params],
192
235
  };
193
236
  }
@@ -201,8 +244,19 @@ function effectiveSourceSql(): SqlFragment {
201
244
  };
202
245
  }
203
246
 
247
+ /**
248
+ * One row per session root: `session_summaries` (migration 253) with the root
249
+ * id as `id`, so the root filters below apply to it as they would to the root
250
+ * task. SQLite flattens the subquery, so the summary indexes still serve them.
251
+ */
252
+ const SESSION_ROOTS = "(SELECT rootId AS id, * FROM session_summaries)";
253
+
254
+ /**
255
+ * Conditions on a session root `r`: a {@link SESSION_ROOTS} row or a root task
256
+ * (`legacySessionSummaryQuery`). Only `q` reads the root task itself.
257
+ */
204
258
  export function sessionRootFilter(opts: SessionFilters = {}): SqlFragment {
205
- const conditions = ["r.parentTaskId IS NULL"];
259
+ const conditions: string[] = [];
206
260
  const params: SqlFragment["params"] = [];
207
261
  const add = (fragment: SqlFragment) => {
208
262
  conditions.push(fragment.sql);
@@ -235,7 +289,8 @@ export function sessionRootFilter(opts: SessionFilters = {}): SqlFragment {
235
289
  if (opts.q?.trim()) {
236
290
  const like = `%${opts.q.trim().toLowerCase()}%`;
237
291
  add({
238
- sql: "(lower(r.task) LIKE ? OR lower(COALESCE(r.title, '')) LIKE ?)",
292
+ sql: `EXISTS (SELECT 1 FROM agent_tasks rt WHERE rt.id = r.id
293
+ AND (lower(rt.task) LIKE ? OR lower(COALESCE(rt.title, '')) LIKE ?))`,
239
294
  params: [like, like],
240
295
  });
241
296
  }
@@ -248,7 +303,8 @@ export function sessionRootFilter(opts: SessionFilters = {}): SqlFragment {
248
303
  });
249
304
  if (opts.viewer) {
250
305
  add(sessionViewerCondition(opts.viewer));
251
- if (opts.tree) conditions.push("r.requestedByUserId IS NOT NULL");
306
+ // Unary +: a full pass over the narrow summaries beats the partial thread index here.
307
+ if (opts.tree) conditions.push("+r.requestedByUserId IS NOT NULL");
252
308
  if (opts.mine) add(sessionMineCondition(opts.viewer.userId));
253
309
  }
254
310
  if (opts.keyPrefix) {
@@ -272,7 +328,7 @@ export function sessionRootFilter(opts: SessionFilters = {}): SqlFragment {
272
328
  : marked,
273
329
  );
274
330
  }
275
- return { sql: conditions.join(" AND "), params };
331
+ return { sql: conditions.length ? conditions.join(" AND ") : "1", params };
276
332
  }
277
333
 
278
334
  function excludesTriaged(opts: SessionFilters): boolean {
@@ -316,19 +372,9 @@ const reopensDoneSql = `(d.id IS NOT NULL AND (
316
372
  WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending' AND ar.createdAt > d.lastUpdatedAt)
317
373
  ))`;
318
374
 
319
- /**
320
- * A session starts at a parentTaskId IS NULL root. One recursive CTE computes
321
- * chain-wide latest activity for list ordering. Root filters precede recursion.
322
- * Read state and chain filters follow aggregation.
323
- */
324
- export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
325
- const root = sessionRootFilter(opts);
326
- const read = opts.viewer?.userId
327
- ? sessionReadStateSql(opts.viewer.userId, opts.now)
328
- : { sql: "NULL", params: [] };
329
- const params = [...root.params];
330
- if (opts.withUserId) params.push(opts.withUserId);
331
- params.push(opts.viewer?.userId ?? null, ...read.params, opts.viewer?.userId ?? null);
375
+ /** Filters on summary rows `a` (read state, latest status, participants), after aggregation. */
376
+ function summaryRowFilters(opts: SessionFilters): SqlFragment {
377
+ const params: SqlFragment["params"] = [];
332
378
  const filters: string[] = [];
333
379
  if (opts.triage === "unread") filters.push("readState = 'unread'");
334
380
  else if (opts.triage === "later" || opts.triage === "done") {
@@ -345,6 +391,73 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
345
391
  WHERE sh.rootTaskId = a.root_id AND sh.revokedAt IS NULL AND sh.userId = ?))`);
346
392
  params.push(opts.withUserId);
347
393
  }
394
+ return { sql: filters.length ? `WHERE ${filters.join(" AND ")}` : "", params };
395
+ }
396
+
397
+ /**
398
+ * One row per visible session root, with its chain aggregates, the viewer's
399
+ * read state and `needsYou`. It reads the trigger-kept `session_summaries`, so
400
+ * no request walks a chain: root filters, then one primary-key join for the
401
+ * viewer's triage row. Same rule and columns as {@link legacySessionSummaryQuery},
402
+ * plus the root columns the list and tree order and group by.
403
+ */
404
+ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
405
+ const root = sessionRootFilter(opts);
406
+ const read = opts.viewer?.userId
407
+ ? sessionReadStateSql(opts.viewer.userId, opts.now, {
408
+ lastActivityAt: "r.lastActivityAt",
409
+ openTasks: "r.openTaskCount",
410
+ pendingApproval: "r.pendingApprovalCount",
411
+ // The newest human event is newer than the Done mark (`reopensDoneSql`).
412
+ reopensDone: "r.lastHumanAt > s.lastUpdatedAt",
413
+ })
414
+ : { sql: "NULL", params: [] };
415
+ const filters = summaryRowFilters(opts);
416
+ // Sessions where the user requested any chain task, from the requester index.
417
+ const withUser = opts.withUserId
418
+ ? `r.id IN (SELECT m.rootId FROM agent_tasks t JOIN session_members m ON m.taskId = t.id
419
+ WHERE t.requestedByUserId = ?) AS withUser,`
420
+ : "";
421
+ return {
422
+ sql: `WITH state AS (
423
+ SELECT r.id AS root_id, r.createdAt AS rootCreatedAt, r."key", r.source, r.slackChannelId,
424
+ r.chainCount, r.latestStatus, r.lastActivityAt, ${withUser}
425
+ r.pendingApprovalCount AS pendingApproval, r.openTaskCount AS openTasks,
426
+ ${read.sql} AS readState, s.snoozeUntil
427
+ FROM ${SESSION_ROOTS} r LEFT JOIN inbox_item_state s
428
+ ON s.userId = ? AND s.itemType = 'to_read' AND s.itemId = r.id
429
+ WHERE ${root.sql}
430
+ ), summary AS (
431
+ SELECT *, (COALESCE(readState = 'unread' AND latestStatus = 'failed', 0) OR pendingApproval) AS needsYou
432
+ FROM state
433
+ )
434
+ SELECT a.* FROM summary a ${filters.sql}`,
435
+ params: [
436
+ ...(opts.withUserId ? [opts.withUserId] : []),
437
+ ...read.params,
438
+ opts.viewer?.userId ?? null,
439
+ ...root.params,
440
+ ...filters.params,
441
+ ],
442
+ };
443
+ }
444
+
445
+ /**
446
+ * The chain walk the summaries replace: a session starts at a parentTaskId IS
447
+ * NULL root, and one recursive CTE computes chain-wide latest activity. Root
448
+ * filters precede recursion; read state and chain filters follow aggregation.
449
+ * Off the request path: tests compare {@link sessionSummaryQuery} with it.
450
+ */
451
+ export function legacySessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
452
+ const root = sessionRootFilter(opts);
453
+ const read = opts.viewer?.userId
454
+ ? sessionReadStateSql(opts.viewer.userId, opts.now)
455
+ : { sql: "NULL", params: [] };
456
+ const filters = summaryRowFilters(opts);
457
+ const params = [...root.params];
458
+ if (opts.withUserId) params.push(opts.withUserId);
459
+ params.push(opts.viewer?.userId ?? null, ...read.params, opts.viewer?.userId ?? null);
460
+ params.push(...filters.params);
348
461
  return {
349
462
  // SQLite takes the bare status from the row with MAX(lastUpdatedAt).
350
463
  // Keep this as the only MIN/MAX aggregate so status follows activity.
@@ -354,7 +467,7 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
354
467
  createdAt, source, requestedByUserIdInherited) AS (
355
468
  SELECT r.id, r.id, r.lastUpdatedAt, r.status, r.requestedByUserId,
356
469
  r.createdAt, r.source, r.requestedByUserIdInherited
357
- FROM agent_tasks r WHERE ${root.sql}
470
+ FROM agent_tasks r WHERE r.parentTaskId IS NULL AND ${root.sql}
358
471
  UNION ALL
359
472
  SELECT c.root_id, t.id, t.lastUpdatedAt, t.status, t.requestedByUserId,
360
473
  t.createdAt, t.source, t.requestedByUserIdInherited
@@ -378,7 +491,7 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
378
491
  SELECT *, (COALESCE(readState = 'unread' AND latestStatus = 'failed', 0) OR pendingApproval) AS needsYou
379
492
  FROM state
380
493
  )
381
- SELECT a.* FROM summary a ${filters.length ? `WHERE ${filters.join(" AND ")}` : ""}`,
494
+ SELECT a.* FROM summary a ${filters.sql}`,
382
495
  params,
383
496
  };
384
497
  }
@@ -407,7 +520,7 @@ export function sessionCountQuery(opts: SessionFilters = {}): SqlFragment {
407
520
  return {
408
521
  sql: chain
409
522
  ? `SELECT COUNT(*) AS count FROM (${query.sql})`
410
- : `SELECT COUNT(*) AS count FROM agent_tasks r WHERE ${query.sql}`,
523
+ : `SELECT COUNT(*) AS count FROM ${SESSION_ROOTS} r WHERE ${query.sql}`,
411
524
  params: query.params,
412
525
  };
413
526
  }
@@ -439,13 +552,11 @@ export async function sessionPageFields(
439
552
  name: string | null;
440
553
  preview: string | null;
441
554
  }>(
442
- `WITH RECURSIVE chain(root_id, id) AS (
443
- SELECT id, id FROM agent_tasks WHERE id IN (${ids.map(() => "?").join(",")})
444
- UNION ALL SELECT c.root_id, t.id FROM chain c JOIN agent_tasks t ON t.parentTaskId = c.id
445
- ) SELECT c.root_id, t.requestedByUserId AS id, u.name,
555
+ `SELECT m.rootId AS root_id, t.requestedByUserId AS id, u.name,
446
556
  CASE WHEN t.source = 'system' AND t.taskType = 'follow-up' THEN NULL ELSE NULLIF(trim(substr(t.output, 1, 200)), '') END AS preview
447
- FROM chain c CROSS JOIN agent_tasks t ON t.id = c.id
557
+ FROM session_members m CROSS JOIN agent_tasks t ON t.id = m.taskId
448
558
  LEFT JOIN users u ON u.id = t.requestedByUserId
559
+ WHERE m.rootId IN (${ids.map(() => "?").join(",")})
449
560
  ORDER BY t.lastUpdatedAt DESC, t.createdAt DESC, t.id DESC`,
450
561
  ids,
451
562
  ),
@@ -563,23 +674,24 @@ export async function getSessionTree(
563
674
  tree: true,
564
675
  mine: opts.mine,
565
676
  });
566
- const threadRoots = threadRootsSql();
567
- const rows = await getDbClient().query<{
568
- id: string;
569
- key: string;
570
- source: string;
571
- slackChannelId: string | null;
572
- lastActivityAt: string;
573
- latestStatus: string;
574
- readState: SessionReadState | null;
575
- needsYou: number;
576
- threadRoots: number;
577
- }>(
578
- `SELECT r.id, r."key", r.source, r.slackChannelId, a.lastActivityAt, a.latestStatus, a.readState, a.needsYou,
579
- ${threadRoots.sql} AS threadRoots
580
- FROM (${query.sql}) a CROSS JOIN agent_tasks r ON r.id = a.root_id`,
581
- [...threadRoots.params, ...query.params],
582
- );
677
+ const [rows, threadRoots] = await Promise.all([
678
+ getDbClient().query<{
679
+ root_id: string;
680
+ key: string;
681
+ source: string;
682
+ slackChannelId: string | null;
683
+ lastActivityAt: string;
684
+ latestStatus: string;
685
+ readState: SessionReadState | null;
686
+ needsYou: number;
687
+ }>(
688
+ `SELECT a.root_id, a."key", a.source, a.slackChannelId, a.lastActivityAt, a.latestStatus,
689
+ a.readState, a.needsYou
690
+ FROM (${query.sql}) a`,
691
+ query.params,
692
+ ),
693
+ threadRootCounts(),
694
+ ]);
583
695
  const counts = (): SessionSectionCounts => ({ total: 0, unread: 0, running: 0, needsYou: 0 });
584
696
  const result: SessionTree = {
585
697
  groups: [],
@@ -608,14 +720,15 @@ export async function getSessionTree(
608
720
  for (const row of rows) {
609
721
  const source = effectiveSource(row.key, row.source);
610
722
  sources.set(source, (sources.get(source) ?? 0) + 1);
611
- const key = sessionThreadGroupKey(row.key, row.threadRoots);
723
+ const rowThreadRoots = threadRoots.get(row.key) ?? 0;
724
+ const key = sessionThreadGroupKey(row.key, rowThreadRoots);
612
725
  const sourceGroupKey = sessionGroupKey(row.key);
613
726
  const channelId = /^shared\/slack\/([^/]+)\/$/.exec(sourceGroupKey)?.[1];
614
727
  if (channelId && row.slackChannelId?.toLowerCase() === channelId) {
615
728
  slackChannels.add(channelId);
616
729
  }
617
- if (row.threadRoots >= NESTED_THREAD_MIN_ROOTS && isSourceThreadKey(row.key)) {
618
- threadRootIds.push(row.id);
730
+ if (rowThreadRoots >= NESTED_THREAD_MIN_ROOTS && isSourceThreadKey(row.key)) {
731
+ threadRootIds.push(row.root_id);
619
732
  sourceGroups.add(sourceGroupKey);
620
733
  }
621
734
  if (sourceOfKey(key)) sourceGroups.add(key);
@@ -13,8 +13,9 @@ export function sessionViewerCondition(viewer: SessionViewer): {
13
13
  sql: string;
14
14
  params: (string | null)[];
15
15
  } {
16
- const shared = `EXISTS (SELECT 1 FROM session_shares s
17
- WHERE s.rootTaskId = r.id AND s.revokedAt IS NULL AND (s.userId IS NULL OR s.userId = ?))`;
16
+ // Uncorrelated, so SQLite builds the shared set once instead of probing per root.
17
+ const shared = `r.id IN (SELECT s.rootTaskId FROM session_shares s
18
+ WHERE s.revokedAt IS NULL AND (s.userId IS NULL OR s.userId = ?))`;
18
19
  if (viewer.scope === "mine") {
19
20
  return { sql: "r.requestedByUserId = ?", params: [viewer.userId] };
20
21
  }
@@ -55,8 +56,8 @@ export function sessionMineCondition(userId: string | null): {
55
56
  return {
56
57
  sql: `((r.requestedByUserId = ? AND (r.source NOT IN (${machine})
57
58
  OR (r.source = 'system' AND r.taskType IN (${humanSystem}))))
58
- OR EXISTS (SELECT 1 FROM session_shares s
59
- WHERE s.rootTaskId = r.id AND s.revokedAt IS NULL AND (s.userId IS NULL OR s.userId = ?)))`,
59
+ OR r.id IN (SELECT s.rootTaskId FROM session_shares s
60
+ WHERE s.revokedAt IS NULL AND (s.userId IS NULL OR s.userId = ?)))`,
60
61
  params: [userId, ...MACHINE_SESSION_SOURCES, ...HUMAN_SYSTEM_TASK_TYPES, userId],
61
62
  };
62
63
  }
package/src/be/db.ts CHANGED
@@ -6283,7 +6283,7 @@ export interface UpdateScheduledTaskData {
6283
6283
  description?: string;
6284
6284
  cronExpression?: string | null;
6285
6285
  intervalMs?: number | null;
6286
- taskTemplate?: string;
6286
+ taskTemplate?: string | null;
6287
6287
  taskType?: string;
6288
6288
  tags?: string[];
6289
6289
  priority?: number;
@@ -13073,6 +13073,9 @@ export async function listRecentSessions(
13073
13073
  ): Promise<SessionListItem[] | SessionListItemSummary[]> {
13074
13074
  const query = sessionSummaryQuery(opts);
13075
13075
  const threadRoots = threadRootsSql();
13076
+ const order = (a: string) =>
13077
+ `${opts.triage === "later" ? `${a}.snoozeUntil ASC,` : ""}
13078
+ ${a}.lastActivityAt DESC, ${a}.rootCreatedAt DESC, ${a}.root_id DESC`;
13076
13079
  const rootRows = await getDbClient().query<
13077
13080
  AgentTaskRow & {
13078
13081
  __chainCount: number;
@@ -13084,13 +13087,14 @@ export async function listRecentSessions(
13084
13087
  __threadRoots: number;
13085
13088
  }
13086
13089
  >(
13090
+ // Page first on the summaries, then read agent_tasks for the page roots only.
13087
13091
  `SELECT r.*, a.chainCount AS __chainCount, a.lastActivityAt AS __lastActivityAt,
13088
13092
  a.latestStatus AS __latestStatus, a.readState AS __readState,
13089
13093
  a.snoozeUntil AS __snoozeUntil, a.needsYou AS __needsYou,
13090
13094
  ${threadRoots.sql} AS __threadRoots
13091
- FROM (${query.sql}) a CROSS JOIN agent_tasks r ON r.id = a.root_id
13092
- ORDER BY ${opts.triage === "later" ? "a.snoozeUntil ASC," : ""}
13093
- a.lastActivityAt DESC, r.createdAt DESC, r.id DESC LIMIT ? OFFSET ?`,
13095
+ FROM (SELECT * FROM (${query.sql}) page ORDER BY ${order("page")} LIMIT ? OFFSET ?) a
13096
+ CROSS JOIN agent_tasks r ON r.id = a.root_id
13097
+ ORDER BY ${order("a")}`,
13094
13098
  [...threadRoots.params, ...query.params, opts.limit ?? 25, opts.offset ?? 0],
13095
13099
  );
13096
13100
  const fields = await sessionPageFields(
@@ -0,0 +1,111 @@
1
+ -- What the session read path needs from session_summaries (migration 253):
2
+ -- the key and source indexes its filters use, and openTaskCount, the number
3
+ -- of chain tasks that are not finished (status outside completed, failed,
4
+ -- cancelled, superseded).
5
+ --
6
+ -- Auto-Done (src/be/db/session-tree.ts) moves a quiet Inbox session to Done
7
+ -- only when every chain task is finished. The session read path now reads
8
+ -- session_summaries instead of walking the chain, so the summary carries the
9
+ -- count. Same consistency rules as migration 253:
10
+ -- - Inserts add the new task's count incrementally.
11
+ -- - A status change applies its +1 or -1 in a BEFORE trigger. An AFTER
12
+ -- trigger of the same statement that recomputes the chain (a rewound
13
+ -- lastUpdatedAt, a requester or source edit) then overwrites it with the
14
+ -- exact count, so the delta is never applied on top of a recompute.
15
+ -- - session_summary_recompute_apply recomputes it with the other aggregates.
16
+
17
+ ALTER TABLE session_summaries ADD COLUMN openTaskCount INTEGER NOT NULL DEFAULT 0;
18
+
19
+ -- The group (key range) and source filters of the list and count. Activity
20
+ -- writes do not touch either column, so neither index costs the hot path.
21
+ CREATE INDEX idx_session_summaries_key ON session_summaries("key");
22
+ CREATE INDEX idx_session_summaries_source ON session_summaries(source);
23
+
24
+ DROP TRIGGER session_summary_recompute_apply;
25
+ CREATE TRIGGER session_summary_recompute_apply
26
+ INSTEAD OF INSERT ON session_summary_recompute
27
+ BEGIN
28
+ UPDATE session_summaries SET
29
+ chainCount = (SELECT COUNT(*) FROM session_members m WHERE m.rootId = NEW.rootId),
30
+ lastActivityAt = (SELECT MAX(t.lastUpdatedAt) FROM session_members m
31
+ JOIN agent_tasks t ON t.id = m.taskId WHERE m.rootId = NEW.rootId),
32
+ latestStatus = (SELECT t.status FROM session_members m
33
+ JOIN agent_tasks t ON t.id = m.taskId WHERE m.rootId = NEW.rootId
34
+ ORDER BY t.lastUpdatedAt DESC LIMIT 1),
35
+ pendingApprovalCount = (SELECT COUNT(*) FROM session_members m
36
+ JOIN approval_requests ar ON ar.sourceTaskId = m.taskId
37
+ WHERE m.rootId = NEW.rootId AND ar.status = 'pending'),
38
+ openTaskCount = (SELECT COUNT(*) FROM session_members m
39
+ JOIN agent_tasks t ON t.id = m.taskId WHERE m.rootId = NEW.rootId
40
+ AND t.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')),
41
+ lastHumanAt = (SELECT MAX(at) FROM (
42
+ SELECT t.createdAt AS at FROM session_members m JOIN agent_tasks t ON t.id = m.taskId
43
+ WHERE m.rootId = NEW.rootId AND t.requestedByUserId IS NOT NULL
44
+ AND t.requestedByUserIdInherited = 0
45
+ AND COALESCE(t.source, '') NOT IN ('mcp', 'system', 'schedule', 'workflow')
46
+ UNION ALL
47
+ SELECT sm.created_at FROM session_members m
48
+ JOIN task_steering_messages sm ON sm.task_id = m.taskId
49
+ WHERE m.rootId = NEW.rootId AND sm.created_by_kind = 'user' AND sm.status != 'cancelled'
50
+ UNION ALL
51
+ SELECT ar.createdAt FROM session_members m
52
+ JOIN approval_requests ar ON ar.sourceTaskId = m.taskId
53
+ WHERE m.rootId = NEW.rootId AND ar.status = 'pending'))
54
+ WHERE rootId = NEW.rootId
55
+ AND EXISTS (SELECT 1 FROM agent_tasks WHERE id = NEW.rootId);
56
+ END;
57
+
58
+ DROP TRIGGER session_task_insert;
59
+ CREATE TRIGGER session_task_insert
60
+ AFTER INSERT ON agent_tasks
61
+ BEGIN
62
+ INSERT OR REPLACE INTO session_members (taskId, rootId)
63
+ SELECT NEW.id, NEW.id WHERE NEW.parentTaskId IS NULL
64
+ UNION ALL
65
+ SELECT NEW.id, m.rootId FROM session_members m WHERE m.taskId = NEW.parentTaskId;
66
+
67
+ INSERT OR REPLACE INTO session_summaries (rootId, createdAt, source, "key", requestedByUserId,
68
+ scheduleId, taskType, slackChannelId, contextKey, chainCount, lastActivityAt, latestStatus,
69
+ pendingApprovalCount, openTaskCount, lastHumanAt)
70
+ SELECT NEW.id, NEW.createdAt, NEW.source, NEW."key", NEW.requestedByUserId, NEW.scheduleId,
71
+ NEW.taskType, NEW.slackChannelId, NEW.contextKey, 1, NEW.lastUpdatedAt, NEW.status, 0,
72
+ NEW.status NOT IN ('completed', 'failed', 'cancelled', 'superseded'),
73
+ CASE WHEN NEW.requestedByUserId IS NOT NULL AND NEW.requestedByUserIdInherited = 0
74
+ AND COALESCE(NEW.source, '') NOT IN ('mcp', 'system', 'schedule', 'workflow')
75
+ THEN NEW.createdAt END
76
+ WHERE NEW.parentTaskId IS NULL;
77
+
78
+ UPDATE session_summaries SET
79
+ chainCount = chainCount + 1,
80
+ openTaskCount = openTaskCount
81
+ + (NEW.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')),
82
+ latestStatus = CASE WHEN NEW.lastUpdatedAt >= lastActivityAt THEN NEW.status ELSE latestStatus END,
83
+ lastActivityAt = MAX(lastActivityAt, NEW.lastUpdatedAt),
84
+ lastHumanAt = CASE WHEN NEW.requestedByUserId IS NOT NULL AND NEW.requestedByUserIdInherited = 0
85
+ AND COALESCE(NEW.source, '') NOT IN ('mcp', 'system', 'schedule', 'workflow')
86
+ AND (lastHumanAt IS NULL OR NEW.createdAt > lastHumanAt)
87
+ THEN NEW.createdAt ELSE lastHumanAt END
88
+ WHERE NEW.parentTaskId IS NOT NULL
89
+ AND rootId = (SELECT rootId FROM session_members WHERE taskId = NEW.id);
90
+ END;
91
+
92
+ -- BEFORE, so an AFTER recompute in the same statement has the last word.
93
+ CREATE TRIGGER session_task_open
94
+ BEFORE UPDATE OF status ON agent_tasks
95
+ WHEN (OLD.status NOT IN ('completed', 'failed', 'cancelled', 'superseded'))
96
+ IS NOT (NEW.status NOT IN ('completed', 'failed', 'cancelled', 'superseded'))
97
+ BEGIN
98
+ UPDATE session_summaries SET
99
+ openTaskCount = openTaskCount
100
+ + CASE WHEN NEW.status NOT IN ('completed', 'failed', 'cancelled', 'superseded') THEN 1 ELSE -1 END
101
+ WHERE rootId = (SELECT rootId FROM session_members WHERE taskId = NEW.id);
102
+ END;
103
+
104
+ -- Backfill: one pass over the unfinished tasks.
105
+ UPDATE session_summaries SET openTaskCount = unfinished.n
106
+ FROM (
107
+ SELECT m.rootId, COUNT(*) AS n FROM agent_tasks t JOIN session_members m ON m.taskId = t.id
108
+ WHERE t.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
109
+ GROUP BY m.rootId
110
+ ) AS unfinished
111
+ WHERE unfinished.rootId = session_summaries.rootId;
@@ -7,7 +7,8 @@
7
7
  * - a `latest:<provider>/<family>` alias that is valid grammar and resolves to a catalog model;
8
8
  * - a catalog id, bare (`claude-opus-5-5`, `gpt-5.6-sol`) or provider-qualified
9
9
  * (`openrouter/deepseek/deepseek-v4.1-flash`, `amazon-bedrock/...`);
10
- * - a Claude CLI shortname (`opus`, `sonnet`, ...), which the CLI resolves itself.
10
+ * - a Claude CLI shortname (`opus`, `sonnet`, ...), which the CLI resolves itself;
11
+ * - a ChatGPT subscription id `openai-codex/<id>` whose `<id>` is in the `openai` section.
11
12
  *
12
13
  * Escape hatch: `allowCustomModel: true` on the call (tools, REST bodies, workflow node
13
14
  * config, the agent runtime PATCH). Custom ids skip the check and are stored as given, for
@@ -23,6 +24,7 @@
23
24
  */
24
25
  import {
25
26
  buildClaudeShortnameMap,
27
+ chatgptSubscriptionModelId,
26
28
  type HarnessCatalogSections,
27
29
  harnessModelMismatch,
28
30
  isAlias,
@@ -55,6 +57,8 @@ function hasModel(catalog: CatalogSections, provider: string, modelId: string):
55
57
  export function isKnownCatalogModel(model: string, catalog: CatalogSections): boolean {
56
58
  const id = model.trim().replace(CONTEXT_SUFFIX_RE, "");
57
59
  if (!id) return false;
60
+ const subscriptionId = chatgptSubscriptionModelId(id);
61
+ if (subscriptionId) return hasModel(catalog, "openai", subscriptionId);
58
62
  const slash = id.indexOf("/");
59
63
  if (slash > 0) {
60
64
  const provider = id.slice(0, slash);