@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,320 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ closeDb,
5
+ createTaskExtended,
6
+ deleteTask,
7
+ getAllTasks,
8
+ getDb,
9
+ getDbClient,
10
+ getTasksCount,
11
+ initDb,
12
+ updateTaskTitle,
13
+ } from "../be/db";
14
+ import { appendDraftTaskText } from "../be/db/tasks/draft-text";
15
+ import {
16
+ backfillTaskSearchIndex,
17
+ buildTaskFtsMatch,
18
+ TASK_FTS_COMPLETE,
19
+ } from "../be/db/tasks/search-index";
20
+
21
+ const TEST_DB_PATH = `./test-task-search-fts-${process.pid}.sqlite`;
22
+ const REBUILD_DB_PATH = `./test-task-search-fts-rebuild-${process.pid}.sqlite`;
23
+
24
+ async function removeDbFiles(path: string): Promise<void> {
25
+ for (const suffix of ["", "-wal", "-shm"]) await unlink(path + suffix).catch(() => {});
26
+ }
27
+
28
+ async function found(search: string): Promise<string[]> {
29
+ const tasks = await getAllTasks({ search, limit: 1000 });
30
+ expect(await getTasksCount({ search })).toBe(tasks.length);
31
+ return tasks.map((task) => task.task).sort();
32
+ }
33
+
34
+ async function newTask(text: string): Promise<string> {
35
+ return (await createTaskExtended(text, {})).id;
36
+ }
37
+
38
+ // FTS5's own check that an external-content index matches its content table.
39
+ async function expectIndexInSync(): Promise<void> {
40
+ await getDbClient().run(
41
+ "INSERT INTO agent_tasks_fts(agent_tasks_fts) VALUES ('integrity-check')",
42
+ );
43
+ }
44
+
45
+ async function indexedThrough(): Promise<number> {
46
+ return (await getDbClient().get<{ indexedThrough: number }>(
47
+ "SELECT indexedThrough FROM agent_tasks_fts_state WHERE id = 1",
48
+ ))!.indexedThrough;
49
+ }
50
+
51
+ async function clearTasks(): Promise<void> {
52
+ const client = getDbClient();
53
+ await client.run("PRAGMA foreign_keys = OFF");
54
+ await client.run("DELETE FROM agent_tasks");
55
+ await client.run("PRAGMA foreign_keys = ON");
56
+ }
57
+
58
+ describe("task search over agent_tasks_fts", () => {
59
+ beforeAll(async () => {
60
+ await removeDbFiles(TEST_DB_PATH);
61
+ initDb(TEST_DB_PATH);
62
+ });
63
+
64
+ afterAll(async () => {
65
+ closeDb();
66
+ await removeDbFiles(TEST_DB_PATH);
67
+ });
68
+
69
+ beforeEach(clearTasks);
70
+
71
+ test("a fresh DB starts with a complete index", async () => {
72
+ expect(await indexedThrough()).toBe(TASK_FTS_COMPLETE);
73
+ });
74
+
75
+ test("matches whole tokens, with a prefix match on the last one", async () => {
76
+ await newTask("Update the rulebook for reviewers");
77
+ await newTask("Fix PR #2443 and 24430");
78
+ await newTask("Bump to 12443");
79
+
80
+ expect(await found("rulebook")).toEqual(["Update the rulebook for reviewers"]);
81
+ expect(await found("rule")).toEqual(["Update the rulebook for reviewers"]);
82
+ expect(await found("RULEBOOK")).toEqual(["Update the rulebook for reviewers"]);
83
+ // A substring inside a token no longer matches.
84
+ expect(await found("ulebo")).toEqual([]);
85
+ // Digits inside a longer token: the accepted loss.
86
+ expect(await found("2443")).toEqual(["Fix PR #2443 and 24430"]);
87
+ expect(await found("443")).toEqual([]);
88
+ });
89
+
90
+ test("matches the input as a phrase, across punctuation", async () => {
91
+ await newTask("Run the Heartbeat Checklist now");
92
+ await newTask("taskType heartbeat-checklist");
93
+ await newTask("checklist before heartbeat");
94
+
95
+ expect(await found("heartbeat checklist")).toEqual([
96
+ "Run the Heartbeat Checklist now",
97
+ "taskType heartbeat-checklist",
98
+ ]);
99
+ // Only the last token is a prefix.
100
+ expect(await found("heart checklist")).toEqual([]);
101
+ expect(await found("the heartbeat check")).toEqual(["Run the Heartbeat Checklist now"]);
102
+ });
103
+
104
+ test("matches the title", async () => {
105
+ const id = await newTask("body text");
106
+ await updateTaskTitle(id, "Deploy landing page");
107
+ expect(await found("landing")).toEqual(["body text"]);
108
+ });
109
+
110
+ test("matches a task id by prefix only", async () => {
111
+ const id = await newTask("find me by id");
112
+ await newTask("another task");
113
+
114
+ expect(await found(id.slice(0, 8))).toEqual(["find me by id"]);
115
+ expect(await found(id)).toEqual(["find me by id"]);
116
+ expect(await found(id.toUpperCase())).toEqual(["find me by id"]);
117
+ expect(await found(id.slice(9, 18))).toEqual([]);
118
+ });
119
+
120
+ test("% and _ are not wildcards, and operators are plain text", async () => {
121
+ await newTask("aXb and fooxbar");
122
+ await newTask("snake foo_bar here");
123
+ await newTask('he said "ship it" NOT later');
124
+
125
+ expect(await found("a%b")).toEqual([]);
126
+ expect(await found("foo_bar")).toEqual(["snake foo_bar here"]);
127
+ expect(await found("%")).toEqual([]);
128
+ expect(await found(" ")).toEqual([]);
129
+ expect(await found('"ship it"')).toEqual(['he said "ship it" NOT later']);
130
+ expect(await found("NOT later")).toEqual(['he said "ship it" NOT later']);
131
+ expect(await found("task:ship OR x*")).toEqual([]);
132
+ expect(buildTaskFtsMatch('say "hi"')).toBe('"say ""hi"""*');
133
+ expect(buildTaskFtsMatch("%_")).toBeNull();
134
+ });
135
+
136
+ test("pages and counts a candidate set larger than the rowid list", async () => {
137
+ await getDbClient().transaction(async (tx) => {
138
+ for (let i = 0; i < 520; i++) {
139
+ await tx.run(
140
+ `INSERT INTO agent_tasks (id, task, status, source, createdAt, lastUpdatedAt)
141
+ VALUES (?, ?, 'completed', 'api', ?, ?)`,
142
+ [
143
+ `bulk-${i}`,
144
+ `landing page variant ${i}`,
145
+ "2026-10-01T00:00:00.000Z",
146
+ new Date(Date.UTC(2026, 9, 1, 0, 0, i)).toISOString(),
147
+ ],
148
+ );
149
+ }
150
+ });
151
+ await newTask("unrelated");
152
+
153
+ const page = await getAllTasks({ search: "landing", limit: 25 });
154
+ expect(page.map((task) => task.id)).toEqual(
155
+ Array.from({ length: 25 }, (_, i) => `bulk-${519 - i}`),
156
+ );
157
+ expect(await getTasksCount({ search: "landing" })).toBe(520);
158
+ expect(await getTasksCount({ search: "page variant 51" })).toBe(11);
159
+ });
160
+
161
+ test("triggers keep the index in sync on insert, update, delete and the draft append", async () => {
162
+ const id = await newTask("original prompt alpha");
163
+ expect(await found("alpha")).toEqual(["original prompt alpha"]);
164
+
165
+ await getDbClient().run("UPDATE agent_tasks SET task = ? WHERE id = ?", [
166
+ "rewritten prompt beta",
167
+ id,
168
+ ]);
169
+ expect(await found("alpha")).toEqual([]);
170
+ expect(await found("beta")).toEqual(["rewritten prompt beta"]);
171
+
172
+ await updateTaskTitle(id, "titled gamma");
173
+ expect(await found("gamma")).toEqual(["rewritten prompt beta"]);
174
+ await updateTaskTitle(id, "titled delta");
175
+ expect(await found("gamma")).toEqual([]);
176
+
177
+ await getDbClient().run("UPDATE agent_tasks SET status = 'draft' WHERE id = ?", [id]);
178
+ expect(await appendDraftTaskText(id, "voice note epsilon")).toBe(true);
179
+ expect(await found("epsilon")).toEqual(["rewritten prompt beta\n\nvoice note epsilon"]);
180
+
181
+ // An update that does not touch task or title leaves the index alone.
182
+ await getDbClient().run("UPDATE agent_tasks SET status = 'pending' WHERE id = ?", [id]);
183
+ await expectIndexInSync();
184
+
185
+ expect(await deleteTask(id)).toBe(true);
186
+ expect(await found("epsilon")).toEqual([]);
187
+ expect(await found("beta")).toEqual([]);
188
+ await expectIndexInSync();
189
+ });
190
+
191
+ test("searches by LIKE until the backfill completes, and writes during it stay in sync", async () => {
192
+ const client = getDbClient();
193
+ const ids: string[] = [];
194
+ for (let i = 0; i < 7; i++) ids.push(await newTask(`backlog item number${i}`));
195
+ // Simulate an upgraded DB: rows exist, the backfill has not run.
196
+ await client.run("INSERT INTO agent_tasks_fts(agent_tasks_fts) VALUES ('delete-all')");
197
+ await client.run("UPDATE agent_tasks_fts_state SET indexedThrough = 0 WHERE id = 1");
198
+
199
+ // LIKE semantics: a substring matches.
200
+ expect(await found("acklo")).toHaveLength(7);
201
+
202
+ // Index the first three rows, as an interrupted backfill would.
203
+ const third = (await client.get<{ rid: number }>(
204
+ "SELECT rowid AS rid FROM agent_tasks WHERE id = ?",
205
+ [ids[2]],
206
+ ))!.rid;
207
+ await client.run(
208
+ "INSERT INTO agent_tasks_fts(rowid, task, title) SELECT rowid, task, title FROM agent_tasks WHERE rowid <= ?",
209
+ [third],
210
+ );
211
+ await client.run("UPDATE agent_tasks_fts_state SET indexedThrough = ? WHERE id = 1", [third]);
212
+
213
+ // Writes on both sides of the mark, and a new row.
214
+ await client.run("UPDATE agent_tasks SET task = 'covered edit' WHERE id = ?", [ids[0]]);
215
+ await client.run("UPDATE agent_tasks SET task = 'uncovered edit' WHERE id = ?", [ids[5]]);
216
+ await deleteTask(ids[1]!);
217
+ await deleteTask(ids[6]!);
218
+ await newTask("arrived mid backfill");
219
+
220
+ const result = await backfillTaskSearchIndex(2);
221
+ expect(result.indexed).toBe(4);
222
+ expect(await indexedThrough()).toBe(TASK_FTS_COMPLETE);
223
+ await expectIndexInSync();
224
+
225
+ expect(await found("acklo")).toEqual([]);
226
+ expect(await found("backlog")).toHaveLength(3);
227
+ expect(await found("edit")).toEqual(["covered edit", "uncovered edit"]);
228
+ expect(await found("arrived")).toEqual(["arrived mid backfill"]);
229
+ expect(await backfillTaskSearchIndex(2)).toMatchObject({ indexed: 0 });
230
+ });
231
+ });
232
+
233
+ describe("agent_tasks rebuild in runtime.ts", () => {
234
+ // The rebuild runs in initDb's cold path, which the preload's in-memory
235
+ // migration template skips; open a real file instead.
236
+ const globals = globalThis as typeof globalThis & { __testMigrationTemplate?: Uint8Array };
237
+ let template: Uint8Array | undefined;
238
+
239
+ beforeAll(() => {
240
+ template = globals.__testMigrationTemplate;
241
+ globals.__testMigrationTemplate = undefined;
242
+ });
243
+
244
+ afterAll(async () => {
245
+ closeDb();
246
+ globals.__testMigrationTemplate = template;
247
+ await removeDbFiles(REBUILD_DB_PATH);
248
+ });
249
+
250
+ test("keeps rowids and the index triggers", async () => {
251
+ closeDb();
252
+ await removeDbFiles(REBUILD_DB_PATH);
253
+ initDb(REBUILD_DB_PATH);
254
+ const first = await newTask("first survivor");
255
+ const gone = await newTask("deleted middle");
256
+ const last = await newTask("last survivor");
257
+ await deleteTask(gone);
258
+
259
+ // Swap in the legacy status CHECK that makes initDb rebuild agent_tasks.
260
+ // The fixture copies rowids, so the gap left by the delete survives it.
261
+ const db = getDb();
262
+ db.run("UPDATE agent_tasks SET status = 'pending'");
263
+ const schema = db
264
+ .prepare<{ sql: string }, []>(
265
+ "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'agent_tasks'",
266
+ )
267
+ .get()!.sql;
268
+ const legacy = schema
269
+ .replace(/^(CREATE TABLE\s+(?:IF NOT EXISTS\s+)?)(?:"agent_tasks"|agent_tasks)/i, "$1legacy")
270
+ .replace(
271
+ /status TEXT NOT NULL DEFAULT 'pending'/,
272
+ "status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','in_progress','completed','failed'))",
273
+ );
274
+ expect(legacy).not.toBe(schema);
275
+ const columns = db
276
+ .prepare<{ name: string }, []>('PRAGMA table_info("agent_tasks")')
277
+ .all()
278
+ .map((column) => `"${column.name}"`)
279
+ .join(", ");
280
+ const objects = db
281
+ .prepare<{ name: string; tbl_name: string; sql: string }, []>(
282
+ `SELECT name, tbl_name, sql FROM sqlite_master
283
+ WHERE type IN ('index', 'trigger') AND sql IS NOT NULL
284
+ AND (tbl_name = 'agent_tasks' OR (type = 'trigger' AND sql LIKE '%agent_tasks%'))`,
285
+ )
286
+ .all();
287
+ db.run("PRAGMA foreign_keys = OFF");
288
+ db.transaction(() => {
289
+ db.run(legacy);
290
+ db.run(`INSERT INTO legacy (rowid, ${columns}) SELECT rowid, ${columns} FROM agent_tasks`);
291
+ for (const object of objects) {
292
+ if (object.tbl_name !== "agent_tasks") db.run(`DROP TRIGGER "${object.name}"`);
293
+ }
294
+ db.run("DROP TABLE agent_tasks");
295
+ db.run("ALTER TABLE legacy RENAME TO agent_tasks");
296
+ for (const object of objects) db.run(object.sql);
297
+ }).immediate();
298
+ db.run("PRAGMA foreign_keys = ON");
299
+ closeDb();
300
+
301
+ initDb(REBUILD_DB_PATH);
302
+ const rebuilt = getDb()
303
+ .prepare<{ sql: string }, []>(
304
+ "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'agent_tasks'",
305
+ )
306
+ .get()!.sql;
307
+ expect(rebuilt).not.toContain("CHECK(status IN");
308
+ await expectIndexInSync();
309
+ expect(await found("survivor")).toEqual(["first survivor", "last survivor"]);
310
+
311
+ await getDbClient().run("UPDATE agent_tasks SET task = 'renamed survivor' WHERE id = ?", [
312
+ last,
313
+ ]);
314
+ await deleteTask(first);
315
+ await newTask("born after rebuild");
316
+ await expectIndexInSync();
317
+ expect(await found("survivor")).toEqual(["renamed survivor"]);
318
+ expect(await found("born")).toEqual(["born after rebuild"]);
319
+ });
320
+ });
@@ -55,6 +55,10 @@ async function callUpdateProfile(
55
55
  return result as { structuredContent: StructuredContent };
56
56
  }
57
57
 
58
+ type RegisteredTool = { inputSchema: { safeParse: (v: unknown) => { success: boolean } } };
59
+ const registeredTools = (server: McpServer) =>
60
+ (server as unknown as { _registeredTools: Record<string, RegisteredTool> })._registeredTools;
61
+
58
62
  describe("update-profile authorization", () => {
59
63
  let server: McpServer;
60
64
 
@@ -250,6 +254,30 @@ describe("update-profile authorization", () => {
250
254
  }
251
255
  });
252
256
 
257
+ test("an agent sets, keeps and resets its own slackIcon", async () => {
258
+ const owl = { type: "emoji", emoji: ":owl:" };
259
+ const set = await callUpdateProfile(server, OTHER_WORKER_ID, { slackIcon: owl });
260
+ expect(set.structuredContent.success).toBe(true);
261
+ expect(set.structuredContent.message).toContain("slackIcon");
262
+ expect((await getAgentById(OTHER_WORKER_ID))?.slackIcon).toEqual(owl);
263
+
264
+ await callUpdateProfile(server, OTHER_WORKER_ID, { role: "Owl" });
265
+ expect((await getAgentById(OTHER_WORKER_ID))?.slackIcon).toEqual(owl);
266
+
267
+ await callUpdateProfile(server, OTHER_WORKER_ID, { slackIcon: null });
268
+ expect((await getAgentById(OTHER_WORKER_ID))?.slackIcon).toBeNull();
269
+
270
+ const { inputSchema } = registeredTools(server)["update-profile"];
271
+ expect(inputSchema.safeParse({ slackIcon: owl }).success).toBe(true);
272
+ for (const slackIcon of [
273
+ { type: "emoji", emoji: "owl" },
274
+ { type: "url", url: "https://" },
275
+ { type: "url", url: "https://exa mple.com/owl.png" },
276
+ ]) {
277
+ expect(inputSchema.safeParse({ slackIcon }).success).toBe(false);
278
+ }
279
+ });
280
+
253
281
  test("missing self agent does not write supplied profile files", async () => {
254
282
  const originalAgentId = process.env.AGENT_ID;
255
283
  const writeSpy = spyOn(Bun, "write").mockImplementation(async () => 0);
@@ -28,6 +28,7 @@ const agentOutputShape = z.looseObject({
28
28
  credentialMissing: z.array(z.string()).nullable().optional(),
29
29
  credStatus: z.looseObject({}).nullable().optional(),
30
30
  avatar: z.looseObject({}).nullable().optional(),
31
+ slackIcon: z.looseObject({}).nullable().optional(),
31
32
  createdAt: z.string().optional(),
32
33
  lastUpdatedAt: z.string().optional(),
33
34
  });
@@ -44,7 +44,12 @@ export const getTasksInputSchema = z.object({
44
44
  readyOnly: z.boolean().optional().describe("Only return tasks whose dependencies are met."),
45
45
  taskType: z.string().optional().describe("Filter by task type (e.g., 'bug', 'feature')."),
46
46
  tags: z.array(z.string()).optional().describe("Filter by any matching tag."),
47
- search: z.string().optional().describe("Search in task description."),
47
+ search: z
48
+ .string()
49
+ .optional()
50
+ .describe(
51
+ "Search task text and title by word or phrase, with a prefix match on the last word, or a task id by prefix. Not a substring match: '2443' does not find '12443', and '%' and '_' are not wildcards.",
52
+ ),
48
53
  scheduleId: z
49
54
  .string()
50
55
  .uuid()
@@ -35,6 +35,7 @@ const agentOutputShape = z.looseObject({
35
35
  credentialMissing: z.array(z.string()).nullable().optional(),
36
36
  credStatus: z.looseObject({}).nullable().optional(),
37
37
  avatar: z.looseObject({}).nullable().optional(),
38
+ slackIcon: z.looseObject({}).nullable().optional(),
38
39
  createdAt: z.string().optional(),
39
40
  lastUpdatedAt: z.string().optional(),
40
41
  });
@@ -28,6 +28,7 @@ const agentOutputShape = z.looseObject({
28
28
  credentialMissing: z.array(z.string()).nullable().optional(),
29
29
  credStatus: z.looseObject({}).nullable().optional(),
30
30
  avatar: z.looseObject({}).nullable().optional(),
31
+ slackIcon: z.looseObject({}).nullable().optional(),
31
32
  createdAt: z.string().optional(),
32
33
  lastUpdatedAt: z.string().optional(),
33
34
  });
@@ -12,7 +12,7 @@ import { buildMrkdwnSectionBlocks, getTaskLink } from "@/slack/blocks";
12
12
  import { withAutoJoin } from "@/slack/channel-join";
13
13
  import { resolveSlackConversationId } from "@/slack/dm";
14
14
  import { externalChannelToolError, getChannelBlockReason } from "@/slack/external-channel";
15
- import { getAgentDisplayName, getAgentEmoji, markdownToSlack } from "@/slack/responses";
15
+ import { getAgentDisplayName, getAgentSlackIcon, markdownToSlack } from "@/slack/responses";
16
16
  import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
17
17
 
18
18
  export const registerSlackPostTool = (server: McpServer) => {
@@ -120,7 +120,7 @@ export const registerSlackPostTool = (server: McpServer) => {
120
120
  unfurl_links: false,
121
121
  unfurl_media: false,
122
122
  username: getAgentDisplayName(agent),
123
- icon_emoji: getAgentEmoji(agent),
123
+ ...getAgentSlackIcon(agent),
124
124
  ...(threadTs ? { thread_ts: threadTs } : {}),
125
125
  // biome-ignore lint/suspicious/noExplicitAny: MCP accepts arbitrary valid Block Kit JSON
126
126
  blocks: messageBlocks as any,
@@ -19,7 +19,7 @@ import {
19
19
  } from "@/slack/blocks";
20
20
  import { withAutoJoin } from "@/slack/channel-join";
21
21
  import { externalChannelToolError, getChannelBlockReason } from "@/slack/external-channel";
22
- import { getAgentDisplayName, getAgentEmoji, markdownToSlack } from "@/slack/responses";
22
+ import { getAgentDisplayName, getAgentSlackIcon, markdownToSlack } from "@/slack/responses";
23
23
  import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
24
24
  import {
25
25
  renderTaskCitations,
@@ -182,7 +182,7 @@ export const registerSlackReplyTool = (server: McpServer) => {
182
182
  unfurl_links: false,
183
183
  unfurl_media: false,
184
184
  username: getAgentDisplayName(agent),
185
- icon_emoji: getAgentEmoji(agent),
185
+ ...getAgentSlackIcon(agent),
186
186
  // biome-ignore lint/suspicious/noExplicitAny: MCP accepts arbitrary valid Block Kit JSON
187
187
  blocks: messageBlocks as any,
188
188
  }),
@@ -7,7 +7,7 @@ import { buildMrkdwnSectionBlocks } from "@/slack/blocks";
7
7
  import { withAutoJoin } from "@/slack/channel-join";
8
8
  import { resolveSlackConversationId } from "@/slack/dm";
9
9
  import { externalChannelToolError, getChannelBlockReason } from "@/slack/external-channel";
10
- import { getAgentDisplayName, getAgentEmoji, markdownToSlack } from "@/slack/responses";
10
+ import { getAgentDisplayName, getAgentSlackIcon, markdownToSlack } from "@/slack/responses";
11
11
  import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
12
12
 
13
13
  export const registerSlackStartThreadTool = (server: McpServer) => {
@@ -88,7 +88,7 @@ export const registerSlackStartThreadTool = (server: McpServer) => {
88
88
  unfurl_links: false,
89
89
  unfurl_media: false,
90
90
  username: getAgentDisplayName(agent),
91
- icon_emoji: getAgentEmoji(agent),
91
+ ...getAgentSlackIcon(agent),
92
92
  // biome-ignore lint/suspicious/noExplicitAny: MCP accepts arbitrary valid Block Kit JSON
93
93
  blocks: messageBlocks as any,
94
94
  }),
@@ -21,7 +21,7 @@ export const registerSlackUpdateTool = (server: McpServer) => {
21
21
  {
22
22
  title: "Edit a Slack message",
23
23
  description:
24
- "Edits (in place) the text of a Slack message that THIS bot authored — use it to post corrections to your own messages. Cannot edit messages authored by humans or other apps. Note: editing may reset the message's display name/icon to the app default (Slack's chat.update cannot set the crown persona). Requires lead privileges.",
24
+ "Edits (in place) the text of a Slack message that THIS bot authored — use it to post corrections to your own messages. Cannot edit messages authored by humans or other apps. Editing keeps the icon the message was posted with. Requires lead privileges.",
25
25
  annotations: { openWorldHint: true },
26
26
 
27
27
  inputSchema: z.object({
@@ -6,7 +6,13 @@ import * as z from "zod";
6
6
  import { getAgentById, updateAgentProfile } from "@/be/db";
7
7
  import { can } from "@/rbac";
8
8
  import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
9
- import { type Agent, AgentAvatarSchema, AgentStatusSchema, ProviderNameSchema } from "@/types";
9
+ import {
10
+ type Agent,
11
+ AgentAvatarSchema,
12
+ AgentSlackIconSchema,
13
+ AgentStatusSchema,
14
+ ProviderNameSchema,
15
+ } from "@/types";
10
16
  import { MAX_PROFILE_FILE_LENGTH } from "@/utils/constants";
11
17
 
12
18
  // Loose mirror of AgentSchema for tool output: every field optional, no
@@ -33,6 +39,7 @@ const agentOutputShape = z.looseObject({
33
39
  credentialMissing: z.array(z.string()).nullable().optional(),
34
40
  credStatus: z.looseObject({}).nullable().optional(),
35
41
  avatar: z.looseObject({}).nullable().optional(),
42
+ slackIcon: z.looseObject({}).nullable().optional(),
36
43
  createdAt: z.string().optional(),
37
44
  lastUpdatedAt: z.string().optional(),
38
45
  });
@@ -154,6 +161,11 @@ export const registerUpdateProfileTool = (server: McpServer) => {
154
161
  .describe(
155
162
  "Custom avatar: { type: 'lucide', icon: '<kebab-case-lucide-name>', color?: '#RRGGBB' }. Pass null to reset to the default deterministic icon/color.",
156
163
  ),
164
+ slackIcon: AgentSlackIconSchema.nullable()
165
+ .optional()
166
+ .describe(
167
+ "Slack icon for this agent's messages: { type: 'emoji', emoji: ':owl:' } or { type: 'url', url: 'https://…' }, or { type: 'app' } for the Slack app's own icon. A link Slack cannot load falls back to the Slack app's icon. Pass null to reset to the default.",
168
+ ),
157
169
  }),
158
170
  outputSchema: swarmToolOutputSchema({
159
171
  yourAgentId: z.string().optional(),
@@ -174,6 +186,7 @@ export const registerUpdateProfileTool = (server: McpServer) => {
174
186
  toolsMd,
175
187
  heartbeatMd,
176
188
  avatar,
189
+ slackIcon,
177
190
  },
178
191
  requestInfo,
179
192
  _meta,
@@ -228,10 +241,11 @@ export const registerUpdateProfileTool = (server: McpServer) => {
228
241
  setupScript === undefined &&
229
242
  toolsMd === undefined &&
230
243
  heartbeatMd === undefined &&
231
- avatar === undefined
244
+ avatar === undefined &&
245
+ slackIcon === undefined
232
246
  ) {
233
247
  return toolErr(
234
- "At least one field (name, description, role, capabilities, claudeMd, soulMd, identityMd, setupScript, toolsMd, heartbeatMd, or avatar) must be provided.",
248
+ "At least one field (name, description, role, capabilities, claudeMd, soulMd, identityMd, setupScript, toolsMd, heartbeatMd, avatar, or slackIcon) must be provided.",
235
249
  { data: { yourAgentId: requestInfo.agentId } },
236
250
  );
237
251
  }
@@ -254,7 +268,7 @@ export const registerUpdateProfileTool = (server: McpServer) => {
254
268
 
255
269
  // Apply name and profile fields in one DB transaction so validation or
256
270
  // update failures cannot leave a partially-applied combined payload.
257
- // `avatar` is spread in only when present so `null` (reset) stays
271
+ // `avatar` and `slackIcon` are spread in only when present so `null` (reset) stays
258
272
  // distinguishable from "not provided".
259
273
  agent = await updateAgentProfile(
260
274
  targetAgentId,
@@ -270,6 +284,7 @@ export const registerUpdateProfileTool = (server: McpServer) => {
270
284
  toolsMd,
271
285
  heartbeatMd,
272
286
  ...(avatar !== undefined ? { avatar } : {}),
287
+ ...(slackIcon !== undefined ? { slackIcon } : {}),
273
288
  },
274
289
  {
275
290
  changeSource: isUpdatingSelf ? "self_edit" : "lead_coaching",
@@ -361,6 +376,7 @@ export const registerUpdateProfileTool = (server: McpServer) => {
361
376
  if (toolsMd !== undefined) updatedFields.push("toolsMd");
362
377
  if (heartbeatMd !== undefined) updatedFields.push("heartbeatMd");
363
378
  if (avatar !== undefined) updatedFields.push("avatar");
379
+ if (slackIcon !== undefined) updatedFields.push("slackIcon");
364
380
 
365
381
  const targetLabel = isUpdatingSelf ? "own" : `agent ${targetAgentId}`;
366
382
  return toolOk(`Updated ${targetLabel} profile: ${updatedFields.join(", ")}.`, {
@@ -18,7 +18,7 @@ import type {
18
18
  ToolAnnotations,
19
19
  } from "@modelcontextprotocol/sdk/types.js";
20
20
  import * as z from "zod";
21
- import { sweepExpiredKvPrefix, upsertKv } from "../be/db";
21
+ import { upsertKv } from "../be/db";
22
22
  import { MCP_OVERFLOW_NAMESPACE, mcpOverflowNamespace } from "../kv-overflow";
23
23
  import { joinKvPath, type KvShapeEntry, splitKvPath, summarizeKvShape } from "../kv-view";
24
24
  import { withSpan } from "../otel";
@@ -815,9 +815,8 @@ const ctxControlMiddleware: FinalizeMiddleware = async (result, ctx) => {
815
815
 
816
816
  // The public KV write surfaces cap values at 2 MiB, while the SQLite TEXT
817
817
  // column and this direct server-side helper have no declared size limit.
818
- // Sweep the whole per-agent namespace family so expired rows from inactive
819
- // agents do not wait for their owner to spill again.
820
- await sweepExpiredKvPrefix(MCP_OVERFLOW_NAMESPACE);
818
+ // Expired values are deleted by the 10-minute sweep in
819
+ // src/be/mcp-overflow-sweep.ts, off this request path.
821
820
  await upsertKv({
822
821
  namespace,
823
822
  key,
package/src/types.ts CHANGED
@@ -335,6 +335,16 @@ export function isTerminalTaskStatus(status: string): status is TerminalTaskStat
335
335
  return (TERMINAL_TASK_STATUSES as readonly string[]).includes(status);
336
336
  }
337
337
 
338
+ /**
339
+ * Every status that is not terminal, derived so a new status lands here unless
340
+ * it is added to `TERMINAL_TASK_STATUSES`. Open tasks are a handful of rows,
341
+ * so hot queries filter `status IN (open)` (an index seek) rather than
342
+ * `status NOT IN (terminal)` (a scan of every finished task).
343
+ */
344
+ export const OPEN_TASK_STATUSES = AgentTaskStatusSchema.options.filter(
345
+ (status) => !isTerminalTaskStatus(status),
346
+ ) as Exclude<AgentTaskStatus, TerminalTaskStatus>[];
347
+
338
348
  // ============================================================================
339
349
  // Lead Inbox Types
340
350
  // ============================================================================
@@ -1248,6 +1258,37 @@ export const AgentAvatarSchema = z.discriminatedUnion("type", [
1248
1258
  ]);
1249
1259
  export type AgentAvatar = z.infer<typeof AgentAvatarSchema>;
1250
1260
 
1261
+ // Separate from `avatar` so an agent can keep its lucide disc and still post
1262
+ // to Slack with its own emoji or image.
1263
+ export const AgentSlackIconSchema = z
1264
+ .discriminatedUnion("type", [
1265
+ z.object({
1266
+ type: z.literal("emoji"),
1267
+ emoji: z
1268
+ .string()
1269
+ .max(102)
1270
+ .regex(/^:[a-z0-9_+'-]+:$/),
1271
+ }),
1272
+ z.object({
1273
+ type: z.literal("url"),
1274
+ url: z
1275
+ .url({ protocol: /^https$/ })
1276
+ .max(2048)
1277
+ .refine((value) => {
1278
+ try {
1279
+ const parsed = new URL(value);
1280
+ return !parsed.username && !parsed.password;
1281
+ } catch {
1282
+ return false;
1283
+ }
1284
+ }, "URL must be valid and contain no credentials"),
1285
+ }),
1286
+ // No icon override: Slack shows the app's own avatar.
1287
+ z.object({ type: z.literal("app") }),
1288
+ ])
1289
+ .openapi("AgentSlackIcon");
1290
+ export type AgentSlackIcon = z.infer<typeof AgentSlackIconSchema>;
1291
+
1251
1292
  export const AgentSchema = z
1252
1293
  .object({
1253
1294
  // Plain string, NOT .uuid(): agents may join with custom IDs (AGENT_ID env /
@@ -1321,6 +1362,12 @@ export const AgentSchema = z
1321
1362
  .nullable()
1322
1363
  .optional(),
1323
1364
 
1365
+ // Null/missing = the default emoji (getAgentEmoji).
1366
+ slackIcon: z
1367
+ .lazy(() => AgentSlackIconSchema)
1368
+ .nullable()
1369
+ .optional(),
1370
+
1324
1371
  // No function defaults — see AgentTaskSchema's timestamp comment
1325
1372
  // (OpenAPI-generation reproducibility).
1326
1373
  createdAt: z.iso.datetime(),