@desplega.ai/agent-swarm 1.145.0 → 1.147.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 (221) hide show
  1. package/README.md +2 -1
  2. package/dist/{acp-adapter-580t41c0.js → acp-adapter-9n319wqc.js} +61 -6
  3. package/dist/{actions-m662tjng.js → actions-2vxqvpr9.js} +9 -8
  4. package/dist/{app-zb3rndm8.js → app-w066xfy0.js} +6 -5
  5. package/dist/assistant-kt31dj2h.js +236 -0
  6. package/dist/{boot-reembed-072x00vn.js → boot-reembed-az5rassp.js} +7 -6
  7. package/dist/{boot-reembed-65zz2smt.js → boot-reembed-j3mm3rfz.js} +8 -7
  8. package/dist/{boot-scrub-logs-yz0ams04.js → boot-scrub-logs-b6h54817.js} +4 -4
  9. package/dist/{claude-adapter-paj270y2.js → claude-adapter-w1z04ann.js} +8 -6
  10. package/dist/{claude-managed-setup-w619jncn.js → claude-managed-setup-f5pmsyrp.js} +24 -3
  11. package/dist/{claude-sdk-session-swt80j9z.js → claude-sdk-session-f0cc1kec.js} +8 -6
  12. package/dist/{cli-dt553ktb.js → cli-0b6y7kxm.js} +1 -1
  13. package/dist/{cli-bxdhw615.js → cli-12fz972k.js} +4 -4
  14. package/dist/{cli-s2f20r5w.js → cli-1th1728g.js} +2 -2
  15. package/dist/{cli-rs6ec3b9.js → cli-3tgwnf43.js} +3 -3
  16. package/dist/{cli-gxnzc6wh.js → cli-4nr988h6.js} +1 -1
  17. package/dist/{cli-axs8e6d4.js → cli-53s590z8.js} +1 -1
  18. package/dist/{cli-ygbjp3z8.js → cli-54wwve05.js} +5 -4
  19. package/dist/{cli-v0asr5yv.js → cli-719k9j2c.js} +2 -2
  20. package/dist/{cli-anktweqr.js → cli-89xfd7f9.js} +21 -47
  21. package/dist/{cli-q34a0e10.js → cli-8v0g1yc8.js} +1 -1
  22. package/dist/cli-9d8ntetj.js +42 -0
  23. package/dist/{cli-z9fprcrf.js → cli-atcve9y8.js} +881 -738
  24. package/dist/{cli-jmb7g47h.js → cli-b5bq9crk.js} +14 -31
  25. package/dist/cli-d8brzxjb.js +190 -0
  26. package/dist/{cli-gzky0gkr.js → cli-de14znh8.js} +2 -2
  27. package/dist/{cli-xytwff7f.js → cli-dttph1bw.js} +2 -2
  28. package/dist/cli-ep19k3mb.js +19 -0
  29. package/dist/{cli-m1xa8be7.js → cli-f146mn5s.js} +7 -50
  30. package/dist/{cli-1fj0chdk.js → cli-f7dsy61y.js} +5 -3
  31. package/dist/{cli-f567d5c1.js → cli-ftht3zzj.js} +2782 -2589
  32. package/dist/{cli-a0g1gbt5.js → cli-hbv0kq6w.js} +2 -2
  33. package/dist/{cli-fhsz3twr.js → cli-jsp14b6c.js} +171 -1
  34. package/dist/{cli-7fnkj1hj.js → cli-kpv03zhj.js} +21 -17
  35. package/dist/{cli-m4j6trn4.js → cli-m8yrsg97.js} +2 -2
  36. package/dist/cli-m9sxbkhm.js +779 -0
  37. package/dist/cli-mmemxxdc.js +823 -0
  38. package/dist/{cli-tqtvj5pt.js → cli-n8508nre.js} +1 -1
  39. package/dist/{cli-z96t0thj.js → cli-p1d5b073.js} +37 -2
  40. package/dist/{cli-gh8y8f36.js → cli-rw51bq3j.js} +13 -10
  41. package/dist/{cli-59ej1c07.js → cli-smby5m17.js} +7 -6
  42. package/dist/{cli-65vqzz3v.js → cli-tzvk9haz.js} +94 -372
  43. package/dist/{cli-dc9k2ba9.js → cli-w9kb3bk1.js} +1 -1
  44. package/dist/{cli-kpev657q.js → cli-y6b6fb76.js} +25 -3
  45. package/dist/{cli-123q2get.js → cli-yz3djrm0.js} +9 -9
  46. package/dist/{cli-wvp6bvy4.js → cli-zhjmqxzh.js} +1 -1
  47. package/dist/cli.js +13 -13
  48. package/dist/{codex-adapter-k98vaw2r.js → codex-adapter-zw4dwsx7.js} +2 -2
  49. package/dist/{codex-hook-9pf19cks.js → codex-hook-bw8p5nh4.js} +2 -2
  50. package/dist/{codex-session-runner-tngtphx7.js → codex-session-runner-k981s65v.js} +2 -2
  51. package/dist/{commands-y8nbr3fv.js → commands-sykrxx7e.js} +4 -4
  52. package/dist/{db-30p4h2t1.js → db-sfbsc8wt.js} +6 -4
  53. package/dist/handlers-0aspcejm.js +603 -0
  54. package/dist/{hook-dcw4jfbj.js → hook-4nvqj2sb.js} +7 -5
  55. package/dist/{hook-21jexvfx.js → hook-hxccs7m5.js} +24 -49
  56. package/dist/{http-r4kq5g8d.js → http-y06z517d.js} +230 -94
  57. package/dist/{index-q3qja6ta.js → index-hssww7kj.js} +15 -14
  58. package/dist/{index-gay66b9w.js → index-k8znfx0z.js} +13 -12
  59. package/dist/{index-53zyht4z.js → index-tce1rss4.js} +371 -110
  60. package/dist/{index-nqvf26za.js → index-x5j894zf.js} +14 -13
  61. package/dist/{keepalive-xs8fa55y.js → keepalive-w733ax66.js} +6 -6
  62. package/dist/{lead-d9m2t4s0.js → lead-75bjqa9d.js} +29 -27
  63. package/dist/{maintenance-3zh82g6z.js → maintenance-w4f1zjck.js} +8 -7
  64. package/dist/{oauth-refresh-sweep-03snsye5.js → oauth-refresh-sweep-wdetky26.js} +5 -5
  65. package/dist/{onboard-0cg1925x.js → onboard-bb5net8s.js} +2 -2
  66. package/dist/{otel-impl-0ab95z27.js → otel-impl-0w7c14ap.js} +1 -1
  67. package/dist/{pi-mono-adapter-dtkz4e7j.js → pi-mono-adapter-2has4gnp.js} +12 -2
  68. package/dist/{pricing-refresh-e5jhzz4j.js → pricing-refresh-9nzsrgcq.js} +6 -6
  69. package/dist/{rbac-roles-p1mf40z3.js → rbac-roles-r3hqjp09.js} +5 -5
  70. package/dist/{rbac-roles-2w3cmrkc.js → rbac-roles-z1jrffbt.js} +4 -4
  71. package/dist/{registry-9kdck5ye.js → registry-v5wytg0s.js} +1 -2
  72. package/dist/{render-v2-xhawry3k.js → render-v2-vbr89fnb.js} +6 -5
  73. package/dist/{seed-pricing-t223sw91.js → seed-pricing-g62hy1pk.js} +7 -5
  74. package/dist/{setup-zcs0rce7.js → setup-gnfqnptp.js} +2 -2
  75. package/dist/{worker-qnp2ka62.js → worker-kznxp4qm.js} +29 -27
  76. package/openapi.json +263 -12
  77. package/package.json +1 -1
  78. package/plugin/commands/start-worker.md +4 -9
  79. package/plugin/pi-skills/start-worker/SKILL.md +4 -9
  80. package/src/be/db/agents.ts +556 -0
  81. package/src/be/db/context-versions.ts +117 -0
  82. package/src/be/db/runtime.ts +406 -0
  83. package/src/be/db/tasks/read.ts +1167 -0
  84. package/src/be/db/tasks/write.ts +953 -0
  85. package/src/be/db.ts +584 -3429
  86. package/src/be/migrations/147_scheduled_tasks_parent_task.sql +7 -0
  87. package/src/be/migrations/148_pages_svg.sql +123 -0
  88. package/src/be/migrations/149_session_tokens.sql +24 -0
  89. package/src/be/scripts/typecheck.ts +1 -0
  90. package/src/be/seed-pricing.ts +13 -8
  91. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
  92. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
  93. package/src/be/seed-scripts/catalog/star-history-refresh.ts +101 -0
  94. package/src/be/seed-scripts/catalog/star-history-renderer.ts +119 -0
  95. package/src/be/seed-scripts/index.ts +17 -0
  96. package/src/be/seed-skills/index.ts +14 -0
  97. package/src/be/swarm-config-guard.ts +5 -2
  98. package/src/be/task-attachment-store.ts +70 -0
  99. package/src/be/users.ts +77 -0
  100. package/src/commands/attachments-section.ts +37 -0
  101. package/src/commands/claude-managed-setup.ts +45 -3
  102. package/src/commands/claude-md-session.ts +408 -0
  103. package/src/commands/context-preamble.ts +15 -3
  104. package/src/commands/profile-sync.ts +205 -5
  105. package/src/commands/provider-credentials.ts +60 -27
  106. package/src/commands/runner.ts +63 -58
  107. package/src/commands/templates.ts +25 -2
  108. package/src/hooks/hook.ts +26 -67
  109. package/src/http/agents.ts +40 -1
  110. package/src/http/auth.ts +13 -1
  111. package/src/http/fs.ts +21 -51
  112. package/src/http/index.ts +2 -0
  113. package/src/http/mcp.ts +32 -0
  114. package/src/http/pages-public.ts +20 -4
  115. package/src/http/pages.ts +11 -3
  116. package/src/http/poll.ts +4 -0
  117. package/src/http/repos.ts +3 -3
  118. package/src/http/sessions.ts +82 -0
  119. package/src/http/tasks.ts +10 -2
  120. package/src/http/utils.ts +79 -1
  121. package/src/prompts/session-templates.ts +12 -8
  122. package/src/providers/acp-adapter.ts +62 -8
  123. package/src/providers/pi-mono-adapter.ts +23 -6
  124. package/src/providers/pricing-sources.md +11 -0
  125. package/src/scheduler/scheduler.test.ts +49 -2
  126. package/src/scheduler/scheduler.ts +4 -0
  127. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  128. package/src/scripts-runtime/types/stdlib.d.ts +8 -0
  129. package/src/scripts-runtime/types/swarm-sdk.d.ts +8 -0
  130. package/src/server.ts +2 -0
  131. package/src/slack/assistant.ts +87 -46
  132. package/src/slack/handlers.test.ts +1 -3
  133. package/src/slack/handlers.ts +102 -87
  134. package/src/slack/inbound-files.ts +466 -0
  135. package/src/slack/templates.ts +14 -0
  136. package/src/tasks/task-terminal-effects.ts +158 -0
  137. package/src/tests/acp-adapter.test.ts +369 -117
  138. package/src/tests/budget-claim-gate.test.ts +1 -0
  139. package/src/tests/build-pi-skills.test.ts +5 -7
  140. package/src/tests/claude-managed-setup.test.ts +52 -4
  141. package/src/tests/claude-md-session.test.ts +603 -0
  142. package/src/tests/codex-skill-resolver.test.ts +19 -0
  143. package/src/tests/context-versioning.test.ts +107 -0
  144. package/src/tests/cors-allowlist.test.ts +309 -0
  145. package/src/tests/credential-check.test.ts +157 -14
  146. package/src/tests/db-agents.test.ts +91 -0
  147. package/src/tests/db-context-versions.test.ts +37 -0
  148. package/src/tests/db-extraction-contract.test.ts +45 -0
  149. package/src/tests/db-runtime.test.ts +70 -0
  150. package/src/tests/db-task-read-contract.test.ts +624 -0
  151. package/src/tests/db-task-write-contract.test.ts +653 -0
  152. package/src/tests/db-tasks-read.test.ts +183 -0
  153. package/src/tests/db-tasks-write.test.ts +230 -0
  154. package/src/tests/defer-task.test.ts +389 -0
  155. package/src/tests/docs-index-footprint.test.js +2 -2
  156. package/src/tests/e2e-visuals-stage.test.ts +151 -0
  157. package/src/tests/file-lock.test.ts +144 -0
  158. package/src/tests/fixtures/file-lock-holder.ts +13 -0
  159. package/src/tests/fixtures/hold-file-lock.ts +25 -0
  160. package/src/tests/heartbeat-expiry.test.ts +188 -0
  161. package/src/tests/http-api-integration.test.ts +78 -1
  162. package/src/tests/pages-public-html.test.ts +43 -0
  163. package/src/tests/pages-svg-migration.test.ts +42 -0
  164. package/src/tests/preload.ts +4 -1
  165. package/src/tests/profile-sync-warning.test.ts +16 -0
  166. package/src/tests/profile-sync.test.ts +126 -0
  167. package/src/tests/prompt-template-session.test.ts +20 -4
  168. package/src/tests/provider-registration.test.ts +93 -0
  169. package/src/tests/rbac-e2e-helpers.test.ts +53 -0
  170. package/src/tests/rbac-e2e-helpers.ts +19 -2
  171. package/src/tests/reload-config.test.ts +26 -0
  172. package/src/tests/routing-affinity-inventory.test.ts +1 -1
  173. package/src/tests/runner-attachments-section.test.ts +22 -2
  174. package/src/tests/runner-context-preamble.test.ts +30 -0
  175. package/src/tests/runner-deployment-resume.test.ts +107 -0
  176. package/src/tests/runner-repo-autostash.test.ts +12 -0
  177. package/src/tests/script-connections.test.ts +4 -0
  178. package/src/tests/seed-scripts.test.ts +2 -2
  179. package/src/tests/session-tokens.test.ts +279 -0
  180. package/src/tests/slack-file-tools-attachments.test.ts +408 -0
  181. package/src/tests/slack-inbound-files.test.ts +847 -0
  182. package/src/tests/star-history-refresh.test.ts +102 -0
  183. package/src/tests/swarm-config-reserved-keys.test.ts +21 -0
  184. package/src/tests/swarm-repos.test.ts +20 -0
  185. package/src/tests/tool-annotations.test.ts +4 -3
  186. package/src/tests/ui-logs-parser.test.ts +176 -0
  187. package/src/tools/create-page.ts +4 -2
  188. package/src/tools/defer-task.ts +239 -0
  189. package/src/tools/repos/update-repo.ts +2 -2
  190. package/src/tools/schedules/create-schedule.ts +1 -0
  191. package/src/tools/schedules/list-schedules.ts +1 -0
  192. package/src/tools/schedules/patch-schedule.ts +1 -0
  193. package/src/tools/schedules/update-schedule.ts +1 -0
  194. package/src/tools/slack-download-file.ts +60 -29
  195. package/src/tools/slack-read.ts +73 -32
  196. package/src/tools/store-progress.ts +16 -146
  197. package/src/tools/tool-config.ts +1 -0
  198. package/src/tools/utils.ts +21 -0
  199. package/src/types.ts +37 -4
  200. package/src/utils/acp-session-token.ts +56 -0
  201. package/src/utils/file-lock.ts +111 -0
  202. package/src/utils/heartbeat-expiry.ts +78 -0
  203. package/src/utils/request-auth-context.ts +2 -0
  204. package/src/utils/task-attachment-links.ts +22 -0
  205. package/templates/skills/code-quality/content.md +8 -5
  206. package/templates/skills/download-task-attachment/content.md +2 -1
  207. package/{plugin/pi-skills → templates/skills}/review-offered-task/SKILL.md +5 -5
  208. package/templates/skills/review-offered-task/config.json +14 -0
  209. package/{plugin/commands/review-offered-task.md → templates/skills/review-offered-task/content.md} +3 -8
  210. package/templates/skills/scheduling/content.md +4 -0
  211. package/templates/skills/slack-interaction/content.md +2 -2
  212. package/templates/skills/work-on-task/SKILL.md +16 -0
  213. package/templates/skills/work-on-task/config.json +14 -0
  214. package/templates/skills/work-on-task/content.md +11 -0
  215. package/dist/assistant-0qt42nzg.js +0 -212
  216. package/dist/cli-fs1kkqgr.js +0 -317
  217. package/dist/cli-jnf5zghe.js +0 -269
  218. package/dist/cli-n1q8q14d.js +0 -177
  219. package/dist/handlers-yfp1qn81.js +0 -592
  220. package/plugin/commands/work-on-task.md +0 -39
  221. package/plugin/pi-skills/work-on-task/SKILL.md +0 -39
@@ -0,0 +1,466 @@
1
+ /**
2
+ * Slack files turned into task attachments: the ones a user shares with the
3
+ * bot, and the ones an agent fetches with `slack-download-file` / `slack-read`.
4
+ *
5
+ * The API downloads each file with the bot token, stores it through the active
6
+ * file provider and records a `task_attachments` row, so the worker gets the
7
+ * same fetch recipe as a file uploaded from the UI. Downloads stream to temp
8
+ * files (the size cap holds mid-stream and a batch never sits in memory). The
9
+ * task is created in `draft` while its uploads run (#1240), with its lease
10
+ * renewed so the abandoned-draft sweep leaves it alone: nobody can claim it
11
+ * before its attachments exist. Every file also stays in the task text as a
12
+ * `[File: …]` line, and the user gets a thread reply naming any file that
13
+ * could not be attached — never dropped silently. A download failure is
14
+ * also flagged on that line; a storage failure happens after the text is
15
+ * written, so it shows up only in the reply and in the missing attachment.
16
+ */
17
+ import { mkdtemp, rm } from "node:fs/promises";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import type { WebClient } from "@slack/web-api";
21
+ import { getTaskAttachments, getTaskById, promoteDraftTask, refreshDraftTaskLease } from "../be/db";
22
+ import { MAX_TASK_ATTACHMENT_BYTES, recordTaskAttachmentUpload } from "../be/task-attachment-store";
23
+ import { providerPath } from "../fs/provider";
24
+ import { getFileStorageProvider } from "../fs/registry";
25
+ import { resolveTemplate } from "../prompts/resolver";
26
+ import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
27
+ import type { AgentTask, CreateTaskOptions, TaskAttachment } from "../types";
28
+ import { scrubSecrets } from "../utils/secret-scrubber";
29
+ import { taskAttachmentFetchCommand } from "../utils/task-attachment-links";
30
+ import { getFileInfo, type SlackFile } from "./files";
31
+ // Side-effect import: registers all Slack event templates in the in-memory registry
32
+ import "./templates";
33
+
34
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
35
+ const SIGNED_OUT_REASON =
36
+ "Slack returned its sign-in page instead of the file (is the files:read scope granted?)";
37
+ /**
38
+ * Slack can serve its sign-in page at the file's own URL, as `text/html`, at
39
+ * whatever length the file's declared size happens to be — so for a file that
40
+ * really is HTML, neither the MIME type, the redirect, nor the size separates
41
+ * the page from the file. Only the page itself does.
42
+ */
43
+ const SIGN_IN_MARKERS = [
44
+ /<title>[^<]*sign in to slack/i,
45
+ /id="signin[_-]form"/i,
46
+ /slack\.com\/(workspace-)?signin/i,
47
+ ];
48
+ /** Enough of an HTML body to carry the markers above; a sign-in page leads with them. */
49
+ const SIGN_IN_SNIFF_BYTES = 4096;
50
+ /** How often a draft's lease is renewed while its uploads run; the sweep waits 5 minutes. */
51
+ let draftLeaseRefreshMs = 60_000;
52
+ /** A file a user sent the bot, like one uploaded from the dashboard composer. */
53
+ const SHARED_FILE_INTENT = "user-upload";
54
+ /** A file an agent pulled from Slack with `slack-download-file` / `slack-read`. */
55
+ const FETCHED_FILE_INTENT = "slack-file";
56
+ const BUFFERED_REASON = "follow-ups queued by ADDITIVE_SLACK carry the file name only";
57
+
58
+ export type SlackFileFailure = { file: SlackFile; reason: string };
59
+
60
+ export type AttachedSlackFile = { file: SlackFile; attachment: TaskAttachment };
61
+
62
+ /** What happened to one file an agent asked to attach: the attachment and how to fetch it, or why not. */
63
+ export type SlackFileOutcome =
64
+ | { attachment: TaskAttachment; fetchCommand: string }
65
+ | { reason: string };
66
+
67
+ /** A downloaded Slack file, spooled to a temp file so a batch never sits in memory. */
68
+ export type FetchedSlackFile = { file: SlackFile; path: string; sizeBytes: number; sha256: string };
69
+
70
+ export type InboundSlackFiles = {
71
+ /** Every file on the message, with full metadata (resolved via `files.info` when the event omitted it). */
72
+ files: SlackFile[];
73
+ fetched: FetchedSlackFile[];
74
+ failed: SlackFileFailure[];
75
+ /** Deletes the downloaded copies; hold the batch with `await using`. */
76
+ [Symbol.asyncDispose](): Promise<void>;
77
+ };
78
+
79
+ /** Error text safe to put in a task or a Slack message. */
80
+ function errorText(error: unknown): string {
81
+ return scrubSecrets(error instanceof Error ? error.message : String(error));
82
+ }
83
+
84
+ /**
85
+ * Format a file size in bytes to a human-readable string.
86
+ */
87
+ export function formatFileSize(bytes: number): string {
88
+ if (bytes < 1024) return `${bytes} B`;
89
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
90
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
91
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
92
+ }
93
+
94
+ /**
95
+ * Build a text representation of file attachments for inclusion in messages.
96
+ * Each file is formatted as: [File: filename.ext (mimetype, size) id=FILE_ID],
97
+ * followed by `(not attached: <reason>)` when the file never made it into the
98
+ * task's attachments.
99
+ */
100
+ export function buildAttachmentText(files: SlackFile[], failed: SlackFileFailure[] = []): string {
101
+ const reasons = new Map(failed.map((f) => [f.file.id, f.reason]));
102
+ return files
103
+ .map((f) => {
104
+ // A file only `files.info` could describe may still lack metadata.
105
+ const size = typeof f.size === "number" ? formatFileSize(f.size) : "unknown size";
106
+ const line = `[File: ${f.name ?? f.id} (${f.mimetype ?? "unknown type"}, ${size}) id=${f.id}]`;
107
+ const reason = reasons.get(f.id);
108
+ return reason ? `${line} (not attached: ${reason})` : line;
109
+ })
110
+ .join("\n");
111
+ }
112
+
113
+ /**
114
+ * Build the effective message text from the original text and any file attachments.
115
+ * - Text only: returns the text as-is
116
+ * - Files only: returns the attachment metadata
117
+ * - Both: returns text followed by attachment metadata
118
+ */
119
+ export function buildEffectiveText(
120
+ text: string | undefined,
121
+ files?: SlackFile[],
122
+ failed?: SlackFileFailure[],
123
+ ): string {
124
+ const hasText = !!text?.trim();
125
+ const hasFiles = files && files.length > 0;
126
+
127
+ if (hasText && hasFiles) {
128
+ return `${text}\n\n${buildAttachmentText(files, failed)}`;
129
+ }
130
+ if (hasFiles) {
131
+ return buildAttachmentText(files, failed);
132
+ }
133
+ return text || "";
134
+ }
135
+
136
+ /**
137
+ * The files of a follow-up that `ADDITIVE_SLACK` queues instead of turning into
138
+ * a task right away: the queued text keeps their `[File: …]` lines, but they
139
+ * are not attached, so they're reported like any other unattached file.
140
+ */
141
+ export function bufferedFileFailures(files: SlackFile[] | undefined): SlackFileFailure[] {
142
+ return (files ?? []).map((file) => ({ file, reason: BUFFERED_REASON }));
143
+ }
144
+
145
+ /**
146
+ * Download every file on a Slack message with the bot token, each to its own
147
+ * temp file. Never throws: a file that can't be fetched lands in `failed` with
148
+ * a reason the user and the agent can read. Hold the result with `await using`
149
+ * so the temp files go away with it.
150
+ */
151
+ export async function fetchSlackFiles(
152
+ client: WebClient,
153
+ files: SlackFile[] | undefined,
154
+ ): Promise<InboundSlackFiles> {
155
+ let dir: string | undefined;
156
+ const result: InboundSlackFiles = {
157
+ files: [],
158
+ fetched: [],
159
+ failed: [],
160
+ async [Symbol.asyncDispose]() {
161
+ if (dir) await rm(dir, { recursive: true, force: true });
162
+ },
163
+ };
164
+ if (!files || files.length === 0) return result;
165
+
166
+ // Disk use is bounded by the per-file cap times the files on this message.
167
+ dir = await mkdtemp(join(tmpdir(), "slack-files-"));
168
+ const token = client.token ?? process.env.SLACK_BOT_TOKEN;
169
+ for (const [index, eventFile] of files.entries()) {
170
+ // Slack Connect and some file_share events carry only the file id.
171
+ const file = eventFile.url_private_download
172
+ ? eventFile
173
+ : ((await getFileInfo(client, eventFile.id)) ?? eventFile);
174
+ result.files.push(file);
175
+
176
+ const outcome = await downloadSlackFile(file, token, join(dir, String(index)));
177
+ if (typeof outcome === "string") {
178
+ console.warn(`[Slack] could not fetch file ${file.id}: ${outcome}`);
179
+ result.failed.push({ file, reason: outcome });
180
+ } else {
181
+ result.fetched.push({ file, ...outcome });
182
+ }
183
+ }
184
+ return result;
185
+ }
186
+
187
+ /** Does the start of an HTML response read like Slack's sign-in page? */
188
+ function looksLikeSignIn(head: Uint8Array[]): boolean {
189
+ const joined = new Uint8Array(head.reduce((total, part) => total + part.byteLength, 0));
190
+ let offset = 0;
191
+ for (const part of head) {
192
+ joined.set(part, offset);
193
+ offset += part.byteLength;
194
+ }
195
+ const text = new TextDecoder().decode(joined);
196
+ return SIGN_IN_MARKERS.some((marker) => marker.test(text));
197
+ }
198
+
199
+ /**
200
+ * Stream one file to `path`, enforcing the attachment cap as bytes arrive, or
201
+ * return why it couldn't be fetched. Without `files:read` Slack serves its HTML
202
+ * sign-in page instead of the file, via a redirect or in place. So an HTML
203
+ * response counts as "not the file" when the file isn't HTML, when it came
204
+ * through a redirect off `/files-pri/`, when the body reads like the sign-in
205
+ * page, or when its size isn't the one Slack declared for the file. Non-HTML
206
+ * responses are never second-guessed.
207
+ */
208
+ async function downloadSlackFile(
209
+ file: SlackFile,
210
+ token: string | undefined,
211
+ path: string,
212
+ ): Promise<Omit<FetchedSlackFile, "file"> | string> {
213
+ const limit = `larger than the ${MAX_TASK_ATTACHMENT_BYTES / (1024 * 1024)} MB limit`;
214
+ if (file.size > MAX_TASK_ATTACHMENT_BYTES) return limit;
215
+ if (!file.url_private_download) return "Slack gave no download URL";
216
+ if (!token) return "no Slack bot token configured";
217
+
218
+ // Aborting — not just leaving the read loop — closes the connection, so the
219
+ // rest of an unwanted body stops arriving instead of being read and dropped.
220
+ const abort = new AbortController();
221
+ try {
222
+ const response = await fetch(file.url_private_download, {
223
+ headers: { Authorization: `Bearer ${token}` },
224
+ signal: AbortSignal.any([abort.signal, AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)]),
225
+ });
226
+ if (!response.ok) return `download failed (HTTP ${response.status})`;
227
+ const isHtml = (response.headers.get("content-type") ?? "").startsWith("text/html");
228
+ const redirectedToHtml =
229
+ isHtml && response.redirected && !new URL(response.url).pathname.includes("/files-pri/");
230
+ if (redirectedToHtml || (isHtml && !file.mimetype?.startsWith("text/html"))) {
231
+ abort.abort();
232
+ return SIGNED_OUT_REASON;
233
+ }
234
+ if (Number(response.headers.get("content-length") ?? 0) > MAX_TASK_ATTACHMENT_BYTES) {
235
+ abort.abort();
236
+ return limit;
237
+ }
238
+
239
+ const hasher = new Bun.CryptoHasher("sha256");
240
+ const sink = Bun.file(path).writer({ highWaterMark: 1024 * 1024 });
241
+ let sizeBytes = 0;
242
+ let overCap = false;
243
+ const head: Uint8Array[] = [];
244
+ let headBytes = 0;
245
+ try {
246
+ for await (const chunk of response.body ?? []) {
247
+ sizeBytes += chunk.byteLength;
248
+ if (sizeBytes > MAX_TASK_ATTACHMENT_BYTES) {
249
+ overCap = true;
250
+ break;
251
+ }
252
+ if (isHtml && headBytes < SIGN_IN_SNIFF_BYTES) {
253
+ const wanted = chunk.subarray(0, SIGN_IN_SNIFF_BYTES - headBytes);
254
+ head.push(wanted);
255
+ headBytes += wanted.byteLength;
256
+ }
257
+ hasher.update(chunk);
258
+ sink.write(chunk);
259
+ }
260
+ } finally {
261
+ await sink.end();
262
+ }
263
+ if (overCap) {
264
+ abort.abort();
265
+ return limit;
266
+ }
267
+ if (isHtml && looksLikeSignIn(head)) return SIGNED_OUT_REASON;
268
+ if (isHtml && file.size > 0 && sizeBytes !== file.size) {
269
+ return `Slack sent ${sizeBytes} bytes of HTML for a ${file.size}-byte file, likely its sign-in page (is the files:read scope granted?)`;
270
+ }
271
+ return { path, sizeBytes, sha256: hasher.digest("hex") };
272
+ } catch (error) {
273
+ return `download failed (${errorText(error)})`;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Create a Slack-sourced task and attach the files fetched from its message.
279
+ * With files, the task is created in `draft` and promoted once every upload
280
+ * has settled — success or not — so a worker can't start before its
281
+ * attachments exist. `unattached` lists every file of the message that the
282
+ * task ended up without: the ones that couldn't be downloaded plus the ones
283
+ * that couldn't be stored.
284
+ */
285
+ export async function createSlackTaskWithFiles(
286
+ description: string,
287
+ options: CreateTaskOptions,
288
+ inbound: InboundSlackFiles,
289
+ ): Promise<{ task: AgentTask; unattached: SlackFileFailure[] }> {
290
+ if (inbound.fetched.length === 0) {
291
+ const task = await createTaskWithSiblingAwareness(description, options);
292
+ return { task, unattached: inbound.failed };
293
+ }
294
+
295
+ const task = await createTaskWithSiblingAwareness(description, { ...options, status: "draft" });
296
+ const unattached = [...inbound.failed];
297
+ // Uploads run one after another and can outlast the abandoned-draft sweep's
298
+ // window; renewing the lease keeps the sweep from promoting a live batch.
299
+ const lease = setInterval(() => {
300
+ void refreshDraftTaskLease(task.id).catch((error) =>
301
+ console.warn(
302
+ `[Slack] could not renew the draft lease of task ${task.id}: ${errorText(error)}`,
303
+ ),
304
+ );
305
+ }, draftLeaseRefreshMs);
306
+ try {
307
+ unattached.push(...(await attachSlackFilesToTask(task.id, inbound.fetched, null)).unattached);
308
+ } finally {
309
+ clearInterval(lease);
310
+ await promoteDraftTask(task.id);
311
+ }
312
+ return { task, unattached };
313
+ }
314
+
315
+ /** Tests shorten the draft lease renewal to observe it; returns the previous interval. */
316
+ export function setDraftLeaseRefreshMsForTests(ms: number): number {
317
+ const previous = draftLeaseRefreshMs;
318
+ draftLeaseRefreshMs = ms;
319
+ return previous;
320
+ }
321
+
322
+ /**
323
+ * Store fetched Slack files as attachments of a task. `agentId` is the agent
324
+ * that fetched them, or `null` for files a user sent the bot.
325
+ *
326
+ * A file whose bytes the task already holds is reused instead of uploaded
327
+ * again. Each blob is keyed by its Slack file id, so two different files can
328
+ * never overwrite each other's bytes — not even from parallel tool calls.
329
+ * The attachment name stays the file's own, and gets the Slack file id as a
330
+ * prefix when another attachment already uses it (pasted screenshots are all
331
+ * called "image.png"). That is only for readability: concurrent calls can still
332
+ * pick the same name, and fetch commands never depend on it — each attachment
333
+ * downloads into its own directory.
334
+ */
335
+ export async function attachSlackFilesToTask(
336
+ taskId: string,
337
+ fetched: InboundSlackFiles["fetched"],
338
+ agentId: string | null,
339
+ ): Promise<{ attached: AttachedSlackFile[]; unattached: SlackFileFailure[] }> {
340
+ const existing = await getTaskAttachments(taskId);
341
+ const bySha = new Map(existing.flatMap((a) => (a.sha256 ? [[a.sha256, a] as const] : [])));
342
+ const usedNames = new Set(existing.map((a) => a.name));
343
+ const provider = getFileStorageProvider();
344
+ const attached: AttachedSlackFile[] = [];
345
+ const unattached: SlackFileFailure[] = [];
346
+
347
+ for (const { file, path, sizeBytes, sha256 } of fetched) {
348
+ const known = bySha.get(sha256);
349
+ if (known) {
350
+ attached.push({ file, attachment: known });
351
+ continue;
352
+ }
353
+ const name = usedNames.has(file.name) ? `${file.id}-${file.name}` : file.name;
354
+ usedNames.add(name);
355
+ const key = providerPath({ taskId, name: `slack-${file.id}-${file.name}` });
356
+ const scope = { taskId, name, key };
357
+ try {
358
+ const uploaded = await provider.upload(scope, Bun.file(path), {
359
+ contentType: file.mimetype,
360
+ sizeBytes,
361
+ message: `Upload ${name} from Slack for task ${taskId}`,
362
+ });
363
+ const attachment = await recordTaskAttachmentUpload({
364
+ provider,
365
+ scope,
366
+ uploaded,
367
+ sizeBytes,
368
+ sha256,
369
+ contentType: file.mimetype,
370
+ agentId,
371
+ intent: agentId ? FETCHED_FILE_INTENT : SHARED_FILE_INTENT,
372
+ description: agentId
373
+ ? `Fetched from Slack (file ${file.id})`
374
+ : `Shared on Slack (file ${file.id})`,
375
+ });
376
+ bySha.set(sha256, attachment);
377
+ attached.push({ file, attachment });
378
+ } catch (error) {
379
+ const reason = `could not be stored (${errorText(error)})`;
380
+ console.warn(`[Slack] file ${file.id} for task ${taskId} ${reason}`);
381
+ unattached.push({ file, reason });
382
+ }
383
+ }
384
+ return { attached, unattached };
385
+ }
386
+
387
+ /**
388
+ * Download Slack files and attach them to `task` on behalf of `agentId`
389
+ * (`slack-download-file`, `slack-read`), one file at a time so a long thread
390
+ * never holds more than one download. One outcome per Slack file id.
391
+ */
392
+ export async function attachSlackFilesForAgent(
393
+ client: WebClient,
394
+ task: AgentTask,
395
+ files: SlackFile[],
396
+ agentId: string,
397
+ ): Promise<Map<string, SlackFileOutcome>> {
398
+ const outcomes = new Map<string, SlackFileOutcome>();
399
+ for (const file of files) {
400
+ if (outcomes.has(file.id)) continue;
401
+ await using inbound = await fetchSlackFiles(client, [file]);
402
+ const { attached, unattached } = await attachSlackFilesToTask(
403
+ task.id,
404
+ inbound.fetched,
405
+ agentId,
406
+ );
407
+ for (const { file: failed, reason } of [...inbound.failed, ...unattached]) {
408
+ outcomes.set(failed.id, { reason });
409
+ }
410
+ for (const { file: stored, attachment } of attached) {
411
+ outcomes.set(stored.id, {
412
+ attachment,
413
+ fetchCommand: taskAttachmentFetchCommand(task.id, attachment.id, attachment.name),
414
+ });
415
+ }
416
+ }
417
+ return outcomes;
418
+ }
419
+
420
+ /**
421
+ * The task a Slack file tool may attach files to: `taskId` if the calling
422
+ * agent owns or created it, `null` otherwise (or when there is no task).
423
+ */
424
+ export async function attachableTask(
425
+ agentId: string,
426
+ taskId: string | undefined,
427
+ ): Promise<AgentTask | null> {
428
+ if (!taskId) return null;
429
+ const task = await getTaskById(taskId);
430
+ if (!task) return null;
431
+ return task.agentId === agentId || task.creatorAgentId === agentId ? task : null;
432
+ }
433
+
434
+ /**
435
+ * Tell the user, in the thread, which of their files the agent won't see —
436
+ * one reply listing each file once, even when several tasks failed on it.
437
+ * Best-effort: a failed post is logged, never thrown.
438
+ */
439
+ export async function notifySlackFileFailures(
440
+ client: WebClient,
441
+ channel: string,
442
+ threadTs: string,
443
+ failures: SlackFileFailure[],
444
+ ): Promise<void> {
445
+ if (failures.length === 0) return;
446
+ const byFile = new Map(failures.map((f) => [f.file.id, f]));
447
+ const failedFiles = [...byFile.values()]
448
+ .map((f) => `\`${f.file.name}\` (${f.reason})`)
449
+ .join(", ");
450
+ const { text } = resolveTemplate("slack.message.attachment_failed", {
451
+ failed_files: failedFiles,
452
+ });
453
+ try {
454
+ await client.chat.postMessage({
455
+ channel,
456
+ thread_ts: threadTs,
457
+ text,
458
+ unfurl_links: false,
459
+ unfurl_media: false,
460
+ });
461
+ } catch (error) {
462
+ console.warn(
463
+ `[Slack] could not post the attachment-failure notice in ${channel}/${threadTs}: ${errorText(error)}`,
464
+ );
465
+ }
466
+ }
@@ -39,6 +39,20 @@ registerTemplate({
39
39
  category: "system",
40
40
  });
41
41
 
42
+ registerTemplate({
43
+ eventType: "slack.message.attachment_failed",
44
+ header: "",
45
+ defaultBody:
46
+ ":warning: Couldn't attach {{failed_files}} to the task. The agent sees the file name, not its contents.",
47
+ variables: [
48
+ {
49
+ name: "failed_files",
50
+ description: "Comma-separated `name` (reason) list of the files that were not attached",
51
+ },
52
+ ],
53
+ category: "system",
54
+ });
55
+
42
56
  registerTemplate({
43
57
  eventType: "slack.message.thread_context",
44
58
  header: "",
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Post-commit side effects of a task reaching a terminal state.
3
+ *
4
+ * Extracted verbatim from `src/tools/store-progress.ts` so a second writer
5
+ * (`src/tools/defer-task.ts`) fires the identical set. Three blocks, all
6
+ * fire-and-forget: none of them may change the task's status or fail the
7
+ * caller's response.
8
+ *
9
+ * 1. task_completion memory write (+ swarm promotion for shared research)
10
+ * 2. server-side memory raters
11
+ * 3. lead follow-up task
12
+ *
13
+ * Call this AFTER the completion transaction commits. Callers own the
14
+ * "should this run at all" guards (idempotent re-calls, forced overwrites);
15
+ * this function assumes the terminal write really happened.
16
+ */
17
+
18
+ import { getSessionLogsByTaskId } from "@/be/db";
19
+ import { getEmbeddingProvider, getMemoryStore } from "@/be/memory";
20
+ import { getRetrievalsForTask } from "@/be/memory/raters/retrieval";
21
+ import { runServerRaters } from "@/be/memory/raters/run-server-raters";
22
+ import { shouldPersistTaskCompletionMemory } from "@/memory/automatic-task-gate";
23
+ import { createWorkerTaskFollowUp } from "@/tasks/worker-follow-up";
24
+ import type { AgentTask } from "@/types";
25
+ import { scrubSecrets } from "@/utils/secret-scrubber";
26
+
27
+ export async function runTaskTerminalEffects(args: {
28
+ task: AgentTask;
29
+ status: "completed" | "failed";
30
+ output?: string;
31
+ failureReason?: string;
32
+ agentId?: string;
33
+ persistMemory?: boolean;
34
+ }): Promise<void> {
35
+ const { task, status, output, failureReason, agentId, persistMemory } = args;
36
+ const taskId = task.id;
37
+
38
+ // Index completed and failed tasks as memory (async, non-blocking).
39
+ // Automatic/recurring tasks are noisy by default; require explicit opt-in.
40
+ if (shouldPersistTaskCompletionMemory(task, persistMemory)) {
41
+ (async () => {
42
+ try {
43
+ const taskContent =
44
+ status === "completed"
45
+ ? `Task: ${task.task}\n\nOutput:\n${output || "(no output)"}`
46
+ : `Task: ${task.task}\n\nFailure reason:\n${failureReason || "No reason provided"}\n\nThis task failed. Learn from this to avoid repeating the mistake.`;
47
+
48
+ // Skip indexing if there's truly no content
49
+ if (taskContent.length < 30) return;
50
+
51
+ const store = getMemoryStore();
52
+ const provider = getEmbeddingProvider();
53
+
54
+ const memory = await store.store({
55
+ agentId: agentId ?? null,
56
+ content: taskContent,
57
+ name: `Task: ${task.task.slice(0, 80)}`,
58
+ scope: "agent",
59
+ source: "task_completion",
60
+ sourceTaskId: taskId,
61
+ });
62
+ const embedding = await provider.embed(taskContent);
63
+ if (embedding) {
64
+ await store.updateEmbedding(memory.id, embedding, provider.name);
65
+ }
66
+
67
+ // Auto-promote high-value completions to swarm memory (P3)
68
+ const shouldShareWithSwarm =
69
+ status === "completed" &&
70
+ (task.taskType === "research" ||
71
+ task.tags?.includes("knowledge") ||
72
+ task.tags?.includes("shared"));
73
+
74
+ if (shouldShareWithSwarm) {
75
+ try {
76
+ const swarmMemory = await store.store({
77
+ agentId: agentId ?? null,
78
+ scope: "swarm",
79
+ name: `Shared: ${task.task.slice(0, 80)}`,
80
+ content: `Task completed by agent ${agentId}:\n\n${taskContent}`,
81
+ source: "task_completion",
82
+ sourceTaskId: taskId,
83
+ });
84
+ const swarmEmbedding = await provider.embed(taskContent);
85
+ if (swarmEmbedding) {
86
+ await store.updateEmbedding(swarmMemory.id, swarmEmbedding, provider.name);
87
+ }
88
+ } catch {
89
+ // Non-blocking — swarm memory promotion failure is not critical
90
+ }
91
+ }
92
+ } catch {
93
+ // Non-blocking — task completion memory failure should not affect task status
94
+ }
95
+ })().catch((err) =>
96
+ console.error(
97
+ "[task-terminal-effects] task completion memory write failed:",
98
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
99
+ ),
100
+ );
101
+ }
102
+
103
+ // Memory rater v1.5 — fire server-side raters on task completion.
104
+ // Plan: thoughts/taras/plans/2026-05-05-memory-rater-v1.5/step-2.md §5
105
+ //
106
+ // Read `memory_retrieval` rows for this task + concatenated session_logs
107
+ // and hand both to `runServerRaters`, which iterates the allow-listed
108
+ // server raters (currently just `implicit-citation`), stamps source,
109
+ // applies the configured weight multiplier, and persists via
110
+ // `applyRating`. The orchestration is extracted so it can be unit-tested
111
+ // with stub raters (see `src/tests/run-server-raters.test.ts`).
112
+ //
113
+ // Fire-and-forget: rater failure must NEVER affect task status.
114
+ (async () => {
115
+ try {
116
+ const retrievals = await getRetrievalsForTask(taskId);
117
+ if (retrievals.length === 0) return;
118
+
119
+ const retrievedMemoryIds = retrievals.map((r) => r.memoryId);
120
+ const logs = await getSessionLogsByTaskId(taskId);
121
+ const evidence = logs.map((l) => l.content).join("\n");
122
+
123
+ await runServerRaters({
124
+ taskId,
125
+ agentId: agentId ?? "",
126
+ retrievedMemoryIds,
127
+ evidence,
128
+ });
129
+ } catch (err) {
130
+ console.error(
131
+ "[task-terminal-effects] server-rater fire failed:",
132
+ err instanceof Error ? err.message : String(err),
133
+ );
134
+ }
135
+ })().catch((err) =>
136
+ console.error(
137
+ "[task-terminal-effects] server rater run failed:",
138
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
139
+ ),
140
+ );
141
+
142
+ // Create follow-up task for the lead when a worker task finishes.
143
+ // This replaces the old poll-based tasks_finished trigger which was unreliable.
144
+ // Skip for workflow-managed tasks — the workflow engine handles sequencing via resume.ts.
145
+ if (!task.workflowRunId) {
146
+ try {
147
+ const followUp = await createWorkerTaskFollowUp({ task, status, output, failureReason });
148
+ if (followUp) {
149
+ console.log(
150
+ `[task-terminal-effects] Created follow-up task ${followUp.id.slice(0, 8)} for ${status} task ${taskId.slice(0, 8)}`,
151
+ );
152
+ }
153
+ } catch (err) {
154
+ // Non-blocking — follow-up task creation failure should not affect the caller's response
155
+ console.warn(`[task-terminal-effects] Failed to create follow-up task: ${err}`);
156
+ }
157
+ }
158
+ }