@desplega.ai/agent-swarm 1.60.0 → 1.62.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.
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.60.0",
5
+ "version": "1.62.0",
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.60.0",
3
+ "version": "1.62.0",
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>",
package/src/be/db.ts CHANGED
@@ -710,6 +710,7 @@ type AgentTaskRow = {
710
710
  slackChannelId: string | null;
711
711
  slackThreadTs: string | null;
712
712
  slackUserId: string | null;
713
+ slackReplySent: number;
713
714
  vcsProvider: string | null;
714
715
  vcsRepo: string | null;
715
716
  vcsEventType: string | null;
@@ -768,6 +769,7 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
768
769
  slackChannelId: row.slackChannelId ?? undefined,
769
770
  slackThreadTs: row.slackThreadTs ?? undefined,
770
771
  slackUserId: row.slackUserId ?? undefined,
772
+ slackReplySent: !!row.slackReplySent,
771
773
  vcsProvider: (row.vcsProvider as "github" | "gitlab" | null) ?? undefined,
772
774
  vcsRepo: row.vcsRepo ?? undefined,
773
775
  vcsEventType: row.vcsEventType ?? undefined,
@@ -968,6 +970,19 @@ export function getTaskById(id: string): AgentTask | null {
968
970
  return row ? rowToAgentTask(row) : null;
969
971
  }
970
972
 
973
+ export function markTaskSlackReplySent(taskId: string): void {
974
+ getDb().run(`UPDATE agent_tasks SET slackReplySent = 1 WHERE id = ?`, [taskId]);
975
+ }
976
+
977
+ export function getChildTasks(parentTaskId: string): AgentTask[] {
978
+ return getDb()
979
+ .prepare<AgentTaskRow, [string]>(
980
+ `SELECT * FROM agent_tasks WHERE parentTaskId = ? ORDER BY createdAt ASC`,
981
+ )
982
+ .all(parentTaskId)
983
+ .map(rowToAgentTask);
984
+ }
985
+
971
986
  export function updateTaskClaudeSessionId(
972
987
  taskId: string,
973
988
  claudeSessionId: string,
@@ -0,0 +1,4 @@
1
+ ALTER TABLE agent_tasks ADD COLUMN slackReplySent INTEGER DEFAULT 0;
2
+
3
+ -- Index on parentTaskId for getChildTasks() query (Phase 4)
4
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_parentTaskId ON agent_tasks(parentTaskId);
@@ -128,6 +128,38 @@ function cancelActionBlock(taskId: string): SlackBlock {
128
128
  };
129
129
  }
130
130
 
131
+ // --- Utilities ---
132
+
133
+ /**
134
+ * Format a duration between two dates in a compact human-readable form.
135
+ * Examples: "45s", "2m 14s", "1h 30m"
136
+ */
137
+ export function formatDuration(start: Date, end: Date): string {
138
+ const ms = end.getTime() - start.getTime();
139
+ const seconds = Math.floor(ms / 1000);
140
+ if (seconds < 60) return `${seconds}s`;
141
+ const minutes = Math.floor(seconds / 60);
142
+ const remainingSeconds = seconds % 60;
143
+ if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
144
+ const hours = Math.floor(minutes / 60);
145
+ const remainingMinutes = minutes % 60;
146
+ return `${hours}h ${remainingMinutes}m`;
147
+ }
148
+
149
+ // --- Tree types ---
150
+
151
+ export interface TreeNode {
152
+ taskId: string;
153
+ agentName: string;
154
+ status: "pending" | "in_progress" | "completed" | "failed" | "cancelled";
155
+ progress?: string;
156
+ duration?: string;
157
+ slackReplySent?: boolean;
158
+ output?: string; // Only used when !slackReplySent on completion
159
+ failureReason?: string; // Always shown on failure
160
+ children: TreeNode[];
161
+ }
162
+
131
163
  // --- High-level block builders ---
132
164
 
133
165
  /**
@@ -139,14 +171,19 @@ export function buildCompletedBlocks(opts: {
139
171
  taskId: string;
140
172
  body: string;
141
173
  duration?: string;
174
+ minimal?: boolean; // true = suppress body (agent already replied via slack-reply)
142
175
  }): SlackBlock[] {
143
176
  const taskLink = getTaskLink(opts.taskId);
144
177
  let line = `✅ *${opts.agentName}* (${taskLink})`;
145
178
  if (opts.duration) line += ` · ${opts.duration}`;
146
179
 
147
180
  const blocks: SlackBlock[] = [sectionBlock(line)];
148
- for (const chunk of splitText(opts.body)) {
149
- blocks.push(sectionBlock(chunk));
181
+
182
+ // Only include body if not minimal (agent didn't reply via slack-reply)
183
+ if (!opts.minimal) {
184
+ for (const chunk of splitText(opts.body)) {
185
+ blocks.push(sectionBlock(chunk));
186
+ }
150
187
  }
151
188
  return blocks;
152
189
  }
@@ -231,3 +268,143 @@ export function buildBufferFlushBlocks(opts: {
231
268
 
232
269
  return [contextBlock(`📡 _${text}_ (${taskLink})`)];
233
270
  }
271
+
272
+ // --- Tree rendering ---
273
+
274
+ const STATUS_ICON: Record<TreeNode["status"], string> = {
275
+ pending: "📡",
276
+ in_progress: "⏳",
277
+ completed: "✅",
278
+ failed: "❌",
279
+ cancelled: "🚫",
280
+ };
281
+
282
+ const MAX_VISIBLE_CHILDREN = 8;
283
+ const MAX_OUTPUT_LENGTH = 120;
284
+
285
+ /**
286
+ * Truncate output to the first sentence or MAX_OUTPUT_LENGTH, whichever is shorter.
287
+ */
288
+ function truncateOutput(text: string): string {
289
+ // Find first sentence boundary (. followed by space or end)
290
+ const sentenceEnd = text.search(/\.\s/);
291
+ const firstSentence = sentenceEnd !== -1 ? text.slice(0, sentenceEnd + 1) : text;
292
+ if (firstSentence.length <= MAX_OUTPUT_LENGTH) return firstSentence;
293
+ return `${text.slice(0, MAX_OUTPUT_LENGTH)}…`;
294
+ }
295
+
296
+ /**
297
+ * Render a single node line: icon + bold name + task link + optional duration.
298
+ */
299
+ function renderNodeLine(node: TreeNode): string {
300
+ const icon = STATUS_ICON[node.status];
301
+ const taskLink = getTaskLink(node.taskId);
302
+ let line = `${icon} *${node.agentName}* (${taskLink})`;
303
+ if (node.duration) line += ` · ${node.duration}`;
304
+ return line;
305
+ }
306
+
307
+ /**
308
+ * Render detail lines for a child node (progress, output, failure reason).
309
+ * Returns an array of indented lines to appear below the child's main line.
310
+ */
311
+ function renderChildDetail(node: TreeNode, indent: string): string[] {
312
+ const lines: string[] = [];
313
+
314
+ if (node.status === "failed" && node.failureReason) {
315
+ lines.push(`${indent}Error: ${node.failureReason}`);
316
+ }
317
+
318
+ if (node.status === "in_progress" && node.progress) {
319
+ lines.push(`${indent}${node.progress}`);
320
+ }
321
+
322
+ if (node.status === "completed" && !node.slackReplySent && node.output) {
323
+ lines.push(`${indent}${truncateOutput(node.output)}`);
324
+ }
325
+
326
+ return lines;
327
+ }
328
+
329
+ /**
330
+ * Render a single root node and its children as a mrkdwn tree string.
331
+ */
332
+ function renderTree(root: TreeNode): string {
333
+ const lines: string[] = [];
334
+
335
+ // Root line
336
+ lines.push(renderNodeLine(root));
337
+
338
+ // Root-level detail (progress for in-progress root with no children)
339
+ if (root.children.length === 0) {
340
+ if (root.status === "in_progress" && root.progress) {
341
+ lines.push(` ${root.progress}`);
342
+ }
343
+ if (root.status === "failed" && root.failureReason) {
344
+ lines.push(` Error: ${root.failureReason}`);
345
+ }
346
+ if (root.status === "completed" && !root.slackReplySent && root.output) {
347
+ lines.push(` ${truncateOutput(root.output)}`);
348
+ }
349
+ return lines.join("\n");
350
+ }
351
+
352
+ const visibleChildren = root.children.slice(0, MAX_VISIBLE_CHILDREN);
353
+ const hiddenCount = root.children.length - visibleChildren.length;
354
+
355
+ for (let i = 0; i < visibleChildren.length; i++) {
356
+ const child = visibleChildren[i] as TreeNode;
357
+ const isLast = i === visibleChildren.length - 1 && hiddenCount === 0;
358
+ const prefix = isLast ? "└ " : "├ ";
359
+ const continuationPrefix = isLast ? " " : "│ ";
360
+
361
+ lines.push(`${prefix}${renderNodeLine(child)}`);
362
+
363
+ for (const detail of renderChildDetail(child, continuationPrefix)) {
364
+ lines.push(detail);
365
+ }
366
+ }
367
+
368
+ if (hiddenCount > 0) {
369
+ lines.push(`└ _and ${hiddenCount} more..._`);
370
+ }
371
+
372
+ return lines.join("\n");
373
+ }
374
+
375
+ /**
376
+ * Check if any node in the tree is still active (pending or in_progress).
377
+ */
378
+ function isTreeActive(node: TreeNode): boolean {
379
+ if (node.status === "pending" || node.status === "in_progress") return true;
380
+ return node.children.some(isTreeActive);
381
+ }
382
+
383
+ /**
384
+ * Build Slack blocks for a tree-based status message.
385
+ *
386
+ * Renders one or more root nodes as mrkdwn trees with status icons,
387
+ * agent names, task links, durations, progress text, and error details.
388
+ *
389
+ * For in-progress trees, includes a cancel button per active root.
390
+ *
391
+ * @param roots - Array of root TreeNode objects (one per assigned task in a round)
392
+ * @returns SlackBlock[] suitable for chat.postMessage / chat.update
393
+ */
394
+ export function buildTreeBlocks(roots: TreeNode[]): SlackBlock[] {
395
+ console.log(
396
+ `[Slack] Building tree blocks for ${roots.length} root(s): ${roots.map((r) => r.taskId.slice(0, 8)).join(", ")}`,
397
+ );
398
+
399
+ const treeTexts = roots.map(renderTree);
400
+ const blocks: SlackBlock[] = [sectionBlock(treeTexts.join("\n\n"))];
401
+
402
+ // Add cancel buttons for active roots
403
+ for (const root of roots) {
404
+ if (isTreeActive(root)) {
405
+ blocks.push(cancelActionBlock(root.taskId));
406
+ }
407
+ }
408
+
409
+ return blocks;
410
+ }
@@ -11,13 +11,13 @@ import {
11
11
  } from "../be/db";
12
12
  import { resolveTemplate } from "../prompts/resolver";
13
13
  import { workflowEventBus } from "../workflows/event-bus";
14
- import { buildAssignmentSummaryBlocks } from "./blocks";
14
+ import { buildTreeBlocks, type TreeNode } from "./blocks";
15
15
  import type { SlackFile } from "./files";
16
16
  import { extractTaskFromMessage, routeMessage } from "./router";
17
17
  // Side-effect import: registers all Slack event templates in the in-memory registry
18
18
  import "./templates";
19
19
  import { bufferThreadMessage, getBufferMessageCount, instantFlush } from "./thread-buffer";
20
- import { registerTaskMessage } from "./watcher";
20
+ import { registerTreeMessage } from "./watcher";
21
21
 
22
22
  // User filtering configuration from environment variables
23
23
  const allowedEmailDomains = (process.env.SLACK_ALLOWED_EMAIL_DOMAINS || "")
@@ -591,9 +591,40 @@ export function registerMessageHandler(app: App): void {
591
591
  }
592
592
  }
593
593
 
594
- // Send consolidated summary with Block Kit
594
+ // Send consolidated summary as initial tree with Block Kit
595
595
  const totalResults = results.assigned.length + results.queued.length + results.failed.length;
596
596
  if (totalResults > 0) {
597
+ // Build initial tree nodes from assignment results
598
+ const initialNodes: TreeNode[] = results.assigned.map(({ agentName, taskId }) => ({
599
+ taskId,
600
+ agentName,
601
+ status: "in_progress" as const,
602
+ children: [],
603
+ }));
604
+
605
+ // Add queued tasks
606
+ for (const q of results.queued) {
607
+ initialNodes.push({
608
+ taskId: q.taskId,
609
+ agentName: q.agentName,
610
+ status: "pending" as const,
611
+ children: [],
612
+ });
613
+ }
614
+
615
+ const blocks = buildTreeBlocks(initialNodes);
616
+
617
+ // Append failed assignment lines as context below the tree
618
+ if (results.failed.length > 0) {
619
+ const failedLines = results.failed
620
+ .map((f) => `⚠️ Could not assign to: *${f.agentName}* — ${f.reason}`)
621
+ .join("\n");
622
+ blocks.push({
623
+ type: "context",
624
+ elements: [{ type: "mrkdwn", text: failedLines }],
625
+ });
626
+ }
627
+
597
628
  // Build plain-text fallback
598
629
  const parts: string[] = [];
599
630
  if (results.assigned.length > 0) {
@@ -609,17 +640,25 @@ export function registerMessageHandler(app: App): void {
609
640
  parts.push(`Could not assign to: ${names}`);
610
641
  }
611
642
 
643
+ console.log(
644
+ `[Slack] Posting initial tree message with ${initialNodes.length} node(s)${results.failed.length > 0 ? ` and ${results.failed.length} failed assignment(s)` : ""}`,
645
+ );
646
+
612
647
  const resp = await say({
613
648
  text: parts.join(". "),
614
- blocks: buildAssignmentSummaryBlocks(results),
649
+ blocks,
615
650
  thread_ts: msg.thread_ts || msg.ts,
616
651
  });
617
652
 
618
- // Register the assignment message so the watcher can update it in-place
619
- // (assignment → progress → completion all in one evolving message)
653
+ // Register the tree message so the watcher can update it in-place
654
+ // (assignment → progress → completion all in one evolving tree message)
620
655
  if (resp?.ts) {
621
656
  for (const { taskId } of results.assigned) {
622
- registerTaskMessage(taskId, msg.channel, threadTs, resp.ts);
657
+ registerTreeMessage(taskId, msg.channel, threadTs, resp.ts);
658
+ }
659
+ // Also register queued tasks so they appear in the tree when they start
660
+ for (const { taskId } of results.queued) {
661
+ registerTreeMessage(taskId, msg.channel, threadTs, resp.ts);
623
662
  }
624
663
  }
625
664
  }
@@ -7,6 +7,7 @@ import {
7
7
  buildCompletedBlocks,
8
8
  buildFailedBlocks,
9
9
  buildProgressBlocks,
10
+ formatDuration,
10
11
  markdownToSlack,
11
12
  } from "./blocks";
12
13
 
@@ -49,11 +50,24 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
49
50
  if (task.status === "completed") {
50
51
  const output = task.output || "Task completed.";
51
52
  const slackOutput = markdownToSlack(output);
52
- const blocks = buildCompletedBlocks({ agentName, taskId: task.id, body: slackOutput });
53
+ const duration =
54
+ task.finishedAt && task.createdAt
55
+ ? formatDuration(new Date(task.createdAt), new Date(task.finishedAt))
56
+ : undefined;
57
+ console.log(
58
+ `[Slack] sendTaskResponse: task=${task.id} slackReplySent=${!!task.slackReplySent} minimal=${!!task.slackReplySent}`,
59
+ );
60
+ const blocks = buildCompletedBlocks({
61
+ agentName,
62
+ taskId: task.id,
63
+ body: slackOutput,
64
+ duration,
65
+ minimal: !!task.slackReplySent,
66
+ });
53
67
  await sendWithPersona(client, {
54
68
  channel: task.slackChannelId,
55
69
  thread_ts: task.slackThreadTs,
56
- text: slackOutput,
70
+ text: task.slackReplySent ? `✅ ${agentName} completed` : slackOutput,
57
71
  username: getAgentDisplayName(agent),
58
72
  icon_emoji: getAgentEmoji(agent),
59
73
  blocks,
@@ -161,8 +175,21 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
161
175
  if (task.status === "completed") {
162
176
  const output = task.output || "Task completed.";
163
177
  const slackOutput = markdownToSlack(output);
164
- blocks = buildCompletedBlocks({ agentName, taskId: task.id, body: slackOutput });
165
- text = slackOutput;
178
+ const duration =
179
+ task.finishedAt && task.createdAt
180
+ ? formatDuration(new Date(task.createdAt), new Date(task.finishedAt))
181
+ : undefined;
182
+ console.log(
183
+ `[Slack] updateToFinal: task=${task.id} slackReplySent=${!!task.slackReplySent} minimal=${!!task.slackReplySent}`,
184
+ );
185
+ blocks = buildCompletedBlocks({
186
+ agentName,
187
+ taskId: task.id,
188
+ body: slackOutput,
189
+ duration,
190
+ minimal: !!task.slackReplySent,
191
+ });
192
+ text = task.slackReplySent ? `✅ ${agentName} completed` : slackOutput;
166
193
  } else if (task.status === "cancelled") {
167
194
  blocks = buildCancelledBlocks({ agentName, taskId: task.id });
168
195
  text = "Task cancelled";
@@ -187,6 +214,34 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
187
214
  }
188
215
  }
189
216
 
217
+ /**
218
+ * Update a tree message directly with pre-built blocks via chat.update.
219
+ * Used by the watcher's tree rendering loop (Phase 5).
220
+ */
221
+ export async function updateTreeMessage(
222
+ channelId: string,
223
+ messageTs: string,
224
+ blocks: unknown[],
225
+ fallbackText: string,
226
+ ): Promise<boolean> {
227
+ const app = getSlackApp();
228
+ if (!app) return false;
229
+
230
+ try {
231
+ await app.client.chat.update({
232
+ channel: channelId,
233
+ ts: messageTs,
234
+ text: fallbackText,
235
+ // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
236
+ blocks: blocks as any,
237
+ });
238
+ return true;
239
+ } catch (error) {
240
+ console.error(`[Slack] Failed to update tree message:`, error);
241
+ return false;
242
+ }
243
+ }
244
+
190
245
  async function sendWithPersona(
191
246
  client: WebClient,
192
247
  options: {
@@ -6,6 +6,7 @@ import {
6
6
  } from "../be/db";
7
7
  import { getSlackApp } from "./app";
8
8
  import { buildBufferFlushBlocks } from "./blocks";
9
+ import { registerTreeMessage } from "./watcher";
9
10
 
10
11
  interface BufferedMessage {
11
12
  text: string;
@@ -197,13 +198,21 @@ async function flushBuffer(key: string, immediate = false): Promise<void> {
197
198
  : `${buffer.messages.length} follow-up message(s) batched into task`;
198
199
 
199
200
  try {
200
- await app.client.chat.postMessage({
201
+ const result = await app.client.chat.postMessage({
201
202
  channel: buffer.channelId,
202
203
  thread_ts: buffer.threadTs,
203
204
  text: fallbackText,
204
205
  // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
205
206
  blocks: blocks as any,
206
207
  });
208
+
209
+ // Register the batching message as the tree message for this task
210
+ if (result.ts && task) {
211
+ registerTreeMessage(task.id, buffer.channelId, buffer.threadTs, result.ts);
212
+ console.log(
213
+ `[Slack] Registered batched task ${task.id.slice(0, 8)} tree message from buffer flush`,
214
+ );
215
+ }
207
216
  } catch (error) {
208
217
  console.error("[Slack] Failed to post buffer flush feedback:", error);
209
218
  }