@desplega.ai/agent-swarm 1.67.1 → 1.67.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.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025-2026 desplega.ai
3
+ Copyright (c) 2025-2026 desplega.sh
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -11,7 +11,12 @@
11
11
  <sub>Built by <a href="https://desplega.sh">desplega.sh</a> — by builders, for builders.</sub>
12
12
  </p>
13
13
 
14
- https://github.com/user-attachments/assets/bd308567-d21e-44a5-87ec-d25aeb1de3d3
14
+ <p align="center">
15
+ <video src="https://github.com/user-attachments/assets/e220712e-c54d-4f46-b059-bac04639d229" controls muted playsinline width="720"></video>
16
+ </p>
17
+ <p align="center">
18
+ <sub>▸ <a href="./assets/agent-swarm.mp4">daily evolution</a> · <a href="./assets/agent-swarm-slack-to-pr.mp4">slack → pr</a> · <a href="./assets/video-source">Making of</a></sub>
19
+ </p>
15
20
 
16
21
  <p align="center">
17
22
  <a href="https://agent-swarm.dev">
@@ -33,101 +38,90 @@ https://github.com/user-attachments/assets/bd308567-d21e-44a5-87ec-d25aeb1de3d3
33
38
 
34
39
  > **What if your AI agents remembered everything, learned from every mistake, and got better with every task?**
35
40
 
36
- Agent Swarm lets you run a team of AI coding agents that coordinate autonomously. A **lead agent** receives tasks (from you, Slack, or GitHub), breaks them down, and delegates to **worker agents** running in Docker containers. Workers execute tasks, report progress, and ship code — all without manual intervention.
37
-
38
- ## Key Features
39
-
40
- - **Lead/Worker coordination** — A lead agent delegates and tracks work across multiple workers
41
- - **Docker isolation** — Each worker runs in its own container with a full dev environment
42
- - **Slack, GitHub, GitLab & Email integration** — Create tasks by messaging the bot, @mentioning it in issues/PRs/MRs, or sending an email
43
- - **Task lifecycle** — Priority queues, dependencies, pause/resume across deployments
44
- - **Compounding memory** — Agents learn from every session and get smarter over time
45
- - **Persistent identity** — Each agent has its own personality, expertise, and working style that evolves
46
- - **Dashboard UI** — Real-time monitoring of agents, tasks, and inter-agent chat
47
- - **Service discovery** — Workers can expose HTTP services and discover each other
48
- - **Scheduled tasks** — Cron-based recurring task automation
49
- - **Templates registry** — Pre-built agent templates (9 official: lead, coder, researcher, reviewer, tester, FDE, content-writer, content-reviewer, content-strategist) with a gallery UI and docker-compose builder
50
- - **GitLab integration** — Full GitLab webhook support alongside GitHub via provider adapter pattern
51
- - **Working directory support** — Tasks can specify a custom starting directory for agents via the `dir` parameter
52
- - **Multi-provider** — Run agents with Claude Code, pi-mono, or OpenAI Codex (`HARNESS_PROVIDER=claude|pi|codex`)
53
- - **Agent-fs integration** — Persistent, searchable filesystem shared across the swarm with auto-registration on first boot
54
- - **Debug dashboard** — SQL query interface with Monaco editor and AG Grid results for database inspection
55
- - **Workflow engine** — DAG-based workflow automation with executor registry, checkpoint durability, webhook/schedule/manual triggers, per-step retry, structured I/O schemas, fan-out/convergence, configurable failure handling, and version history
56
- - **Linear integration** — Bidirectional ticket tracker sync via OAuth + webhooks with AgentSession lifecycle and generic tracker abstraction
57
- - **Portless local dev** — Friendly URLs for local development (`api.swarm.localhost:1355`) via portless proxy
58
- - **Onboarding wizard** — Interactive CLI wizard (`agent-swarm onboard`) to set up a new swarm from scratch with presets, credential collection, and docker-compose generation
59
- - **Skill system** — Reusable procedural knowledge: create, install, publish, and sync skills from GitHub with scope resolution (agent → swarm → global)
60
- - **Human-in-the-Loop** — Workflow nodes that pause for human approval or input, with a dashboard UI for reviewing and responding to requests
61
- - **MCP server management** — Register, install, and manage MCP servers for agents with scope cascade (agent → swarm → global) and auto-injection into worker containers
62
- - **Context usage tracking** — Monitor context window utilization and compaction events per task with visual indicators in the dashboard
41
+ ## What it does
63
42
 
64
- ## Quick Start
43
+ Agent Swarm runs a team of AI coding agents that coordinate autonomously. A **lead agent** receives tasks — from Slack, GitHub, GitLab, email, or the API — breaks them down, and delegates to **worker agents** running in Docker containers. Workers execute tasks, ship code, and write their learnings back to a shared memory so the whole swarm gets smarter every session.
65
44
 
66
- ### Prerequisites
45
+ Learn more in the [architecture overview](https://docs.agent-swarm.dev/docs/architecture/overview).
67
46
 
68
- - [Docker](https://docker.com) and Docker Compose
69
- - A [Claude Code](https://docs.anthropic.com/en/docs/claude-code) OAuth token (`claude setup-token`)
47
+ ```mermaid
48
+ flowchart LR
49
+ subgraph IN["Tasks come in"]
50
+ direction TB
51
+ S["Slack"]
52
+ G["GitHub / GitLab"]
53
+ E["Email"]
54
+ A["API / CLI"]
55
+ end
70
56
 
71
- ### Option A: Docker Compose (recommended)
57
+ LEAD(["Lead Agent<br/>plans &amp; delegates"])
72
58
 
73
- The fastest way to get a full swarm running — API server, lead agent, and 2 workers.
59
+ subgraph WORKERS["Workers in Docker"]
60
+ direction TB
61
+ W1["Worker"]
62
+ W2["Worker"]
63
+ W3["Worker"]
64
+ end
74
65
 
75
- ```bash
76
- git clone https://github.com/desplega-ai/agent-swarm.git
77
- cd agent-swarm
66
+ subgraph BRAIN["Persistent brain"]
67
+ direction TB
68
+ MEM["Memory<br/>(vector search)"]
69
+ ID["Identity<br/>(SOUL, CLAUDE.md)"]
70
+ end
78
71
 
79
- # Configure environment
80
- cp .env.docker.example .env
81
- # Edit .env — set API_KEY and CLAUDE_CODE_OAUTH_TOKEN at minimum
72
+ subgraph OUT["Work ships"]
73
+ direction TB
74
+ PR["Pull Requests"]
75
+ REPLY["Slack replies"]
76
+ EMAIL["Email replies"]
77
+ end
82
78
 
83
- # Start everything
84
- docker compose -f docker-compose.example.yml --env-file .env up -d
79
+ IN --> LEAD --> WORKERS
80
+ WORKERS -->|reads context| BRAIN
81
+ WORKERS -->|writes learnings| BRAIN
82
+ WORKERS --> OUT
85
83
  ```
86
84
 
87
- The API runs on port `3013`. The dashboard is available separately (see [Dashboard](#dashboard)).
85
+ ## Highlights
88
86
 
89
- The API includes interactive documentation at `http://localhost:3013/docs` (Scalar UI) and a machine-readable OpenAPI 3.1 spec at `http://localhost:3013/openapi.json`.
87
+ - **Lead/worker orchestration in Docker** isolated dev environments, priority queues, pause/resume across deploys. [Architecture →](https://docs.agent-swarm.dev/docs/architecture/overview)
88
+ - **Compounding memory & persistent identity** — agents remember past sessions and evolve their own persona, expertise, and notes. [Memory →](https://docs.agent-swarm.dev/docs/architecture/memory) · [Agents →](https://docs.agent-swarm.dev/docs/architecture/agents)
89
+ - **Multi-channel inputs** — Slack, GitHub, GitLab, email, Linear, and the HTTP API all create tasks. [Integrations](#integrations)
90
+ - **Workflow engine with Human-in-the-Loop** — DAG-based automation with approval gates, retries, and structured I/O. [Workflows →](https://docs.agent-swarm.dev/docs/concepts/workflows)
91
+ - **Scheduled & recurring tasks** — cron-based automation for standing work. [Scheduling →](https://docs.agent-swarm.dev/docs/concepts/scheduling)
92
+ - **Multi-provider** — run with Claude Code, OpenAI Codex, or pi-mono. [Harness config →](https://docs.agent-swarm.dev/docs/guides/harness-configuration)
93
+ - **Skills & MCP servers** — reusable procedural knowledge and per-agent MCP servers with scope cascade. [MCP tools →](https://docs.agent-swarm.dev/docs/reference/mcp-tools)
94
+ - **Real-time dashboard** — monitor agents, tasks, and inter-agent chat. [app.agent-swarm.dev →](https://app.agent-swarm.dev)
90
95
 
91
- ### Option B: Local API + Docker Workers
96
+ ## Quick Start
92
97
 
93
- Run the API locally and connect Docker workers to it.
98
+ **Prerequisites:** [Docker](https://docker.com) and a [Claude Code](https://docs.anthropic.com/en/docs/claude-code) OAuth token (`claude setup-token`).
94
99
 
95
- ```bash
96
- git clone https://github.com/desplega-ai/agent-swarm.git
97
- cd agent-swarm
98
- bun install
100
+ The fastest way is the onboarding wizard — it collects credentials, picks presets, and generates a working `docker-compose.yml`:
99
101
 
100
- # 1. Configure and start the API server
101
- cp .env.example .env
102
- # Edit .env — set API_KEY
103
- bun run start:http
102
+ ```bash
103
+ bunx @desplega.ai/agent-swarm onboard
104
104
  ```
105
105
 
106
- In a new terminal, start a worker:
106
+ Prefer manual setup? Clone and run with Docker Compose:
107
107
 
108
108
  ```bash
109
- # 2. Configure and run a Docker worker
110
- cp .env.docker.example .env.docker
111
- # Edit .env.docker — set API_KEY (same as above) and CLAUDE_CODE_OAUTH_TOKEN
112
- bun run docker:build:worker
113
- mkdir -p ./logs ./work/shared ./work/worker-1
114
- bun run docker:run:worker
109
+ git clone https://github.com/desplega-ai/agent-swarm.git
110
+ cd agent-swarm
111
+ cp .env.docker.example .env
112
+ # edit .env — set API_KEY and CLAUDE_CODE_OAUTH_TOKEN
113
+ docker compose -f docker-compose.example.yml --env-file .env up -d
115
114
  ```
116
115
 
117
- ### Option C: Claude Code as Lead Agent
116
+ The API runs on port `3013`, with interactive docs at `http://localhost:3013/docs` and an OpenAPI 3.1 spec at `http://localhost:3013/openapi.json`.
118
117
 
119
- Use Claude Code directly as the lead agent — no Docker required for the lead.
120
-
121
- ```bash
122
- # After starting the API server (Option B, step 1):
123
- bunx @desplega.ai/agent-swarm connect
124
- ```
118
+ <details>
119
+ <summary><strong>Other setups</strong></summary>
125
120
 
126
- This configures Claude Code to connect to the swarm. Start Claude Code and tell it:
121
+ - **Local API + Docker workers** run the API on your host, workers in Docker. See [Getting Started](https://docs.agent-swarm.dev/docs/getting-started).
122
+ - **Claude Code as the lead agent** — `bunx @desplega.ai/agent-swarm connect`, then tell Claude Code to register as the lead.
127
123
 
128
- ```
129
- Register yourself as the lead agent in the agent-swarm.
130
- ```
124
+ </details>
131
125
 
132
126
  ## How It Works
133
127
 
@@ -141,312 +135,37 @@ Worker Worker Worker
141
135
  (Docker containers with full dev environments)
142
136
  ```
143
137
 
144
- 1. **You send a task** via Slack DM, GitHub @mention, email, or directly through the API
145
- 2. **Lead agent plans** — breaks the task down and assigns subtasks to workers
146
- 3. **Workers execute** — each in an isolated Docker container with git, Node.js, Python, etc.
147
- 4. **Progress is tracked** — real-time updates in the dashboard, Slack threads, or API
148
- 5. **Results are delivered** PRs created, issues closed, Slack replies sent
149
- 6. **Agents learn** — every session's learnings are extracted and recalled in future tasks
150
-
151
- ## Agents Get Smarter Over Time
152
-
153
- Agent Swarm agents aren't stateless. They build compounding knowledge through multiple automatic mechanisms:
154
-
155
- ### Memory System
138
+ 1. A task arrives via Slack DM, GitHub @mention, email, or the API.
139
+ 2. The lead plans and delegates subtasks to workers.
140
+ 3. Workers execute in isolated Docker containers (git, Node.js, Python, etc.).
141
+ 4. Progress streams to the dashboard, Slack threads, or the API.
142
+ 5. Results ship back out as PRs, issue replies, or Slack messages.
143
+ 6. Session learnings are extracted and become memory for future tasks.
156
144
 
157
- Every agent has a searchable memory backed by OpenAI embeddings (`text-embedding-3-small`). Memories are automatically created from:
158
-
159
- - **Session summaries** — At the end of each session, a lightweight model extracts key learnings: mistakes made, patterns discovered, failed approaches, and codebase knowledge. These summaries become searchable memories.
160
- - **Task completions** — Every completed (or failed) task's output is indexed. Failed tasks include notes about what went wrong, so the agent avoids repeating the same mistake.
161
- - **File-based notes** — Agents write to `/workspace/personal/memory/` in their per-agent directory. Files are automatically indexed and can be promoted to swarm scope.
162
- - **Lead-to-worker injection** — The lead agent can push specific learnings into any worker's memory using the `inject-learning` tool, closing the feedback loop.
163
-
164
- Before starting each task, the runner automatically searches for relevant memories and includes them in the agent's context. Past experience directly informs future work.
165
-
166
- ### Persistent Identity
167
-
168
- Each agent has four identity files that persist across sessions and evolve over time:
169
-
170
- | File | Purpose | Example |
171
- |------|---------|---------|
172
- | **SOUL.md** | Core persona, values, behavioral directives | "You're not a chatbot. Be thorough. Own your mistakes." |
173
- | **IDENTITY.md** | Expertise, working style, track record | "I'm the coding arm of the swarm. I ship fast and clean." |
174
- | **TOOLS.md** | Environment knowledge — repos, services, APIs | "The API runs on port 3013. Use `wts` for worktree management." |
175
- | **CLAUDE.md** | Persistent notes and instructions | Learnings, preferences, important context |
176
-
177
- Agents can edit these files directly during a session. Changes are synced to the database in real-time (on every file edit) and at session end. When the agent restarts, its identity is restored from the database. Version history is tracked for all changes.
178
-
179
- The default templates encourage self-improvement:
180
- - Tools you wished you had? Update your startup script.
181
- - Environment knowledge gained? Record it in TOOLS.md.
182
- - Patterns discovered? Add them to your notes.
183
- - Mistakes to avoid? Add guardrails.
184
-
185
- ### Startup Scripts
186
-
187
- Each agent has a startup script (`/workspace/start-up.sh`) that runs at every container start. Agents can modify this script to install tools, configure their environment, or set up workflows — and the changes persist across restarts. An agent that discovers it needs `ripgrep` will install it once, and it'll be there for every future session.
188
-
189
- ## Agent Configuration
190
-
191
- ### Identity Management
192
-
193
- Agent identity is stored in the database and synced to the filesystem at session start. There are three ways to configure it:
194
-
195
- 1. **Default generation** — On first registration, the system generates templates based on the agent's name, role, and description.
196
- 2. **Self-editing** — Agents modify their own identity files during sessions. A PostToolUse hook syncs changes to the database in real-time.
197
- 3. **API / MCP tool** — Use the `update-profile` tool to programmatically set any identity field (soulMd, identityMd, toolsMd, claudeMd, setupScript).
198
-
199
- ### System Prompt Assembly
200
-
201
- The system prompt is built from multiple layers, assembled at task start:
202
-
203
- 1. **Base role instructions** — Lead or worker-specific behavior rules
204
- 2. **Agent identity** — SOUL.md + IDENTITY.md content
205
- 3. **Repository context** — If the task targets a specific GitHub repo, that repo's CLAUDE.md is included
206
- 4. **Filesystem guide** — Memory directories, personal/shared workspace, setup script instructions
207
- 5. **Self-awareness** — How the agent is built (runtime, hooks, memory system, task lifecycle)
208
- 6. **Additional prompt** — Custom text from `SYSTEM_PROMPT` env var or `--system-prompt` CLI flag
209
-
210
- ### Hook System
211
-
212
- Six hooks fire during each Claude Code session, providing safety, context management, and persistence:
213
-
214
- | Hook | When | What it does |
215
- |------|------|-------------|
216
- | **SessionStart** | Session begins | Writes CLAUDE.md from DB, loads concurrent session context for leads |
217
- | **PreCompact** | Before context compaction | Injects a "goal reminder" with current task details so the agent doesn't lose track |
218
- | **PreToolUse** | Before each tool call | Checks for task cancellation, detects tool loops (same tool/args repeated), blocks excessive polling |
219
- | **PostToolUse** | After each tool call | Sends heartbeat, syncs identity file edits to DB, auto-indexes memory files |
220
- | **UserPromptSubmit** | New iteration starts | Checks for task cancellation |
221
- | **Stop** | Session ends | Saves PM2 state, syncs all identity files, runs session summarization via Haiku, marks agent offline |
145
+ More detail in the [task lifecycle docs](https://docs.agent-swarm.dev/docs/concepts/task-lifecycle).
222
146
 
223
147
  ## Integrations
224
148
 
225
- ### Slack
226
-
227
- Create a [Slack App](https://api.slack.com/apps) with Socket Mode enabled. Required scopes: `chat:write`, `users:read`, `users:read.email`, `channels:history`, `im:history`.
228
-
229
- ```bash
230
- # Add to your .env
231
- SLACK_BOT_TOKEN=xoxb-... # Bot User OAuth Token
232
- SLACK_APP_TOKEN=xapp-... # App-Level Token (Socket Mode)
233
- ```
234
-
235
- Message the bot directly to create tasks. Workers reply in threads with progress updates. Optionally restrict access with `SLACK_ALLOWED_EMAIL_DOMAINS` or `SLACK_ALLOWED_USER_IDS`.
236
-
237
- ### GitHub App
238
-
239
- Set up a [GitHub App](https://github.com/settings/apps/new) to receive webhooks when the bot is @mentioned or assigned to issues/PRs.
240
-
241
- **Webhook URL:** `https://<your-domain>/api/github/webhook`
242
-
243
- **Required permissions:**
244
- - Issues: Read & Write
245
- - Pull requests: Read & Write
246
-
247
- **Subscribe to events:** Issues, Issue comments, Pull requests, Pull request reviews, Pull request review comments, Check runs, Check suites, Workflow runs
248
-
249
- ```bash
250
- # Add to your .env
251
- GITHUB_WEBHOOK_SECRET=your-webhook-secret
252
- GITHUB_BOT_NAME=your-bot-name # Default: agent-swarm-bot
253
-
254
- # Optional: Enable bot reactions (emoji acknowledgments on GitHub)
255
- GITHUB_APP_ID=123456
256
- GITHUB_APP_PRIVATE_KEY=base64-encoded-key
257
- ```
258
-
259
- **Supported events:**
260
-
261
- | Event | What happens |
262
- |-------|-------------|
263
- | Bot assigned to PR/issue | Creates a task for the lead agent |
264
- | Review requested from bot | Creates a review task |
265
- | `@bot-name` in comment/issue/PR | Creates a task with the mention context |
266
- | PR review submitted (on bot's PR) | Creates a notification task with review feedback |
267
- | CI failure (on PRs with existing tasks) | Creates a CI notification task |
268
-
269
- <details>
270
- <summary><strong>Flow Diagrams</strong> (click to expand)</summary>
271
-
272
- #### Task Creation Flow
273
-
274
- How GitHub events become tasks in the swarm:
275
-
276
- ```mermaid
277
- %%{init: {'theme': 'dark', 'themeVariables': {'fontSize': '13px', 'nodeSpacing': 30, 'rankSpacing': 40}}}%%
278
- flowchart TB
279
- subgraph ENTRY["1. GitHub Webhook Entry Points"]
280
- direction LR
281
- E1["Issue<br/>opened/edited"]
282
- E2["PR<br/>opened/edited"]
283
- E3["Comment<br/>created"]
284
- E4["Bot Assigned<br/>to Issue/PR"]
285
- E5["Review Requested<br/>from Bot"]
286
- end
287
-
288
- subgraph GATE["2. Trigger Gate"]
289
- M{"@agent-swarm<br/>mention?"}
290
- A{"Bot is<br/>assignee?"}
291
- D{"Duplicate?<br/>60s TTL"}
292
- end
293
-
294
- subgraph CREATE["3. Task Creation"]
295
- LEAD["Find Lead Agent<br/>(online > offline > none)"]
296
- TPL["resolveTemplate()"]
297
- TASK["createTaskExtended()"]
298
- end
299
-
300
- subgraph OUT["4. Output"]
301
- ASSIGN["Task assigned<br/>to Lead"]
302
- POOL["Task in pool<br/>(no lead)"]
303
- REACT["eyes reaction<br/>on GitHub"]
304
- end
305
-
306
- E1 & E2 & E3 --> M
307
- E4 & E5 --> A
308
-
309
- M -->|Yes| D
310
- A -->|Yes| D
311
- M & A -->|No| DROP1(("skip"))
312
-
313
- D -->|New| LEAD
314
- D -->|Dup| DROP2(("skip"))
315
-
316
- LEAD --> TPL --> TASK
317
-
318
- TASK -->|lead found| ASSIGN
319
- TASK -.->|no lead| POOL
320
- TASK --> REACT
321
- ```
322
-
323
- [PNG fallback](assets/github-task-creation-flow.png)
324
-
325
- #### Follow-up Flows
326
-
327
- Events that create secondary tasks when an active task already exists for a PR:
328
-
329
- ```mermaid
330
- %%{init: {'theme': 'dark', 'themeVariables': {'fontSize': '13px'}}}%%
331
- flowchart TB
332
- subgraph EVENTS["GitHub Follow-up Events (require existing active task)"]
333
- direction LR
334
- F1["PR Closed<br/>(merged/closed)"]
335
- F2["PR Synchronize<br/>(new commits)"]
336
- F3["Review Submitted<br/>(approved/changes_requested)"]
337
- F4["Check Run Failed"]
338
- F5["Check Suite Failed"]
339
- F6["Workflow Run Failed"]
340
- end
341
-
342
- FIND{"findTaskByVcs()<br/>Active task for<br/>repo + PR number?"}
343
-
344
- EVENTS --> FIND
345
-
346
- FIND -->|No task| SKIP(("skip"))
347
-
348
- subgraph FOLLOWUP["Follow-up Task Created (assigned to Lead)"]
349
- direction LR
350
- T1["github-pr-status<br/>PR merged/closed"]
351
- T2["github-pr-update<br/>New commits pushed"]
352
- T3["github-review<br/>Review feedback"]
353
- T4["github-ci<br/>CI failure alert"]
354
- end
355
-
356
- F1 --> FIND -->|task found| T1
357
- F2 --> FIND -->|task found| T2
358
- F3 --> FIND -->|task found| T3
359
- F4 & F5 & F6 --> FIND -->|task found| T4
360
-
361
- NOTE["All follow-up tasks reference<br/>the original task ID for routing"]
362
-
363
- FOLLOWUP --> NOTE
364
- ```
365
-
366
- [PNG fallback](assets/github-followup-flows.png)
367
-
368
- #### Cancellation Flows
369
-
370
- How unassigning the bot cancels active tasks:
371
-
372
- ```mermaid
373
- %%{init: {'theme': 'dark', 'themeVariables': {'fontSize': '13px'}}}%%
374
- flowchart TB
375
- subgraph EVENTS["Cancellation Events"]
376
- direction LR
377
- C1["Bot Unassigned<br/>from Issue"]
378
- C2["Bot Unassigned<br/>from PR"]
379
- C3["Review Request<br/>Removed from Bot"]
380
- end
381
-
382
- BOT{"isBotAssignee()"}
383
- FIND{"findTaskByVcs()<br/>Active task?"}
384
- CANCEL["failTask()<br/>Cancel with reason"]
385
- NOOP(("no-op"))
386
-
387
- EVENTS --> BOT
388
- BOT -->|Not bot| NOOP
389
- BOT -->|Is bot| FIND
390
- FIND -->|No task| NOOP
391
- FIND -->|Task found| CANCEL
392
- ```
393
-
394
- [PNG fallback](assets/github-cancellation-flows.png)
395
-
396
- </details>
397
-
398
- ### GitLab
399
-
400
- Set up a GitLab webhook to receive events when the bot is @mentioned or assigned to issues/MRs.
401
-
402
- **Webhook URL:** `https://<your-domain>/api/gitlab/webhook`
403
-
404
- ```bash
405
- # Add to your .env
406
- GITLAB_WEBHOOK_SECRET=your-webhook-secret
407
- GITLAB_TOKEN=your-gitlab-token # PAT or Group Access Token
408
- GITLAB_BOT_NAME=agent-swarm-bot # Bot name for @mentions
409
- GITLAB_URL=https://gitlab.com # GitLab instance URL
410
- ```
411
-
412
- **Supported events:**
413
-
414
- | Event | What happens |
415
- |-------|-------------|
416
- | Bot assigned to MR/issue | Creates a task for the lead agent |
417
- | `@bot-name` in comment/issue/MR | Creates a task with the mention context |
418
- | Pipeline failure (on MRs with existing tasks) | Creates a CI notification task |
419
-
420
- Workers have `glab` CLI pre-installed for GitLab operations (creating MRs, commenting on issues, etc.).
421
-
422
- ### AgentMail
423
-
424
- Give your agents email addresses via [AgentMail](https://agentmail.to). Emails are routed to agents as tasks or inbox messages.
425
-
426
- **Webhook URL:** `https://<your-domain>/api/agentmail/webhook`
427
-
428
- ```bash
429
- # Add to your .env
430
- AGENTMAIL_WEBHOOK_SECRET=your-svix-secret
431
- ```
432
-
433
- Agents self-register which inboxes they receive mail from using the `register-agentmail-inbox` MCP tool. Emails to a worker's inbox become tasks; emails to a lead's inbox become inbox messages for triage. Follow-up emails in the same thread are automatically routed to the same agent.
434
-
435
- ### Sentry
436
-
437
- Workers can investigate Sentry issues directly with the `/investigate-sentry-issue` command. Add `SENTRY_AUTH_TOKEN` and `SENTRY_ORG` to your worker's environment.
149
+ | Integration | What it does | Setup |
150
+ |---|---|---|
151
+ | **Slack** | DM or @mention the bot to create tasks; workers reply in threads | [Guide](https://docs.agent-swarm.dev/docs/guides/slack-integration) |
152
+ | **GitHub App** | @mention or assign the bot on issues/PRs; CI failures create follow-up tasks | [Guide](https://docs.agent-swarm.dev/docs/guides/github-integration) |
153
+ | **GitLab** | Same model as GitHub — webhooks on issues/MRs, `glab` preinstalled in workers | [Guide](https://docs.agent-swarm.dev/docs/guides/gitlab-integration) |
154
+ | **AgentMail** | Give each agent an inbox; emails become tasks or lead messages | [Guide](https://docs.agent-swarm.dev/docs/guides/agentmail-integration) |
155
+ | **Linear** | Bidirectional ticket sync via OAuth + webhooks | [Guide](https://docs.agent-swarm.dev/docs/guides/linear-integration) |
156
+ | **Sentry** | Workers can triage Sentry issues with the `/investigate-sentry-issue` command | [Guide](https://docs.agent-swarm.dev/docs/guides/sentry-integration) |
438
157
 
439
158
  ## Dashboard
440
159
 
441
- A React-based monitoring dashboard for real-time visibility into your swarm.
160
+ Real-time monitoring of agents, tasks, and inter-agent chat. Use the hosted version at [app.agent-swarm.dev](https://app.agent-swarm.dev), or run locally:
442
161
 
443
162
  ```bash
444
163
  cd new-ui && pnpm install && pnpm run dev
445
164
  ```
446
165
 
447
- Opens at `http://localhost:5173`. See [UI.md](./UI.md) for details.
166
+ Opens at `http://localhost:5274`.
448
167
 
449
- ## CLI
168
+ ## [CLI](https://docs.agent-swarm.dev/docs/reference/cli)
450
169
 
451
170
  ```bash
452
171
  bunx @desplega.ai/agent-swarm <command>
@@ -456,33 +175,23 @@ bunx @desplega.ai/agent-swarm <command>
456
175
  |---------|-------------|
457
176
  | `onboard` | Set up a new swarm from scratch (Docker Compose wizard) |
458
177
  | `connect` | Connect this project to an existing swarm |
459
- | `api` | Start the API + MCP HTTP server |
460
- | `claude` | Run Claude CLI with optional message and headless mode |
461
- | `worker` | Run a worker agent |
462
- | `lead` | Run a lead agent |
463
- | `docs` | Open documentation (`--open` to launch in browser) |
464
- | `artifact` | Manage agent artifacts |
178
+ | `api` | Start the API + MCP HTTP server |
179
+ | `worker` | Run a worker agent |
180
+ | `lead` | Run a lead agent |
181
+ | `docs` | Open documentation (`--open` to launch in browser) |
465
182
 
466
183
  ## Deployment
467
184
 
468
- For production deployments, see [DEPLOYMENT.md](./DEPLOYMENT.md) which covers:
469
-
470
- - Docker Compose setup with multiple workers
471
- - systemd deployment for the API server
472
- - Graceful shutdown and task resume
473
- - Integration configuration (Slack, GitHub, AgentMail, Sentry)
185
+ For production deployments (Docker Compose with multiple workers, systemd for the API, graceful shutdown, integration config), see [DEPLOYMENT.md](./DEPLOYMENT.md) and the [deployment guide](https://docs.agent-swarm.dev/docs/guides/deployment).
474
186
 
475
187
  ## Documentation
476
188
 
477
- | Resource | Description |
478
- |----------|-------------|
479
- | [docs.agent-swarm.dev](https://docs.agent-swarm.dev) | Full documentation site |
480
- | [app.agent-swarm.dev](https://app.agent-swarm.dev) | Hosted dashboard connect your deployed swarm |
481
- | [DEPLOYMENT.md](./DEPLOYMENT.md) | Production deployment guide |
482
- | [Environment Variables](https://docs.agent-swarm.dev/docs/reference/environment-variables) | Complete environment variables reference |
483
- | [CONTRIBUTING.md](./CONTRIBUTING.md) | Development setup and project structure |
484
- | [UI.md](./UI.md) | Dashboard UI overview |
485
- | [MCP.md](./MCP.md) | MCP tools reference (auto-generated) |
189
+ Everything lives at **[docs.agent-swarm.dev](https://docs.agent-swarm.dev)**. Good starting points:
190
+
191
+ - [Getting Started](https://docs.agent-swarm.dev/docs/getting-started) install, configure, and run your first task
192
+ - [Architecture overview](https://docs.agent-swarm.dev/docs/architecture/overview) how the swarm is wired together
193
+ - [CLI reference](https://docs.agent-swarm.dev/docs/reference/cli) and [Environment variables](https://docs.agent-swarm.dev/docs/reference/environment-variables)
194
+ - [API reference](https://docs.agent-swarm.dev/docs/api-reference) every HTTP endpoint
486
195
 
487
196
  ## Contributing
488
197
 
@@ -509,4 +218,4 @@ Join our [Discord](https://discord.gg/KZgfyyDVZa) if you have questions or want
509
218
 
510
219
  ## License
511
220
 
512
- [MIT](./LICENSE) — 2025-2026 [desplega.ai](https://desplega.ai)
221
+ [MIT](./LICENSE) — 2025-2026 [desplega.sh](https://desplega.sh)
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.67.1",
5
+ "version": "1.67.3",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.67.1",
3
+ "version": "1.67.3",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -26,22 +26,39 @@ const ENV_KEY = "SECRETS_ENCRYPTION_KEY";
26
26
  const ENV_KEY_FILE = "SECRETS_ENCRYPTION_KEY_FILE";
27
27
  const KEY_FILENAME = ".encryption-key";
28
28
 
29
+ const HEX_32_BYTE_RE = /^[0-9a-fA-F]{64}$/;
30
+
31
+ function keyGenerationHelp(): string {
32
+ return [
33
+ "",
34
+ "How to generate a valid key:",
35
+ " openssl rand -base64 32 # 43-char base64 (recommended)",
36
+ " openssl rand -hex 32 # 64-char hex (also accepted)",
37
+ "",
38
+ "Common mistake: `openssl rand -base64 39` produces a 52-char string that",
39
+ "decodes to 39 bytes — the number passed to `openssl rand` is the decoded",
40
+ "byte count, and AES-256 requires exactly 32.",
41
+ ].join("\n");
42
+ }
43
+
29
44
  function decodeAndValidate(source: string, content: string): Buffer {
30
45
  const trimmed = content.trim();
31
- let decoded: Buffer;
32
- try {
33
- decoded = Buffer.from(trimmed, "base64");
34
- } catch (err) {
35
- throw new Error(
36
- `Invalid encryption key at ${source}: base64 decode failed: ${(err as Error).message}`,
37
- );
38
- }
39
- if (decoded.length !== AES_KEY_BYTES) {
40
- throw new Error(
41
- `Invalid encryption key at ${source}: expected ${AES_KEY_BYTES} bytes after base64 decode, got ${decoded.length} bytes`,
42
- );
46
+
47
+ // Hex path: a 64-char string of [0-9a-fA-F] is unambiguously hex — it would
48
+ // decode to 48 bytes as base64, never 32, so there is no overlap with the
49
+ // base64 happy path.
50
+ if (HEX_32_BYTE_RE.test(trimmed)) {
51
+ const decoded = Buffer.from(trimmed, "hex");
52
+ if (decoded.length === AES_KEY_BYTES) return decoded;
43
53
  }
44
- return decoded;
54
+
55
+ const decoded = Buffer.from(trimmed, "base64");
56
+ if (decoded.length === AES_KEY_BYTES) return decoded;
57
+
58
+ throw new Error(
59
+ `Invalid encryption key at ${source}: expected ${AES_KEY_BYTES} bytes, ` +
60
+ `got ${decoded.length} bytes after base64 decode.\n${keyGenerationHelp()}`,
61
+ );
45
62
  }
46
63
 
47
64
  type ResolveEncryptionKeyOptions = {
package/src/be/db.ts CHANGED
@@ -84,6 +84,7 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
84
84
  const templateBytes = templateGlobals.__testMigrationTemplate;
85
85
  if (templateBytes) {
86
86
  db = Database.deserialize(templateBytes);
87
+ db.run("PRAGMA busy_timeout = 5000;");
87
88
  db.run("PRAGMA foreign_keys = ON;");
88
89
  configureDbResolver(resolvePromptTemplate);
89
90
  // Ensure the encryption key is resolved even when restoring from the test
@@ -98,14 +99,24 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
98
99
 
99
100
  const database = db;
100
101
  database.run("PRAGMA journal_mode = WAL;");
102
+ database.run("PRAGMA busy_timeout = 5000;");
101
103
  database.run("PRAGMA foreign_keys = ON;");
102
104
 
103
- // Load sqlite-vec extension for vector search
105
+ // Load sqlite-vec extension for vector search.
106
+ // In compiled binaries (`bun build --compile`) the JS lives in /$bunfs/ and
107
+ // `require.resolve("sqlite-vec-<platform>/vec0.so")` can't find the native
108
+ // asset — so we prefer an explicit filesystem path when set, and only fall
109
+ // back to the npm resolver for normal dev runs.
104
110
  try {
105
- const sqliteVec = require("sqlite-vec");
106
- sqliteVec.load(database);
111
+ const extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH;
112
+ if (extensionPath) {
113
+ database.loadExtension(extensionPath);
114
+ } else {
115
+ const sqliteVec = require("sqlite-vec");
116
+ sqliteVec.load(database);
117
+ }
107
118
  sqliteVecAvailable = true;
108
- console.log("[db] sqlite-vec loaded");
119
+ console.log(`[db] sqlite-vec loaded${extensionPath ? ` from ${extensionPath}` : ""}`);
109
120
  } catch (err) {
110
121
  console.warn(
111
122
  "[db] sqlite-vec not available, falling back to in-memory cosine:",
@@ -1479,6 +1490,26 @@ export function getMostRecentTaskInThread(channelId: string, threadTs: string):
1479
1490
  return row ? rowToAgentTask(row) : null;
1480
1491
  }
1481
1492
 
1493
+ export function findCompletedTaskInThread(
1494
+ channelId: string,
1495
+ threadTs: string,
1496
+ windowMinutes: number,
1497
+ ): AgentTask | null {
1498
+ const since = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
1499
+ const row = getDb()
1500
+ .prepare<AgentTaskRow, [string, string, string]>(
1501
+ `SELECT * FROM agent_tasks
1502
+ WHERE slackChannelId = ?
1503
+ AND slackThreadTs = ?
1504
+ AND status = 'completed'
1505
+ AND lastUpdatedAt > ?
1506
+ ORDER BY lastUpdatedAt DESC
1507
+ LIMIT 1`,
1508
+ )
1509
+ .get(channelId, threadTs, since);
1510
+ return row ? rowToAgentTask(row) : null;
1511
+ }
1512
+
1482
1513
  export function completeTask(id: string, output?: string): AgentTask | null {
1483
1514
  const oldTask = getTaskById(id);
1484
1515
  const finishedAt = new Date().toISOString();
@@ -0,0 +1,3 @@
1
+ -- Composite index for Slack thread queries (findCompletedTaskInThread, getMostRecentTaskInThread)
2
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_slack_thread
3
+ ON agent_tasks(slackChannelId, slackThreadTs, status);
package/src/http/index.ts CHANGED
@@ -43,6 +43,15 @@ import { getPathSegments, parseQueryParams, setCorsHeaders } from "./utils";
43
43
  import { handleWebhooks } from "./webhooks";
44
44
  import { handleWorkflows } from "./workflows";
45
45
 
46
+ // Last-line-of-defense: never let a single bad request (e.g. a SQLITE_BUSY
47
+ // thrown out of a transaction callback) kill the API process. Log and keep going.
48
+ process.on("uncaughtException", (err) => {
49
+ console.error("[fatal] uncaughtException:", err);
50
+ });
51
+ process.on("unhandledRejection", (reason) => {
52
+ console.error("[fatal] unhandledRejection:", reason);
53
+ });
54
+
46
55
  const port = parseInt(process.env.PORT || process.argv[2] || "3013", 10);
47
56
  const apiKey = process.env.API_KEY || "";
48
57
 
@@ -94,7 +94,7 @@ If you explicitly assign to a different worker, session resume gracefully falls
94
94
  When a worker completes or fails a task, you receive an automatic follow-up task. Handle it by:
95
95
  1. Review the output/failure reason
96
96
  2. If the task has Slack metadata, use \`slack-reply\` with the task's ID to post the result back to the originating thread
97
- 3. Decide: is the goal met? If not, create next task(s) with \`parentTaskId\` for session continuity. If blocked, notify the stakeholder.
97
+ 3. Complete this task. Do NOT re-delegate or create new worker tasks from a follow-up \u2014 the worker's result IS the answer. Only escalate to the stakeholder if the worker explicitly failed and the failure needs human attention.
98
98
 
99
99
  #### Heartbeat Checklist
100
100
 
@@ -0,0 +1,267 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlinkSync } from "node:fs";
3
+ import {
4
+ closeDb,
5
+ completeTask,
6
+ createAgent,
7
+ createTaskExtended,
8
+ findCompletedTaskInThread,
9
+ getDb,
10
+ getTaskById,
11
+ initDb,
12
+ } from "../be/db";
13
+
14
+ const TEST_DB_PATH = "./test-follow-up-redelivery-guard.sqlite";
15
+
16
+ beforeAll(() => {
17
+ initDb(TEST_DB_PATH);
18
+ });
19
+
20
+ afterAll(() => {
21
+ closeDb();
22
+ try {
23
+ unlinkSync(TEST_DB_PATH);
24
+ unlinkSync(`${TEST_DB_PATH}-wal`);
25
+ unlinkSync(`${TEST_DB_PATH}-shm`);
26
+ } catch {
27
+ // ignore if files don't exist
28
+ }
29
+ });
30
+
31
+ describe("findCompletedTaskInThread", () => {
32
+ test("finds completed tasks in a thread within the time window", () => {
33
+ const agent = createAgent({
34
+ name: "dedup-worker-1",
35
+ isLead: false,
36
+ status: "idle",
37
+ capabilities: [],
38
+ });
39
+
40
+ const task = createTaskExtended("test task for thread", {
41
+ agentId: agent.id,
42
+ slackChannelId: "C_DEDUP_1",
43
+ slackThreadTs: "1000.0001",
44
+ });
45
+
46
+ // Mark as completed
47
+ completeTask(task.id, "done");
48
+
49
+ // Should find the completed task within a 2880-minute (48h) window
50
+ const result = findCompletedTaskInThread("C_DEDUP_1", "1000.0001", 2880);
51
+ expect(result).not.toBeNull();
52
+ expect(result!.id).toBe(task.id);
53
+ expect(result!.status).toBe("completed");
54
+ });
55
+
56
+ test("returns null when no completed tasks exist in the thread", () => {
57
+ const agent = createAgent({
58
+ name: "dedup-worker-2",
59
+ isLead: false,
60
+ status: "idle",
61
+ capabilities: [],
62
+ });
63
+
64
+ // Create a task but don't complete it
65
+ createTaskExtended("pending task in thread", {
66
+ agentId: agent.id,
67
+ slackChannelId: "C_DEDUP_2",
68
+ slackThreadTs: "2000.0001",
69
+ });
70
+
71
+ const result = findCompletedTaskInThread("C_DEDUP_2", "2000.0001", 2880);
72
+ expect(result).toBeNull();
73
+ });
74
+
75
+ test("returns null outside the time window", () => {
76
+ const agent = createAgent({
77
+ name: "dedup-worker-3",
78
+ isLead: false,
79
+ status: "idle",
80
+ capabilities: [],
81
+ });
82
+
83
+ const task = createTaskExtended("old completed task", {
84
+ agentId: agent.id,
85
+ slackChannelId: "C_DEDUP_3",
86
+ slackThreadTs: "3000.0001",
87
+ });
88
+
89
+ completeTask(task.id, "done long ago");
90
+
91
+ // Backdate the lastUpdatedAt to 49 hours ago (beyond the 48h window)
92
+ const fortyNineHoursAgo = new Date(Date.now() - 49 * 60 * 60 * 1000).toISOString();
93
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
94
+ fortyNineHoursAgo,
95
+ task.id,
96
+ ]);
97
+
98
+ // Should not find with a 48 hour window
99
+ const result = findCompletedTaskInThread("C_DEDUP_3", "3000.0001", 2880);
100
+ expect(result).toBeNull();
101
+ });
102
+
103
+ test("returns null for a different thread", () => {
104
+ const agent = createAgent({
105
+ name: "dedup-worker-4",
106
+ isLead: false,
107
+ status: "idle",
108
+ capabilities: [],
109
+ });
110
+
111
+ const task = createTaskExtended("task in different thread", {
112
+ agentId: agent.id,
113
+ slackChannelId: "C_DEDUP_4",
114
+ slackThreadTs: "4000.0001",
115
+ });
116
+
117
+ completeTask(task.id, "done");
118
+
119
+ // Search in a different thread — should not find
120
+ const result = findCompletedTaskInThread("C_DEDUP_4", "4000.9999", 2880);
121
+ expect(result).toBeNull();
122
+ });
123
+ });
124
+
125
+ describe("follow-up re-delegation guard logic", () => {
126
+ let leadAgent: ReturnType<typeof createAgent>;
127
+ let workerAgent: ReturnType<typeof createAgent>;
128
+
129
+ beforeAll(() => {
130
+ leadAgent = createAgent({
131
+ name: "guard-lead",
132
+ isLead: true,
133
+ status: "idle",
134
+ capabilities: [],
135
+ });
136
+ workerAgent = createAgent({
137
+ name: "guard-worker",
138
+ isLead: false,
139
+ status: "idle",
140
+ capabilities: [],
141
+ maxTasks: 5,
142
+ });
143
+ });
144
+
145
+ test("blocks re-delegation when source task is a follow-up and thread has completed work", () => {
146
+ // Step 1: Create and complete a worker task in a Slack thread
147
+ const workerTask = createTaskExtended("implement feature X", {
148
+ agentId: workerAgent.id,
149
+ slackChannelId: "C_GUARD_1",
150
+ slackThreadTs: "5000.0001",
151
+ });
152
+ completeTask(workerTask.id, "Feature X implemented");
153
+
154
+ // Step 2: Create a follow-up task (as store-progress would)
155
+ const followUpTask = createTaskExtended("Worker task completed — review needed.", {
156
+ agentId: leadAgent.id,
157
+ source: "system",
158
+ taskType: "follow-up",
159
+ parentTaskId: workerTask.id,
160
+ slackChannelId: "C_GUARD_1",
161
+ slackThreadTs: "5000.0001",
162
+ });
163
+
164
+ // Step 3: Simulate the guard logic from send-task.ts
165
+ // The lead's sourceTaskId would be the follow-up task
166
+ const sourceTask = getTaskById(followUpTask.id);
167
+ expect(sourceTask).not.toBeNull();
168
+ expect(sourceTask!.taskType).toBe("follow-up");
169
+ expect(sourceTask!.slackChannelId).toBe("C_GUARD_1");
170
+ expect(sourceTask!.slackThreadTs).toBe("5000.0001");
171
+
172
+ // The guard should find the completed worker task
173
+ const recentCompleted = findCompletedTaskInThread(
174
+ sourceTask!.slackChannelId!,
175
+ sourceTask!.slackThreadTs!,
176
+ 2880,
177
+ );
178
+ expect(recentCompleted).not.toBeNull();
179
+ expect(recentCompleted!.id).toBe(workerTask.id);
180
+
181
+ // → Guard would block: re-delegation should be prevented
182
+ });
183
+
184
+ test("allows delegation when source task is NOT a follow-up (normal behavior)", () => {
185
+ // Create a normal Slack task (not a follow-up)
186
+ const slackTask = createTaskExtended("user asked a question", {
187
+ agentId: leadAgent.id,
188
+ source: "slack",
189
+ taskType: "inbox",
190
+ slackChannelId: "C_GUARD_2",
191
+ slackThreadTs: "6000.0001",
192
+ });
193
+
194
+ // Even if there are completed tasks in the thread, guard shouldn't trigger
195
+ // because the source task is not a "follow-up"
196
+ const sourceTask = getTaskById(slackTask.id);
197
+ expect(sourceTask).not.toBeNull();
198
+ expect(sourceTask!.taskType).not.toBe("follow-up");
199
+
200
+ // Guard condition: sourceTask?.taskType === "follow-up" → false
201
+ // → Guard does NOT block: delegation proceeds normally
202
+ const shouldBlock =
203
+ sourceTask?.taskType === "follow-up" && sourceTask.slackThreadTs && sourceTask.slackChannelId;
204
+ expect(shouldBlock).toBeFalsy();
205
+ });
206
+
207
+ test("allows delegation when source task is a follow-up but thread has NO completed work", () => {
208
+ // Create a follow-up task in a thread with no completed work
209
+ const followUpTask = createTaskExtended("Worker task failed — action needed.", {
210
+ agentId: leadAgent.id,
211
+ source: "system",
212
+ taskType: "follow-up",
213
+ slackChannelId: "C_GUARD_3",
214
+ slackThreadTs: "7000.0001",
215
+ });
216
+
217
+ const sourceTask = getTaskById(followUpTask.id);
218
+ expect(sourceTask).not.toBeNull();
219
+ expect(sourceTask!.taskType).toBe("follow-up");
220
+
221
+ // No completed tasks in this thread
222
+ const recentCompleted = findCompletedTaskInThread(
223
+ sourceTask!.slackChannelId!,
224
+ sourceTask!.slackThreadTs!,
225
+ 2880,
226
+ );
227
+ expect(recentCompleted).toBeNull();
228
+
229
+ // → Guard does NOT block: first-time delegation is fine
230
+ });
231
+
232
+ test("allows delegation when source task is a follow-up but completed work is outside time window", () => {
233
+ // Create and complete a worker task, then backdate it
234
+ const oldWorkerTask = createTaskExtended("old task", {
235
+ agentId: workerAgent.id,
236
+ slackChannelId: "C_GUARD_4",
237
+ slackThreadTs: "8000.0001",
238
+ });
239
+ completeTask(oldWorkerTask.id, "done long ago");
240
+
241
+ // Backdate to 49 hours ago (beyond the 48h window)
242
+ const fortyNineHoursAgo = new Date(Date.now() - 49 * 60 * 60 * 1000).toISOString();
243
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
244
+ fortyNineHoursAgo,
245
+ oldWorkerTask.id,
246
+ ]);
247
+
248
+ // Create a follow-up in the same thread
249
+ const followUpTask = createTaskExtended("Worker task completed — review needed.", {
250
+ agentId: leadAgent.id,
251
+ source: "system",
252
+ taskType: "follow-up",
253
+ slackChannelId: "C_GUARD_4",
254
+ slackThreadTs: "8000.0001",
255
+ });
256
+
257
+ const sourceTask = getTaskById(followUpTask.id);
258
+ const recentCompleted = findCompletedTaskInThread(
259
+ sourceTask!.slackChannelId!,
260
+ sourceTask!.slackThreadTs!,
261
+ 2880,
262
+ );
263
+ expect(recentCompleted).toBeNull();
264
+
265
+ // → Guard does NOT block: completed work is too old
266
+ });
267
+ });
@@ -157,6 +157,57 @@ describe("key-bootstrap: validation", () => {
157
157
  expect(() => resolveEncryptionKey(dbPath)).toThrow(/got 16 bytes/);
158
158
  });
159
159
 
160
+ it("error message includes openssl generation commands and the -base64 39 hint", () => {
161
+ process.env[ENV_KEY] = Buffer.alloc(16).toString("base64");
162
+ const dbPath = path.join(tmpDir, "test.sqlite");
163
+ let caught: Error | null = null;
164
+ try {
165
+ resolveEncryptionKey(dbPath);
166
+ } catch (err) {
167
+ caught = err as Error;
168
+ }
169
+ expect(caught).toBeTruthy();
170
+ const msg = caught?.message ?? "";
171
+ expect(msg).toContain("openssl rand -base64 32");
172
+ expect(msg).toContain("openssl rand -hex 32");
173
+ expect(msg).toContain("openssl rand -base64 39");
174
+ });
175
+
176
+ it("accepts a hex-encoded 32-byte key via SECRETS_ENCRYPTION_KEY env var", () => {
177
+ const keyBytes = randomBytes(AES_KEY_BYTES);
178
+ process.env[ENV_KEY] = keyBytes.toString("hex");
179
+ const dbPath = path.join(tmpDir, "test.sqlite");
180
+ const resolved = resolveEncryptionKey(dbPath);
181
+ expect(resolved.equals(keyBytes)).toBe(true);
182
+ });
183
+
184
+ it("accepts a hex-encoded 32-byte key via SECRETS_ENCRYPTION_KEY_FILE", () => {
185
+ const keyBytes = randomBytes(AES_KEY_BYTES);
186
+ const keyPath = path.join(tmpDir, "hex.key");
187
+ writeFileSync(keyPath, keyBytes.toString("hex"));
188
+ process.env[ENV_KEY_FILE] = keyPath;
189
+ const dbPath = path.join(tmpDir, "test.sqlite");
190
+ const resolved = resolveEncryptionKey(dbPath);
191
+ expect(resolved.equals(keyBytes)).toBe(true);
192
+ });
193
+
194
+ it("accepts a hex-encoded 32-byte key via on-disk .encryption-key", () => {
195
+ const keyBytes = randomBytes(AES_KEY_BYTES);
196
+ const keyFilePath = path.join(tmpDir, ".encryption-key");
197
+ writeFileSync(keyFilePath, keyBytes.toString("hex"));
198
+ const dbPath = path.join(tmpDir, "test.sqlite");
199
+ const resolved = resolveEncryptionKey(dbPath);
200
+ expect(resolved.equals(keyBytes)).toBe(true);
201
+ });
202
+
203
+ it("accepts uppercase hex-encoded keys", () => {
204
+ const keyBytes = randomBytes(AES_KEY_BYTES);
205
+ process.env[ENV_KEY] = keyBytes.toString("hex").toUpperCase();
206
+ const dbPath = path.join(tmpDir, "test.sqlite");
207
+ const resolved = resolveEncryptionKey(dbPath);
208
+ expect(resolved.equals(keyBytes)).toBe(true);
209
+ });
210
+
160
211
  it("throws when SECRETS_ENCRYPTION_KEY_FILE points to a malformed file", () => {
161
212
  const keyPath = path.join(tmpDir, "bad.key");
162
213
  writeFileSync(keyPath, Buffer.alloc(10).toString("base64"));
@@ -339,6 +339,11 @@ Task: "Fix the login page CSS"
339
339
  Output:
340
340
  Fixed the CSS alignment issue on the login form
341
341
 
342
+ IMPORTANT: Do NOT re-delegate or re-answer the original request. The worker has already handled it. Your job is ONLY to:
343
+ 1. Review the output above
344
+ 2. If the task has Slack metadata, use \`slack-reply\` to post the result to the thread (if the worker hasn't already)
345
+ 3. Complete this follow-up task
346
+
342
347
  Use \`get-task-details\` with taskId "task-abc-123" for full details.`;
343
348
 
344
349
  expect(result.text).toBe(expected);
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
3
  import {
4
4
  createTaskExtended,
5
+ findCompletedTaskInThread,
5
6
  getActiveTaskCount,
6
7
  getAgentById,
7
8
  getDb,
@@ -187,6 +188,36 @@ export const registerSendTaskTool = (server: McpServer) => {
187
188
  }
188
189
  }
189
190
 
191
+ // Guard: prevent re-delegation from follow-up tasks
192
+ // When the source task is a "follow-up" (worker completed/failed notification),
193
+ // check if there are completed tasks in the same Slack thread recently.
194
+ // This prevents the cycle: worker completes → follow-up → Lead re-delegates → repeat.
195
+ if (requestInfo.sourceTaskId) {
196
+ const sourceTask = getTaskById(requestInfo.sourceTaskId);
197
+ if (
198
+ sourceTask?.taskType === "follow-up" &&
199
+ sourceTask.slackThreadTs &&
200
+ sourceTask.slackChannelId
201
+ ) {
202
+ const recentCompleted = findCompletedTaskInThread(
203
+ sourceTask.slackChannelId,
204
+ sourceTask.slackThreadTs,
205
+ 2880, // 48 hours in minutes
206
+ );
207
+ if (recentCompleted) {
208
+ const msg = `Blocked: re-delegation from follow-up task in a thread that already has completed work (task ${recentCompleted.id.slice(0, 8)}). The original request was already handled.`;
209
+ return {
210
+ content: [{ type: "text", text: msg }],
211
+ structuredContent: {
212
+ yourAgentId: requestInfo.agentId,
213
+ success: false,
214
+ message: msg,
215
+ },
216
+ };
217
+ }
218
+ }
219
+ }
220
+
190
221
  const txn = getDb().transaction(() => {
191
222
  const finalTags = tags;
192
223
 
@@ -22,6 +22,11 @@ Task: "{{task_desc}}"
22
22
  Output:
23
23
  {{output_summary}}
24
24
 
25
+ IMPORTANT: Do NOT re-delegate or re-answer the original request. The worker has already handled it. Your job is ONLY to:
26
+ 1. Review the output above
27
+ 2. If the task has Slack metadata, use \`slack-reply\` to post the result to the thread (if the worker hasn't already)
28
+ 3. Complete this follow-up task
29
+
25
30
  Use \`get-task-details\` with taskId "{{task_id}}" for full details.`,
26
31
  variables: [
27
32
  { name: "agent_name", description: "Worker agent name or ID prefix" },
package/tsconfig.json CHANGED
@@ -44,6 +44,7 @@
44
44
  "work",
45
45
  "logs",
46
46
  "plugin",
47
- "docs-site"
47
+ "docs-site",
48
+ "assets/video-source"
48
49
  ]
49
50
  }