@desplega.ai/agent-swarm 2.7.2 → 2.7.3

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 (220) hide show
  1. package/README.md +6 -0
  2. package/dist/{acp-adapter-624yge7a.js → acp-adapter-pdzw76ja.js} +1 -1
  3. package/dist/{actions-rd9xn281.js → actions-a90cfpca.js} +21 -21
  4. package/dist/{amp-adapter-k27hc00p.js → amp-adapter-4vtywcjk.js} +2 -2
  5. package/dist/{app-sjm3xc7g.js → app-8k68gygk.js} +3 -3
  6. package/dist/{assistant-6jbzs8wa.js → assistant-ck01q2yg.js} +32 -21
  7. package/dist/{boot-reembed-e2348eqc.js → boot-reembed-33spavpd.js} +1 -1
  8. package/dist/{boot-reembed-w53pd5xt.js → boot-reembed-xzge83a2.js} +1 -1
  9. package/dist/{boot-scrub-sweep-cpy4yg97.js → boot-scrub-sweep-mxvb8dr3.js} +3 -3
  10. package/dist/{boot-seeds-f6j4t6k6.js → boot-seeds-rpm8bb5r.js} +4 -4
  11. package/dist/{claude-adapter-v3ac8esd.js → claude-adapter-q12bcfj9.js} +3 -3
  12. package/dist/{claude-managed-setup-yyrkqvtm.js → claude-managed-setup-11vb6q89.js} +1 -1
  13. package/dist/{claude-sdk-session-85j69v6d.js → claude-sdk-session-gkv1107j.js} +10325 -10243
  14. package/dist/{cli-rs59z1qx.js → cli-07pcqwaj.js} +1 -1
  15. package/dist/{cli-6vq3ymqm.js → cli-0tkm519n.js} +1 -1
  16. package/dist/{cli-s7cm1927.js → cli-0xzbk52h.js} +4 -4
  17. package/dist/{cli-g0zc1b40.js → cli-0zae6bs6.js} +2 -2
  18. package/dist/{cli-p8yrsmfn.js → cli-10kjx0dm.js} +1 -1
  19. package/dist/{cli-7xsq63pe.js → cli-157ykn37.js} +10 -9
  20. package/dist/{cli-vjcj9k2p.js → cli-2h0rfmqk.js} +1 -1
  21. package/dist/{cli-9wasax9f.js → cli-35nzvqaf.js} +1 -1
  22. package/dist/{cli-e1p7dqe1.js → cli-4aydmmdy.js} +24 -3
  23. package/dist/{cli-942pp3bc.js → cli-54ax65rn.js} +3 -3
  24. package/dist/{cli-10cp92df.js → cli-5ke7gxwr.js} +6 -6
  25. package/dist/{cli-femw4q81.js → cli-6151a7dr.js} +2 -2
  26. package/dist/{cli-3cywb44q.js → cli-77eq8txs.js} +85 -3
  27. package/dist/{cli-atpfk4yd.js → cli-7cwrwb5x.js} +5 -5
  28. package/dist/{cli-bfbpvccx.js → cli-8a1k6mat.js} +61 -49
  29. package/dist/{cli-c18941tx.js → cli-8ch3b2r7.js} +3 -3
  30. package/dist/{cli-rs2s1t05.js → cli-9fk56y8c.js} +4 -4
  31. package/dist/{cli-0wx63vvw.js → cli-9km88w11.js} +1 -1
  32. package/dist/{cli-qy0z3mfj.js → cli-9vttw9nz.js} +56 -46
  33. package/dist/{cli-mzgppb7q.js → cli-a0w50zw9.js} +4 -4
  34. package/dist/{cli-z8a4mfz1.js → cli-aqp59jmv.js} +4 -4
  35. package/dist/{cli-5d8wd50e.js → cli-bs0fwb23.js} +1 -1
  36. package/dist/{cli-fa4kxxx3.js → cli-ct5920jb.js} +2 -2
  37. package/dist/{cli-0awbtmv2.js → cli-dek8hqjc.js} +10 -10
  38. package/dist/{cli-qp6adcmw.js → cli-e24w2mvk.js} +1 -1
  39. package/dist/{cli-t3npd8hm.js → cli-ghfhn1qc.js} +1 -1
  40. package/dist/{cli-jmcf8cnz.js → cli-ja369zab.js} +1 -1
  41. package/dist/{cli-wtjg6je3.js → cli-jhh7tbx6.js} +1 -1
  42. package/dist/{cli-e19bgatr.js → cli-jmk31131.js} +2 -2
  43. package/dist/{cli-2cj8fqv1.js → cli-kjcdzea8.js} +1 -1
  44. package/dist/{cli-1xpr5992.js → cli-kjha1q9k.js} +3 -3
  45. package/dist/{cli-9jfty8px.js → cli-ktcdczth.js} +538 -30
  46. package/dist/{cli-exd52qn4.js → cli-mcmpbpcc.js} +2 -2
  47. package/dist/{cli-2m2p3snc.js → cli-mmf43vcp.js} +6 -6
  48. package/dist/{cli-b2rhbaty.js → cli-pnm99spe.js} +10 -10
  49. package/dist/{cli-9jn9cj0q.js → cli-v9jk2k63.js} +1 -1
  50. package/dist/{cli-mt7bxjfd.js → cli-vhgcwppn.js} +1 -1
  51. package/dist/{cli-qa4hgda3.js → cli-vvz64zc5.js} +14 -13
  52. package/dist/{cli-txr0z97c.js → cli-wmhajw68.js} +171 -32
  53. package/dist/{cli-hzrmv8pg.js → cli-x38rmhap.js} +3 -3
  54. package/dist/{cli-hq9hd4hp.js → cli-xz6qjdqj.js} +1 -1
  55. package/dist/{cli-g9ypd5en.js → cli-z52gq55b.js} +4 -0
  56. package/dist/{cli-2qbmjfta.js → cli-z6219sd3.js} +4 -4
  57. package/dist/cli.js +19 -19
  58. package/dist/{codex-adapter-zm1bkdde.js → codex-adapter-46kbvvkx.js} +1 -1
  59. package/dist/{codex-hook-tkag7fxq.js → codex-hook-tm9ecrd5.js} +1 -1
  60. package/dist/{codex-session-runner-8k02mqw2.js → codex-session-runner-qy93hdrv.js} +1 -1
  61. package/dist/{commands-nw1dn4av.js → commands-hwdhacfx.js} +2 -2
  62. package/dist/{controller-3kkkmps0.js → controller-hjza5p1f.js} +3 -3
  63. package/dist/{controllers-connect-n2c4167r.js → controllers-connect-7m25cay2.js} +20 -20
  64. package/dist/{cursor-adapter-h5028emw.js → cursor-adapter-1qh7ps8w.js} +1 -1
  65. package/dist/{db-76y2msnf.js → db-w23k9301.js} +2 -2
  66. package/dist/{db-8rk0p8j1.js → db-wm8by383.js} +3 -3
  67. package/dist/{dispatcher-xp4wsx30.js → dispatcher-en25ebgc.js} +18 -18
  68. package/dist/{dsh-adapter-vyrr31bn.js → dsh-adapter-zkme32vd.js} +1 -1
  69. package/dist/{e2b-eesfj689.js → e2b-9z9mvt2p.js} +1 -1
  70. package/dist/{e2b-03nha1wz.js → e2b-yhvn70es.js} +1 -1
  71. package/dist/{embedding-space-ejefyep1.js → embedding-space-rgf4aypt.js} +6 -6
  72. package/dist/{embeddings-w297xaw0.js → embeddings-z9cmxhx1.js} +1 -1
  73. package/dist/{external-channel-mf9vp1fe.js → external-channel-g6vv85zv.js} +2 -2
  74. package/dist/{grok-adapter-mpzyyprz.js → grok-adapter-f6n1tpq3.js} +2 -2
  75. package/dist/handlers-ss5948rq.js +42 -0
  76. package/dist/{heartbeat-qnaycjzs.js → heartbeat-5xcdzz45.js} +21 -21
  77. package/dist/{hook-h6sqfzxr.js → hook-6423rme3.js} +2 -2
  78. package/dist/{hook-4zqdgea4.js → hook-8dk1csdd.js} +4 -4
  79. package/dist/{http-65ts210h.js → http-6mfh2ww9.js} +255 -60
  80. package/dist/{index-sqe7f6mj.js → index-0js6bgff.js} +1 -1
  81. package/dist/{index-farm1zsn.js → index-0ng1dd1t.js} +10937 -9527
  82. package/dist/{index-k3ewf1jg.js → index-5eyn1wc5.js} +18 -18
  83. package/dist/{index-vp4wa3qn.js → index-71gnebsn.js} +18 -18
  84. package/dist/{index-hdfad00b.js → index-q5gzpjdd.js} +12 -10
  85. package/dist/index-z3b1vfjx.js +44 -0
  86. package/dist/{keepalive-52k6skha.js → keepalive-cymckeg6.js} +3 -3
  87. package/dist/{lead-y9rj0gr1.js → lead-baknj2dh.js} +29 -29
  88. package/dist/{maintenance-f86wqfq6.js → maintenance-dfrnsx3n.js} +1 -1
  89. package/dist/{oauth-refresh-sweep-vddv9ky7.js → oauth-refresh-sweep-5dztk8x6.js} +2 -2
  90. package/dist/{onboard-8z2qk6gq.js → onboard-h36vja6y.js} +11 -6
  91. package/dist/{otel-impl-epet9j18.js → otel-impl-jerq8vz8.js} +1 -1
  92. package/dist/{pricing-refresh-6wm42a51.js → pricing-refresh-t3nx5krn.js} +3 -3
  93. package/dist/{rbac-roles-2kfcs1sc.js → rbac-roles-dw1awawd.js} +2 -2
  94. package/dist/{rbac-roles-13a0vj6d.js → rbac-roles-km0fc3e8.js} +1 -1
  95. package/dist/{registry-ajhexe44.js → registry-hzfh268v.js} +1 -1
  96. package/dist/{render-v2-cy8zsfsf.js → render-v2-65x4ayj8.js} +3 -3
  97. package/dist/{resume-c783qe6b.js → resume-61znk3hj.js} +6 -6
  98. package/dist/{session-access-s4tv0az8.js → session-access-4yc9htrg.js} +4 -4
  99. package/dist/{session-source-backfill-qzapz0ne.js → session-source-backfill-kr5bdjtp.js} +2 -2
  100. package/dist/{setup-st7htx1k.js → setup-9h61yr2p.js} +1 -1
  101. package/dist/{work-objects-ftj4zw70.js → work-objects-x7rb53qn.js} +27 -22
  102. package/dist/{worker-859fk25k.js → worker-7f3khgn0.js} +29 -29
  103. package/openapi.json +134 -3
  104. package/package.json +5 -5
  105. package/src/be/db/agents.ts +16 -1
  106. package/src/be/db/runtime.ts +25 -15
  107. package/src/be/db/session-summaries.ts +120 -0
  108. package/src/be/db/tasks/read.ts +7 -4
  109. package/src/be/db/tasks/search-index.ts +159 -0
  110. package/src/be/db/tasks/write.ts +3 -0
  111. package/src/be/db-queries/slack-inbound.ts +77 -0
  112. package/src/be/db.ts +27 -8
  113. package/src/be/mcp-overflow-sweep.ts +39 -0
  114. package/src/be/migrations/253_session_summaries.sql +300 -0
  115. package/src/be/migrations/254_query_audit_indexes.sql +25 -0
  116. package/src/be/migrations/256_agent_slack_icon.sql +2 -0
  117. package/src/be/migrations/257_agent_tasks_fts.sql +55 -0
  118. package/src/comb/config.ts +6 -2
  119. package/src/comb/requester.ts +69 -0
  120. package/src/comb/review-batch.ts +24 -2
  121. package/src/commands/onboard/agent-fs-version.ts +1 -1
  122. package/src/commands/onboard/compose-generator.ts +7 -2
  123. package/src/commands/onboard/env-generator.ts +8 -3
  124. package/src/commands/runner.ts +41 -24
  125. package/src/fs/agent-fs-provider.ts +9 -0
  126. package/src/heartbeat/heartbeat.ts +13 -6
  127. package/src/http/agents.ts +10 -2
  128. package/src/http/index.ts +15 -0
  129. package/src/http/integrations.ts +20 -0
  130. package/src/http/list-total-cache.ts +62 -0
  131. package/src/http/sessions.ts +9 -2
  132. package/src/http/slack-inbound.ts +1 -0
  133. package/src/http/status.ts +4 -1
  134. package/src/http/tasks.ts +12 -2
  135. package/src/providers/amp-adapter.ts +1 -1
  136. package/src/providers/cursor-adapter.ts +1 -1
  137. package/src/providers/dsh-adapter.ts +1 -1
  138. package/src/queue-stall-alarm.ts +4 -1
  139. package/src/slack/actions.ts +2 -2
  140. package/src/slack/app-icon.ts +50 -0
  141. package/src/slack/app.ts +13 -1
  142. package/src/slack/assistant.ts +22 -1
  143. package/src/slack/event-dedup.test.ts +11 -0
  144. package/src/slack/event-dedup.ts +8 -4
  145. package/src/slack/inbound-dispatch.ts +58 -0
  146. package/src/slack/render-v2.ts +7 -8
  147. package/src/slack/responses.ts +21 -8
  148. package/src/slack/socket-ack.ts +237 -0
  149. package/src/slack/thread-buffer.ts +3 -5
  150. package/src/slack/watcher.ts +2 -2
  151. package/src/slack/work-objects.ts +5 -1
  152. package/src/tests/agent-fs-release-notes.test.ts +40 -0
  153. package/src/tests/agent-slack-icon.test.ts +85 -0
  154. package/src/tests/approval-cancellation-migration.test.ts +2 -0
  155. package/src/tests/asset-key-migration.test.ts +26 -0
  156. package/src/tests/comb-default.test.ts +159 -0
  157. package/src/tests/comb-review-batch.test.ts +192 -4
  158. package/src/tests/comb-status.test.ts +65 -2
  159. package/src/tests/db-agents.test.ts +13 -0
  160. package/src/tests/db-tasks-read.test.ts +10 -15
  161. package/src/tests/deploy-one-box.test.ts +161 -0
  162. package/src/tests/deploy-stubs.ts +68 -0
  163. package/src/tests/docs-index-footprint.test.js +3 -3
  164. package/src/tests/dsh-adapter.test.ts +1 -1
  165. package/src/tests/e2e-codex-auth-chain.test.ts +18 -1
  166. package/src/tests/fixtures/deploy-stubs/bun +3 -0
  167. package/src/tests/fixtures/deploy-stubs/bunx +27 -0
  168. package/src/tests/fixtures/deploy-stubs/curl +26 -0
  169. package/src/tests/fixtures/deploy-stubs/docker +122 -0
  170. package/src/tests/fixtures/onboard/content.compose.yml.txt +5 -5
  171. package/src/tests/fixtures/onboard/dev.compose.yml.txt +4 -4
  172. package/src/tests/fixtures/onboard/full-all.compose.yml.txt +12 -12
  173. package/src/tests/fixtures/onboard/full.compose.yml.txt +12 -12
  174. package/src/tests/fixtures/onboard/research.compose.yml.txt +4 -4
  175. package/src/tests/fixtures/onboard/solo.compose.yml.txt +2 -2
  176. package/src/tests/heartbeat-expiry.test.ts +151 -25
  177. package/src/tests/http-api-integration.test.ts +31 -0
  178. package/src/tests/human-free-reclassify.test.ts +6 -0
  179. package/src/tests/install-sh-bind.test.ts +242 -0
  180. package/src/tests/install-sh-tunnel.test.ts +89 -0
  181. package/src/tests/kv-storage.test.ts +28 -0
  182. package/src/tests/list-endpoint-slimming.test.ts +3 -0
  183. package/src/tests/list-total-cache.test.ts +63 -0
  184. package/src/tests/mcp-overflow-sweep.test.ts +84 -0
  185. package/src/tests/onboard-compose.test.ts +17 -3
  186. package/src/tests/onboard-env.test.ts +13 -0
  187. package/src/tests/onboarding.test.ts +1 -1
  188. package/src/tests/preload.ts +1 -0
  189. package/src/tests/query-audit-plans.test.ts +314 -0
  190. package/src/tests/script-connections.test.ts +6 -0
  191. package/src/tests/session-summaries.test.ts +384 -0
  192. package/src/tests/sessions-tree.test.ts +5 -1
  193. package/src/tests/slack-app-icon.test.ts +90 -0
  194. package/src/tests/slack-external-channel-egress.test.ts +1 -1
  195. package/src/tests/slack-inbound-receipts.test.ts +34 -0
  196. package/src/tests/slack-inline-output.test.ts +29 -2
  197. package/src/tests/slack-render-v2-query-plans.test.ts +3 -2
  198. package/src/tests/slack-render-v2.test.ts +35 -0
  199. package/src/tests/slack-socket-ack.test.ts +553 -0
  200. package/src/tests/task-lineage.test.ts +3 -0
  201. package/src/tests/task-search-filter.test.ts +4 -4
  202. package/src/tests/task-search-fts.test.ts +320 -0
  203. package/src/tests/update-profile-auth.test.ts +28 -0
  204. package/src/tools/get-swarm.ts +1 -0
  205. package/src/tools/get-tasks.ts +6 -1
  206. package/src/tools/join-swarm.ts +1 -0
  207. package/src/tools/my-agent-info.ts +1 -0
  208. package/src/tools/slack-post.ts +2 -2
  209. package/src/tools/slack-reply.ts +2 -2
  210. package/src/tools/slack-start-thread.ts +2 -2
  211. package/src/tools/slack-update.ts +1 -1
  212. package/src/tools/update-profile.ts +20 -4
  213. package/src/tools/utils.ts +3 -4
  214. package/src/types.ts +47 -0
  215. package/src/utils/constants.ts +127 -5
  216. package/templates/official/lead/HEARTBEAT.md +4 -2
  217. package/templates/official/lead/config.json +2 -2
  218. package/templates/skills/swarm-docs/content.md +2 -0
  219. package/dist/handlers-7x8sywgr.js +0 -42
  220. package/dist/index-mc30r1hs.js +0 -44
@@ -0,0 +1,300 @@
1
+ -- Session summaries: one narrow row per session root, kept exact by triggers.
2
+ --
3
+ -- A session is a root task (parentTaskId IS NULL) plus every task reachable
4
+ -- through parentTaskId. The session list, count and tree walk that chain with
5
+ -- a recursive CTE on every request (src/be/db/session-tree.ts). These tables
6
+ -- store the walk's result so the read path can skip it:
7
+ --
8
+ -- - session_members(taskId, rootId): the root of every chain task. A task
9
+ -- whose parent is missing belongs to no session, as in the chain walk.
10
+ -- - session_summaries(rootId, ...): copies of the root columns the list
11
+ -- filters on, plus the chain aggregates the list, count and tree read.
12
+ --
13
+ -- Nothing reads these tables yet. The read path switches over in a later
14
+ -- change, after a production drift check against the chain walk
15
+ -- (sessionSummaryDriftQuery in src/be/db/session-summaries.ts).
16
+ --
17
+ -- Consistency rules:
18
+ -- - Inserts and progress writes update a summary incrementally (the hot path).
19
+ -- - Rare, non-monotonic changes recompute the whole chain through the
20
+ -- session_summary_recompute view, the one copy of the recompute SQL.
21
+ -- - parentTaskId is immutable: no code moves a task between chains, and the
22
+ -- members table depends on it. Changing it aborts.
23
+ -- - Trigger bodies cannot use WITH, so deleteTask (the only task delete)
24
+ -- drops the members rows of a deleted task's descendants itself, before the
25
+ -- DELETE. The delete trigger then recomputes the chain.
26
+ -- - session_summary_recompute_apply reads agent_tasks, task_steering_messages
27
+ -- and approval_requests. A later migration that rebuilds one of those
28
+ -- tables (create, copy, drop, rename) must drop that trigger before the
29
+ -- rename and re-create it after, or the rename fails its schema check. The
30
+ -- boot-time agent_tasks rebuild in src/be/db/runtime.ts already does.
31
+
32
+ CREATE TABLE session_members (
33
+ taskId TEXT PRIMARY KEY,
34
+ rootId TEXT NOT NULL
35
+ );
36
+ CREATE INDEX idx_session_members_root ON session_members(rootId);
37
+
38
+ CREATE TABLE session_summaries (
39
+ rootId TEXT PRIMARY KEY,
40
+ -- Root copies (filters, ordering, tree grouping).
41
+ createdAt TEXT NOT NULL,
42
+ source TEXT,
43
+ "key" TEXT NOT NULL,
44
+ requestedByUserId TEXT,
45
+ scheduleId TEXT,
46
+ taskType TEXT,
47
+ slackChannelId TEXT,
48
+ contextKey TEXT,
49
+ -- Chain aggregates.
50
+ chainCount INTEGER NOT NULL,
51
+ lastActivityAt TEXT NOT NULL,
52
+ latestStatus TEXT NOT NULL,
53
+ pendingApprovalCount INTEGER NOT NULL DEFAULT 0,
54
+ -- Newest event that reopens a Done session: a human message (a task a
55
+ -- person requested directly from a human channel), a person's live steering
56
+ -- message, or a pending approval. Done reopens when this is newer than the
57
+ -- Done mark, which is what the chain walk's per-row check asks.
58
+ lastHumanAt TEXT
59
+ );
60
+ CREATE INDEX idx_session_summaries_activity
61
+ ON session_summaries(lastActivityAt DESC, createdAt DESC, rootId DESC);
62
+ CREATE INDEX idx_session_summaries_thread
63
+ ON session_summaries("key") WHERE requestedByUserId IS NOT NULL;
64
+
65
+ -- INSERT INTO session_summary_recompute (rootId) VALUES (?) recomputes one
66
+ -- summary from its members. A missing root is a no-op, including a root whose
67
+ -- row is mid-delete: an FK cascade (steering messages) fires its trigger after
68
+ -- the row is gone but before the delete trigger drops the summary.
69
+ CREATE VIEW session_summary_recompute AS SELECT NULL AS rootId;
70
+
71
+ CREATE TRIGGER session_summary_recompute_apply
72
+ INSTEAD OF INSERT ON session_summary_recompute
73
+ BEGIN
74
+ UPDATE session_summaries SET
75
+ chainCount = (SELECT COUNT(*) FROM session_members m WHERE m.rootId = NEW.rootId),
76
+ lastActivityAt = (SELECT MAX(t.lastUpdatedAt) FROM session_members m
77
+ JOIN agent_tasks t ON t.id = m.taskId WHERE m.rootId = NEW.rootId),
78
+ latestStatus = (SELECT t.status FROM session_members m
79
+ JOIN agent_tasks t ON t.id = m.taskId WHERE m.rootId = NEW.rootId
80
+ ORDER BY t.lastUpdatedAt DESC LIMIT 1),
81
+ pendingApprovalCount = (SELECT COUNT(*) FROM session_members m
82
+ JOIN approval_requests ar ON ar.sourceTaskId = m.taskId
83
+ WHERE m.rootId = NEW.rootId AND ar.status = 'pending'),
84
+ lastHumanAt = (SELECT MAX(at) FROM (
85
+ SELECT t.createdAt AS at FROM session_members m JOIN agent_tasks t ON t.id = m.taskId
86
+ WHERE m.rootId = NEW.rootId AND t.requestedByUserId IS NOT NULL
87
+ AND t.requestedByUserIdInherited = 0
88
+ AND COALESCE(t.source, '') NOT IN ('mcp', 'system', 'schedule', 'workflow')
89
+ UNION ALL
90
+ SELECT sm.created_at FROM session_members m
91
+ JOIN task_steering_messages sm ON sm.task_id = m.taskId
92
+ WHERE m.rootId = NEW.rootId AND sm.created_by_kind = 'user' AND sm.status != 'cancelled'
93
+ UNION ALL
94
+ SELECT ar.createdAt FROM session_members m
95
+ JOIN approval_requests ar ON ar.sourceTaskId = m.taskId
96
+ WHERE m.rootId = NEW.rootId AND ar.status = 'pending'))
97
+ WHERE rootId = NEW.rootId
98
+ AND EXISTS (SELECT 1 FROM agent_tasks WHERE id = NEW.rootId);
99
+ END;
100
+
101
+ -- A new root opens a session; a new child joins its parent's session. A child
102
+ -- of a missing parent joins nothing (task ids are fresh UUIDs, so no later
103
+ -- insert can adopt it).
104
+ CREATE TRIGGER session_task_insert
105
+ AFTER INSERT ON agent_tasks
106
+ BEGIN
107
+ INSERT OR REPLACE INTO session_members (taskId, rootId)
108
+ SELECT NEW.id, NEW.id WHERE NEW.parentTaskId IS NULL
109
+ UNION ALL
110
+ SELECT NEW.id, m.rootId FROM session_members m WHERE m.taskId = NEW.parentTaskId;
111
+
112
+ INSERT OR REPLACE INTO session_summaries (rootId, createdAt, source, "key", requestedByUserId,
113
+ scheduleId, taskType, slackChannelId, contextKey, chainCount, lastActivityAt, latestStatus,
114
+ pendingApprovalCount, lastHumanAt)
115
+ SELECT NEW.id, NEW.createdAt, NEW.source, NEW."key", NEW.requestedByUserId, NEW.scheduleId,
116
+ NEW.taskType, NEW.slackChannelId, NEW.contextKey, 1, NEW.lastUpdatedAt, NEW.status, 0,
117
+ CASE WHEN NEW.requestedByUserId IS NOT NULL AND NEW.requestedByUserIdInherited = 0
118
+ AND COALESCE(NEW.source, '') NOT IN ('mcp', 'system', 'schedule', 'workflow')
119
+ THEN NEW.createdAt END
120
+ WHERE NEW.parentTaskId IS NULL;
121
+
122
+ UPDATE session_summaries SET
123
+ chainCount = chainCount + 1,
124
+ latestStatus = CASE WHEN NEW.lastUpdatedAt >= lastActivityAt THEN NEW.status ELSE latestStatus END,
125
+ lastActivityAt = MAX(lastActivityAt, NEW.lastUpdatedAt),
126
+ lastHumanAt = CASE WHEN NEW.requestedByUserId IS NOT NULL AND NEW.requestedByUserIdInherited = 0
127
+ AND COALESCE(NEW.source, '') NOT IN ('mcp', 'system', 'schedule', 'workflow')
128
+ AND (lastHumanAt IS NULL OR NEW.createdAt > lastHumanAt)
129
+ THEN NEW.createdAt ELSE lastHumanAt END
130
+ WHERE NEW.parentTaskId IS NOT NULL
131
+ AND rootId = (SELECT rootId FROM session_members WHERE taskId = NEW.id);
132
+ END;
133
+
134
+ -- Progress and status writes (the hot path): newest activity wins.
135
+ CREATE TRIGGER session_task_activity
136
+ AFTER UPDATE OF lastUpdatedAt, status ON agent_tasks
137
+ WHEN NEW.lastUpdatedAt >= OLD.lastUpdatedAt
138
+ BEGIN
139
+ UPDATE session_summaries SET
140
+ latestStatus = CASE WHEN NEW.lastUpdatedAt >= lastActivityAt THEN NEW.status ELSE latestStatus END,
141
+ lastActivityAt = MAX(lastActivityAt, NEW.lastUpdatedAt)
142
+ WHERE rootId = (SELECT rootId FROM session_members WHERE taskId = NEW.id);
143
+ END;
144
+
145
+ -- A timestamp moved back may have been the chain's newest one.
146
+ CREATE TRIGGER session_task_activity_rewind
147
+ AFTER UPDATE OF lastUpdatedAt ON agent_tasks
148
+ WHEN NEW.lastUpdatedAt < OLD.lastUpdatedAt
149
+ BEGIN
150
+ INSERT INTO session_summary_recompute (rootId)
151
+ SELECT rootId FROM session_members WHERE taskId = NEW.id;
152
+ END;
153
+
154
+ -- Requester or source edits change which tasks count as human messages.
155
+ CREATE TRIGGER session_task_human
156
+ AFTER UPDATE OF requestedByUserId, requestedByUserIdInherited, source, createdAt ON agent_tasks
157
+ WHEN OLD.requestedByUserId IS NOT NEW.requestedByUserId
158
+ OR OLD.requestedByUserIdInherited IS NOT NEW.requestedByUserIdInherited
159
+ OR OLD.source IS NOT NEW.source
160
+ OR OLD.createdAt IS NOT NEW.createdAt
161
+ BEGIN
162
+ INSERT INTO session_summary_recompute (rootId)
163
+ SELECT rootId FROM session_members WHERE taskId = NEW.id;
164
+ END;
165
+
166
+ -- Root column copies (session moves rewrite the root key).
167
+ CREATE TRIGGER session_root_columns
168
+ AFTER UPDATE OF createdAt, source, "key", requestedByUserId, scheduleId, taskType,
169
+ slackChannelId, contextKey ON agent_tasks
170
+ WHEN NEW.parentTaskId IS NULL AND (
171
+ OLD.createdAt IS NOT NEW.createdAt OR OLD.source IS NOT NEW.source
172
+ OR OLD."key" IS NOT NEW."key" OR OLD.requestedByUserId IS NOT NEW.requestedByUserId
173
+ OR OLD.scheduleId IS NOT NEW.scheduleId OR OLD.taskType IS NOT NEW.taskType
174
+ OR OLD.slackChannelId IS NOT NEW.slackChannelId OR OLD.contextKey IS NOT NEW.contextKey)
175
+ BEGIN
176
+ UPDATE session_summaries SET
177
+ createdAt = NEW.createdAt, source = NEW.source, "key" = NEW."key",
178
+ requestedByUserId = NEW.requestedByUserId, scheduleId = NEW.scheduleId,
179
+ taskType = NEW.taskType, slackChannelId = NEW.slackChannelId, contextKey = NEW.contextKey
180
+ WHERE rootId = NEW.id;
181
+ END;
182
+
183
+ CREATE TRIGGER session_task_parent_immutable
184
+ BEFORE UPDATE OF parentTaskId ON agent_tasks
185
+ WHEN OLD.parentTaskId IS NOT NEW.parentTaskId
186
+ BEGIN
187
+ SELECT RAISE(ABORT, 'agent_tasks.parentTaskId is immutable (session_members depends on it)');
188
+ END;
189
+
190
+ -- A deleted root ends its session, and its chain tasks join nothing. A deleted
191
+ -- chain task leaves its chain (deleteTask already dropped its descendants).
192
+ CREATE TRIGGER session_task_delete
193
+ AFTER DELETE ON agent_tasks
194
+ BEGIN
195
+ DELETE FROM session_members WHERE taskId = OLD.id OR rootId = OLD.id;
196
+ DELETE FROM session_summaries WHERE rootId = OLD.id;
197
+ INSERT INTO session_summary_recompute (rootId)
198
+ SELECT rootId FROM session_members WHERE taskId = OLD.parentTaskId;
199
+ END;
200
+
201
+ -- Steering messages and approvals are rare: recompute on any change that
202
+ -- can move lastHumanAt or pendingApprovalCount.
203
+ CREATE TRIGGER session_steering_insert
204
+ AFTER INSERT ON task_steering_messages
205
+ WHEN NEW.created_by_kind = 'user' AND NEW.status != 'cancelled'
206
+ BEGIN
207
+ INSERT INTO session_summary_recompute (rootId)
208
+ SELECT rootId FROM session_members WHERE taskId = NEW.task_id;
209
+ END;
210
+
211
+ CREATE TRIGGER session_steering_update
212
+ AFTER UPDATE OF task_id, created_by_kind, status, created_at ON task_steering_messages
213
+ WHEN OLD.task_id IS NOT NEW.task_id OR OLD.created_by_kind IS NOT NEW.created_by_kind
214
+ OR OLD.created_at IS NOT NEW.created_at
215
+ OR (OLD.status = 'cancelled') IS NOT (NEW.status = 'cancelled')
216
+ BEGIN
217
+ INSERT INTO session_summary_recompute (rootId)
218
+ SELECT rootId FROM session_members WHERE taskId IN (OLD.task_id, NEW.task_id);
219
+ END;
220
+
221
+ CREATE TRIGGER session_steering_delete
222
+ AFTER DELETE ON task_steering_messages
223
+ WHEN OLD.created_by_kind = 'user' AND OLD.status != 'cancelled'
224
+ BEGIN
225
+ INSERT INTO session_summary_recompute (rootId)
226
+ SELECT rootId FROM session_members WHERE taskId = OLD.task_id;
227
+ END;
228
+
229
+ CREATE TRIGGER session_approval_insert
230
+ AFTER INSERT ON approval_requests
231
+ WHEN NEW.sourceTaskId IS NOT NULL AND NEW.status = 'pending'
232
+ BEGIN
233
+ INSERT INTO session_summary_recompute (rootId)
234
+ SELECT rootId FROM session_members WHERE taskId = NEW.sourceTaskId;
235
+ END;
236
+
237
+ CREATE TRIGGER session_approval_update
238
+ AFTER UPDATE OF sourceTaskId, status, createdAt ON approval_requests
239
+ WHEN OLD.sourceTaskId IS NOT NEW.sourceTaskId OR OLD.createdAt IS NOT NEW.createdAt
240
+ OR (OLD.status = 'pending') IS NOT (NEW.status = 'pending')
241
+ BEGIN
242
+ INSERT INTO session_summary_recompute (rootId)
243
+ SELECT rootId FROM session_members WHERE taskId IN (OLD.sourceTaskId, NEW.sourceTaskId);
244
+ END;
245
+
246
+ CREATE TRIGGER session_approval_delete
247
+ AFTER DELETE ON approval_requests
248
+ WHEN OLD.sourceTaskId IS NOT NULL AND OLD.status = 'pending'
249
+ BEGIN
250
+ INSERT INTO session_summary_recompute (rootId)
251
+ SELECT rootId FROM session_members WHERE taskId = OLD.sourceTaskId;
252
+ END;
253
+
254
+ -- Backfill: one chain walk for the members, then set-based aggregates (a
255
+ -- recompute per root reads every chain task several times). The packed MAX
256
+ -- takes the status of the newest task, as the bare column does in the walk.
257
+ INSERT INTO session_members (taskId, rootId)
258
+ WITH RECURSIVE chain(root_id, id) AS (
259
+ SELECT id, id FROM agent_tasks WHERE parentTaskId IS NULL
260
+ UNION ALL
261
+ SELECT c.root_id, t.id FROM chain c JOIN agent_tasks t ON t.parentTaskId = c.id
262
+ )
263
+ SELECT id, root_id FROM chain;
264
+
265
+ INSERT INTO session_summaries (rootId, createdAt, source, "key", requestedByUserId, scheduleId,
266
+ taskType, slackChannelId, contextKey, chainCount, lastActivityAt, latestStatus, lastHumanAt)
267
+ SELECT r.id, r.createdAt, r.source, r."key", r.requestedByUserId, r.scheduleId, r.taskType,
268
+ r.slackChannelId, r.contextKey, w.chainCount, w.lastActivityAt,
269
+ substr(w.newest, instr(w.newest, char(31)) + 1), w.humanAt
270
+ FROM (
271
+ SELECT m.rootId, COUNT(*) AS chainCount, MAX(t.lastUpdatedAt) AS lastActivityAt,
272
+ MAX(t.lastUpdatedAt || char(31) || t.status) AS newest,
273
+ MAX(CASE WHEN t.requestedByUserId IS NOT NULL AND t.requestedByUserIdInherited = 0
274
+ AND COALESCE(t.source, '') NOT IN ('mcp', 'system', 'schedule', 'workflow')
275
+ THEN t.createdAt END) AS humanAt
276
+ FROM session_members m JOIN agent_tasks t ON t.id = m.taskId
277
+ GROUP BY m.rootId
278
+ ) w JOIN agent_tasks r ON r.id = w.rootId;
279
+
280
+ UPDATE session_summaries SET
281
+ pendingApprovalCount = (SELECT COUNT(*) FROM session_members m
282
+ JOIN approval_requests ar ON ar.sourceTaskId = m.taskId
283
+ WHERE m.rootId = session_summaries.rootId AND ar.status = 'pending'),
284
+ lastHumanAt = (SELECT MAX(at) FROM (
285
+ SELECT session_summaries.lastHumanAt AS at
286
+ UNION ALL
287
+ SELECT sm.created_at FROM session_members m
288
+ JOIN task_steering_messages sm ON sm.task_id = m.taskId
289
+ WHERE m.rootId = session_summaries.rootId AND sm.created_by_kind = 'user'
290
+ AND sm.status != 'cancelled'
291
+ UNION ALL
292
+ SELECT ar.createdAt FROM session_members m
293
+ JOIN approval_requests ar ON ar.sourceTaskId = m.taskId
294
+ WHERE m.rootId = session_summaries.rootId AND ar.status = 'pending'))
295
+ WHERE rootId IN (
296
+ SELECT m.rootId FROM session_members m JOIN task_steering_messages sm ON sm.task_id = m.taskId
297
+ WHERE sm.created_by_kind = 'user' AND sm.status != 'cancelled'
298
+ UNION
299
+ SELECT m.rootId FROM session_members m JOIN approval_requests ar ON ar.sourceTaskId = m.taskId
300
+ WHERE ar.status = 'pending');
@@ -0,0 +1,25 @@
1
+ -- Indexes for hot lookups that scan agent_tasks or script_runs today.
2
+ --
3
+ -- agent_tasks rows carry large `task` text, so every scan of the table reads
4
+ -- its overflow pages and costs hundreds of milliseconds, whatever column it
5
+ -- filters. Production has no sqlite_stat1, so each index below is shaped so
6
+ -- the planner picks it without statistics.
7
+
8
+ -- getTaskByWorkflowRunStepId and getStuckWorkflowRuns look tasks up by step.
9
+ -- Partial: only workflow-step tasks carry the column.
10
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_workflowRunStepId
11
+ ON agent_tasks(workflowRunStepId) WHERE workflowRunStepId IS NOT NULL;
12
+
13
+ -- getRecentCompletedCount: `status = ? AND finishedAt > ?` becomes a covering
14
+ -- range instead of reading finishedAt from every completed row.
15
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_status_finishedAt
16
+ ON agent_tasks(status, finishedAt);
17
+
18
+ -- Session root counts: `parentTaskId IS NULL AND source IN (...)` becomes a
19
+ -- covering range instead of reading source from every root.
20
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_parentTaskId_source
21
+ ON agent_tasks(parentTaskId, source);
22
+
23
+ -- countScriptRuns / listScriptRuns filtered by script name.
24
+ CREATE INDEX IF NOT EXISTS idx_script_runs_scriptName_startedAt
25
+ ON script_runs(scriptName, startedAt DESC);
@@ -0,0 +1,2 @@
1
+ -- JSON of AgentSlackIcon for the agent's Slack posts. NULL = default emoji.
2
+ ALTER TABLE agents ADD COLUMN slack_icon TEXT;
@@ -0,0 +1,55 @@
1
+ -- Token index for task search (getAllTasks / getTasksCount `search`), which
2
+ -- used to run `task LIKE '%q%'` over every task body. External content: the
3
+ -- index stores no copy of the text, rows are keyed by agent_tasks.rowid, and
4
+ -- the default unicode61 tokenizer (no porter: ids and numbers stay exact).
5
+ CREATE VIRTUAL TABLE IF NOT EXISTS agent_tasks_fts USING fts5(
6
+ task,
7
+ title,
8
+ content='agent_tasks',
9
+ content_rowid='rowid'
10
+ );
11
+
12
+ -- Rows with rowid <= indexedThrough are in the index. A full backfill would
13
+ -- block boot and hold the write lock, so the migration indexes nothing and
14
+ -- the API backfills in rowid batches after boot (backfillTaskSearchIndex in
15
+ -- src/be/db/tasks/search-index.ts), raising indexedThrough in the same
16
+ -- transaction as each batch. Once it reaches 9007199254740991 every row is
17
+ -- indexed and search switches from LIKE to the index. An empty table starts
18
+ -- complete.
19
+ CREATE TABLE IF NOT EXISTS agent_tasks_fts_state (
20
+ id INTEGER PRIMARY KEY CHECK (id = 1),
21
+ indexedThrough INTEGER NOT NULL
22
+ );
23
+
24
+ INSERT OR IGNORE INTO agent_tasks_fts_state (id, indexedThrough)
25
+ SELECT 1, CASE WHEN EXISTS (SELECT 1 FROM agent_tasks) THEN 0 ELSE 9007199254740991 END;
26
+
27
+ -- Triggers keep the index in sync on every write path (the two INSERT sites,
28
+ -- title updates, the draft append, the boot scrub sweep, deletes). They touch
29
+ -- only rows the backfill has reached: an external-content 'delete' must name
30
+ -- a row the index holds, or it corrupts the index. A rebuild of agent_tasks
31
+ -- must keep rowids (the index is keyed by them) and recreate these triggers;
32
+ -- src/tests/task-search-fts.test.ts covers the runtime.ts rebuild.
33
+ CREATE TRIGGER IF NOT EXISTS agent_tasks_fts_insert
34
+ AFTER INSERT ON agent_tasks
35
+ WHEN NEW.rowid <= (SELECT indexedThrough FROM agent_tasks_fts_state WHERE id = 1)
36
+ BEGIN
37
+ INSERT INTO agent_tasks_fts(rowid, task, title) VALUES (NEW.rowid, NEW.task, NEW.title);
38
+ END;
39
+
40
+ CREATE TRIGGER IF NOT EXISTS agent_tasks_fts_update
41
+ AFTER UPDATE OF task, title ON agent_tasks
42
+ WHEN OLD.rowid <= (SELECT indexedThrough FROM agent_tasks_fts_state WHERE id = 1)
43
+ BEGIN
44
+ INSERT INTO agent_tasks_fts(agent_tasks_fts, rowid, task, title)
45
+ VALUES ('delete', OLD.rowid, OLD.task, OLD.title);
46
+ INSERT INTO agent_tasks_fts(rowid, task, title) VALUES (NEW.rowid, NEW.task, NEW.title);
47
+ END;
48
+
49
+ CREATE TRIGGER IF NOT EXISTS agent_tasks_fts_delete
50
+ AFTER DELETE ON agent_tasks
51
+ WHEN OLD.rowid <= (SELECT indexedThrough FROM agent_tasks_fts_state WHERE id = 1)
52
+ BEGIN
53
+ INSERT INTO agent_tasks_fts(agent_tasks_fts, rowid, task, title)
54
+ VALUES ('delete', OLD.rowid, OLD.task, OLD.title);
55
+ END;
@@ -2,14 +2,16 @@ import {
2
2
  getAgentFsDefaultDriveId,
3
3
  getAgentFsDefaultOrgId,
4
4
  getAgentFsLiveUrl,
5
+ getCombState,
5
6
  getPublicMcpBaseUrl,
6
7
  isAgentFsProxyEnabled,
7
- isCombEnabled,
8
8
  } from "../utils/constants";
9
9
 
10
10
  /** The Comb block of `/status` (`agent_fs.comb`), before snake-casing. */
11
11
  export interface CombConfig {
12
12
  enabled: boolean;
13
+ /** Why Comb is off; null when on. */
14
+ reason: string | null;
13
15
  /** Browser-facing agent-fs URL. */
14
16
  apiUrl: string | null;
15
17
  liveUrl: string;
@@ -32,8 +34,10 @@ export function getCombConfig(): CombConfig {
32
34
  // With the proxy on, the browser reaches agent-fs through the API itself.
33
35
  const browserDefault =
34
36
  isAgentFsProxyEnabled() && internalUrl ? `${getPublicMcpBaseUrl()}/agentfs` : internalUrl;
37
+ const state = getCombState();
35
38
  return {
36
- enabled: isCombEnabled(),
39
+ enabled: state.enabled,
40
+ reason: state.reason,
37
41
  apiUrl: trimUrl(process.env.AGENT_FS_PUBLIC_URL) ?? browserDefault,
38
42
  liveUrl: getAgentFsLiveUrl(),
39
43
  orgId: getAgentFsDefaultOrgId() ?? null,
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Who sent a Comb batch, when the request names no user.
3
+ *
4
+ * The dashboard sends with the swarm API key, so the request carries no user.
5
+ * The comment authors do: an agent-fs author maps to a swarm user through a
6
+ * linked `agent-fs` identity, else through the email agent-fs has for that
7
+ * drive member. The browser's agent-fs key never reaches the swarm API, so no
8
+ * request proves which author pressed Send. With several mapped authors, the
9
+ * author of the newest comment wins: the person who commented last is the
10
+ * one most likely to have sent the batch.
11
+ */
12
+
13
+ import { findUserByEmail, findUserByExternalId } from "../be/users";
14
+ import type { AgentFsComment, AgentFsDrive, AgentFsProvider } from "../fs/agent-fs-provider";
15
+ import { scrubSecrets } from "../utils/secret-scrubber";
16
+
17
+ /** `user_external_ids.kind` of an agent-fs user id. */
18
+ export const AGENT_FS_IDENTITY_KIND = "agent-fs";
19
+
20
+ /**
21
+ * agent-fs user id -> swarm user id, for each author that maps. The swarm
22
+ * service account is never a requester. Reads the drive members from
23
+ * agent-fs only when an author has no linked identity.
24
+ */
25
+ export async function commentAuthorUsers(
26
+ agentFs: AgentFsProvider,
27
+ drive: AgentFsDrive,
28
+ comments: AgentFsComment[],
29
+ serviceUserId: string,
30
+ ): Promise<Map<string, string>> {
31
+ const authors = [...new Set(comments.map((comment) => comment.author))].filter(
32
+ (author) => author && author !== serviceUserId,
33
+ );
34
+ const users = new Map<string, string>();
35
+ const unlinked: string[] = [];
36
+ for (const author of authors) {
37
+ const user = await findUserByExternalId(AGENT_FS_IDENTITY_KIND, author);
38
+ if (user) users.set(author, user.id);
39
+ else unlinked.push(author);
40
+ }
41
+ if (unlinked.length === 0) return users;
42
+
43
+ const members = await agentFs.getDriveMembers(drive).catch((error: unknown) => {
44
+ const message = error instanceof Error ? error.message : String(error);
45
+ console.warn(scrubSecrets(`[comb] could not read the drive members: ${message}`));
46
+ return [];
47
+ });
48
+ for (const author of unlinked) {
49
+ const email = members.find((member) => member.userId === author)?.email;
50
+ const user = email ? await findUserByEmail(email) : null;
51
+ if (user) users.set(author, user.id);
52
+ }
53
+ return users;
54
+ }
55
+
56
+ /** The author of the newest comment that maps to a swarm user, else null. */
57
+ export function batchRequester(
58
+ comments: AgentFsComment[],
59
+ users: Map<string, string>,
60
+ ): string | null {
61
+ let newest: AgentFsComment | null = null;
62
+ for (const comment of comments) {
63
+ if (!users.has(comment.author)) continue;
64
+ if (!newest || Date.parse(comment.createdAt) > Date.parse(newest.createdAt)) {
65
+ newest = comment;
66
+ }
67
+ }
68
+ return newest ? (users.get(newest.author) ?? null) : null;
69
+ }
@@ -27,6 +27,7 @@ import { scrubSecrets } from "../utils/secret-scrubber";
27
27
  import { combAgentFs } from "./agent-fs";
28
28
  import { getCombConfig } from "./config";
29
29
  import { sentMarker, sentTaskId } from "./markers";
30
+ import { batchRequester, commentAuthorUsers } from "./requester";
30
31
  // Side-effect import: registers the comb.review.* templates.
31
32
  import "./templates";
32
33
 
@@ -67,6 +68,7 @@ export interface ReviewBatchInput {
67
68
  scopePath: string;
68
69
  /** The sender's note for the lead about the whole batch. Blank means none. */
69
70
  note?: string;
71
+ /** The authenticated sender. Null: the author of the newest mapped comment (`./requester`). */
70
72
  requestedByUserId: string | null;
71
73
  }
72
74
 
@@ -153,6 +155,18 @@ export async function sendReviewBatch(
153
155
  if (skip) skipped.push(skip);
154
156
  else candidates.push(read);
155
157
  }
158
+ // Without an authenticated sender, map the comment authors to swarm users
159
+ // now: it may read the drive members from agent-fs. Without the service
160
+ // identity, a swarm-authored comment could pass for a person: no requester.
161
+ // The caller picks the comments, so an author requester is caller-chosen
162
+ // attribution: TRUST_BODY_REQUESTED_BY_USER_ID=false turns it off, like a
163
+ // body `requestedByUserId` on POST /api/tasks.
164
+ const authorUsers =
165
+ input.requestedByUserId ||
166
+ !serviceUserId ||
167
+ process.env.TRUST_BODY_REQUESTED_BY_USER_ID === "false"
168
+ ? null
169
+ : await commentAuthorUsers(agentFs, drive, candidates.map(commentOf), serviceUserId);
156
170
 
157
171
  // 2. Claim in one transaction, so two sends never split one set of comments.
158
172
  const claim = crypto.randomUUID();
@@ -175,8 +189,11 @@ export async function sendReviewBatch(
175
189
  let taskId: string | null = null;
176
190
  let leadState: ReviewBatchLead = "none";
177
191
  if (won.length > 0) {
192
+ const requestedByUserId =
193
+ input.requestedByUserId ??
194
+ (authorUsers ? batchRequester(won.map(commentOf), authorUsers) : null);
178
195
  try {
179
- const text = await renderTaskText(input, won);
196
+ const text = await renderTaskText({ ...input, requestedByUserId }, won);
180
197
  if (text === null) {
181
198
  throw new ReviewBatchError(503, "The Comb review template is disabled", [
182
199
  ...skipped,
@@ -194,7 +211,7 @@ export async function sendReviewBatch(
194
211
  source: "comb",
195
212
  taskType: "comb-review",
196
213
  tags: ["comb"],
197
- requestedByUserId: input.requestedByUserId ?? undefined,
214
+ requestedByUserId: requestedByUserId ?? undefined,
198
215
  },
199
216
  { origin: "rest" },
200
217
  );
@@ -279,6 +296,11 @@ async function readComment(
279
296
  return { id, thread, version };
280
297
  }
281
298
 
299
+ /** The root comment of a read that `skipOf` kept. */
300
+ function commentOf(read: CommentRead): AgentFsComment {
301
+ return (read.thread as AgentFsCommentThread).comment;
302
+ }
303
+
282
304
  function skipOf({ id, thread }: CommentRead, serviceUserId: string | null): SkippedComment | null {
283
305
  if (!thread) return { id, reason: "not-found" };
284
306
  if (thread.comment.parentId) return { id, reason: "reply" };
@@ -2,4 +2,4 @@
2
2
  * agent-fs image tag bundled by `agent-swarm onboard --yes`.
3
3
  * `bun run bump:agent-fs` rewrites it together with the other pins.
4
4
  */
5
- export const AGENT_FS_VERSION = "0.18.11";
5
+ export const AGENT_FS_VERSION = "0.18.12";
@@ -14,6 +14,11 @@ const DEFAULT_WORKER_MAX_CONCURRENT_TASKS = 1;
14
14
  // which moves on every commit to main; operators pin a release in .env.
15
15
  // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
16
16
  const IMAGE_TAG_EXPRESSION = "${AGENT_SWARM_VERSION:-latest}";
17
+ // Host address for published ports, from AGENT_SWARM_BIND_ADDRESS in .env. Blank
18
+ // publishes on every interface, as before. 127.0.0.1 keeps the ports behind a host
19
+ // reverse proxy: Docker-published ports bypass host firewalls such as ufw.
20
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
21
+ const BIND_ADDRESS_PREFIX = "${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}";
17
22
  // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
18
23
  const ENV_API_KEY = " - API_KEY=${API_KEY}";
19
24
  // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
@@ -275,7 +280,7 @@ export function generateCompose(state: OnboardState): string {
275
280
 
276
281
  lines.push("");
277
282
  lines.push(" ports:");
278
- lines.push(` - "${port}:3013"`);
283
+ lines.push(` - "${BIND_ADDRESS_PREFIX}${port}:3013"`);
279
284
  lines.push("");
280
285
  lines.push(" volumes:");
281
286
  lines.push(" - swarm_data:/app/data");
@@ -353,7 +358,7 @@ export function generateCompose(state: OnboardState): string {
353
358
 
354
359
  lines.push("");
355
360
  lines.push(" ports:");
356
- lines.push(` - "${port}:3000"`);
361
+ lines.push(` - "${BIND_ADDRESS_PREFIX}${port}:3000"`);
357
362
  lines.push("");
358
363
  lines.push(" volumes:");
359
364
  // Same targets as docker-compose.example.yml: the worker logs to /logs and
@@ -35,9 +35,14 @@ export function generateEnv(state: OnboardState, env: NodeJS.ProcessEnv = proces
35
35
  lines.push(`INSTALL_PRESET=${state.presetId}`);
36
36
  }
37
37
 
38
- // Telemetry knobs from the installer's environment, so the first boot already
39
- // honours them (`ANONYMIZED_TELEMETRY=false`, `DESPLEGA_TELEMETRY_ENV=<cohort>`).
40
- for (const key of ["ANONYMIZED_TELEMETRY", "DESPLEGA_TELEMETRY_ENV"]) {
38
+ // Knobs from the installer's environment, so the first boot already honours
39
+ // them: telemetry (`ANONYMIZED_TELEMETRY=false`, `DESPLEGA_TELEMETRY_ENV=<cohort>`)
40
+ // and the host address for published ports (`AGENT_SWARM_BIND_ADDRESS=127.0.0.1`).
41
+ for (const key of [
42
+ "ANONYMIZED_TELEMETRY",
43
+ "DESPLEGA_TELEMETRY_ENV",
44
+ "AGENT_SWARM_BIND_ADDRESS",
45
+ ]) {
41
46
  const value = env[key]?.trim();
42
47
  if (value) lines.push(`${key}=${value}`);
43
48
  }