@desplega.ai/agent-swarm 1.59.3 โ†’ 1.61.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.59.3",
5
+ "version": "1.61.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.59.3",
3
+ "version": "1.61.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
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { addEyesReactionOnTaskStart } from "../github/task-reactions";
2
3
  import { configureDbResolver } from "../prompts/resolver";
3
4
  import type {
4
5
  ActiveSession,
@@ -709,6 +710,7 @@ type AgentTaskRow = {
709
710
  slackChannelId: string | null;
710
711
  slackThreadTs: string | null;
711
712
  slackUserId: string | null;
713
+ slackReplySent: number;
712
714
  vcsProvider: string | null;
713
715
  vcsRepo: string | null;
714
716
  vcsEventType: string | null;
@@ -716,6 +718,8 @@ type AgentTaskRow = {
716
718
  vcsCommentId: number | null;
717
719
  vcsAuthor: string | null;
718
720
  vcsUrl: string | null;
721
+ vcsInstallationId: number | null;
722
+ vcsNodeId: string | null;
719
723
  agentmailInboxId: string | null;
720
724
  agentmailMessageId: string | null;
721
725
  agentmailThreadId: string | null;
@@ -765,6 +769,7 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
765
769
  slackChannelId: row.slackChannelId ?? undefined,
766
770
  slackThreadTs: row.slackThreadTs ?? undefined,
767
771
  slackUserId: row.slackUserId ?? undefined,
772
+ slackReplySent: !!row.slackReplySent,
768
773
  vcsProvider: (row.vcsProvider as "github" | "gitlab" | null) ?? undefined,
769
774
  vcsRepo: row.vcsRepo ?? undefined,
770
775
  vcsEventType: row.vcsEventType ?? undefined,
@@ -772,6 +777,8 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
772
777
  vcsCommentId: row.vcsCommentId ?? undefined,
773
778
  vcsAuthor: row.vcsAuthor ?? undefined,
774
779
  vcsUrl: row.vcsUrl ?? undefined,
780
+ vcsInstallationId: row.vcsInstallationId ?? undefined,
781
+ vcsNodeId: row.vcsNodeId ?? undefined,
775
782
  agentmailInboxId: row.agentmailInboxId ?? undefined,
776
783
  agentmailMessageId: row.agentmailMessageId ?? undefined,
777
784
  agentmailThreadId: row.agentmailThreadId ?? undefined,
@@ -950,7 +957,12 @@ export function startTask(taskId: string): AgentTask | null {
950
957
  });
951
958
  } catch {}
952
959
  }
953
- return row ? rowToAgentTask(row) : null;
960
+ const result = row ? rowToAgentTask(row) : null;
961
+ // Fire-and-forget: add eyes reaction for GitHub-sourced tasks
962
+ if (result && oldTask.status !== "in_progress") {
963
+ addEyesReactionOnTaskStart(result).catch(() => {});
964
+ }
965
+ return result;
954
966
  }
955
967
 
956
968
  export function getTaskById(id: string): AgentTask | null {
@@ -958,6 +970,19 @@ export function getTaskById(id: string): AgentTask | null {
958
970
  return row ? rowToAgentTask(row) : null;
959
971
  }
960
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
+
961
986
  export function updateTaskClaudeSessionId(
962
987
  taskId: string,
963
988
  claudeSessionId: string,
@@ -1800,6 +1825,8 @@ export interface CreateTaskOptions {
1800
1825
  vcsCommentId?: number;
1801
1826
  vcsAuthor?: string;
1802
1827
  vcsUrl?: string;
1828
+ vcsInstallationId?: number;
1829
+ vcsNodeId?: string;
1803
1830
  agentmailInboxId?: string;
1804
1831
  agentmailMessageId?: string;
1805
1832
  agentmailThreadId?: string;
@@ -1906,10 +1933,11 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
1906
1933
  taskType, tags, priority, dependsOn, offeredTo, offeredAt,
1907
1934
  slackChannelId, slackThreadTs, slackUserId,
1908
1935
  vcsProvider, vcsRepo, vcsEventType, vcsNumber, vcsCommentId, vcsAuthor, vcsUrl,
1936
+ vcsInstallationId, vcsNodeId,
1909
1937
  agentmailInboxId, agentmailMessageId, agentmailThreadId,
1910
1938
  mentionMessageId, mentionChannelId, dir, parentTaskId, model, scheduleId,
1911
1939
  workflowRunId, workflowRunStepId, outputSchema, requestedByUserId, createdAt, lastUpdatedAt
1912
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
1940
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
1913
1941
  )
1914
1942
  .get(
1915
1943
  id,
@@ -1934,6 +1962,8 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
1934
1962
  options?.vcsCommentId ?? null,
1935
1963
  options?.vcsAuthor ?? null,
1936
1964
  options?.vcsUrl ?? null,
1965
+ options?.vcsInstallationId ?? null,
1966
+ options?.vcsNodeId ?? null,
1937
1967
  options?.agentmailInboxId ?? null,
1938
1968
  options?.agentmailMessageId ?? null,
1939
1969
  options?.agentmailThreadId ?? null,
@@ -2005,7 +2035,12 @@ export function claimTask(taskId: string, agentId: string): AgentTask | null {
2005
2035
  } catch {}
2006
2036
  }
2007
2037
 
2008
- return row ? rowToAgentTask(row) : null;
2038
+ const result = row ? rowToAgentTask(row) : null;
2039
+ // Fire-and-forget: add eyes reaction for GitHub-sourced tasks
2040
+ if (result) {
2041
+ addEyesReactionOnTaskStart(result).catch(() => {});
2042
+ }
2043
+ return result;
2009
2044
  }
2010
2045
 
2011
2046
  export function releaseTask(taskId: string): AgentTask | null {
@@ -0,0 +1,4 @@
1
+ -- Add GitHub installation ID and GraphQL node ID to tasks
2
+ -- Needed for adding reactions (eyes emoji) when agents pick up GitHub-sourced tasks
3
+ ALTER TABLE agent_tasks ADD COLUMN vcsInstallationId INTEGER;
4
+ ALTER TABLE agent_tasks ADD COLUMN vcsNodeId TEXT;
@@ -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);
@@ -183,6 +183,7 @@ export async function handlePullRequest(
183
183
  vcsAuthor: sender.login,
184
184
  requestedByUserId,
185
185
  vcsUrl: pr.html_url,
186
+ vcsInstallationId: installation?.id,
186
187
  });
187
188
 
188
189
  if (lead) {
@@ -281,6 +282,7 @@ export async function handlePullRequest(
281
282
  vcsAuthor: sender.login,
282
283
  requestedByUserId,
283
284
  vcsUrl: pr.html_url,
285
+ vcsInstallationId: installation?.id,
284
286
  });
285
287
 
286
288
  if (lead) {
@@ -371,6 +373,7 @@ export async function handlePullRequest(
371
373
  vcsAuthor: sender.login,
372
374
  requestedByUserId,
373
375
  vcsUrl: pr.html_url,
376
+ vcsInstallationId: installation?.id,
374
377
  });
375
378
 
376
379
  if (lead) {
@@ -458,6 +461,7 @@ export async function handlePullRequest(
458
461
  vcsNumber: pr.number,
459
462
  vcsAuthor: sender.login,
460
463
  vcsUrl: pr.html_url,
464
+ vcsInstallationId: installation?.id,
461
465
  });
462
466
 
463
467
  if (lead) {
@@ -531,6 +535,7 @@ export async function handleIssue(
531
535
  vcsAuthor: sender.login,
532
536
  requestedByUserId,
533
537
  vcsUrl: issue.html_url,
538
+ vcsInstallationId: installation?.id,
534
539
  });
535
540
 
536
541
  if (lead) {
@@ -617,6 +622,7 @@ export async function handleIssue(
617
622
  vcsAuthor: sender.login,
618
623
  requestedByUserId,
619
624
  vcsUrl: issue.html_url,
625
+ vcsInstallationId: installation?.id,
620
626
  });
621
627
 
622
628
  if (lead) {
@@ -686,6 +692,7 @@ export async function handleIssue(
686
692
  vcsNumber: issue.number,
687
693
  vcsAuthor: sender.login,
688
694
  vcsUrl: issue.html_url,
695
+ vcsInstallationId: installation?.id,
689
696
  });
690
697
 
691
698
  if (lead) {
@@ -784,6 +791,8 @@ export async function handleComment(
784
791
  vcsCommentId: comment.id,
785
792
  vcsAuthor: sender.login,
786
793
  vcsUrl: targetUrl,
794
+ vcsInstallationId: installation?.id,
795
+ vcsNodeId: comment.node_id,
787
796
  });
788
797
 
789
798
  if (lead) {
@@ -896,6 +905,8 @@ export async function handlePullRequestReview(
896
905
  vcsNumber: pr.number,
897
906
  vcsAuthor: sender.login,
898
907
  vcsUrl: review.html_url,
908
+ vcsInstallationId: installation?.id,
909
+ vcsNodeId: review.node_id,
899
910
  });
900
911
 
901
912
  if (lead) {
@@ -102,6 +102,125 @@ export async function addIssueReaction(
102
102
  }
103
103
  }
104
104
 
105
+ /**
106
+ * Add a reaction to a PR review comment (inline comment on a diff)
107
+ * Uses the pulls/comments endpoint (different from issues/comments)
108
+ */
109
+ export async function addPullReviewCommentReaction(
110
+ repo: string,
111
+ commentId: number,
112
+ reaction: ReactionType,
113
+ installationId: number,
114
+ ): Promise<boolean> {
115
+ if (!isReactionsEnabled()) {
116
+ console.log("[GitHub] Reactions not enabled, skipping");
117
+ return false;
118
+ }
119
+
120
+ const token = await getInstallationToken(installationId);
121
+ if (!token) {
122
+ console.log("[GitHub] No installation token, skipping reaction");
123
+ return false;
124
+ }
125
+
126
+ try {
127
+ const response = await fetch(
128
+ `https://api.github.com/repos/${repo}/pulls/comments/${commentId}/reactions`,
129
+ {
130
+ method: "POST",
131
+ headers: {
132
+ Accept: "application/vnd.github+json",
133
+ Authorization: `Bearer ${token}`,
134
+ "X-GitHub-Api-Version": "2022-11-28",
135
+ "Content-Type": "application/json",
136
+ },
137
+ body: JSON.stringify({ content: reaction }),
138
+ },
139
+ );
140
+
141
+ if (!response.ok) {
142
+ const errorText = await response.text();
143
+ console.error(
144
+ `[GitHub] Failed to add PR review comment reaction: ${response.status} ${errorText}`,
145
+ );
146
+ return false;
147
+ }
148
+
149
+ console.log(`[GitHub] Added ${reaction} reaction to PR review comment ${commentId}`);
150
+ return true;
151
+ } catch (error) {
152
+ console.error("[GitHub] Error adding PR review comment reaction:", error);
153
+ return false;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Add a reaction via the GraphQL API (for PR review bodies which REST doesn't support)
159
+ * Requires the node_id of the subject
160
+ */
161
+ export async function addGraphQLReaction(
162
+ nodeId: string,
163
+ reaction:
164
+ | "THUMBS_UP"
165
+ | "THUMBS_DOWN"
166
+ | "LAUGH"
167
+ | "HOORAY"
168
+ | "CONFUSED"
169
+ | "HEART"
170
+ | "ROCKET"
171
+ | "EYES",
172
+ installationId: number,
173
+ ): Promise<boolean> {
174
+ if (!isReactionsEnabled()) {
175
+ console.log("[GitHub] Reactions not enabled, skipping");
176
+ return false;
177
+ }
178
+
179
+ const token = await getInstallationToken(installationId);
180
+ if (!token) {
181
+ console.log("[GitHub] No installation token, skipping GraphQL reaction");
182
+ return false;
183
+ }
184
+
185
+ try {
186
+ const response = await fetch("https://api.github.com/graphql", {
187
+ method: "POST",
188
+ headers: {
189
+ Authorization: `Bearer ${token}`,
190
+ "Content-Type": "application/json",
191
+ },
192
+ body: JSON.stringify({
193
+ query: `mutation AddReaction($input: AddReactionInput!) {
194
+ addReaction(input: $input) {
195
+ reaction { content }
196
+ }
197
+ }`,
198
+ variables: {
199
+ input: { subjectId: nodeId, content: reaction },
200
+ },
201
+ }),
202
+ });
203
+
204
+ if (!response.ok) {
205
+ const errorText = await response.text();
206
+ console.error(`[GitHub] GraphQL reaction failed: ${response.status} ${errorText}`);
207
+ return false;
208
+ }
209
+
210
+ const data = (await response.json()) as { errors?: Array<{ message: string }> };
211
+ if (data.errors?.length) {
212
+ console.error(`[GitHub] GraphQL reaction errors: ${JSON.stringify(data.errors)}`);
213
+ return false;
214
+ }
215
+
216
+ console.log(`[GitHub] Added ${reaction} GraphQL reaction to node ${nodeId}`);
217
+ return true;
218
+ } catch (error) {
219
+ console.error("[GitHub] Error adding GraphQL reaction:", error);
220
+ return false;
221
+ }
222
+ }
223
+
105
224
  /**
106
225
  * Post a comment on an issue or PR
107
226
  * Appears as agent-swarm-bot[bot] commenting
@@ -0,0 +1,66 @@
1
+ import type { AgentTask } from "../types";
2
+ import {
3
+ addGraphQLReaction,
4
+ addIssueReaction,
5
+ addPullReviewCommentReaction,
6
+ addReaction,
7
+ } from "./reactions";
8
+
9
+ /**
10
+ * Add an ๐Ÿ‘€ eyes reaction to the source GitHub comment/review when a task starts.
11
+ * Called when a task transitions to `in_progress`.
12
+ *
13
+ * Handles three GitHub event types:
14
+ * - issue_comment: REST reaction on issue comment
15
+ * - pull_request_review_comment: REST reaction on PR review comment (inline)
16
+ * - pull_request_review: GraphQL reaction on review body (REST doesn't support review reactions)
17
+ */
18
+ export async function addEyesReactionOnTaskStart(task: AgentTask): Promise<void> {
19
+ // Only for GitHub-sourced tasks with installation info
20
+ if (task.source !== "github" || task.vcsProvider !== "github") return;
21
+ if (!task.vcsInstallationId) return;
22
+
23
+ const installationId = task.vcsInstallationId;
24
+ const repo = task.vcsRepo;
25
+ if (!repo) return;
26
+
27
+ try {
28
+ switch (task.vcsEventType) {
29
+ case "issue_comment": {
30
+ // Issue comment โ€” use REST issues/comments endpoint
31
+ if (task.vcsCommentId) {
32
+ await addReaction(repo, task.vcsCommentId, "eyes", installationId);
33
+ }
34
+ break;
35
+ }
36
+
37
+ case "pull_request_review_comment": {
38
+ // Inline PR review comment โ€” use REST pulls/comments endpoint
39
+ if (task.vcsCommentId) {
40
+ await addPullReviewCommentReaction(repo, task.vcsCommentId, "eyes", installationId);
41
+ }
42
+ break;
43
+ }
44
+
45
+ case "pull_request_review": {
46
+ // PR review body โ€” requires GraphQL API
47
+ if (task.vcsNodeId) {
48
+ await addGraphQLReaction(task.vcsNodeId, "EYES", installationId);
49
+ }
50
+ break;
51
+ }
52
+
53
+ case "pull_request":
54
+ case "issues": {
55
+ // PR or issue opened/labeled โ€” react on the issue/PR itself
56
+ if (task.vcsNumber) {
57
+ await addIssueReaction(repo, task.vcsNumber, "eyes", installationId);
58
+ }
59
+ break;
60
+ }
61
+ }
62
+ } catch (error) {
63
+ // Never fail the task start due to a reaction error
64
+ console.error("[GitHub] Failed to add eyes reaction on task start:", error);
65
+ }
66
+ }
@@ -37,6 +37,7 @@ export interface IssueEvent extends GitHubWebhookEvent {
37
37
  export interface CommentEvent extends GitHubWebhookEvent {
38
38
  comment: {
39
39
  id: number;
40
+ node_id?: string;
40
41
  body: string;
41
42
  html_url: string;
42
43
  user: { login: string };
@@ -49,6 +50,7 @@ export interface PullRequestReviewEvent extends GitHubWebhookEvent {
49
50
  action: "submitted" | "edited" | "dismissed";
50
51
  review: {
51
52
  id: number;
53
+ node_id?: string;
52
54
  body: string | null;
53
55
  state: "approved" | "changes_requested" | "commented" | "dismissed";
54
56
  html_url: string;
@@ -17,6 +17,7 @@ When someone @mentions the bot in a thread, the router checks whether a worker a
17
17
  - Message must be in a thread (`thread_ts` present)
18
18
  - An agent must have an active task (`in_progress` or `pending`) linked to that thread via `slackChannelId` + `slackThreadTs`
19
19
  - The matched agent must not be `offline`
20
+ - If `SLACK_THREAD_FOLLOWUP_REQUIRE_MENTION=true`, non-mention thread messages are silently dropped instead of routing to the working agent. DM assistant threads are unaffected.
20
21
 
21
22
  **Why this matters:** Without thread follow-up routing, every @mention in a thread goes to the lead, who then has to re-delegate. This shortcut sends the message directly to the worker already handling that conversation.
22
23
 
@@ -89,6 +90,7 @@ When the additive buffer flushes normally (not via `!now`), the created task use
89
90
  |------|---------|-------------|
90
91
  | `ADDITIVE_SLACK` | `false` | Enables non-mention thread message buffering and batching |
91
92
  | `ADDITIVE_SLACK_BUFFER_MS` | `10000` (10s) | Debounce window in milliseconds for the thread buffer |
93
+ | `SLACK_THREAD_FOLLOWUP_REQUIRE_MENTION` | `false` | Requires @mention for thread follow-up routing; non-mention thread messages are silently dropped |
92
94
 
93
95
  Both are read from environment variables. `ADDITIVE_SLACK` must be exactly `"true"` to enable. `ADDITIVE_SLACK_BUFFER_MS` is parsed as a number with fallback to 10000.
94
96
 
@@ -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
+ }