@desplega.ai/agent-swarm 1.2.1 → 1.9.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 (119) hide show
  1. package/.claude/settings.local.json +20 -1
  2. package/.env.docker.example +22 -1
  3. package/.env.example +17 -0
  4. package/.github/workflows/docker-publish.yml +92 -0
  5. package/CONTRIBUTING.md +270 -0
  6. package/DEPLOYMENT.md +391 -0
  7. package/Dockerfile.worker +29 -1
  8. package/FAQ.md +19 -0
  9. package/LICENSE +21 -0
  10. package/MCP.md +249 -0
  11. package/README.md +103 -207
  12. package/assets/agent-swarm-logo-orange.png +0 -0
  13. package/assets/agent-swarm-logo.png +0 -0
  14. package/docker-compose.example.yml +137 -0
  15. package/docker-entrypoint.sh +223 -7
  16. package/package.json +8 -3
  17. package/{cc-plugin → plugin}/.claude-plugin/plugin.json +1 -1
  18. package/plugin/README.md +1 -0
  19. package/plugin/agents/.gitkeep +0 -0
  20. package/plugin/agents/codebase-analyzer.md +143 -0
  21. package/plugin/agents/codebase-locator.md +122 -0
  22. package/plugin/agents/codebase-pattern-finder.md +227 -0
  23. package/plugin/agents/web-search-researcher.md +109 -0
  24. package/plugin/commands/create-plan.md +415 -0
  25. package/plugin/commands/implement-plan.md +89 -0
  26. package/plugin/commands/research.md +200 -0
  27. package/plugin/commands/start-leader.md +101 -0
  28. package/plugin/commands/start-worker.md +56 -0
  29. package/plugin/commands/swarm-chat.md +78 -0
  30. package/plugin/commands/todos.md +66 -0
  31. package/plugin/commands/work-on-task.md +44 -0
  32. package/plugin/skills/.gitkeep +0 -0
  33. package/scripts/generate-mcp-docs.ts +415 -0
  34. package/slack-manifest.json +69 -0
  35. package/src/be/db.ts +1431 -25
  36. package/src/cli.tsx +135 -11
  37. package/src/commands/lead.ts +13 -0
  38. package/src/commands/runner.ts +255 -0
  39. package/src/commands/worker.ts +8 -220
  40. package/src/hooks/hook.ts +102 -14
  41. package/src/http.ts +361 -5
  42. package/src/prompts/base-prompt.ts +131 -0
  43. package/src/server.ts +56 -0
  44. package/src/slack/app.ts +73 -0
  45. package/src/slack/commands.ts +88 -0
  46. package/src/slack/handlers.ts +281 -0
  47. package/src/slack/index.ts +3 -0
  48. package/src/slack/responses.ts +175 -0
  49. package/src/slack/router.ts +170 -0
  50. package/src/slack/types.ts +20 -0
  51. package/src/slack/watcher.ts +119 -0
  52. package/src/tools/create-channel.ts +80 -0
  53. package/src/tools/get-tasks.ts +54 -21
  54. package/src/tools/join-swarm.ts +28 -4
  55. package/src/tools/list-channels.ts +37 -0
  56. package/src/tools/list-services.ts +110 -0
  57. package/src/tools/poll-task.ts +46 -3
  58. package/src/tools/post-message.ts +87 -0
  59. package/src/tools/read-messages.ts +192 -0
  60. package/src/tools/register-service.ts +118 -0
  61. package/src/tools/send-task.ts +80 -7
  62. package/src/tools/store-progress.ts +9 -3
  63. package/src/tools/task-action.ts +211 -0
  64. package/src/tools/unregister-service.ts +110 -0
  65. package/src/tools/update-profile.ts +105 -0
  66. package/src/tools/update-service-status.ts +118 -0
  67. package/src/types.ts +110 -3
  68. package/src/utils/pretty-print.ts +224 -0
  69. package/thoughts/shared/plans/.gitkeep +0 -0
  70. package/thoughts/shared/plans/2025-12-18-inverse-teleport.md +1142 -0
  71. package/thoughts/shared/plans/2025-12-18-slack-integration.md +1195 -0
  72. package/thoughts/shared/plans/2025-12-19-agent-log-streaming.md +732 -0
  73. package/thoughts/shared/plans/2025-12-19-role-based-swarm-plugin.md +361 -0
  74. package/thoughts/shared/plans/2025-12-20-mobile-responsive-ui.md +501 -0
  75. package/thoughts/shared/plans/2025-12-20-startup-team-swarm.md +560 -0
  76. package/thoughts/shared/research/.gitkeep +0 -0
  77. package/thoughts/shared/research/2025-12-18-slack-integration.md +442 -0
  78. package/thoughts/shared/research/2025-12-19-agent-log-streaming.md +339 -0
  79. package/thoughts/shared/research/2025-12-19-agent-secrets-cli-research.md +390 -0
  80. package/thoughts/shared/research/2025-12-21-gemini-cli-integration.md +376 -0
  81. package/thoughts/shared/research/2025-12-22-setup-experience-improvements.md +264 -0
  82. package/tsconfig.json +3 -1
  83. package/ui/bun.lock +692 -0
  84. package/ui/index.html +22 -0
  85. package/ui/package.json +32 -0
  86. package/ui/pnpm-lock.yaml +3034 -0
  87. package/ui/postcss.config.js +6 -0
  88. package/ui/public/logo.png +0 -0
  89. package/ui/src/App.tsx +43 -0
  90. package/ui/src/components/ActivityFeed.tsx +415 -0
  91. package/ui/src/components/AgentDetailPanel.tsx +534 -0
  92. package/ui/src/components/AgentsPanel.tsx +549 -0
  93. package/ui/src/components/ChatPanel.tsx +1820 -0
  94. package/ui/src/components/ConfigModal.tsx +232 -0
  95. package/ui/src/components/Dashboard.tsx +534 -0
  96. package/ui/src/components/Header.tsx +168 -0
  97. package/ui/src/components/ServicesPanel.tsx +612 -0
  98. package/ui/src/components/StatsBar.tsx +288 -0
  99. package/ui/src/components/StatusBadge.tsx +124 -0
  100. package/ui/src/components/TaskDetailPanel.tsx +807 -0
  101. package/ui/src/components/TasksPanel.tsx +575 -0
  102. package/ui/src/hooks/queries.ts +170 -0
  103. package/ui/src/index.css +235 -0
  104. package/ui/src/lib/api.ts +161 -0
  105. package/ui/src/lib/config.ts +35 -0
  106. package/ui/src/lib/theme.ts +214 -0
  107. package/ui/src/lib/utils.ts +48 -0
  108. package/ui/src/main.tsx +32 -0
  109. package/ui/src/types/api.ts +164 -0
  110. package/ui/src/vite-env.d.ts +1 -0
  111. package/ui/tailwind.config.js +35 -0
  112. package/ui/tsconfig.json +31 -0
  113. package/ui/vite.config.ts +22 -0
  114. package/cc-plugin/README.md +0 -49
  115. package/cc-plugin/commands/setup-leader.md +0 -73
  116. package/cc-plugin/commands/start-worker.md +0 -64
  117. package/docker-compose.worker.yml +0 -35
  118. package/example-req-meta.json +0 -24
  119. /package/{cc-plugin → plugin}/hooks/hooks.json +0 -0
@@ -0,0 +1,73 @@
1
+ import { App, LogLevel } from "@slack/bolt";
2
+ import { startTaskWatcher, stopTaskWatcher } from "./watcher";
3
+
4
+ let app: App | null = null;
5
+ let initialized = false;
6
+
7
+ export function getSlackApp(): App | null {
8
+ return app;
9
+ }
10
+
11
+ export async function initSlackApp(): Promise<App | null> {
12
+ // Prevent double initialization
13
+ if (initialized) {
14
+ console.log("[Slack] Already initialized, skipping");
15
+ return app;
16
+ }
17
+ initialized = true;
18
+
19
+ // Check if Slack is explicitly disabled
20
+ const slackDisable = process.env.SLACK_DISABLE;
21
+ if (slackDisable === "true" || slackDisable === "1") {
22
+ console.log("[Slack] Disabled via SLACK_DISABLE");
23
+ return null;
24
+ }
25
+
26
+ const botToken = process.env.SLACK_BOT_TOKEN;
27
+ const appToken = process.env.SLACK_APP_TOKEN;
28
+
29
+ if (!botToken || !appToken) {
30
+ console.log("[Slack] Missing SLACK_BOT_TOKEN or SLACK_APP_TOKEN, Slack integration disabled");
31
+ return null;
32
+ }
33
+
34
+ app = new App({
35
+ token: botToken,
36
+ appToken: appToken,
37
+ socketMode: true,
38
+ logLevel: process.env.NODE_ENV === "development" ? LogLevel.DEBUG : LogLevel.INFO,
39
+ });
40
+
41
+ // Register handlers
42
+ const { registerMessageHandler } = await import("./handlers");
43
+ const { registerCommandHandler } = await import("./commands");
44
+
45
+ registerMessageHandler(app);
46
+ registerCommandHandler(app);
47
+
48
+ return app;
49
+ }
50
+
51
+ export async function startSlackApp(): Promise<void> {
52
+ if (!app) {
53
+ await initSlackApp();
54
+ }
55
+
56
+ if (app) {
57
+ await app.start();
58
+ console.log("[Slack] Bot connected via Socket Mode");
59
+
60
+ // Start watching for task completions
61
+ startTaskWatcher();
62
+ }
63
+ }
64
+
65
+ export async function stopSlackApp(): Promise<void> {
66
+ stopTaskWatcher();
67
+
68
+ if (app) {
69
+ await app.stop();
70
+ app = null;
71
+ console.log("[Slack] Bot disconnected");
72
+ }
73
+ }
@@ -0,0 +1,88 @@
1
+ import type { App } from "@slack/bolt";
2
+ import { getAllAgents, getAllTasks } from "../be/db";
3
+
4
+ export function registerCommandHandler(app: App): void {
5
+ app.command("/agent-swarm-status", async ({ ack, respond }) => {
6
+ await ack();
7
+
8
+ const agents = getAllAgents();
9
+ const tasks = getAllTasks({ status: "in_progress" });
10
+
11
+ const statusEmoji: Record<string, string> = {
12
+ idle: ":white_circle:",
13
+ busy: ":large_blue_circle:",
14
+ offline: ":black_circle:",
15
+ };
16
+
17
+ const agentLines = agents.map((agent) => {
18
+ const emoji = statusEmoji[agent.status] || ":question:";
19
+ const role = agent.isLead ? " (Lead)" : "";
20
+ const activeTask = tasks.find((t) => t.agentId === agent.id);
21
+ const taskInfo = activeTask ? ` - Working on: ${activeTask.task.slice(0, 50)}...` : "";
22
+ return `${emoji} *${agent.name}*${role}: ${agent.status}${taskInfo}`;
23
+ });
24
+
25
+ const summary = {
26
+ total: agents.length,
27
+ idle: agents.filter((a) => a.status === "idle").length,
28
+ busy: agents.filter((a) => a.status === "busy").length,
29
+ offline: agents.filter((a) => a.status === "offline").length,
30
+ };
31
+
32
+ await respond({
33
+ response_type: "ephemeral",
34
+ blocks: [
35
+ {
36
+ type: "header",
37
+ text: { type: "plain_text", text: "Agent Swarm Status" },
38
+ },
39
+ {
40
+ type: "section",
41
+ text: {
42
+ type: "mrkdwn",
43
+ text: `*Summary:* ${summary.total} agents (${summary.idle} idle, ${summary.busy} busy, ${summary.offline} offline)`,
44
+ },
45
+ },
46
+ {
47
+ type: "divider",
48
+ },
49
+ {
50
+ type: "section",
51
+ text: {
52
+ type: "mrkdwn",
53
+ text: agentLines.join("\n") || "_No agents registered_",
54
+ },
55
+ },
56
+ ],
57
+ });
58
+ });
59
+
60
+ app.command("/agent-swarm-help", async ({ ack, respond }) => {
61
+ await ack();
62
+
63
+ await respond({
64
+ response_type: "ephemeral",
65
+ blocks: [
66
+ {
67
+ type: "header",
68
+ text: { type: "plain_text", text: "Agent Swarm Help" },
69
+ },
70
+ {
71
+ type: "section",
72
+ text: {
73
+ type: "mrkdwn",
74
+ text: `*How to assign tasks:*
75
+ • Mention an agent by name: \`Hey Alpha, can you review this code?\`
76
+ • Use explicit ID: \`swarm#<uuid> please analyze the logs\`
77
+ • Broadcast to all: \`swarm#all status report please\`
78
+ • Mention the bot: \`@agent-swarm help me\` (routes to lead agent)
79
+
80
+ *Commands:*
81
+ • \`/agent-swarm-status\` - Show all agents and their current status
82
+ • \`/agent-swarm-help\` - Show this help message`,
83
+ },
84
+ },
85
+ ],
86
+ });
87
+ });
88
+ }
@@ -0,0 +1,281 @@
1
+ import type { App } from "@slack/bolt";
2
+ import type { WebClient } from "@slack/web-api";
3
+ import { createTask, getAgentById, getTasksByAgentId } from "../be/db";
4
+ import { extractTaskFromMessage, routeMessage } from "./router";
5
+
6
+ interface MessageEvent {
7
+ type: string;
8
+ subtype?: string;
9
+ text?: string;
10
+ user?: string;
11
+ channel: string;
12
+ ts: string;
13
+ thread_ts?: string;
14
+ }
15
+
16
+ interface ThreadMessage {
17
+ user?: string;
18
+ text?: string;
19
+ ts: string;
20
+ }
21
+
22
+ // Cache for user display names
23
+ const userNameCache = new Map<string, string>();
24
+
25
+ async function getUserDisplayName(client: WebClient, userId: string): Promise<string> {
26
+ if (userNameCache.has(userId)) {
27
+ // biome-ignore lint: This is fine
28
+ return userNameCache.get(userId)!;
29
+ }
30
+ try {
31
+ const result = await client.users.info({ user: userId });
32
+ const name = result.user?.profile?.display_name || result.user?.real_name || userId;
33
+ userNameCache.set(userId, name);
34
+ return name;
35
+ } catch {
36
+ return userId;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Fetch thread history and format as context for the task.
42
+ * Returns empty string if not in a thread or no previous messages.
43
+ */
44
+ async function getThreadContext(
45
+ client: WebClient,
46
+ channel: string,
47
+ threadTs: string | undefined,
48
+ currentTs: string,
49
+ botUserId: string,
50
+ ): Promise<string> {
51
+ // Not in a thread - no context needed
52
+ if (!threadTs) return "";
53
+
54
+ try {
55
+ const result = await client.conversations.replies({
56
+ channel,
57
+ ts: threadTs,
58
+ limit: 20, // Last 20 messages max
59
+ });
60
+
61
+ const messages = (result.messages || []) as ThreadMessage[];
62
+ // Filter out the current message only (keep bot messages for context)
63
+ const previousMessages = messages.filter((m) => m.ts !== currentTs && m.text);
64
+
65
+ if (previousMessages.length === 0) return "";
66
+
67
+ // Format messages with user names or [Agent] for bot messages
68
+ const formattedMessages: string[] = [];
69
+ for (const m of previousMessages) {
70
+ if (m.user === botUserId) {
71
+ // Bot/agent message - truncate if too long
72
+ const truncatedText = m.text && m.text.length > 500 ? `${m.text.slice(0, 500)}...` : m.text;
73
+ formattedMessages.push(`[Agent]: ${truncatedText}`);
74
+ } else {
75
+ const userName = m.user ? await getUserDisplayName(client, m.user) : "Unknown";
76
+ formattedMessages.push(`${userName}: ${m.text}`);
77
+ }
78
+ }
79
+
80
+ return `<thread_context>\n${formattedMessages.join("\n")}\n</thread_context>\n\n`;
81
+ } catch (error) {
82
+ console.error("[Slack] Failed to fetch thread context:", error);
83
+ return "";
84
+ }
85
+ }
86
+
87
+ const appUrl = process.env.APP_URL || "";
88
+
89
+ /**
90
+ * Get a link to the task in the dashboard, or just the task ID if no APP_URL.
91
+ */
92
+ function getTaskLink(taskId: string): string {
93
+ const shortId = taskId.slice(0, 8);
94
+ if (appUrl) {
95
+ return `<${appUrl}?tab=tasks&task=${taskId}&expand=true|\`${shortId}\`>`;
96
+ }
97
+ return `\`${shortId}\``;
98
+ }
99
+
100
+ // Message deduplication (prevents duplicate event processing)
101
+ const processedMessages = new Set<string>();
102
+ const MESSAGE_DEDUP_TTL = 60_000; // 1 minute
103
+
104
+ function isMessageProcessed(messageKey: string): boolean {
105
+ if (processedMessages.has(messageKey)) {
106
+ console.log(`[Slack] Duplicate event detected: ${messageKey}`);
107
+ return true;
108
+ }
109
+ processedMessages.add(messageKey);
110
+ setTimeout(() => processedMessages.delete(messageKey), MESSAGE_DEDUP_TTL);
111
+ console.log(`[Slack] Processing new message: ${messageKey}`);
112
+ return false;
113
+ }
114
+
115
+ // Rate limiting
116
+ const rateLimitMap = new Map<string, number>();
117
+ const RATE_LIMIT_WINDOW = 60_000; // 1 minute
118
+ const MAX_REQUESTS_PER_WINDOW = 10;
119
+
120
+ function checkRateLimit(userId: string): boolean {
121
+ const userRequests = rateLimitMap.get(userId) || 0;
122
+
123
+ if (userRequests >= MAX_REQUESTS_PER_WINDOW) {
124
+ return false;
125
+ }
126
+
127
+ rateLimitMap.set(userId, userRequests + 1);
128
+
129
+ // Decrement after window
130
+ setTimeout(() => {
131
+ const current = rateLimitMap.get(userId) || 0;
132
+ if (current > 0) {
133
+ rateLimitMap.set(userId, current - 1);
134
+ }
135
+ }, RATE_LIMIT_WINDOW);
136
+
137
+ return true;
138
+ }
139
+
140
+ export function registerMessageHandler(app: App): void {
141
+ // Handle all message events
142
+ app.event("message", async ({ event, client, say }) => {
143
+ // Ignore bot messages and message_changed events
144
+ if (
145
+ "subtype" in event &&
146
+ (event.subtype === "bot_message" || event.subtype === "message_changed")
147
+ ) {
148
+ return;
149
+ }
150
+
151
+ const msg = event as MessageEvent;
152
+ if (!msg.text || !msg.user) return;
153
+
154
+ // Deduplicate events (Slack can send same event twice)
155
+ const messageKey = `${msg.channel}:${msg.ts}`;
156
+ if (isMessageProcessed(messageKey)) {
157
+ return;
158
+ }
159
+
160
+ // Get bot's user ID
161
+ const authResult = await client.auth.test();
162
+ const botUserId = authResult.user_id as string;
163
+
164
+ // Check if bot was mentioned
165
+ const botMentioned = msg.text.includes(`<@${botUserId}>`);
166
+
167
+ // Build thread context for routing (if we're in a thread)
168
+ const routingThreadContext = msg.thread_ts
169
+ ? { channelId: msg.channel, threadTs: msg.thread_ts }
170
+ : undefined;
171
+
172
+ // Route message to agents
173
+ const matches = routeMessage(msg.text, botUserId, botMentioned, routingThreadContext);
174
+
175
+ if (matches.length === 0) {
176
+ // No agents matched - ignore message unless bot was directly mentioned
177
+ if (botMentioned) {
178
+ await say({
179
+ text: ":satellite: _No agents are currently available. Use `/agent-swarm-status` to check the swarm._",
180
+ thread_ts: msg.thread_ts || msg.ts,
181
+ });
182
+ }
183
+ return;
184
+ }
185
+
186
+ // Rate limit check
187
+ if (!checkRateLimit(msg.user)) {
188
+ await say({
189
+ text: ":satellite: _You're sending too many requests. Please slow down._",
190
+ thread_ts: msg.thread_ts || msg.ts,
191
+ });
192
+ return;
193
+ }
194
+
195
+ // Extract task description
196
+ const taskDescription = extractTaskFromMessage(msg.text, botUserId);
197
+ if (!taskDescription) {
198
+ await say({
199
+ text: ":satellite: _Please provide a task description after mentioning an agent._",
200
+ thread_ts: msg.thread_ts || msg.ts,
201
+ });
202
+ return;
203
+ }
204
+
205
+ // Create tasks for each matched agent
206
+ const threadTs = msg.thread_ts || msg.ts;
207
+
208
+ // Fetch thread context if in a thread
209
+ const threadContext = await getThreadContext(
210
+ client,
211
+ msg.channel,
212
+ msg.thread_ts,
213
+ msg.ts,
214
+ botUserId,
215
+ );
216
+ const fullTaskDescription = threadContext + taskDescription;
217
+ const results: { assigned: string[]; queued: string[]; failed: string[] } = {
218
+ assigned: [],
219
+ queued: [],
220
+ failed: [],
221
+ };
222
+
223
+ for (const match of matches) {
224
+ const agent = getAgentById(match.agent.id);
225
+
226
+ if (!agent) {
227
+ results.failed.push(`\`${match.agent.name}\` (not found)`);
228
+ continue;
229
+ }
230
+
231
+ try {
232
+ const task = createTask(agent.id, fullTaskDescription, {
233
+ source: "slack",
234
+ slackChannelId: msg.channel,
235
+ slackThreadTs: threadTs,
236
+ slackUserId: msg.user,
237
+ });
238
+
239
+ // Check if agent has an in-progress task in this thread (queued follow-up)
240
+ const agentTasks = getTasksByAgentId(agent.id);
241
+ const inProgressInThread = agentTasks.find(
242
+ (t) => t.id !== task.id && t.status === "in_progress" && t.slackThreadTs === threadTs,
243
+ );
244
+
245
+ if (inProgressInThread) {
246
+ results.queued.push(`*${agent.name}* (${getTaskLink(task.id)})`);
247
+ } else {
248
+ results.assigned.push(`*${agent.name}* (${getTaskLink(task.id)})`);
249
+ }
250
+ } catch {
251
+ results.failed.push(`\`${agent.name}\` (error)`);
252
+ }
253
+ }
254
+
255
+ // Send consolidated summary
256
+ const parts: string[] = [];
257
+ if (results.assigned.length > 0) {
258
+ parts.push(`:satellite: _Task assigned to: ${results.assigned.join(", ")}_`);
259
+ }
260
+ if (results.queued.length > 0) {
261
+ parts.push(`:satellite: _Task queued for: ${results.queued.join(", ")}_`);
262
+ }
263
+ if (results.failed.length > 0) {
264
+ parts.push(`:satellite: _Could not assign to: ${results.failed.join(", ")}_`);
265
+ }
266
+
267
+ if (parts.length > 0) {
268
+ await say({
269
+ text: parts.join("\n"),
270
+ thread_ts: msg.thread_ts || msg.ts,
271
+ });
272
+ }
273
+ });
274
+
275
+ // Handle app_mention events specifically
276
+ app.event("app_mention", async ({ event }) => {
277
+ // app_mention is already handled by the message event above
278
+ // but we can add specific behavior here if needed
279
+ console.log(`[Slack] App mentioned in channel ${event.channel}`);
280
+ });
281
+ }
@@ -0,0 +1,3 @@
1
+ export { getSlackApp, initSlackApp, startSlackApp, stopSlackApp } from "./app";
2
+ export { extractTaskFromMessage, routeMessage } from "./router";
3
+ export type { AgentMatch, SlackConfig, SlackMessageContext } from "./types";
@@ -0,0 +1,175 @@
1
+ import type { WebClient } from "@slack/web-api";
2
+ import { getAgentById } from "../be/db";
3
+ import type { Agent, AgentTask } from "../types";
4
+ import { getSlackApp } from "./app";
5
+
6
+ const isDev = process.env.ENV === "development";
7
+ const appUrl = process.env.APP_URL || "";
8
+
9
+ /**
10
+ * Convert GitHub-flavored markdown to Slack mrkdwn format.
11
+ */
12
+ function markdownToSlack(text: string): string {
13
+ return (
14
+ text
15
+ // Headers to bold (# Header -> *Header*)
16
+ .replace(/^#{1,6}\s+(.+)$/gm, "*$1*")
17
+ // Bold **text** -> *text*
18
+ .replace(/\*\*(.+?)\*\*/g, "*$1*")
19
+ // Links [text](url) -> <url|text>
20
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>")
21
+ // Inline code already works the same
22
+ // Bullet points already work the same
23
+ // Remove excessive blank lines
24
+ .replace(/\n{3,}/g, "\n\n")
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Get the display name for an agent, with (dev) prefix if in development mode.
30
+ */
31
+ function getAgentDisplayName(agent: Agent): string {
32
+ return isDev ? `(dev) ${agent.name}` : agent.name;
33
+ }
34
+
35
+ /**
36
+ * Get a link to the task in the dashboard, or just the task ID if no APP_URL.
37
+ */
38
+ function getTaskLink(taskId: string): string {
39
+ const shortId = taskId.slice(0, 8);
40
+ if (appUrl) {
41
+ return `<${appUrl}?tab=tasks&task=${taskId}&expand=true|\`${shortId}\`>`;
42
+ }
43
+ return `\`${shortId}\``;
44
+ }
45
+
46
+ /**
47
+ * Send a task completion message to Slack with the agent's persona.
48
+ */
49
+ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
50
+ const app = getSlackApp();
51
+ if (!app || !task.slackChannelId || !task.slackThreadTs) {
52
+ return false;
53
+ }
54
+
55
+ if (!task.agentId) {
56
+ console.error(`[Slack] Task ${task.id} has no assigned agent`);
57
+ return false;
58
+ }
59
+
60
+ const agent = getAgentById(task.agentId);
61
+ if (!agent) {
62
+ console.error(`[Slack] Agent not found for task ${task.id}`);
63
+ return false;
64
+ }
65
+
66
+ const client = app.client;
67
+ const taskLink = getTaskLink(task.id);
68
+ const footer = `_Check the full logs at ${taskLink}_`;
69
+
70
+ try {
71
+ if (task.status === "completed") {
72
+ const output = task.output || "_Task completed._";
73
+ const slackOutput = markdownToSlack(output);
74
+ await sendWithPersona(client, {
75
+ channel: task.slackChannelId,
76
+ thread_ts: task.slackThreadTs,
77
+ text: `${slackOutput}\n\n${footer}`,
78
+ username: getAgentDisplayName(agent),
79
+ icon_emoji: getAgentEmoji(agent),
80
+ });
81
+ } else if (task.status === "failed") {
82
+ const reason = task.failureReason || "Unknown error";
83
+ await sendWithPersona(client, {
84
+ channel: task.slackChannelId,
85
+ thread_ts: task.slackThreadTs,
86
+ text: `:x: *Task failed*\n\`\`\`${reason}\`\`\`\n${footer}`,
87
+ username: getAgentDisplayName(agent),
88
+ icon_emoji: getAgentEmoji(agent),
89
+ });
90
+ }
91
+
92
+ return true;
93
+ } catch (error) {
94
+ console.error(`[Slack] Failed to send response for task ${task.id}:`, error);
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Send a progress update to Slack.
101
+ */
102
+ export async function sendProgressUpdate(task: AgentTask, progress: string): Promise<boolean> {
103
+ const app = getSlackApp();
104
+ if (!app || !task.slackChannelId || !task.slackThreadTs) {
105
+ return false;
106
+ }
107
+
108
+ if (!task.agentId) return false;
109
+
110
+ const agent = getAgentById(task.agentId);
111
+ if (!agent) return false;
112
+
113
+ const taskLink = getTaskLink(task.id);
114
+ const footer = `_Check progress at ${taskLink}_`;
115
+
116
+ try {
117
+ await sendWithPersona(app.client, {
118
+ channel: task.slackChannelId,
119
+ thread_ts: task.slackThreadTs,
120
+ text: `:hourglass_flowing_sand: _${progress}_\n\n${footer}`,
121
+ username: getAgentDisplayName(agent),
122
+ icon_emoji: getAgentEmoji(agent),
123
+ });
124
+ return true;
125
+ } catch (error) {
126
+ console.error(`[Slack] Failed to send progress update:`, error);
127
+ return false;
128
+ }
129
+ }
130
+
131
+ async function sendWithPersona(
132
+ client: WebClient,
133
+ options: {
134
+ channel: string;
135
+ thread_ts: string;
136
+ text: string;
137
+ username: string;
138
+ icon_emoji: string;
139
+ },
140
+ ): Promise<void> {
141
+ await client.chat.postMessage({
142
+ channel: options.channel,
143
+ thread_ts: options.thread_ts,
144
+ text: options.text, // Fallback for notifications
145
+ username: options.username,
146
+ icon_emoji: options.icon_emoji,
147
+ blocks: [
148
+ {
149
+ type: "section",
150
+ text: {
151
+ type: "mrkdwn",
152
+ text: options.text,
153
+ },
154
+ },
155
+ ],
156
+ });
157
+ }
158
+
159
+ function getAgentEmoji(agent: Agent): string {
160
+ if (agent.isLead) return ":crown:";
161
+
162
+ // Generate consistent emoji based on agent name hash
163
+ const emojis = [
164
+ ":robot_face:",
165
+ ":gear:",
166
+ ":zap:",
167
+ ":rocket:",
168
+ ":star:",
169
+ ":crystal_ball:",
170
+ ":bulb:",
171
+ ":wrench:",
172
+ ];
173
+ const hash = agent.name.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
174
+ return emojis[hash % emojis.length] ?? ":robot_face:";
175
+ }