@desplega.ai/agent-swarm 1.152.2 → 1.154.0

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 (284) hide show
  1. package/README.md +2 -2
  2. package/dist/{acp-adapter-pfeje2v5.js → acp-adapter-eb2aj8xy.js} +4 -4
  3. package/dist/{actions-0sttc7f5.js → actions-kpnwp1kz.js} +31 -27
  4. package/dist/{anthropic-messages-2y8nktar.js → anthropic-messages-9vqsbznt.js} +112 -84
  5. package/dist/{app-p8teva95.js → app-zk1apkf6.js} +10 -7
  6. package/dist/{assistant-r45jnsrt.js → assistant-kwq4y50q.js} +32 -28
  7. package/dist/{azure-openai-responses-f38x2avq.js → azure-openai-responses-04qwdwfq.js} +27 -13
  8. package/dist/{boot-reembed-je26h5y7.js → boot-reembed-22hd3jjn.js} +9 -7
  9. package/dist/{boot-reembed-1qmybsd4.js → boot-reembed-qe168424.js} +10 -8
  10. package/dist/{boot-scrub-logs-gte3h1mm.js → boot-scrub-logs-pkdpsrjd.js} +7 -5
  11. package/dist/{claude-adapter-csc1mzka.js → claude-adapter-3fx90nww.js} +18 -16
  12. package/dist/{claude-managed-adapter-dsmkcrjy.js → claude-managed-adapter-ea2aga6w.js} +3 -3
  13. package/dist/{claude-sdk-session-fcd6f078.js → claude-sdk-session-z7fahf1k.js} +18 -16
  14. package/dist/{cli-r2fpe7cx.js → cli-00w2c66p.js} +61 -55
  15. package/dist/{cli-wbyk465z.js → cli-09y3tjxz.js} +1 -1
  16. package/dist/{cli-ht5rs63n.js → cli-0e31nm4n.js} +16 -7
  17. package/dist/cli-0x5qzvkg.js +4279 -0
  18. package/dist/{cli-d3p82erb.js → cli-18amys3y.js} +9 -9
  19. package/dist/{cli-4ahpr4sw.js → cli-1jpjafw4.js} +5 -5
  20. package/dist/{cli-jbmc0r7r.js → cli-243tzws8.js} +1 -1
  21. package/dist/{cli-ch38q5z4.js → cli-24k806sc.js} +6789 -2542
  22. package/dist/cli-3bkz2p7j.js +2828 -0
  23. package/dist/cli-3er6gcsn.js +1849 -0
  24. package/dist/cli-4k7jazkq.js +3339 -0
  25. package/dist/{cli-c4eeb5q2.js → cli-5b6b62tz.js} +1 -1
  26. package/dist/{cli-qd65dvr6.js → cli-5vs0nh43.js} +65 -44
  27. package/dist/{cli-z3wkng35.js → cli-6r3cgpk2.js} +224 -156
  28. package/dist/{cli-pgj4a5fb.js → cli-71cj99hx.js} +4 -3
  29. package/dist/cli-8sea27q4.js +14075 -0
  30. package/dist/{cli-jvy9qgjs.js → cli-913yakhv.js} +22 -7
  31. package/dist/{cli-pf9q3696.js → cli-927erqqt.js} +5 -2
  32. package/dist/cli-95jnpnqg.js +230 -0
  33. package/dist/{cli-wpv5rgg2.js → cli-abp3g4ax.js} +15 -2
  34. package/dist/{cli-ckz5t2hb.js → cli-acy6bfhr.js} +10 -7
  35. package/dist/{cli-4yc5d8fk.js → cli-avkd6mqv.js} +50 -0
  36. package/dist/{cli-8wrympd7.js → cli-bzr0ftht.js} +26 -7
  37. package/dist/{cli-qn042eh7.js → cli-c1v10xb2.js} +194 -1913
  38. package/dist/cli-cgj4hc12.js +18902 -0
  39. package/dist/{cli-2nnd56zt.js → cli-e0pg4zeh.js} +3 -2
  40. package/dist/{cli-r84ctdxn.js → cli-e31ehqwy.js} +2 -2
  41. package/dist/{cli-gphgnvpf.js → cli-e3229na9.js} +3 -3
  42. package/dist/{cli-gpqcs935.js → cli-e3cyf59d.js} +2 -2
  43. package/dist/{cli-s25avqg9.js → cli-e781c6f9.js} +1 -0
  44. package/dist/{cli-s2qjdd6z.js → cli-eetrvv91.js} +1 -1
  45. package/dist/{cli-j4nv2wqy.js → cli-fdzg6jhf.js} +10 -4
  46. package/dist/{cli-fm4ytsdk.js → cli-g2h29qar.js} +2 -2
  47. package/dist/{cli-zvgj5zg8.js → cli-ge67p080.js} +302 -3497
  48. package/dist/cli-h38x0qhg.js +6912 -0
  49. package/dist/{cli-9xvdm3bx.js → cli-h60qfasa.js} +742 -278
  50. package/dist/{cli-jew3jda5.js → cli-hws0vn86.js} +2 -2
  51. package/dist/{cli-j8dggbcr.js → cli-k0g1sjzm.js} +23 -31
  52. package/dist/{cli-xw7vak96.js → cli-kp3qs53m.js} +5 -5
  53. package/dist/{cli-rgvp6r2x.js → cli-mcnhgnn1.js} +193 -4
  54. package/dist/{cli-zvkq5csn.js → cli-myaxtgh8.js} +7 -6
  55. package/dist/{cli-56h18ng2.js → cli-n62atwh9.js} +1 -1
  56. package/dist/cli-n7h1kqpf.js +29 -0
  57. package/dist/{cli-fmy65jdn.js → cli-pf1v8xhb.js} +7 -5
  58. package/dist/{cli-xy1cchaf.js → cli-q85qhckh.js} +238 -11
  59. package/dist/{cli-3bpzj8me.js → cli-r1d3dzt6.js} +2 -2
  60. package/dist/{pi-mono-adapter-jcqt71cg.js → cli-rxkqvngf.js} +67343 -101083
  61. package/dist/{cli-kj76p1ez.js → cli-scwq51gn.js} +13 -7
  62. package/dist/{cli-b9dd0h3k.js → cli-stwsmd6k.js} +3 -2
  63. package/dist/{cli-c2mk1kgf.js → cli-v4en75c5.js} +8 -1
  64. package/dist/{cli-z61ykvam.js → cli-wg04s9kr.js} +4 -3
  65. package/dist/cli-yn0a417a.js +4069 -0
  66. package/dist/{cli-zfzznn7c.js → cli-ytxs7mn1.js} +26 -2
  67. package/dist/{cli-4j8k1m4w.js → cli-zt1227k1.js} +1162 -192
  68. package/dist/cli.js +28 -35
  69. package/dist/{codex-adapter-fxmpsabw.js → codex-adapter-ttdm995v.js} +12 -11
  70. package/dist/{codex-hook-8jexc34x.js → codex-hook-8gx7bcz0.js} +3 -3
  71. package/dist/{codex-session-runner-rr1e47ea.js → codex-session-runner-3v8nh4c8.js} +12 -11
  72. package/dist/{commands-0f5rxz51.js → commands-1cxwbhhq.js} +7 -5
  73. package/dist/{db-r3jxf3wy.js → db-y9zwsr79.js} +25 -5
  74. package/dist/{dispatcher-06vph6q4.js → dispatcher-9m9zss36.js} +30 -26
  75. package/dist/dsh-adapter-gsb2qqf9.js +19 -0
  76. package/dist/{e2b-413b8t4v.js → e2b-93x1k0j5.js} +6 -7
  77. package/dist/{google-generative-ai-ndjmj5fa.js → google-generative-ai-gwwrnzqn.js} +33 -73
  78. package/dist/{google-vertex-fr665tqs.js → google-vertex-5wfawpwz.js} +38 -68
  79. package/dist/{handlers-0hgtc910.js → handlers-36fm1bwn.js} +33 -29
  80. package/dist/{hook-9kvrv1er.js → hook-pntw4r8h.js} +12 -10
  81. package/dist/{hook-fbfhkat3.js → hook-vwq8y26p.js} +13 -11
  82. package/dist/{http-xbyx8k5j.js → http-20jesakk.js} +357 -261
  83. package/dist/{index-jsw3aynb.js → index-6artrehg.js} +30 -26
  84. package/dist/{cli-pdz9qde4.js → index-9ch1n761.js} +10 -11
  85. package/dist/{index-rnyahfn5.js → index-9n5z2492.js} +30 -26
  86. package/dist/{index-66xz5e9j.js → index-h5gs261z.js} +521 -9
  87. package/dist/{index-tfsdxyv6.js → index-j5wrf1yw.js} +40 -25
  88. package/dist/{index-2sp0384z.js → index-kkeftv5h.js} +738 -21
  89. package/dist/{index-sgk50mbg.js → index-m2xqpz5r.js} +31 -27
  90. package/dist/{index-gpfn1741.js → index-m9ks8a7t.js} +9 -7
  91. package/dist/index-w097eg0x.js +5 -0
  92. package/dist/jiti-loader-b4837mtm.js +34 -0
  93. package/dist/jiti-static-loader-q2g5ds0y.js +31267 -0
  94. package/dist/{keepalive-s3xpr7sw.js → keepalive-pwxvxd2c.js} +9 -7
  95. package/dist/{lead-rq4n5r16.js → lead-m789wqa3.js} +46 -41
  96. package/dist/{maintenance-f0atj1ab.js → maintenance-bvg6w7e2.js} +10 -8
  97. package/dist/{mistral-conversations-qhr84ph3.js → mistral-conversations-zx0kn554.js} +24 -18
  98. package/dist/{node-x1g4v0pq.js → node-5qtycf6a.js} +16 -11
  99. package/dist/{oauth-refresh-sweep-q0t3bxre.js → oauth-refresh-sweep-6v4bd737.js} +8 -6
  100. package/dist/{onboard-br5j1kch.js → onboard-snckpcj1.js} +115 -35
  101. package/dist/{openai-codex-responses-wy6a43wd.js → openai-codex-responses-4vjqzvjp.js} +33 -27
  102. package/dist/{openai-completions-32rdwmze.js → openai-completions-pxsmym7g.js} +52 -64
  103. package/dist/{openai-responses-6xwf7bzg.js → openai-responses-tmn0rqp0.js} +23 -24
  104. package/dist/{opencode-adapter-28axqa09.js → opencode-adapter-qbmv4tpy.js} +6 -6
  105. package/dist/{otel-impl-vdqge2nt.js → otel-impl-e0ytens5.js} +4 -4
  106. package/dist/{pi-messages-b1mbr6gp.js → pi-messages-txpjj39q.js} +3 -3
  107. package/dist/pi-mono-adapter-c4ggemhz.js +1316 -0
  108. package/dist/{pricing-refresh-hb3691fd.js → pricing-refresh-52g2ywrw.js} +9 -7
  109. package/dist/{rbac-roles-t78xt39b.js → rbac-roles-5455kfdn.js} +8 -6
  110. package/dist/{rbac-roles-jh280k9g.js → rbac-roles-hbf0xa52.js} +9 -7
  111. package/dist/{render-v2-7p8r2ymg.js → render-v2-npm8xdzh.js} +16 -9
  112. package/dist/{seed-pricing-hf889enh.js → seed-pricing-650wbr1n.js} +8 -6
  113. package/dist/{setup-w6mb03jv.js → setup-k1mew5rf.js} +4 -5
  114. package/dist/virtual-modules-2q5b2pk4.js +54 -0
  115. package/dist/{work-objects-nt8gy1kj.js → work-objects-2ea3kvwg.js} +33 -29
  116. package/dist/{worker-dwy617t0.js → worker-ec6hvc4m.js} +46 -41
  117. package/openapi.json +109 -16
  118. package/package.json +7 -6
  119. package/plugin/opencode-plugins/lib/memory-raters.ts +7 -0
  120. package/src/be/automation-preflight.ts +6 -4
  121. package/src/be/db/agents.ts +75 -2
  122. package/src/be/db/tasks/read.ts +2 -0
  123. package/src/be/db/tasks/write.ts +7 -1
  124. package/src/be/db.ts +140 -18
  125. package/src/be/extensions/db.ts +35 -4
  126. package/src/be/memory/raters/llm.ts +2 -6
  127. package/src/be/memory/raters/noop.ts +2 -3
  128. package/src/be/memory/raters/registry.ts +4 -10
  129. package/src/be/migrations/157_defer_provenance_and_marker.sql +27 -0
  130. package/src/be/models-catalog.ts +2 -0
  131. package/src/be/modelsdev-cache.json +213 -0
  132. package/src/be/rbac-audit.ts +2 -0
  133. package/src/be/seed/agent-fs-provision.ts +23 -0
  134. package/src/be/seed-pricing.ts +3 -1
  135. package/src/be/swarm-config-guard.ts +1 -0
  136. package/src/commands/onboard/compose-generator.ts +16 -1
  137. package/src/commands/onboard/env-generator.ts +14 -1
  138. package/src/commands/onboard/manifest.ts +1 -0
  139. package/src/commands/onboard/steps/integration-slack.tsx +56 -16
  140. package/src/commands/onboard/steps/review.tsx +5 -0
  141. package/src/commands/onboard/types.ts +6 -0
  142. package/src/commands/onboard.tsx +5 -1
  143. package/src/commands/provider-credentials.ts +14 -2
  144. package/src/commands/runner.ts +18 -2
  145. package/src/extensions/identity.ts +15 -7
  146. package/src/extensions/lifecycle.ts +15 -21
  147. package/src/hooks/codex-hook.ts +7 -1
  148. package/src/http/agents.ts +12 -4
  149. package/src/http/config.ts +1 -1
  150. package/src/http/core.ts +5 -3
  151. package/src/http/extensions.ts +108 -34
  152. package/src/http/poll.ts +7 -0
  153. package/src/http/schedules.ts +10 -0
  154. package/src/http/script-runs.ts +35 -26
  155. package/src/http/status.ts +24 -11
  156. package/src/http/tasks.ts +6 -1
  157. package/src/prompts/base-prompt.ts +15 -7
  158. package/src/prompts/memories.ts +5 -7
  159. package/src/prompts/session-templates.ts +3 -2
  160. package/src/prompts/steering-delivery.ts +2 -0
  161. package/src/providers/claude-managed-models.ts +8 -0
  162. package/src/providers/codex-adapter.ts +3 -6
  163. package/src/providers/codex-models.ts +15 -1
  164. package/src/providers/dsh-adapter.ts +248 -0
  165. package/src/providers/index.ts +5 -1
  166. package/src/providers/modelsdev-reasoning.json +50 -0
  167. package/src/providers/pi-mono-extension.ts +2 -5
  168. package/src/rbac/legacy-policy.ts +15 -1
  169. package/src/rbac/types.ts +2 -0
  170. package/src/server.ts +7 -1
  171. package/src/slack/ack.ts +43 -8
  172. package/src/slack/app.ts +32 -10
  173. package/src/slack/config.ts +29 -0
  174. package/src/slack/handlers.ts +7 -1
  175. package/src/slack/render-v2.ts +186 -17
  176. package/src/slack/responses.ts +1 -3
  177. package/src/slack/task-output.ts +66 -8
  178. package/src/slack/thread-buffer.ts +27 -3
  179. package/src/telemetry.ts +2 -3
  180. package/src/tests/agent-fs-provision-seeder.test.ts +56 -2
  181. package/src/tests/agents-harness-provider.test.ts +19 -0
  182. package/src/tests/agents-list-model-display.test.ts +60 -1
  183. package/src/tests/base-prompt.test.ts +25 -0
  184. package/src/tests/claude-managed-adapter.test.ts +6 -0
  185. package/src/tests/codex-gpt6-release.test.ts +97 -0
  186. package/src/tests/codex-steering-hook.test.ts +2 -2
  187. package/src/tests/config-api-only-keys.test.ts +9 -0
  188. package/src/tests/context-window.test.ts +1 -0
  189. package/src/tests/db-task-read-contract.test.ts +9 -0
  190. package/src/tests/db-task-write-contract.test.ts +9 -0
  191. package/src/tests/defer-task.test.ts +115 -16
  192. package/src/tests/dsh-adapter.test.ts +276 -0
  193. package/src/tests/e2e-nightly-report.test.ts +3 -3
  194. package/src/tests/e2e-redact.test.ts +2 -0
  195. package/src/tests/env-flag.test.ts +6 -0
  196. package/src/tests/extension-agent-visibility.test.ts +474 -0
  197. package/src/tests/extensions-db.test.ts +41 -0
  198. package/src/tests/extensions-http.test.ts +148 -4
  199. package/src/tests/extensions-lifecycle.test.ts +21 -1
  200. package/src/tests/extensions-mcp-tools.test.ts +9 -5
  201. package/src/tests/fetch-resolved-env.test.ts +43 -0
  202. package/src/tests/internal-ai/complete-structured.test.ts +32 -9
  203. package/src/tests/internal-ai/credentials.test.ts +4 -4
  204. package/src/tests/internal-ai/models.test.ts +20 -0
  205. package/src/tests/memory-rate-tool.test.ts +7 -10
  206. package/src/tests/memory-rater-defaults.test.ts +23 -0
  207. package/src/tests/memory-rater-e2e.test.ts +2 -3
  208. package/src/tests/migration-046-budgets.test.ts +2 -0
  209. package/src/tests/model-control.test.ts +2 -2
  210. package/src/tests/onboard-compose.test.ts +14 -0
  211. package/src/tests/onboard-env.test.ts +17 -0
  212. package/src/tests/onboard-manifest.test.ts +12 -0
  213. package/src/tests/opus-5-5-registry.test.ts +48 -0
  214. package/src/tests/pricing-refresh.test.ts +1 -1
  215. package/src/tests/pricing-routes.test.ts +15 -3
  216. package/src/tests/provider-registration.test.ts +5 -2
  217. package/src/tests/rbac-admission-e2e.test.ts +54 -0
  218. package/src/tests/rbac-e2e-helpers.ts +2 -0
  219. package/src/tests/rbac-engine.test.ts +32 -0
  220. package/src/tests/rbac-wire-e2e.test.ts +33 -0
  221. package/src/tests/reasoning-effort.test.ts +8 -0
  222. package/src/tests/reload-config.test.ts +33 -0
  223. package/src/tests/script-connections.test.ts +3 -0
  224. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  225. package/src/tests/slack-config.test.ts +136 -0
  226. package/src/tests/slack-mode-lifecycle.test.ts +69 -0
  227. package/src/tests/slack-render-v2.test.ts +446 -16
  228. package/src/tests/slack-steering.test.ts +69 -1
  229. package/src/tests/slack-task-output.test.ts +33 -12
  230. package/src/tests/status.test.ts +22 -2
  231. package/src/tests/steering-core.test.ts +35 -0
  232. package/src/tests/steering-delivery.test.ts +39 -0
  233. package/src/tests/steering-transport.test.ts +2 -2
  234. package/src/tests/task-tool-preload-mcp.test.ts +3 -1
  235. package/src/tests/telemetry-init.test.ts +18 -0
  236. package/src/tests/workflow-live-recovery.test.ts +126 -0
  237. package/src/tests/workflow-llm.test.ts +3 -3
  238. package/src/tools/defer-task.ts +64 -26
  239. package/src/tools/extension-common.ts +4 -0
  240. package/src/tools/extension-install.ts +1 -1
  241. package/src/tools/extension-list.ts +4 -3
  242. package/src/tools/join-swarm.ts +1 -1
  243. package/src/tools/schedules/create-schedule.ts +11 -1
  244. package/src/tools/schedules/patch-schedule.ts +5 -0
  245. package/src/tools/schedules/update-schedule.ts +5 -0
  246. package/src/tools/send-task.ts +6 -0
  247. package/src/tools/utils.ts +1 -1
  248. package/src/types.ts +30 -8
  249. package/src/utils/context-window.ts +1 -0
  250. package/src/utils/credentials.ts +8 -0
  251. package/src/utils/internal-ai/complete-structured.ts +8 -0
  252. package/src/utils/internal-ai/models.ts +5 -5
  253. package/src/utils/memory-raters.ts +3 -0
  254. package/src/workflows/engine.ts +28 -0
  255. package/src/workflows/recovery.ts +7 -4
  256. package/templates/schedules/daily-status-report/content.md +25 -12
  257. package/templates/schedules/daily-workflow-health-audit/content.md +1 -1
  258. package/templates/skills/swarm-extensions/content.md +9 -5
  259. package/dist/cli-4wppfkcm.js +0 -16384
  260. package/dist/cli-55qrx3q8.js +0 -6041
  261. package/dist/cli-cg4vnyh4.js +0 -305
  262. package/dist/cli-jyetr64j.js +0 -52
  263. package/dist/cli-pp9ax9pa.js +0 -520
  264. package/dist/cli-qa66qaf0.js +0 -6076
  265. package/dist/cli-qghpvca2.js +0 -590
  266. package/dist/cli-rembwgt8.js +0 -861
  267. package/dist/cli-tcvj7j2r.js +0 -1971
  268. package/dist/cli-vfcth11k.js +0 -36
  269. package/dist/cli-w1d1bgkk.js +0 -1422
  270. package/dist/cli-y6x0gqtg.js +0 -5977
  271. package/dist/es5-tj3tn4jj.js +0 -844
  272. package/dist/index-1kq5hw2p.js +0 -467
  273. package/dist/index-1p8wdstt.js +0 -7
  274. package/dist/index-8fdjzb2f.js +0 -100
  275. package/dist/index-bxmwzbgw.js +0 -365
  276. package/dist/index-nzerxd5z.js +0 -7
  277. package/dist/index-pyymnzat.js +0 -1168
  278. package/dist/index-qv6atbab.js +0 -619
  279. package/dist/index-rja32qa7.js +0 -801
  280. package/dist/index-tzg5srfp.js +0 -211
  281. package/dist/index-vhncw983.js +0 -90
  282. package/dist/index-xk036zsk.js +0 -859
  283. package/dist/index-zyazbzcb.js +0 -6
  284. package/dist/{claude-managed-setup-48dwg9bv.js → claude-managed-setup-f5pmsyrp.js} +3 -3
@@ -56,6 +56,20 @@ const leadOrOperatorOrUser: LegacyRule = {
56
56
  principal.kind === "operator" || principal.kind === "user" || actsAsLead(principal),
57
57
  };
58
58
 
59
+ const extensionWrite: LegacyRule = {
60
+ name: "extension-owner-or-elevated",
61
+ denyReason: "requires extension owner, lead agent, operator, or user authentication",
62
+ evaluate: (principal, resource) => {
63
+ if (leadOrOperatorOrUser.evaluate(principal, resource)) return true;
64
+ return (
65
+ principal.kind === "agent" &&
66
+ principal.agentId !== "" &&
67
+ resource?.kind === "extension" &&
68
+ (resource.extensionId === undefined || resource.createdByAgentId === principal.agentId)
69
+ );
70
+ },
71
+ };
72
+
59
73
  // Deployment-only: agents cannot reset this through swarm_config.
60
74
  const extensionActivation: LegacyRule = {
61
75
  name: "extension-activation",
@@ -242,6 +256,6 @@ export const LEGACY_POLICY = {
242
256
  "script.api.update": leadOnly,
243
257
  "script.api.rotate": leadOnly,
244
258
  "script.api.delete": leadOnly,
245
- "extension.write": leadOrOperatorOrUser,
259
+ "extension.write": extensionWrite,
246
260
  "extension.activate": extensionActivation,
247
261
  } as const satisfies Record<PermissionVerb, LegacyRule>;
package/src/rbac/types.ts CHANGED
@@ -32,6 +32,8 @@ export type RbacResource =
32
32
  | { kind: "app"; appId: string }
33
33
  /** Skills, mcp-servers, memory entries, scripts. */
34
34
  | { kind: "owned"; ownerAgentId?: string | null; scope?: string }
35
+ /** An omitted extensionId denotes a new install; existing rows retain their creator. */
36
+ | { kind: "extension"; extensionId?: string; createdByAgentId?: string | null }
35
37
  | { kind: "none" };
36
38
 
37
39
  export type RbacDecision =
package/src/server.ts CHANGED
@@ -9,6 +9,7 @@ import { registerGithubTaskReactions } from "./github/task-reactions";
9
9
  import { loadGlobalConfigsIntoEnv } from "./http/core";
10
10
  import { resolveTemplate } from "./prompts/resolver";
11
11
  import { isRbacEnabled } from "./rbac";
12
+ import { getSlackConfiguration } from "./slack/config";
12
13
  import { registerAcceptSteerTool } from "./tools/accept-steer";
13
14
  import { registerAppDiffTool } from "./tools/app-diff";
14
15
  import { registerAppGetTool } from "./tools/app-get";
@@ -268,7 +269,12 @@ export function hasCapability(cap: CAPABILITIES_T): boolean {
268
269
  }
269
270
 
270
271
  export function getEnabledCapabilities(): CAPABILITIES_T[] {
271
- return Array.from(getCapabilities());
272
+ const capabilities = Array.from(getCapabilities());
273
+ // Phase 1 has no HTTP receiver. Do not advertise usable Slack tools to
274
+ // workers when HTTP (or an invalid transport) was selected.
275
+ return getSlackConfiguration().mode === "socket"
276
+ ? capabilities
277
+ : capabilities.filter((capability) => capability !== "slack");
272
278
  }
273
279
 
274
280
  /**
package/src/slack/ack.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { WebClient } from "@slack/web-api";
2
- import { getLogsByTaskIdChronological, getSlackTasksInThread } from "../be/db";
2
+ import { getLogsByTaskIdChronological, getSlackTasksInThread, getTaskById } from "../be/db";
3
3
  import { recordSlackReactionInvalidName } from "../otel";
4
4
  import { type AgentTask, isTerminalTaskStatus } from "../types";
5
5
  import { scrubSecrets } from "../utils/secret-scrubber";
@@ -10,6 +10,7 @@ import {
10
10
  SLACK_REACTION_DEFAULTS,
11
11
  type SlackReactionEvent,
12
12
  } from "./reaction-shortcode";
13
+ import { deferralWakes, isAwaitingWake } from "./task-output";
13
14
 
14
15
  export { reactionName, type SlackReactionEvent } from "./reaction-shortcode";
15
16
 
@@ -170,28 +171,62 @@ export async function finalizeSlackSteerReactions(
170
171
  }
171
172
  }
172
173
 
174
+ // Deferral chains are short; the cap only guards against a parent cycle.
175
+ const MAX_DEFERRAL_CHAIN_HOPS = 10;
176
+
177
+ /**
178
+ * The Slack message a task answers. A deferral's wake-up does not inherit
179
+ * `slackTriggerMessageTs`, so walk its `parentTaskId` chain back to the task
180
+ * that deferred on the human's message.
181
+ */
182
+ async function triggerMessageTs(task: AgentTask): Promise<string | undefined> {
183
+ let current: AgentTask | null = task;
184
+ for (let hop = 0; current && hop <= MAX_DEFERRAL_CHAIN_HOPS; hop++) {
185
+ if (current.slackTriggerMessageTs) return current.slackTriggerMessageTs;
186
+ if (current.taskType !== "deferred" || !current.parentTaskId) return undefined;
187
+ current = await getTaskById(current.parentTaskId);
188
+ }
189
+ return undefined;
190
+ }
191
+
192
+ /** The tasks answering `timestamp`, plus the wake-ups that continue them. */
193
+ function tasksAnsweringTrigger(threadTasks: AgentTask[], timestamp: string): AgentTask[] {
194
+ const linked = threadTasks.filter((task) => task.slackTriggerMessageTs === timestamp);
195
+ for (let index = 0; index < linked.length; index++) {
196
+ for (const wake of deferralWakes(linked[index]!, threadTasks)) {
197
+ if (!linked.includes(wake)) linked.push(wake);
198
+ }
199
+ }
200
+ return linked;
201
+ }
202
+
173
203
  export async function finalizeTerminalSlackReactions(tasks: AgentTask[]): Promise<void> {
174
204
  const app = getSlackApp();
175
205
  if (!app) return;
176
206
 
177
207
  const triggers = new Map<string, { channelId: string; threadTs: string; timestamp: string }>();
178
208
  for (const task of tasks) {
179
- if (!task.slackChannelId || !task.slackThreadTs || !task.slackTriggerMessageTs) continue;
180
- const key = `${task.slackChannelId}\0${task.slackTriggerMessageTs}`;
209
+ if (!task.slackChannelId || !task.slackThreadTs) continue;
210
+ const timestamp = await triggerMessageTs(task);
211
+ if (!timestamp) continue;
212
+ const key = `${task.slackChannelId}\0${timestamp}`;
181
213
  triggers.set(key, {
182
214
  channelId: task.slackChannelId,
183
215
  threadTs: task.slackThreadTs,
184
- timestamp: task.slackTriggerMessageTs,
216
+ timestamp,
185
217
  });
186
218
  }
187
219
 
188
220
  for (const { channelId, threadTs, timestamp } of triggers.values()) {
189
- const linkedTasks = (await getSlackTasksInThread(channelId, threadTs)).filter(
190
- (task) => task.slackTriggerMessageTs === timestamp,
191
- );
221
+ const threadTasks = await getSlackTasksInThread(channelId, threadTs);
222
+ const linkedTasks = tasksAnsweringTrigger(threadTasks, timestamp);
223
+ // A parked deferral is stored `completed`, but the message is not
224
+ // answered yet: keep 👀 until its wake-up settles.
192
225
  if (
193
226
  linkedTasks.length === 0 ||
194
- linkedTasks.some((task) => !isTerminalTaskStatus(task.status))
227
+ linkedTasks.some(
228
+ (task) => !isTerminalTaskStatus(task.status) || isAwaitingWake(task, threadTasks),
229
+ )
195
230
  ) {
196
231
  continue;
197
232
  }
package/src/slack/app.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { App, LogLevel } from "@slack/bolt";
2
2
  import { emitBuiltInIntegrationConnectedOnce, ensureSlackRenderV2Activation } from "../be/db";
3
+ import { getSlackConfiguration } from "./config";
3
4
  import { getSlackSocketModeBlockReason, SLACK_DEV_SOCKET_MODE_OPT_IN } from "./socket-mode-guard";
4
5
  import { startTaskWatcher, stopTaskWatcher } from "./watcher";
5
6
 
@@ -16,23 +17,38 @@ export async function initSlackApp(): Promise<App | null> {
16
17
  console.log("[Slack] Already initialized, skipping");
17
18
  return app;
18
19
  }
19
- initialized = true;
20
-
21
- // Check if Slack is explicitly disabled
22
- const slackDisable = process.env.SLACK_DISABLE;
23
- if (slackDisable === "true" || slackDisable === "1") {
20
+ const config = getSlackConfiguration();
21
+ if (config.disabled) {
24
22
  console.log("[Slack] Disabled via SLACK_DISABLE");
25
23
  return null;
26
24
  }
27
25
 
28
- const botToken = process.env.SLACK_BOT_TOKEN;
29
- const appToken = process.env.SLACK_APP_TOKEN;
26
+ if (!config.mode) {
27
+ console.error(
28
+ "[Slack] Invalid SLACK_MODE; expected socket or http. Slack integration disabled",
29
+ );
30
+ return null;
31
+ }
32
+
33
+ if (config.missingCredentials.length > 0) {
34
+ console.log(
35
+ `[Slack] Missing ${config.missingCredentials.join(" or ")} for ${config.mode} mode, Slack integration disabled`,
36
+ );
37
+ return null;
38
+ }
30
39
 
31
- if (!botToken || !appToken) {
32
- console.log("[Slack] Missing SLACK_BOT_TOKEN or SLACK_APP_TOKEN, Slack integration disabled");
40
+ // Phase 1 establishes the transport contract only. Never fall back to a
41
+ // socket when HTTP was explicitly selected; the receiver lands in Phase 3.
42
+ if (config.mode === "http") {
43
+ console.error(
44
+ "[Slack] HTTP mode is configured but unavailable until the HTTP receiver is installed",
45
+ );
33
46
  return null;
34
47
  }
35
48
 
49
+ const botToken = process.env.SLACK_BOT_TOKEN as string;
50
+ const appToken = process.env.SLACK_APP_TOKEN as string;
51
+
36
52
  const socketModeBlockReason = getSlackSocketModeBlockReason(process.env);
37
53
  if (socketModeBlockReason) {
38
54
  console.error(
@@ -51,6 +67,9 @@ export async function initSlackApp(): Promise<App | null> {
51
67
  ...(slackApiUrl ? { clientOptions: { slackApiUrl } } : {}),
52
68
  });
53
69
 
70
+ // Failed validation must remain retryable without requiring stopSlackApp().
71
+ initialized = true;
72
+
54
73
  // Register handlers
55
74
  const { registerMessageHandler } = await import("./handlers");
56
75
  const { registerCommandHandler } = await import("./commands");
@@ -69,7 +88,7 @@ export async function initSlackApp(): Promise<App | null> {
69
88
  return app;
70
89
  }
71
90
 
72
- export async function startSlackApp(): Promise<void> {
91
+ export async function startSlackApp(): Promise<boolean> {
73
92
  if (!app) {
74
93
  await initSlackApp();
75
94
  }
@@ -84,7 +103,10 @@ export async function startSlackApp(): Promise<void> {
84
103
 
85
104
  // Start watching for task completions
86
105
  await startTaskWatcher();
106
+ return true;
87
107
  }
108
+
109
+ return false;
88
110
  }
89
111
 
90
112
  export async function stopSlackApp(): Promise<void> {
@@ -0,0 +1,29 @@
1
+ export const SLACK_MODES = ["socket", "http"] as const;
2
+ export type SlackMode = (typeof SLACK_MODES)[number];
3
+
4
+ export type SlackConfiguration = {
5
+ disabled: boolean;
6
+ mode: SlackMode | null;
7
+ missingCredentials: string[];
8
+ };
9
+
10
+ /** Resolve Slack transport and credential presence without exposing credential values. */
11
+ export function getSlackConfiguration(env: NodeJS.ProcessEnv = process.env): SlackConfiguration {
12
+ const rawMode = env.SLACK_MODE === undefined ? "socket" : env.SLACK_MODE.trim();
13
+ const mode = SLACK_MODES.includes(rawMode as SlackMode) ? (rawMode as SlackMode) : null;
14
+ const missingCredentials = ["SLACK_BOT_TOKEN"];
15
+
16
+ if (mode === "socket") missingCredentials.push("SLACK_APP_TOKEN");
17
+ if (mode === "http") missingCredentials.push("SLACK_SIGNING_SECRET");
18
+
19
+ return {
20
+ disabled: env.SLACK_DISABLE === "true" || env.SLACK_DISABLE === "1",
21
+ mode,
22
+ missingCredentials: missingCredentials.filter((key) => !env[key]?.trim()),
23
+ };
24
+ }
25
+
26
+ export function isSlackConfigured(env: NodeJS.ProcessEnv = process.env): boolean {
27
+ const config = getSlackConfiguration(env);
28
+ return !config.disabled && config.mode !== null && config.missingCredentials.length === 0;
29
+ }
@@ -8,6 +8,7 @@ import {
8
8
  getLeadAgent,
9
9
  getMostRecentTaskInThread,
10
10
  getTasksByAgentId,
11
+ isExtensionAgent,
11
12
  } from "../be/db";
12
13
  import { dispatchPre } from "../extensions/dispatcher";
13
14
  import { resolveTemplate } from "../prompts/resolver";
@@ -621,7 +622,12 @@ export function registerMessageHandler(app: App): void {
621
622
  const { target } = routeResult.data;
622
623
  if (target.kind === "agent") {
623
624
  const agent = await getAgentById(target.agentId);
624
- if (agent) {
625
+ if (agent && isExtensionAgent(agent)) {
626
+ console.warn(
627
+ "[Slack] Extension selected an extension-identity agent. Using the built-in router:",
628
+ scrubSecrets(target.agentId),
629
+ );
630
+ } else if (agent) {
625
631
  matches = [{ agent, matchedText: "extension" }];
626
632
  } else {
627
633
  console.warn(
@@ -8,6 +8,7 @@ import {
8
8
  ensureSlackDelegationActivation,
9
9
  ensureSlackRenderV2Activation,
10
10
  getAgentById,
11
+ getResolvableDeferralOutcomes,
11
12
  getSlackOutcomeMessage,
12
13
  getSlackTasksInThread,
13
14
  getSlackTasksMissingTree,
@@ -17,6 +18,7 @@ import {
17
18
  getTaskAttachments,
18
19
  getTaskById,
19
20
  isPendingSlackMessage,
21
+ markSlackDeferralResolved,
20
22
  markSlackTreeRendered,
21
23
  reserveSlackMessage,
22
24
  type SlackConclusionKind,
@@ -44,7 +46,7 @@ import {
44
46
  import { buildAskClosure, type ClosureState, closureState } from "./closure";
45
47
  import { reactionName, type SlackReactionEvent } from "./reaction-shortcode";
46
48
  import { getAgentDisplayName, getAgentEmoji } from "./responses";
47
- import { isSlackEtaOnlyNotice, slackTaskOutput } from "./task-output";
49
+ import { isAwaitingWake, isDeferredTask, slackTaskOutput } from "./task-output";
48
50
 
49
51
  const TREE_UPDATE_DEBOUNCE_MS = 500;
50
52
  const TREE_UPDATE_MIN_INTERVAL_MS = 3_000;
@@ -219,8 +221,16 @@ function isStalledMember(task: AgentTask, now: Date): boolean {
219
221
  * Blocked detection reuses `checkDependencies` — the same source `get-tasks`
220
222
  * `readyOnly` uses — rather than re-deriving it from the tree's own task list,
221
223
  * since a dependency can point outside the current Slack thread.
224
+ *
225
+ * A deferral reads ⏳ only while it is parked: once its wake-up settles in
226
+ * `threadTasks`, it falls through to the `completed` it is stored as.
222
227
  */
223
- async function taskStateGlyph(task: AgentTask, now: Date): Promise<string> {
228
+ async function taskStateGlyph(
229
+ task: AgentTask,
230
+ now: Date,
231
+ threadTasks: readonly AgentTask[] = [],
232
+ ): Promise<string> {
233
+ if (isAwaitingWake(task, threadTasks)) return "⏳";
224
234
  switch (task.status) {
225
235
  case "backlog":
226
236
  case "unassigned":
@@ -335,6 +345,7 @@ async function renderNodeLines(
335
345
  depth: number,
336
346
  now: Date,
337
347
  isAsk: boolean,
348
+ threadTasks: readonly AgentTask[],
338
349
  ): Promise<string[]> {
339
350
  const duration = formatV2Duration(new Date(node.task.createdAt), terminalEnd(node.task, now));
340
351
  const indent = FIGURE_SPACE.repeat(
@@ -344,7 +355,7 @@ async function renderNodeLines(
344
355
  indent.length > MAX_TREE_PREFIX_LENGTH
345
356
  ? `${indent.slice(0, MAX_TREE_PREFIX_LENGTH - 1)}…`
346
357
  : indent;
347
- let line = `${boundedPrefix}↳ ${await taskStateGlyph(node.task, now)} ${await renderNodeLabel(node, isAsk)} · ${duration} · ${getTaskLink(node.task.id)}`;
358
+ let line = `${boundedPrefix}↳ ${await taskStateGlyph(node.task, now, threadTasks)} ${await renderNodeLabel(node, isAsk)} · ${duration} · ${getTaskLink(node.task.id)}`;
348
359
  const progress = renderProgress(node.task.progress);
349
360
  const suffix = isTerminalTreeStatus(node.task.status) ? "" : progress ? ` · ${progress}` : "";
350
361
  if (suffix && line.length + suffix.length <= MAX_TREE_NODE_LINE_LENGTH) {
@@ -355,7 +366,7 @@ async function renderNodeLines(
355
366
  }
356
367
  const lines = [line];
357
368
  for (const child of node.children) {
358
- lines.push(...(await renderNodeLines(child, depth + 1, now, false)));
369
+ lines.push(...(await renderNodeLines(child, depth + 1, now, false, threadTasks)));
359
370
  }
360
371
  return lines;
361
372
  }
@@ -382,6 +393,8 @@ function threadHeaderText(tasks: AgentTask[], now: Date, duration: string): stri
382
393
  if (tasks.some((task) => isStalledMember(task, now))) return `🧵 ⚠️ stalled — ${duration}`;
383
394
  if (tasks.some((task) => !isTerminalTreeStatus(task.status)))
384
395
  return `🧵 🔄 working — ${duration}`;
396
+ // A parked deferral is stored `completed`, but the ask is not answered yet.
397
+ if (tasks.some((task) => isAwaitingWake(task, tasks))) return `🧵 ⏳ waiting — ${duration}`;
385
398
  const hasFailure = tasks.some((task) => task.status === "failed");
386
399
  return hasFailure ? `🧵 ❌ done with failures — ${duration}` : `🧵 ✅ done — ${duration}`;
387
400
  }
@@ -406,7 +419,7 @@ export async function renderThreadTree(
406
419
  const lines = [threadHeaderText(tasks, now, threadDuration)];
407
420
  const roots = buildRenderForest(tasks);
408
421
  for (const root of roots) {
409
- lines.push(...(await renderNodeLines(root, 1, now, root.task.source === "slack")));
422
+ lines.push(...(await renderNodeLines(root, 1, now, root.task.source === "slack", tasks)));
410
423
  }
411
424
  const text = normalizeV2Text(lines.join("\n"));
412
425
  if (text.length <= MAX_SECTION_LENGTH) return text;
@@ -416,7 +429,7 @@ export async function renderThreadTree(
416
429
  for (let index = tasks.length - 1; index >= 0; index--) {
417
430
  const task = tasks[index]!;
418
431
  const recentLine = (
419
- await renderNodeLines({ task, children: [] }, 1, now, task.source === "slack")
432
+ await renderNodeLines({ task, children: [] }, 1, now, task.source === "slack", tasks)
420
433
  )[0]!;
421
434
  const candidateLines = [recentLine, ...recentLines];
422
435
  const omitted = index;
@@ -741,24 +754,52 @@ function outcomeText(value: string | null | undefined, fallback: string): string
741
754
  return value?.trim() ? value : fallback;
742
755
  }
743
756
 
757
+ // Markdown that only parses at the start of a line: a code fence, a list
758
+ // item, a blockquote, a heading, a table row, a thematic break, or an
759
+ // indented code block. Put after the status icon, it would render as literal
760
+ // text, so the body keeps its own line.
761
+ const LINE_START_BLOCK =
762
+ /^(?:`{3}|~{3}|[-*+]\s|\d+[.)]\s|>|#{1,6}\s|\||(?:-{3,}|\*{3,}|_{3,})[ \t]*(?:\r?\n|$)|(?: {4}|\t))/;
763
+
764
+ /**
765
+ * `✅ The build passed.` — the status icon (and label, when there is one)
766
+ * opens the body's first line instead of standing alone above it. A body
767
+ * that opens with a line-start construct (see `LINE_START_BLOCK`) keeps the
768
+ * blank line so its markdown still renders.
769
+ */
770
+ export function withStatusLead(lead: string, body: string): string {
771
+ const text = body.replace(/^(?:[ \t]*\r?\n)+/, "");
772
+ if (LINE_START_BLOCK.test(text)) return `${lead}\n\n${body}`;
773
+ return `${lead} ${text.trimStart()}`;
774
+ }
775
+
744
776
  async function outcomeContent(task: AgentTask, slackReplySent: boolean): Promise<string> {
745
777
  if (task.status === "failed") {
746
- return `❌ **Failed**\n\n${outcomeText(task.failureReason, "Task failed.")}`;
778
+ return withStatusLead("❌ **Failed:**", outcomeText(task.failureReason, "Task failed."));
747
779
  }
748
780
  if (task.status === "cancelled") {
749
- return `🚫 **Cancelled**\n\n${outcomeText(task.failureReason, "Task was cancelled.")}`;
781
+ return withStatusLead(
782
+ "🚫 **Cancelled:**",
783
+ outcomeText(task.failureReason, "Task was cancelled."),
784
+ );
750
785
  }
751
786
  // A deferral's output is engine-authored, never posted by hand via
752
787
  // slack-reply, so it must not collapse into the "agent completed" summary
753
788
  // even when the agent also sent a slack-reply earlier in the same task.
754
- const isDeferred = task.tags?.includes("deferred");
789
+ const isDeferred = isDeferredTask(task);
755
790
  if (slackReplySent && task.status === "completed" && !isDeferred) {
756
791
  const agentName = task.agentId
757
792
  ? ((await getAgentById(task.agentId))?.name ?? "Agent")
758
793
  : "Agent";
759
794
  return `✅ ${agentName} completed`;
760
795
  }
761
- return `✅\n\n${outcomeText(slackTaskOutput(task), "Task completed.")}`;
796
+ // A deferral is `completed` in the database but unfinished to a human: the
797
+ // work resumes in a wake-up task. Rendering it ✅ told the thread the ask
798
+ // was answered when it was only parked.
799
+ if (isDeferred) {
800
+ return withStatusLead("⏳", outcomeText(slackTaskOutput(task), "Deferred."));
801
+ }
802
+ return withStatusLead("✅", outcomeText(slackTaskOutput(task), "Task completed."));
762
803
  }
763
804
 
764
805
  async function agentDisplayNameFor(task: AgentTask): Promise<string> {
@@ -782,12 +823,24 @@ export async function childOutcomeContent(
782
823
  ): Promise<string> {
783
824
  const agentName = await agentDisplayNameFor(task);
784
825
  if (task.status === "failed") {
785
- return `↳ ❌ ${agentName} — failed\n\n${outcomeText(task.failureReason, "Task failed.")}`;
826
+ return withStatusLead(
827
+ `↳ ❌ ${agentName} — failed:`,
828
+ outcomeText(task.failureReason, "Task failed."),
829
+ );
786
830
  }
787
- if (slackReplySent && !task.tags?.includes("deferred")) {
831
+ if (slackReplySent && !isDeferredTask(task)) {
788
832
  return `↳ ✅ ${agentName} completed`;
789
833
  }
790
- return `↳ ✅ ${agentName} — result\n\n${outcomeText(slackTaskOutput(task), "Task completed.")}`;
834
+ if (isDeferredTask(task)) {
835
+ return withStatusLead(
836
+ `↳ ⏳ ${agentName} — deferred:`,
837
+ outcomeText(slackTaskOutput(task), "Deferred."),
838
+ );
839
+ }
840
+ return withStatusLead(
841
+ `↳ ✅ ${agentName} — result:`,
842
+ outcomeText(slackTaskOutput(task), "Task completed."),
843
+ );
791
844
  }
792
845
 
793
846
  // A deferred wake continues a human conversation even though the scheduler
@@ -809,7 +862,6 @@ function isSlackContinuation(task: AgentTask): boolean {
809
862
  */
810
863
  function isChildCardCandidate(task: AgentTask, delegationActivatedAt: string): boolean {
811
864
  return (
812
- !isSlackEtaOnlyNotice(task) &&
813
865
  task.source !== "slack" &&
814
866
  !isSlackContinuation(task) &&
815
867
  !!task.slackChannelId &&
@@ -823,7 +875,6 @@ function isChildCardCandidate(task: AgentTask, delegationActivatedAt: string): b
823
875
 
824
876
  function taskNeedsDirectOutcome(task: AgentTask, activatedAt: string): boolean {
825
877
  return (
826
- !isSlackEtaOnlyNotice(task) &&
827
878
  ((task.source === "slack" && isAskOutcomeStatus(task.status)) ||
828
879
  (isSlackContinuation(task) && isOutcomeStatus(task.status))) &&
829
880
  task.createdAt >= activatedAt
@@ -842,7 +893,7 @@ async function conclusionResultsLines(closure: AgentTask[]): Promise<string[]> {
842
893
  const lines: string[] = [];
843
894
  for (const member of closure) {
844
895
  if (member.taskType === "follow-up" || member.taskType === "reroute-decision") continue;
845
- if (!isOutcomeStatus(member.status) || isSlackEtaOnlyNotice(member)) continue;
896
+ if (!isOutcomeStatus(member.status)) continue;
846
897
  const glyph = await taskStateGlyph(member, new Date());
847
898
  const agentName = await agentDisplayNameFor(member);
848
899
  const card = await getSlackOutcomeMessage(member.id);
@@ -1212,6 +1263,119 @@ export async function streamOutcomeCard(
1212
1263
  });
1213
1264
  }
1214
1265
 
1266
+ /**
1267
+ * The body a resolved deferral card is rewritten to. The ⏳ block promised
1268
+ * the thread an answer; this is that promise being kept in place, instead of
1269
+ * in a second card below it — `isAnsweredByDeferralCard` keeps the wake-up
1270
+ * from posting one, so the answer lands in the thread exactly once. It is
1271
+ * the card the wake-up would have posted, collapsed the same way when the
1272
+ * wake-up already answered by hand with `slack-reply`.
1273
+ *
1274
+ * A wake-up that deferred again does post its own card — the next ⏳ in the
1275
+ * chain, which its own wake-up resolves — so this card only points at it.
1276
+ */
1277
+ async function resolvedDeferralContent(wake: AgentTask): Promise<string> {
1278
+ if (!isDeferredTask(wake)) {
1279
+ const content = await outcomeContent(wake, wake.slackReplySent);
1280
+ return outcomePresentation(wake, content, attachmentLine(await getTaskAttachments(wake.id)));
1281
+ }
1282
+ const card = await getSlackOutcomeMessage(wake.id);
1283
+ const pointer = card?.permalink ? ` — ${card.permalink}` : ".";
1284
+ return `↪️ Resumed by ${await agentDisplayNameFor(wake)} and deferred again${pointer}`;
1285
+ }
1286
+
1287
+ /**
1288
+ * True when `task` is a wake-up whose answer goes into its deferral's ⏳ card
1289
+ * (`refreshResolvedDeferralCards`), so it must not post an outcome card of
1290
+ * its own. False — and the wake-up posts its own card as before — when it
1291
+ * deferred again, when the deferral never got a card, and when the in-place
1292
+ * rewrite was abandoned.
1293
+ */
1294
+ async function isAnsweredByDeferralCard(
1295
+ task: AgentTask,
1296
+ threadTasks: readonly AgentTask[],
1297
+ ): Promise<boolean> {
1298
+ if (!isSlackContinuation(task) || !task.parentTaskId || isDeferredTask(task)) return false;
1299
+ const deferred = threadTasks.find((candidate) => candidate.id === task.parentTaskId);
1300
+ // Mirrors getResolvableDeferralOutcomes: only a `deferredAt` card is rewritten.
1301
+ if (!deferred?.deferredAt) return false;
1302
+ const card = await getSlackOutcomeMessage(deferred.id);
1303
+ return !!card?.finalizedAt && !isPendingSlackMessage(card) && !card.deferralAbandonedAt;
1304
+ }
1305
+
1306
+ /**
1307
+ * `callSlackWithRetry` only ever retries rate limits, so any error that
1308
+ * escapes it still carrying a Slack API error code is a verdict rather than a
1309
+ * blip: `message_not_found`, `channel_not_found`, `cant_update_message` and
1310
+ * `msg_too_long` all answer identically on the next tick.
1311
+ */
1312
+ function isTerminalSlackError(error: unknown): boolean {
1313
+ const code = (error as { data?: { error?: string } }).data?.error;
1314
+ return typeof code === "string" && code.length > 0 && code !== "ratelimited";
1315
+ }
1316
+
1317
+ // Everything else reaching the catch has no Slack verdict attached (a socket
1318
+ // reset, a DNS blip, a local read that threw), so it is worth retrying — but
1319
+ // only a bounded number of times. Without a ceiling a card that fails for a
1320
+ // reason we cannot classify is re-attempted at tick cadence forever.
1321
+ const DEFERRAL_REFRESH_MAX_ATTEMPTS = 5;
1322
+ const deferralRefreshAttempts = new Map<string, number>();
1323
+
1324
+ /**
1325
+ * Rewrite every ⏳ deferral card whose wake-up task has settled, in place.
1326
+ *
1327
+ * Runs after the tree loop and independently of it: resolution needs only
1328
+ * the retained outcome `ts`, so it must not be skipped when the thread has
1329
+ * no other pending render work. After, so a wake-up that deferred again has
1330
+ * already posted the card this one points at. Each card is rewritten exactly
1331
+ * once (`deferral_resolved_at`).
1332
+ *
1333
+ * The marker is also burned when Slack refuses the rewrite for good, and when
1334
+ * an unclassifiable failure has used up its attempts — a card that can never
1335
+ * be rewritten must stop competing for the per-tick row budget with cards
1336
+ * that still can. Those are marked abandoned, so the wake-up's answer goes
1337
+ * out in its own card on the next tick instead of being lost.
1338
+ */
1339
+ export async function refreshResolvedDeferralCards(): Promise<void> {
1340
+ const app = getSlackApp();
1341
+ if (!app) return;
1342
+ for (const { card, wakeTaskId } of await getResolvableDeferralOutcomes()) {
1343
+ try {
1344
+ const wake = await getTaskById(wakeTaskId);
1345
+ if (!wake) continue;
1346
+ const content = await resolvedDeferralContent(wake);
1347
+ await callSlackWithRetry(app.client, "chat.update", {
1348
+ channel: card.channelId,
1349
+ ts: card.ts,
1350
+ text: content,
1351
+ });
1352
+ await markSlackDeferralResolved(card.id);
1353
+ deferralRefreshAttempts.delete(card.id);
1354
+ // The rewrite is the wake-up's outcome, so it settles the ask's reaction.
1355
+ await finalizeTerminalSlackReactions([wake]);
1356
+ } catch (error) {
1357
+ const attempts = (deferralRefreshAttempts.get(card.id) ?? 0) + 1;
1358
+ deferralRefreshAttempts.set(card.id, attempts);
1359
+ const terminal = isTerminalSlackError(error);
1360
+ if (terminal || attempts >= DEFERRAL_REFRESH_MAX_ATTEMPTS) {
1361
+ await markSlackDeferralResolved(card.id, { abandoned: true });
1362
+ deferralRefreshAttempts.delete(card.id);
1363
+ console.error(
1364
+ `[Slack] Giving up on deferral card ${card.id} after ${attempts} attempt(s)` +
1365
+ `${terminal ? " (Slack refused the rewrite)" : ""}:`,
1366
+ error,
1367
+ );
1368
+ continue;
1369
+ }
1370
+ console.error(
1371
+ `[Slack] Failed to resolve deferral card ${card.id} ` +
1372
+ `(attempt ${attempts}/${DEFERRAL_REFRESH_MAX_ATTEMPTS}):`,
1373
+ error,
1374
+ );
1375
+ }
1376
+ }
1377
+ }
1378
+
1215
1379
  export async function processSlackRenderV2(): Promise<void> {
1216
1380
  if (!isSlackRenderV2Enabled()) return;
1217
1381
  const activatedAt = await ensureSlackRenderV2Activation();
@@ -1270,7 +1434,9 @@ export async function processSlackRenderV2(): Promise<void> {
1270
1434
  // not represent immediately active outcome work, so avoid waking an old
1271
1435
  // tree solely to verify its permalink on every render tick.
1272
1436
  const isDirectCandidate =
1273
- taskNeedsDirectOutcome(task, activatedAt) && isOutcomeStatus(task.status);
1437
+ taskNeedsDirectOutcome(task, activatedAt) &&
1438
+ isOutcomeStatus(task.status) &&
1439
+ !(await isAnsweredByDeferralCard(task, tasks));
1274
1440
  const isChildCandidate =
1275
1441
  delegationEnabled &&
1276
1442
  delegationActivatedAt !== null &&
@@ -1365,6 +1531,7 @@ export async function processSlackRenderV2(): Promise<void> {
1365
1531
  if (!isSlackRenderV2Enabled()) return;
1366
1532
  if (!taskNeedsDirectOutcome(task, activatedAt)) continue;
1367
1533
  if ((await getSlackOutcomeMessage(task.id))?.finalizedAt) continue;
1534
+ if (await isAnsweredByDeferralCard(task, tasks)) continue;
1368
1535
  const deferByClosure =
1369
1536
  task.source === "slack" &&
1370
1537
  delegationEnabled &&
@@ -1444,6 +1611,8 @@ export async function processSlackRenderV2(): Promise<void> {
1444
1611
  console.error(`[Slack] Failed to update v2 tree ${tree.id}:`, error);
1445
1612
  }
1446
1613
  }
1614
+
1615
+ await refreshResolvedDeferralCards();
1447
1616
  }
1448
1617
 
1449
1618
  export function _resetSlackRenderV2ForTests(): void {
@@ -15,7 +15,7 @@ import {
15
15
  markdownToSlack,
16
16
  splitSlackSectionText,
17
17
  } from "./blocks";
18
- import { isSlackEtaOnlyNotice, slackTaskOutput } from "./task-output";
18
+ import { slackTaskOutput } from "./task-output";
19
19
 
20
20
  // Re-export for backward compatibility
21
21
  export { markdownToSlack } from "./blocks";
@@ -116,7 +116,6 @@ export async function sendInlineTaskOutput(task: AgentTask): Promise<boolean> {
116
116
  * Send a task completion message to Slack with the agent's persona.
117
117
  */
118
118
  export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
119
- if (isSlackEtaOnlyNotice(task)) return true;
120
119
  const app = getSlackApp();
121
120
  if (!app || !task.slackChannelId || !task.slackThreadTs) {
122
121
  return false;
@@ -287,7 +286,6 @@ export async function updateToFinal(
287
286
  task: AgentTask,
288
287
  messageTs: string,
289
288
  ): Promise<SlackUpdateResult> {
290
- if (isSlackEtaOnlyNotice(task)) return "ok";
291
289
  const app = getSlackApp();
292
290
  if (!app || !task.slackChannelId || !task.agentId) return "failed";
293
291