@desplega.ai/agent-swarm 2.5.2 → 2.5.4

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 (236) hide show
  1. package/dist/{acp-adapter-csh0zz3b.js → acp-adapter-ek38cwsv.js} +5 -5
  2. package/dist/{actions-6xx079d1.js → actions-92mepq03.js} +19 -19
  3. package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-9gaxg015.js} +4 -4
  4. package/dist/{app-ft6r6v23.js → app-ctn8gsgx.js} +3 -3
  5. package/dist/{assistant-vt2cyfzd.js → assistant-zyr5kz47.js} +21 -21
  6. package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-dwzfttdr.js} +1 -1
  7. package/dist/{boot-reembed-r3e808ky.js → boot-reembed-w75maga2.js} +1 -1
  8. package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
  9. package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-x28v3fhb.js} +4 -4
  10. package/dist/{claude-adapter-s7j47k06.js → claude-adapter-f0afqnne.js} +5 -5
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-x81a7r65.js} +3 -3
  12. package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-bqzwjd9x.js} +2 -2
  13. package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
  14. package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
  15. package/dist/{cli-4qkg9968.js → cli-19202kew.js} +4 -4
  16. package/dist/{cli-cyfgxnvb.js → cli-1r5ad7j5.js} +19 -17
  17. package/dist/{cli-jmcjbjkt.js → cli-25n3mv5d.js} +9 -7
  18. package/dist/cli-2655k16q.js +140 -0
  19. package/dist/{cli-wadzapwb.js → cli-277c31dw.js} +1 -1
  20. package/dist/{cli-n6kmqngw.js → cli-4b2z6n0m.js} +29 -7
  21. package/dist/{cli-7ccf7akh.js → cli-5bnsc0e1.js} +9 -7
  22. package/dist/{cli-qrjv7t18.js → cli-621ce7kw.js} +1 -1
  23. package/dist/{cli-xzge46sj.js → cli-64mh9mf3.js} +20 -6
  24. package/dist/{cli-b6tnjdvx.js → cli-6a1xg1p7.js} +6 -2
  25. package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
  26. package/dist/{cli-e91vshb1.js → cli-7p4jxgtt.js} +5 -5
  27. package/dist/{cli-f3pfn4x3.js → cli-9j129brq.js} +9 -3
  28. package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
  29. package/dist/{cli-15xd0j7w.js → cli-abs8ndtr.js} +12 -12
  30. package/dist/{cli-h5d22pfs.js → cli-asq5ydm4.js} +219 -309
  31. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  32. package/dist/{cli-0vgy5wfw.js → cli-bvpgr9ny.js} +2 -2
  33. package/dist/{cli-3e9vs93g.js → cli-c61mbdds.js} +4 -4
  34. package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
  35. package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
  36. package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
  37. package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
  38. package/dist/{cli-b63d08rs.js → cli-eftzyh9y.js} +1 -1
  39. package/dist/{cli-fgn7yg5r.js → cli-ethgy7ha.js} +1 -1
  40. package/dist/{cli-86zpfje5.js → cli-ff1tm9n9.js} +1 -1
  41. package/dist/{cli-cf63dnxj.js → cli-g9vd9ece.js} +37 -12
  42. package/dist/{cli-j3dwd4kz.js → cli-gbfxp48k.js} +770 -126
  43. package/dist/{cli-n2918sed.js → cli-gn5af0x5.js} +21 -21
  44. package/dist/{cli-d172svcw.js → cli-gwhjmh8g.js} +4 -4
  45. package/dist/{cli-stxdrqs2.js → cli-hh5we352.js} +24 -5
  46. package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
  47. package/dist/{cli-csh8fnbg.js → cli-jb30dzvk.js} +473 -58
  48. package/dist/{cli-vaxw3bg0.js → cli-jbnn4vxb.js} +3 -3
  49. package/dist/{cli-ha22h0eg.js → cli-k4tjsdx9.js} +45 -40
  50. package/dist/{cli-f55b6ky0.js → cli-m4ecdxvz.js} +40 -13
  51. package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
  52. package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
  53. package/dist/{cli-3qv8naq8.js → cli-rr7zx19j.js} +1591 -353
  54. package/dist/{cli-b1tv22wr.js → cli-s682zgqz.js} +1 -1
  55. package/dist/{cli-bpbcpd8b.js → cli-sbwsxvtd.js} +1 -1
  56. package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
  57. package/dist/{cli-6y43v79h.js → cli-tsma7p8v.js} +1 -1
  58. package/dist/{cli-edtm741g.js → cli-wghjkr0e.js} +5 -5
  59. package/dist/{cli-aaqnqpyw.js → cli-wx2nj19m.js} +30 -11
  60. package/dist/{cli-y3dzwyy3.js → cli-x0f2v69c.js} +180 -60
  61. package/dist/{cli-kcnwn85g.js → cli-xtrz6bzs.js} +7 -7
  62. package/dist/{cli-14v1sxxy.js → cli-xzfq4at3.js} +7 -7
  63. package/dist/cli.js +20 -20
  64. package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-wch12q99.js} +3 -3
  65. package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
  66. package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-41pfcr95.js} +2 -2
  67. package/dist/{commands-1cnvseqx.js → commands-9849dkj6.js} +2 -2
  68. package/dist/{controller-kc7s9xhn.js → controller-ja0sd95p.js} +29 -3
  69. package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-xp05dj6x.js} +20 -20
  70. package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-623qxqew.js} +1 -2
  71. package/dist/{db-4vq88jkc.js → db-47vj40dy.js} +3 -3
  72. package/dist/{db-v5ytg5pw.js → db-w759qmx0.js} +2 -2
  73. package/dist/{dispatcher-trtcrqae.js → dispatcher-7b8e4z2j.js} +17 -17
  74. package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
  75. package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-2ezw3xea.js} +2 -3
  76. package/dist/{external-channel-n9bymc2n.js → external-channel-e2s028da.js} +2 -2
  77. package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-ys7gaqee.js} +6 -6
  78. package/dist/{handlers-ptsraqa8.js → handlers-wy1dr6ja.js} +20 -20
  79. package/dist/{heartbeat-60qtxtjf.js → heartbeat-w3mntghj.js} +20 -20
  80. package/dist/{hook-rbktcstg.js → hook-3jjywnpb.js} +9 -3
  81. package/dist/{hook-ngtvh729.js → hook-g7kvv6x9.js} +3 -2
  82. package/dist/{http-z8xy72gp.js → http-tkp7d4kb.js} +2984 -2483
  83. package/dist/{index-r70q0xjx.js → index-1ef1myq2.js} +17 -17
  84. package/dist/{index-a9nsavhx.js → index-3n8m9ea2.js} +1 -1
  85. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  86. package/dist/{index-s193j5fh.js → index-nmm79qgr.js} +20 -20
  87. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  88. package/dist/{index-06qkmcx6.js → index-q7m26kvs.js} +33 -23
  89. package/dist/{index-1cscynkb.js → index-xdaahthb.js} +17 -17
  90. package/dist/{keepalive-a9srcphj.js → keepalive-v4ta8wgg.js} +3 -3
  91. package/dist/{lead-3devneqy.js → lead-sawnyqxk.js} +35 -35
  92. package/dist/{maintenance-0er16h2y.js → maintenance-84n1ex21.js} +1 -1
  93. package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
  94. package/dist/{onboard-wqywz5py.js → onboard-sfmpe4np.js} +2 -2
  95. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-jmpst4q4.js} +4 -4
  96. package/dist/{otel-impl-vk32s884.js → otel-impl-v6cc0zve.js} +1 -1
  97. package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
  98. package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-fsk1e1zc.js} +3 -3
  99. package/dist/{rbac-roles-452665y0.js → rbac-roles-prmkdnkw.js} +2 -2
  100. package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-zxmxqa39.js} +1 -1
  101. package/dist/{render-v2-0ttsk2j4.js → render-v2-pjwxvnkw.js} +3 -3
  102. package/dist/{resume-a26kvr41.js → resume-frdtgfa0.js} +9 -5
  103. package/dist/{session-access-dgh9nkwk.js → session-access-fpez4y5q.js} +4 -4
  104. package/dist/{setup-9vc9qahm.js → setup-mq6m3m8q.js} +1 -1
  105. package/dist/{work-objects-vn8nkxwn.js → work-objects-kfahhjsq.js} +20 -20
  106. package/dist/{worker-1x1h8vnm.js → worker-v5e5rxj3.js} +35 -35
  107. package/openapi.json +3078 -2424
  108. package/package.json +7 -7
  109. package/plugin/commands/create-pr.md +2 -2
  110. package/plugin/pi-skills/create-pr/SKILL.md +2 -2
  111. package/src/be/approval-config-question.ts +389 -0
  112. package/src/be/automation-preflight.ts +37 -3
  113. package/src/be/db/approvals.ts +48 -0
  114. package/src/be/db/session-tree.ts +73 -15
  115. package/src/be/db/skill-search.ts +87 -0
  116. package/src/be/db/tasks/read.ts +29 -2
  117. package/src/be/db/tasks/write.ts +15 -5
  118. package/src/be/db/workflow-idempotency.ts +183 -0
  119. package/src/be/db/workflow-runs.ts +366 -8
  120. package/src/be/db.ts +92 -22
  121. package/src/be/migrations/239_skills_fts.sql +40 -0
  122. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  123. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  124. package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
  125. package/src/be/sealed-json.ts +1 -0
  126. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
  127. package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
  128. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  129. package/src/be/sql-connections.ts +7 -1
  130. package/src/be/swarm-config-guard.ts +25 -0
  131. package/src/be/users.ts +9 -0
  132. package/src/commands/controller.ts +33 -0
  133. package/src/commands/runner.ts +6 -2
  134. package/src/extensions/apply-task-create.ts +57 -11
  135. package/src/extensions/catalog.generated.json +4 -4
  136. package/src/extensions/contract-types.generated.ts +1 -1
  137. package/src/extensions/contract.ts +25 -0
  138. package/src/extensions/dispatcher.ts +4 -0
  139. package/src/extensions/post-bridge.ts +8 -0
  140. package/src/hooks/context-bound-note.ts +196 -0
  141. package/src/hooks/hook.ts +12 -0
  142. package/src/http/approval-requests.ts +209 -38
  143. package/src/http/config.ts +70 -53
  144. package/src/http/idempotency-key.ts +117 -0
  145. package/src/http/sessions.ts +27 -2
  146. package/src/http/workflows.ts +129 -24
  147. package/src/integrations/chat-sdk/inbound.ts +5 -4
  148. package/src/prompts/context-bound-note.ts +48 -0
  149. package/src/prompts/session-templates.ts +25 -1
  150. package/src/providers/amp-adapter.ts +30 -8
  151. package/src/providers/claude-adapter.ts +29 -4
  152. package/src/providers/claude-managed-adapter.ts +26 -0
  153. package/src/providers/claude-sdk-session.ts +10 -0
  154. package/src/providers/claude-session-events.ts +13 -0
  155. package/src/providers/codex-adapter.ts +12 -0
  156. package/src/providers/dsh-adapter.ts +1 -1
  157. package/src/providers/opencode-adapter.ts +18 -0
  158. package/src/providers/pi-mono-adapter.ts +35 -0
  159. package/src/runtime-providers/docker.ts +6 -2
  160. package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
  161. package/src/tests/amp-adapter.test.ts +97 -0
  162. package/src/tests/approval-config-question.test.ts +702 -0
  163. package/src/tests/approval-requests.test.ts +10 -7
  164. package/src/tests/approval-sweeps.test.ts +12 -6
  165. package/src/tests/base-prompt.test.ts +1 -1
  166. package/src/tests/chat-sdk-inbound.test.ts +20 -0
  167. package/src/tests/claude-hook-output.test.ts +79 -1
  168. package/src/tests/claude-managed-adapter.test.ts +275 -0
  169. package/src/tests/codex-adapter.test.ts +163 -0
  170. package/src/tests/context-bound-note.test.ts +269 -0
  171. package/src/tests/dsh-adapter.test.ts +1 -1
  172. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  173. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  174. package/src/tests/extensions-pre-task.test.ts +273 -0
  175. package/src/tests/fetch-resolved-env.test.ts +17 -0
  176. package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
  177. package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
  178. package/src/tests/fixtures/fake-amp.ts +22 -8
  179. package/src/tests/fixtures/held-template-render.ts +42 -0
  180. package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
  181. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  182. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  183. package/src/tests/openapi-route-headers.test.ts +2 -0
  184. package/src/tests/opencode-adapter.test.ts +134 -0
  185. package/src/tests/pi-mono-adapter.test.ts +105 -0
  186. package/src/tests/prompt-template-session.test.ts +4 -3
  187. package/src/tests/runner-push-delivery.test.ts +25 -0
  188. package/src/tests/runtime-controller-liveness.test.ts +133 -0
  189. package/src/tests/runtime-providers-docker.test.ts +2 -0
  190. package/src/tests/script-connections.test.ts +9 -0
  191. package/src/tests/script-run-config-scope.test.ts +91 -3
  192. package/src/tests/session-logs.test.ts +70 -0
  193. package/src/tests/sessions-tree.test.ts +205 -1
  194. package/src/tests/skill-search-long-content.test.ts +193 -0
  195. package/src/tests/skill-search.test.ts +173 -22
  196. package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
  197. package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
  198. package/src/tests/workflow-idempotency.test.ts +915 -0
  199. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  200. package/src/tools/request-human-input.ts +27 -5
  201. package/src/tools/workflows/cancel-workflow-run.ts +1 -1
  202. package/src/types.ts +7 -0
  203. package/src/utils/context-window.ts +8 -6
  204. package/src/workflows/approval-cancel.ts +15 -13
  205. package/src/workflows/approval-notifications.ts +15 -4
  206. package/src/workflows/checkpoint.ts +72 -35
  207. package/src/workflows/engine.ts +262 -93
  208. package/src/workflows/executors/base.ts +6 -0
  209. package/src/workflows/executors/foreach.ts +2 -2
  210. package/src/workflows/executors/human-in-the-loop.ts +25 -1
  211. package/src/workflows/executors/sub-workflow.ts +45 -16
  212. package/src/workflows/foreach-driver.ts +10 -3
  213. package/src/workflows/foreach-join.ts +29 -6
  214. package/src/workflows/recovery.ts +68 -1
  215. package/src/workflows/resume.ts +228 -77
  216. package/src/workflows/retry-poller.ts +74 -7
  217. package/src/workflows/task-step-routing.ts +29 -7
  218. package/templates/ai-toolbox.manifest.json +8 -8
  219. package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
  220. package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
  221. package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
  222. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  223. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  224. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  225. package/templates/skills/agent-browser/SKILL.md +4 -4
  226. package/templates/skills/agent-browser/content.md +4 -4
  227. package/templates/skills/code-quality/config.json +1 -1
  228. package/templates/skills/code-quality/content.md +2 -2
  229. package/templates/skills/delegate-work/content.md +29 -25
  230. package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
  231. package/templates/skills/qa/content.md +2 -2
  232. package/templates/skills/researching/content.md +2 -0
  233. package/templates/skills/turso-interaction/content.md +1 -1
  234. package/templates/skills/work-on-task/SKILL.md +1 -1
  235. package/templates/skills/work-on-task/content.md +1 -1
  236. package/templates/skills/workflow-iterate/content.md +8 -1
@@ -16,8 +16,13 @@ export type SessionReadState = "unread" | "read" | "later" | "done";
16
16
  export interface SessionFilters {
17
17
  /** Restrict roots to these task sources. */
18
18
  source?: string[];
19
- /** Leave out roots of these task sources (the sessions v2 source filter). */
19
+ /** Leave out roots of these task sources (the older sessions v2 source filter). */
20
20
  hideSource?: string[];
21
+ /**
22
+ * Keep only roots of these task sources (the sessions v2 source filter).
23
+ * Unlike `source`, an empty list matches no root.
24
+ */
25
+ showSource?: string[];
21
26
  /** Match root task text or title with a case-insensitive substring. */
22
27
  q?: string;
23
28
  /** Match the root requester, excluding NULL requester rows. */
@@ -50,13 +55,17 @@ export interface SessionFilters {
50
55
 
51
56
  type SqlFragment = { sql: string; params: (string | number | null)[] };
52
57
 
53
- /** Shared read-state rule with bound ISO timestamps for the list and tree. */
58
+ /**
59
+ * Shared read-state rule with bound ISO timestamps for the list and tree.
60
+ * Done holds through agent activity (progress, status changes, child tasks):
61
+ * only `a.reopensDone`, counted in `sessionSummaryQuery`, brings it back.
62
+ */
54
63
  function sessionReadStateSql(now = new Date().toISOString()): SqlFragment {
55
64
  return {
56
65
  sql: `CASE
57
66
  WHEN s.id IS NULL THEN CASE WHEN a.lastActivityAt > ? THEN 'unread' ELSE 'read' END
58
67
  WHEN s.status = 'dismissed' THEN 'done'
59
- WHEN s.status = 'done' THEN CASE WHEN a.lastActivityAt > s.lastUpdatedAt THEN 'unread' ELSE 'done' END
68
+ WHEN s.status = 'done' THEN CASE WHEN a.reopensDone > 0 THEN 'unread' ELSE 'done' END
60
69
  WHEN s.status = 'snoozed' THEN CASE WHEN s.snoozeUntil > ? THEN 'later' ELSE 'unread' END
61
70
  WHEN a.lastActivityAt > COALESCE(s.lastReadAt, s.lastUpdatedAt) THEN 'unread'
62
71
  ELSE 'read'
@@ -100,6 +109,14 @@ export function sessionRootFilter(opts: SessionFilters = {}): SqlFragment {
100
109
  const hidden = opts.hideSource?.filter(Boolean) ?? [];
101
110
  if (hidden.length)
102
111
  add({ sql: `r.source NOT IN (${hidden.map(() => "?").join(",")})`, params: hidden });
112
+ if (opts.showSource) {
113
+ const shown = opts.showSource.filter(Boolean);
114
+ add(
115
+ shown.length
116
+ ? { sql: `r.source IN (${shown.map(() => "?").join(",")})`, params: shown }
117
+ : { sql: "0", params: [] },
118
+ );
119
+ }
103
120
  if (opts.q?.trim()) {
104
121
  const like = `%${opts.q.trim().toLowerCase()}%`;
105
122
  add({
@@ -152,6 +169,35 @@ function hasSessionChainFilter(opts: SessionFilters): boolean {
152
169
 
153
170
  const runningSql = `latestStatus NOT IN (${TERMINAL_TASK_STATUSES.map((s) => `'${s}'`).join(",")})`;
154
171
 
172
+ /**
173
+ * Sources of tasks that agents and the server create: delegation and scripts
174
+ * (`mcp`), follow-ups (`system`), deferred wake-ups (`schedule`) and workflow
175
+ * steps. Agents also set `requestedByUserId` explicitly on these, so a requester
176
+ * alone does not mark a human message.
177
+ */
178
+ const AGENT_TASK_SOURCES = ["mcp", "system", "schedule", "workflow"] as const;
179
+
180
+ /**
181
+ * Per chain row, over `chain` and the viewer's Done row `d`: whether the row
182
+ * needs the user since they marked the session Done. That is a human message
183
+ * (a task attributed to a person directly, not copied from its parent, from a
184
+ * human channel), a person's steering message on a chain task (steer or
185
+ * queue, not cancelled), or a pending approval, created after the Done mark.
186
+ * Steering `created_at` and the Done mark's `lastUpdatedAt` are both written
187
+ * as `%Y-%m-%dT%H:%M:%fZ`, so they compare as text.
188
+ */
189
+ const reopensDoneSql = `(d.id IS NOT NULL AND (
190
+ (chain.createdAt > d.lastUpdatedAt
191
+ AND chain.requestedByUserId IS NOT NULL
192
+ AND chain.requestedByUserIdInherited = 0
193
+ AND COALESCE(chain.source, '') NOT IN (${AGENT_TASK_SOURCES.map((s) => `'${s}'`).join(",")}))
194
+ OR EXISTS (SELECT 1 FROM task_steering_messages sm
195
+ WHERE sm.task_id = chain.id AND sm.created_by_kind = 'user'
196
+ AND sm.status != 'cancelled' AND sm.created_at > d.lastUpdatedAt)
197
+ OR EXISTS (SELECT 1 FROM approval_requests ar
198
+ WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending' AND ar.createdAt > d.lastUpdatedAt)
199
+ ))`;
200
+
155
201
  /**
156
202
  * A session starts at a parentTaskId IS NULL root. One recursive CTE computes
157
203
  * chain-wide latest activity for list ordering. Root filters precede recursion.
@@ -162,7 +208,7 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
162
208
  const read = opts.viewer?.userId ? sessionReadStateSql(opts.now) : { sql: "NULL", params: [] };
163
209
  const params = [...root.params];
164
210
  if (opts.withUserId) params.push(opts.withUserId);
165
- params.push(...read.params, opts.viewer?.userId ?? null);
211
+ params.push(opts.viewer?.userId ?? null, ...read.params, opts.viewer?.userId ?? null);
166
212
  const filters: string[] = [];
167
213
  if (opts.triage === "unread") filters.push("readState = 'unread'");
168
214
  else if (opts.triage === "later" || opts.triage === "done") {
@@ -182,19 +228,27 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
182
228
  return {
183
229
  // SQLite takes the bare status from the row with MAX(lastUpdatedAt).
184
230
  // Keep this as the only MIN/MAX aggregate so status follows activity.
185
- sql: `WITH RECURSIVE chain(root_id, id, lastUpdatedAt, status, requestedByUserId) AS (
186
- SELECT r.id, r.id, r.lastUpdatedAt, r.status, r.requestedByUserId
231
+ // `d` is the viewer's Done row, so `reopensDone` can compare chain rows
232
+ // against the Done mark (SUM keeps MAX the only MIN/MAX aggregate).
233
+ sql: `WITH RECURSIVE chain(root_id, id, lastUpdatedAt, status, requestedByUserId,
234
+ createdAt, source, requestedByUserIdInherited) AS (
235
+ SELECT r.id, r.id, r.lastUpdatedAt, r.status, r.requestedByUserId,
236
+ r.createdAt, r.source, r.requestedByUserIdInherited
187
237
  FROM agent_tasks r WHERE ${root.sql}
188
238
  UNION ALL
189
- SELECT c.root_id, t.id, t.lastUpdatedAt, t.status, t.requestedByUserId
239
+ SELECT c.root_id, t.id, t.lastUpdatedAt, t.status, t.requestedByUserId,
240
+ t.createdAt, t.source, t.requestedByUserIdInherited
190
241
  FROM chain c JOIN agent_tasks t ON t.parentTaskId = c.id
191
242
  ), agg AS (
192
- SELECT root_id, COUNT(*) AS chainCount,
193
- status AS latestStatus, MAX(lastUpdatedAt) AS lastActivityAt,
194
- ${opts.withUserId ? "SUM(requestedByUserId = ?) AS withUser," : ""}
243
+ SELECT chain.root_id, COUNT(*) AS chainCount,
244
+ chain.status AS latestStatus, MAX(chain.lastUpdatedAt) AS lastActivityAt,
245
+ ${opts.withUserId ? "SUM(chain.requestedByUserId = ?) AS withUser," : ""}
195
246
  SUM(EXISTS (SELECT 1 FROM approval_requests ar
196
- WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending')) AS pendingApproval
197
- FROM chain GROUP BY root_id
247
+ WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending')) AS pendingApproval,
248
+ SUM(${reopensDoneSql}) AS reopensDone
249
+ FROM chain LEFT JOIN inbox_item_state d
250
+ ON d.userId = ? AND d.itemType = 'to_read' AND d.itemId = chain.root_id AND d.status = 'done'
251
+ GROUP BY chain.root_id
198
252
  ), state AS (
199
253
  SELECT a.*, ${read.sql} AS readState, s.snoozeUntil
200
254
  FROM agg a LEFT JOIN inbox_item_state s
@@ -341,7 +395,7 @@ export interface SessionTree {
341
395
  export async function getSessionTree(
342
396
  actor: { kind: "bypass" } | { kind: "user"; userId: string | null },
343
397
  now?: string,
344
- opts: { mine?: boolean; hideSource?: string[] } = {},
398
+ opts: { mine?: boolean; hideSource?: string[]; showSource?: string[] } = {},
345
399
  ): Promise<SessionTree> {
346
400
  // No source filter in SQL: the source counts must include hidden sources,
347
401
  // so the filter menu can offer them back. The loop skips them instead.
@@ -373,6 +427,7 @@ export async function getSessionTree(
373
427
  sources: [],
374
428
  };
375
429
  const hidden = new Set(opts.hideSource);
430
+ const shown = opts.showSource ? new Set(opts.showSource) : null;
376
431
  const sources = new Map<string, number>();
377
432
  const groups = new Map<string, SessionTreeGroup>();
378
433
  const add = (section: SessionSectionCounts, row: (typeof rows)[number]) => {
@@ -383,7 +438,7 @@ export async function getSessionTree(
383
438
  };
384
439
  for (const row of rows) {
385
440
  sources.set(row.source, (sources.get(row.source) ?? 0) + 1);
386
- if (hidden.has(row.source)) continue;
441
+ if (hidden.has(row.source) || (shown && !shown.has(row.source))) continue;
387
442
  const key = sessionGroupKey(row.key);
388
443
  const noGroup =
389
444
  key === "shared/" || (actor.kind === "user" && key === `personal/${actor.userId}/`);
@@ -425,7 +480,10 @@ const UNREAD_CURSOR = "1970-01-01T00:00:00.000Z";
425
480
  */
426
481
  export async function triageSessionList(
427
482
  userId: string,
428
- filters: Pick<SessionFilters, "triage" | "noGroup" | "groupKey" | "mine" | "hideSource" | "now">,
483
+ filters: Pick<
484
+ SessionFilters,
485
+ "triage" | "noGroup" | "groupKey" | "mine" | "hideSource" | "showSource" | "now"
486
+ >,
429
487
  action: SessionListTriage,
430
488
  ): Promise<number> {
431
489
  const now = filters.now ?? new Date().toISOString();
@@ -0,0 +1,87 @@
1
+ import { getDbClient } from "./runtime";
2
+
3
+ // bm25() takes one weight per FTS column, in declaration order.
4
+ // skills_fts (migration 239): skill_id (UNINDEXED), name, description, content.
5
+ // skills_meta_fts (migration 241): skill_id (UNINDEXED), name, description.
6
+ const SKILLS_FTS_BM25 = "bm25(skills_fts, 0.0, 3.0, 2.0, 1.0)";
7
+ const SKILLS_META_FTS_BM25 = "bm25(skills_meta_fts, 0.0, 3.0, 2.0)";
8
+
9
+ // FTS5 bm25 normalizes by the length of the whole row, so in skills_fts a
10
+ // long SKILL.md body buries a name or description hit. skills_meta_fts holds
11
+ // only those short fields; its score is added at this multiple. On the live
12
+ // catalog, multiples from 2 to 10 rank the same; 3 is mid-range.
13
+ const META_SCORE_MULTIPLE = 3;
14
+
15
+ // English function words that agents put in natural-language queries. Each
16
+ // one matches most of the catalog ("a" matched 189 of 189 skills) and only
17
+ // adds noise to the ranking.
18
+ const STOPWORDS = new Set(
19
+ (
20
+ "a an and are as at be but by can do does for from get got have how i if in into is it its " +
21
+ "me my need of on or our should so that the their them then there these this to use using " +
22
+ "want was we what when where which who why will with would you your"
23
+ ).split(" "),
24
+ );
25
+
26
+ /**
27
+ * Build a skills_fts MATCH expression from free text. Every term is quoted as
28
+ * an FTS5 string, so user text cannot inject operators (NEAR, AND, -, *, ^,
29
+ * column filters). Stopwords are dropped and the remaining terms are OR-ed:
30
+ * ranked recall, ordered by bm25. Returns null when no term is left, so the
31
+ * caller falls back to the substring match.
32
+ */
33
+ export function buildSkillsFtsMatch(query: string): string | null {
34
+ const terms = query
35
+ .split(/[^\p{L}\p{N}_-]+/u)
36
+ .filter((term) => /[\p{L}\p{N}]/u.test(term) && !STOPWORDS.has(term.toLowerCase()))
37
+ .slice(0, 12);
38
+ if (terms.length === 0) return null;
39
+ return terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
40
+ }
41
+
42
+ /**
43
+ * Enabled skills ranked by BM25: the skills_fts score (name 3, description 2,
44
+ * content 1) plus META_SCORE_MULTIPLE times the skills_meta_fts score (name 3,
45
+ * description 2). A skill whose name equals the whole query (case-insensitive)
46
+ * ranks first. Returns [] when the query has no indexable terms or the
47
+ * backend cannot run the FTS query, so the caller can fall back.
48
+ *
49
+ * `columns` is a select list over `skills`; it must not name a bare
50
+ * `skill_id` or `score`, which the joined `hits` and `meta` CTEs also expose.
51
+ */
52
+ export async function searchSkillRowsRanked<T>(
53
+ query: string,
54
+ limit: number,
55
+ columns: string,
56
+ ): Promise<T[]> {
57
+ const match = buildSkillsFtsMatch(query);
58
+ if (!match) return [];
59
+ const exactName = query.trim();
60
+ try {
61
+ return await getDbClient().query<T>(
62
+ `WITH hits AS (
63
+ SELECT skill_id, ${SKILLS_FTS_BM25} AS score FROM skills_fts WHERE skills_fts MATCH ?
64
+ ),
65
+ meta AS (
66
+ SELECT skill_id, ${SKILLS_META_FTS_BM25} AS score
67
+ FROM skills_meta_fts WHERE skills_meta_fts MATCH ?
68
+ )
69
+ SELECT ${columns} FROM skills
70
+ LEFT JOIN hits ON hits.skill_id = skills.id
71
+ LEFT JOIN meta ON meta.skill_id = skills.id
72
+ WHERE skills.isEnabled = 1
73
+ AND (hits.skill_id IS NOT NULL OR lower(skills.name) = lower(?))
74
+ ORDER BY lower(skills.name) = lower(?) DESC,
75
+ COALESCE(hits.score, 0) + ${META_SCORE_MULTIPLE} * COALESCE(meta.score, 0) ASC,
76
+ skills.name ASC
77
+ LIMIT ?`,
78
+ [match, match, exactName, exactName, limit],
79
+ );
80
+ } catch (err) {
81
+ console.warn(
82
+ "[skills-fts] ranked query failed, using substring match:",
83
+ (err as Error).message,
84
+ );
85
+ return [];
86
+ }
87
+ }
@@ -196,12 +196,39 @@ type AgentTaskTimelineRow = Pick<
196
196
  * past it).
197
197
  */
198
198
  const TASK_PREVIEW_BYTES = 4 * (TASK_PREVIEW_LENGTH + 1);
199
- const TASK_PREVIEW_SQL = `substr(CAST(agent_tasks.task AS BLOB), 1, ${TASK_PREVIEW_BYTES}) AS task`;
199
+ /**
200
+ * A leading `<sibling_tasks_in_progress>` block (`src/tasks/sibling-block.ts`)
201
+ * is generated context that can fill the whole preview, so the list row never
202
+ * reaches the ask. The preview starts after the block and the `\n\n`
203
+ * `prependSiblingBlock` puts after it. A block with no closing tag stays.
204
+ */
205
+ const SIBLING_OPEN = "<sibling_tasks_in_progress>";
206
+ const SIBLING_CLOSE = "</sibling_tasks_in_progress>";
207
+ const TASK_BLOB = "CAST(agent_tasks.task AS BLOB)";
208
+ const SIBLING_CLOSE_AT = `instr(${TASK_BLOB}, CAST('${SIBLING_CLOSE}' AS BLOB))`;
209
+ const SIBLING_END = `(${SIBLING_CLOSE_AT} + ${SIBLING_CLOSE.length})`;
210
+ const TASK_PREVIEW_START = `CASE
211
+ WHEN substr(${TASK_BLOB}, 1, ${SIBLING_OPEN.length}) = CAST('${SIBLING_OPEN}' AS BLOB)
212
+ AND ${SIBLING_CLOSE_AT} > 0
213
+ THEN ${SIBLING_END} + (substr(${TASK_BLOB}, ${SIBLING_END}, 2) = x'0a0a') * 2
214
+ ELSE 1 END`;
215
+ const TASK_PREVIEW_SQL = `substr(${TASK_BLOB}, ${TASK_PREVIEW_START}, ${TASK_PREVIEW_BYTES}) AS task`;
200
216
  // ignoreBOM keeps a leading U+FEFF, which the full-text preview keeps too.
201
217
  const taskPreviewDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
202
218
 
219
+ /** What `TASK_PREVIEW_START` does in SQL, for a full-text row. */
220
+ function skipSiblingBlock(text: string): string {
221
+ if (!text.startsWith(SIBLING_OPEN)) return text;
222
+ const close = text.indexOf(SIBLING_CLOSE);
223
+ if (close === -1) return text;
224
+ const end = close + SIBLING_CLOSE.length;
225
+ return text.slice(text.startsWith("\n\n", end) ? end + 2 : end);
226
+ }
227
+
203
228
  function taskPreview(task: TaskPreviewSource["task"]): string {
204
- const text = task instanceof Uint8Array ? taskPreviewDecoder.decode(task) : task;
229
+ // Bytes come from `TASK_PREVIEW_SQL`, which already skipped the block.
230
+ const text =
231
+ task instanceof Uint8Array ? taskPreviewDecoder.decode(task) : skipSiblingBlock(task ?? "");
205
232
  return dependencies.previewText(text, TASK_PREVIEW_LENGTH);
206
233
  }
207
234
 
@@ -657,7 +657,15 @@ export async function overwriteTerminalTaskResultText(
657
657
  return row ? rowToAgentTask(row) : task;
658
658
  }
659
659
 
660
- export async function cancelTask(id: string, reason?: string): Promise<AgentTask | null> {
660
+ /**
661
+ * `cascadeDependents: false` skips the dependents sweep for a caller that
662
+ * already knows no task depends on this one (a bulk cancel looks once).
663
+ */
664
+ export async function cancelTask(
665
+ id: string,
666
+ reason?: string,
667
+ options: { cascadeDependents?: boolean } = {},
668
+ ): Promise<AgentTask | null> {
661
669
  const oldTask = await getTaskById(id);
662
670
  if (!oldTask) return null;
663
671
 
@@ -736,10 +744,12 @@ export async function cancelTask(id: string, reason?: string): Promise<AgentTask
736
744
  );
737
745
  }
738
746
 
739
- try {
740
- await dependencies.cascadeFailDependents(id, "cancelled");
741
- } catch (err) {
742
- console.error("[cancelTask] cascade-fail dependents error:", err);
747
+ if (options.cascadeDependents !== false) {
748
+ try {
749
+ await dependencies.cascadeFailDependents(id, "cancelled");
750
+ } catch (err) {
751
+ console.error("[cancelTask] cascade-fail dependents error:", err);
752
+ }
743
753
  }
744
754
  }
745
755
 
@@ -0,0 +1,183 @@
1
+ import { getCurrentRequestUserId } from "../../utils/request-auth-context";
2
+ import { openSealedJson, sealJson } from "../sealed-json";
3
+ import { getDbClient } from "./runtime";
4
+
5
+ /**
6
+ * Server-side `Idempotency-Key` records for workflow create and trigger.
7
+ *
8
+ * A record is written in the transaction that accepts the operation (the
9
+ * workflow insert, or the run insert with its initial state), so it exists if
10
+ * and only if the operation committed. Readers look it up before re-running
11
+ * any check that can change between attempts (enabled, cooldown, readiness,
12
+ * trigger schema) and replay the stored response instead.
13
+ */
14
+
15
+ export type WorkflowIdempotencyOperation = "workflow.create" | "workflow.trigger";
16
+
17
+ /** Who may replay a record: one caller, one operation, one target, one key. */
18
+ export interface WorkflowIdempotencyScope {
19
+ /** Stable caller identity, e.g. `user:<id>`; never a credential value. */
20
+ principal: string;
21
+ operation: WorkflowIdempotencyOperation;
22
+ /** The triggered workflow id; empty for create. */
23
+ targetId: string;
24
+ /** The client's `Idempotency-Key` value. Stored only as a hash. */
25
+ key: string;
26
+ }
27
+
28
+ /** A claim for one request: its scope plus the fingerprint of what it asked for. */
29
+ export interface WorkflowIdempotencyClaim extends WorkflowIdempotencyScope {
30
+ fingerprint: string;
31
+ }
32
+
33
+ export interface AcceptedWorkflowOperation {
34
+ fingerprint: string;
35
+ status: number;
36
+ /** The original response body; null once the workflow it names was deleted. */
37
+ body: unknown;
38
+ resourceDeletedAt: string | null;
39
+ }
40
+
41
+ /**
42
+ * The key was accepted for another request while this one was getting ready.
43
+ * Thrown inside the accepting transaction, so this request commits nothing;
44
+ * the caller looks the record up again and answers from it.
45
+ */
46
+ export class IdempotencyKeyTakenError extends Error {
47
+ constructor() {
48
+ super("Idempotency-Key was accepted by a concurrent request");
49
+ this.name = "IdempotencyKeyTakenError";
50
+ }
51
+ }
52
+
53
+ type AcceptedRow = {
54
+ requestFingerprint: string;
55
+ responseStatus: number;
56
+ responseBody: string | null;
57
+ resourceDeletedAt: string | null;
58
+ };
59
+
60
+ function sha256(text: string): string {
61
+ const hasher = new Bun.CryptoHasher("sha256");
62
+ hasher.update(text);
63
+ return hasher.digest("hex");
64
+ }
65
+
66
+ function sortKeys(value: unknown): unknown {
67
+ if (Array.isArray(value)) return value.map(sortKeys);
68
+ if (!value || typeof value !== "object") return value;
69
+ // Code-unit order, not localeCompare: the fingerprint must not depend on the
70
+ // server's locale.
71
+ return Object.fromEntries(
72
+ Object.entries(value as Record<string, unknown>)
73
+ .filter(([, nested]) => nested !== undefined)
74
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
75
+ .map(([key, nested]) => [key, sortKeys(nested)]),
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Fingerprint of an accepted request body. Object key order does not matter,
81
+ * array order does, and every value counts as sent (nothing is redacted first,
82
+ * so two requests that differ only in a secret never match). Only the hash is
83
+ * stored.
84
+ */
85
+ export function workflowRequestFingerprint(
86
+ operation: WorkflowIdempotencyOperation,
87
+ body: unknown,
88
+ ): string {
89
+ return sha256(`${operation}\n${JSON.stringify(sortKeys(body ?? null))}`);
90
+ }
91
+
92
+ const SCOPE_WHERE = "principal = ? AND operation = ? AND targetId = ? AND keyHash = ?";
93
+
94
+ function scopeParams(scope: WorkflowIdempotencyScope): string[] {
95
+ return [scope.principal, scope.operation, scope.targetId, sha256(scope.key)];
96
+ }
97
+
98
+ export async function findAcceptedWorkflowOperation(
99
+ scope: WorkflowIdempotencyScope,
100
+ ): Promise<AcceptedWorkflowOperation | null> {
101
+ const row = await getDbClient().get<AcceptedRow>(
102
+ `SELECT requestFingerprint, responseStatus, responseBody, resourceDeletedAt
103
+ FROM workflow_idempotency_keys WHERE ${SCOPE_WHERE}`,
104
+ scopeParams(scope),
105
+ );
106
+ if (!row) return null;
107
+ return {
108
+ fingerprint: row.requestFingerprint,
109
+ status: row.responseStatus,
110
+ body: row.responseBody === null ? null : openSealedJson(row.responseBody),
111
+ resourceDeletedAt: row.resourceDeletedAt,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Throw `IdempotencyKeyTakenError` when the scope already has a record. Inside
117
+ * the accepting transaction, call it before writing a resource whose insert
118
+ * could collide with the concurrent winner's (a unique workflow name), so the
119
+ * loser replays instead of failing on that constraint.
120
+ */
121
+ export async function assertWorkflowIdempotencyKeyUnused(
122
+ scope: WorkflowIdempotencyScope,
123
+ ): Promise<void> {
124
+ const taken = await getDbClient().get(
125
+ `SELECT 1 FROM workflow_idempotency_keys WHERE ${SCOPE_WHERE}`,
126
+ scopeParams(scope),
127
+ );
128
+ if (taken) throw new IdempotencyKeyTakenError();
129
+ }
130
+
131
+ /**
132
+ * Record the accepted operation. Call it inside the transaction that creates
133
+ * the resource: a rollback drops both. Throws `IdempotencyKeyTakenError` when
134
+ * the scope already has a record (a concurrent request committed first); the
135
+ * unique index backs this across processes.
136
+ */
137
+ export async function recordAcceptedWorkflowOperation(
138
+ claim: WorkflowIdempotencyClaim,
139
+ accepted: { workflowId: string; runId?: string; status: number; body: unknown },
140
+ ): Promise<void> {
141
+ await assertWorkflowIdempotencyKeyUnused(claim);
142
+ const actor = getCurrentRequestUserId() ?? null;
143
+ try {
144
+ await getDbClient().run(
145
+ `INSERT INTO workflow_idempotency_keys
146
+ (id, principal, operation, targetId, keyHash, requestFingerprint, workflowId, runId,
147
+ responseStatus, responseBody, createdAt, created_by, updated_by)
148
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
149
+ [
150
+ crypto.randomUUID(),
151
+ ...scopeParams(claim),
152
+ claim.fingerprint,
153
+ accepted.workflowId,
154
+ accepted.runId ?? null,
155
+ accepted.status,
156
+ sealJson(accepted.body),
157
+ new Date().toISOString(),
158
+ actor,
159
+ actor,
160
+ ],
161
+ );
162
+ } catch (err) {
163
+ if (err instanceof Error && err.message.includes("UNIQUE constraint failed")) {
164
+ throw new IdempotencyKeyTakenError();
165
+ }
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Turn every record naming `workflowId` into a tombstone: the stored response
172
+ * (which may carry the deleted workflow's definition) is cleared and the key
173
+ * stays taken, so a retry is told the resource is gone instead of creating or
174
+ * triggering anything again. Runs in the delete's transaction.
175
+ */
176
+ export async function tombstoneWorkflowIdempotencyKeys(workflowId: string): Promise<void> {
177
+ await getDbClient().run(
178
+ `UPDATE workflow_idempotency_keys
179
+ SET responseBody = NULL, resourceDeletedAt = ?, updated_by = ?
180
+ WHERE workflowId = ? AND resourceDeletedAt IS NULL`,
181
+ [new Date().toISOString(), getCurrentRequestUserId() ?? null, workflowId],
182
+ );
183
+ }