@desplega.ai/agent-swarm 1.52.0 → 1.52.1

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/README.md CHANGED
@@ -56,6 +56,8 @@ Agent Swarm lets you run a team of AI coding agents that coordinate autonomously
56
56
  - **Linear integration** — Bidirectional ticket tracker sync via OAuth + webhooks with AgentSession lifecycle and generic tracker abstraction
57
57
  - **Portless local dev** — Friendly URLs for local development (`api.swarm.localhost:1355`) via portless proxy
58
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
59
61
 
60
62
  ## Quick Start
61
63
 
@@ -262,6 +264,135 @@ GITHUB_APP_PRIVATE_KEY=base64-encoded-key
262
264
  | PR review submitted (on bot's PR) | Creates a notification task with review feedback |
263
265
  | CI failure (on PRs with existing tasks) | Creates a CI notification task |
264
266
 
267
+ <details>
268
+ <summary><strong>Flow Diagrams</strong> (click to expand)</summary>
269
+
270
+ #### Task Creation Flow
271
+
272
+ How GitHub events become tasks in the swarm:
273
+
274
+ ```mermaid
275
+ %%{init: {'theme': 'dark', 'themeVariables': {'fontSize': '13px', 'nodeSpacing': 30, 'rankSpacing': 40}}}%%
276
+ flowchart TB
277
+ subgraph ENTRY["1. GitHub Webhook Entry Points"]
278
+ direction LR
279
+ E1["Issue<br/>opened/edited"]
280
+ E2["PR<br/>opened/edited"]
281
+ E3["Comment<br/>created"]
282
+ E4["Bot Assigned<br/>to Issue/PR"]
283
+ E5["Review Requested<br/>from Bot"]
284
+ end
285
+
286
+ subgraph GATE["2. Trigger Gate"]
287
+ M{"@agent-swarm<br/>mention?"}
288
+ A{"Bot is<br/>assignee?"}
289
+ D{"Duplicate?<br/>60s TTL"}
290
+ end
291
+
292
+ subgraph CREATE["3. Task Creation"]
293
+ LEAD["Find Lead Agent<br/>(online > offline > none)"]
294
+ TPL["resolveTemplate()"]
295
+ TASK["createTaskExtended()"]
296
+ end
297
+
298
+ subgraph OUT["4. Output"]
299
+ ASSIGN["Task assigned<br/>to Lead"]
300
+ POOL["Task in pool<br/>(no lead)"]
301
+ REACT["eyes reaction<br/>on GitHub"]
302
+ end
303
+
304
+ E1 & E2 & E3 --> M
305
+ E4 & E5 --> A
306
+
307
+ M -->|Yes| D
308
+ A -->|Yes| D
309
+ M & A -->|No| DROP1(("skip"))
310
+
311
+ D -->|New| LEAD
312
+ D -->|Dup| DROP2(("skip"))
313
+
314
+ LEAD --> TPL --> TASK
315
+
316
+ TASK -->|lead found| ASSIGN
317
+ TASK -.->|no lead| POOL
318
+ TASK --> REACT
319
+ ```
320
+
321
+ [PNG fallback](assets/github-task-creation-flow.png)
322
+
323
+ #### Follow-up Flows
324
+
325
+ Events that create secondary tasks when an active task already exists for a PR:
326
+
327
+ ```mermaid
328
+ %%{init: {'theme': 'dark', 'themeVariables': {'fontSize': '13px'}}}%%
329
+ flowchart TB
330
+ subgraph EVENTS["GitHub Follow-up Events (require existing active task)"]
331
+ direction LR
332
+ F1["PR Closed<br/>(merged/closed)"]
333
+ F2["PR Synchronize<br/>(new commits)"]
334
+ F3["Review Submitted<br/>(approved/changes_requested)"]
335
+ F4["Check Run Failed"]
336
+ F5["Check Suite Failed"]
337
+ F6["Workflow Run Failed"]
338
+ end
339
+
340
+ FIND{"findTaskByVcs()<br/>Active task for<br/>repo + PR number?"}
341
+
342
+ EVENTS --> FIND
343
+
344
+ FIND -->|No task| SKIP(("skip"))
345
+
346
+ subgraph FOLLOWUP["Follow-up Task Created (assigned to Lead)"]
347
+ direction LR
348
+ T1["github-pr-status<br/>PR merged/closed"]
349
+ T2["github-pr-update<br/>New commits pushed"]
350
+ T3["github-review<br/>Review feedback"]
351
+ T4["github-ci<br/>CI failure alert"]
352
+ end
353
+
354
+ F1 --> FIND -->|task found| T1
355
+ F2 --> FIND -->|task found| T2
356
+ F3 --> FIND -->|task found| T3
357
+ F4 & F5 & F6 --> FIND -->|task found| T4
358
+
359
+ NOTE["All follow-up tasks reference<br/>the original task ID for routing"]
360
+
361
+ FOLLOWUP --> NOTE
362
+ ```
363
+
364
+ [PNG fallback](assets/github-followup-flows.png)
365
+
366
+ #### Cancellation Flows
367
+
368
+ How unassigning the bot cancels active tasks:
369
+
370
+ ```mermaid
371
+ %%{init: {'theme': 'dark', 'themeVariables': {'fontSize': '13px'}}}%%
372
+ flowchart TB
373
+ subgraph EVENTS["Cancellation Events"]
374
+ direction LR
375
+ C1["Bot Unassigned<br/>from Issue"]
376
+ C2["Bot Unassigned<br/>from PR"]
377
+ C3["Review Request<br/>Removed from Bot"]
378
+ end
379
+
380
+ BOT{"isBotAssignee()"}
381
+ FIND{"findTaskByVcs()<br/>Active task?"}
382
+ CANCEL["failTask()<br/>Cancel with reason"]
383
+ NOOP(("no-op"))
384
+
385
+ EVENTS --> BOT
386
+ BOT -->|Not bot| NOOP
387
+ BOT -->|Is bot| FIND
388
+ FIND -->|No task| NOOP
389
+ FIND -->|Task found| CANCEL
390
+ ```
391
+
392
+ [PNG fallback](assets/github-cancellation-flows.png)
393
+
394
+ </details>
395
+
265
396
  ### GitLab
266
397
 
267
398
  Set up a GitLab webhook to receive events when the bot is @mentioned or assigned to issues/MRs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.52.0",
3
+ "version": "1.52.1",
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>",
@@ -74,6 +74,7 @@
74
74
  "e2e:workflows:docker": "bun scripts/e2e-workflow-test.ts --with-docker",
75
75
  "docs:mcp": "bun scripts/generate-mcp-docs.ts",
76
76
  "docs:openapi": "bun scripts/generate-openapi.ts",
77
+ "docs:business-use": "bun scripts/generate-business-use-docs.ts",
77
78
  "pm2-start": "pm2 start ecosystem.config.cjs",
78
79
  "pm2-stop": "pm2 stop ecosystem.config.cjs",
79
80
  "pm2-restart": "pm2 restart ecosystem.config.cjs",
@@ -93,6 +94,7 @@
93
94
  "dependencies": {
94
95
  "@ai-sdk/openai": "^3.0.41",
95
96
  "@asteasolutions/zod-to-openapi": "^8.0.0",
97
+ "@desplega.ai/business-use": "^0.4.2",
96
98
  "@desplega.ai/localtunnel": "^2.2.0",
97
99
  "@inkjs/ui": "^2.0.0",
98
100
  "@linear/sdk": "^77.0.0",
package/src/be/db.ts CHANGED
@@ -940,6 +940,23 @@ export function getTasksByAgentId(agentId: string): AgentTask[] {
940
940
  return taskQueries.getByAgentId().all(agentId).map(rowToAgentTask);
941
941
  }
942
942
 
943
+ /**
944
+ * Get the most recently updated in-progress task for an agent.
945
+ * Used as a fallback when X-Source-Task-Id header is missing (e.g. lead agent HITL requests).
946
+ *
947
+ * Note: if agent has multiple in-progress tasks, returns the most recently
948
+ * updated one. This is a best-effort fallback — the X-Source-Task-Id header
949
+ * is the authoritative source when available.
950
+ */
951
+ export function getAgentCurrentTask(agentId: string): AgentTask | null {
952
+ const row = getDb()
953
+ .prepare<AgentTaskRow, [string]>(
954
+ "SELECT * FROM agent_tasks WHERE agentId = ? AND status = 'in_progress' ORDER BY lastUpdatedAt DESC LIMIT 1",
955
+ )
956
+ .get(agentId);
957
+ return row ? rowToAgentTask(row) : null;
958
+ }
959
+
943
960
  export function getTasksByStatus(status: AgentTaskStatus): AgentTask[] {
944
961
  return taskQueries.getByStatus().all(status).map(rowToAgentTask);
945
962
  }
@@ -6881,6 +6898,16 @@ export function resolveApprovalRequest(
6881
6898
  return row ? rowToApprovalRequest(row) : null;
6882
6899
  }
6883
6900
 
6901
+ export function updateApprovalRequestNotifications(
6902
+ id: string,
6903
+ notificationChannels: Array<{ channel: string; target: string; messageTs?: string }>,
6904
+ ): void {
6905
+ const now = new Date().toISOString();
6906
+ getDb()
6907
+ .prepare("UPDATE approval_requests SET notificationChannels = ?, updatedAt = ? WHERE id = ?")
6908
+ .run(JSON.stringify(notificationChannels), now, id);
6909
+ }
6910
+
6884
6911
  export function listApprovalRequests(filters?: {
6885
6912
  status?: string;
6886
6913
  workflowRunId?: string;
@@ -129,7 +129,7 @@ export function runMigrations(db: Database): void {
129
129
  const initialMigration = migrations.find((m) => m.version === 1);
130
130
  if (initialMigration) {
131
131
  if (shouldBootstrapInitialMigration(db)) {
132
- console.log("[migrations] Existing database detected — bootstrapping migration tracking");
132
+ console.debug("[migrations] Existing database detected — bootstrapping migration tracking");
133
133
  db.run(
134
134
  "INSERT INTO _migrations (version, name, applied_at, checksum) VALUES (?, ?, ?, ?)",
135
135
  [
@@ -145,7 +145,7 @@ export function runMigrations(db: Database): void {
145
145
  checksum: initialMigration.checksum,
146
146
  });
147
147
  } else {
148
- console.log(
148
+ console.warn(
149
149
  "[migrations] Existing database appears incomplete — applying 001_initial migration",
150
150
  );
151
151
  }
@@ -169,7 +169,7 @@ export function runMigrations(db: Database): void {
169
169
  }
170
170
 
171
171
  // Apply migration in a transaction
172
- console.log(`[migrations] Applying: ${migration.name}`);
172
+ console.debug(`[migrations] Applying: ${migration.name}`);
173
173
  const start = performance.now();
174
174
 
175
175
  db.transaction(() => {
@@ -183,6 +183,6 @@ export function runMigrations(db: Database): void {
183
183
  })();
184
184
 
185
185
  const elapsed = (performance.now() - start).toFixed(1);
186
- console.log(`[migrations] Applied: ${migration.name} (${elapsed}ms)`);
186
+ console.debug(`[migrations] Applied: ${migration.name} (${elapsed}ms)`);
187
187
  }
188
188
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { ensure, initialize } from "@desplega.ai/business-use";
3
4
  import type { TemplateResponse } from "../../templates/schema.ts";
4
5
  import { type BasePromptArgs, getBasePrompt } from "../prompts/base-prompt.ts";
5
6
  import {
@@ -215,10 +216,94 @@ async function fetchResolvedEnv(
215
216
  return env;
216
217
  }
217
218
 
219
+ /** Tools that produce noise — skip auto-progress for these */
220
+ const SKIP_PROGRESS_TOOLS = new Set(["ToolSearch", "TodoRead", "TodoWrite"]);
221
+
222
+ /** Pretty labels for agent-swarm MCP tools. null = skip (meta/noise). */
223
+ const SWARM_TOOL_LABELS: Record<string, string | null> = {
224
+ "store-progress": null,
225
+ "get-task-details": "📋 Reviewing task details",
226
+ "get-tasks": "📋 Checking task list",
227
+ "poll-task": "📡 Polling for tasks",
228
+ "send-task": "📤 Delegating task",
229
+ "task-action": "⚡ Performing task action",
230
+ "join-swarm": "🔗 Joining swarm",
231
+ "my-agent-info": "🪪 Checking agent info",
232
+ "get-swarm": "👥 Checking swarm status",
233
+ "post-message": "💬 Sending message",
234
+ "read-messages": "💬 Reading messages",
235
+ "request-human-input": "🙋 Requesting human input",
236
+ "cancel-task": "🚫 Cancelling task",
237
+ "db-query": "🗃️ Querying database",
238
+ "inject-learning": "🧠 Storing learning",
239
+ "memory-search": "🧠 Searching memory",
240
+ "memory-get": "🧠 Retrieving memory",
241
+ "update-profile": "🪪 Updating profile",
242
+ // Slack
243
+ "slack-post": "💬 Posting to Slack",
244
+ "slack-reply": "💬 Replying in Slack",
245
+ "slack-read": "💬 Reading Slack",
246
+ "slack-list-channels": "💬 Listing Slack channels",
247
+ "slack-download-file": "📥 Downloading from Slack",
248
+ "slack-upload-file": "📤 Uploading to Slack",
249
+ // Tracker
250
+ "tracker-status": "📊 Checking tracker status",
251
+ "tracker-sync-status": "📊 Syncing tracker status",
252
+ "tracker-link-task": "🔗 Linking task to tracker",
253
+ "tracker-link-epic": "🔗 Linking epic to tracker",
254
+ "tracker-unlink": "🔗 Unlinking from tracker",
255
+ "tracker-map-agent": "🔗 Mapping agent to tracker",
256
+ // Epics
257
+ "create-epic": "📦 Creating epic",
258
+ "get-epic-details": "📦 Reviewing epic",
259
+ "list-epics": "📦 Listing epics",
260
+ "update-epic": "📦 Updating epic",
261
+ // Workflows
262
+ "trigger-workflow": "⚙️ Triggering workflow",
263
+ "get-workflow": "⚙️ Checking workflow",
264
+ "list-workflows": "⚙️ Listing workflows",
265
+ "create-workflow": "⚙️ Creating workflow",
266
+ // Skills
267
+ "skill-search": "🔎 Searching skills",
268
+ "skill-install": "📦 Installing skill",
269
+ "skill-install-remote": "📦 Installing remote skill",
270
+ "skill-get": "📦 Getting skill details",
271
+ "skill-list": "📦 Listing skills",
272
+ // Config
273
+ "get-config": "⚙️ Reading config",
274
+ "set-config": "⚙️ Setting config",
275
+ "list-config": "⚙️ Listing config",
276
+ // Schedules
277
+ "create-schedule": "📅 Creating schedule",
278
+ "list-schedules": "📅 Listing schedules",
279
+ "run-schedule-now": "📅 Running schedule",
280
+ // Context
281
+ "context-diff": "📜 Viewing context diff",
282
+ "context-history": "📜 Viewing context history",
283
+ // Channels
284
+ "create-channel": "📢 Creating channel",
285
+ "list-channels": "📢 Listing channels",
286
+ "delete-channel": "📢 Deleting channel",
287
+ // Services
288
+ "register-service": "🔌 Registering service",
289
+ "list-services": "🔌 Listing services",
290
+ "unregister-service": "🔌 Unregistering service",
291
+ "update-service-status": "🔌 Updating service status",
292
+ };
293
+
294
+ /** Convert kebab-case to sentence case: "get-task-details" → "Get task details" */
295
+ export function humanizeToolName(name: string): string {
296
+ if (!name) return name;
297
+ return name.charAt(0).toUpperCase() + name.slice(1).replaceAll("-", " ");
298
+ }
299
+
218
300
  /**
219
301
  * Convert a tool call into a human-readable progress description.
302
+ * Returns null for noisy/meta tools that should be skipped.
220
303
  */
221
- function toolCallToProgress(toolName: string, args: unknown): string {
304
+ export function toolCallToProgress(toolName: string, args: unknown): string | null {
305
+ if (SKIP_PROGRESS_TOOLS.has(toolName)) return null;
306
+
222
307
  const a = args as Record<string, unknown>;
223
308
  const shortPath = (p: unknown) => {
224
309
  if (typeof p !== "string") return "";
@@ -229,30 +314,43 @@ function toolCallToProgress(toolName: string, args: unknown): string {
229
314
 
230
315
  switch (toolName) {
231
316
  case "Read":
232
- return `Reading ${shortPath(a.file_path)}`;
317
+ return `📖 Reading ${shortPath(a.file_path)}`;
233
318
  case "Edit":
234
319
  case "MultiEdit":
235
- return `Editing ${shortPath(a.file_path)}`;
320
+ return `✏️ Editing ${shortPath(a.file_path)}`;
236
321
  case "Write":
237
- return `Writing ${shortPath(a.file_path)}`;
322
+ return `📝 Writing ${shortPath(a.file_path)}`;
238
323
  case "Bash":
239
- return a.description ? `${a.description}` : "Running shell command";
324
+ return a.description ? `⚡ ${a.description}` : "Running shell command";
240
325
  case "Grep":
241
- return `Searching for "${a.pattern}"`;
326
+ return `🔍 Searching for "${a.pattern}"`;
242
327
  case "Glob":
243
- return `Finding files matching ${a.pattern}`;
328
+ return `📁 Finding files matching ${a.pattern}`;
244
329
  case "Agent":
245
330
  case "Task":
246
- return a.description ? `${a.description}` : "Delegating sub-task";
331
+ return a.description ? `🤖 ${a.description}` : "🤖 Delegating sub-task";
247
332
  case "Skill":
248
- return `Running /${a.skill}`;
333
+ return `⚙️ Running /${a.skill}`;
249
334
  default: {
250
- // MCP tools: mcp__server__tool → "server:tool"
335
+ // MCP tools: mcp__server__tool
251
336
  if (toolName.startsWith("mcp__")) {
252
337
  const parts = toolName.split("__");
253
- return parts.length >= 3 ? `Using ${parts[1]}:${parts[2]}` : `Using ${toolName}`;
338
+ if (parts.length >= 3) {
339
+ const server = parts[1];
340
+ const tool = parts.slice(2).join("__");
341
+ // Agent-swarm tools get pretty labels
342
+ if (server === "agent-swarm") {
343
+ const label = SWARM_TOOL_LABELS[tool];
344
+ if (label === null) return null; // skip
345
+ if (label) return label;
346
+ return `🔌 ${humanizeToolName(tool)}`;
347
+ }
348
+ // Other MCP servers: "🔌 server: Humanized tool"
349
+ return `🔌 ${server}: ${humanizeToolName(tool)}`;
350
+ }
351
+ return `🔌 ${toolName}`;
254
352
  }
255
- return `Using ${toolName}`;
353
+ return `🔧 ${toolName}`;
256
354
  }
257
355
  }
258
356
  }
@@ -1369,9 +1467,13 @@ async function spawnProviderProcess(
1369
1467
  // Auto-progress: report tool activity as task progress (throttled)
1370
1468
  const now = Date.now();
1371
1469
  if (effectiveTaskId && opts.apiUrl && now - lastProgressTime >= PROGRESS_THROTTLE_MS) {
1372
- lastProgressTime = now;
1373
1470
  const progress = toolCallToProgress(event.toolName, event.args);
1374
- updateProgressViaAPI(opts.apiUrl, opts.apiKey, effectiveTaskId, progress).catch(() => {});
1471
+ if (progress) {
1472
+ lastProgressTime = now;
1473
+ updateProgressViaAPI(opts.apiUrl, opts.apiKey, effectiveTaskId, progress).catch(
1474
+ () => {},
1475
+ );
1476
+ }
1375
1477
  }
1376
1478
  break;
1377
1479
  }
@@ -1579,6 +1681,22 @@ async function checkCompletedProcesses(
1579
1681
  }
1580
1682
  await ensureTaskFinished(apiConfig, role, taskId, result.exitCode, failureReason);
1581
1683
 
1684
+ ensure({
1685
+ id: "worker_process_finished",
1686
+ flow: "task",
1687
+ runId: taskId,
1688
+ depIds: ["worker_process_spawned"],
1689
+ data: {
1690
+ taskId,
1691
+ agentId: apiConfig.agentId,
1692
+ role,
1693
+ exitCode: result.exitCode,
1694
+ success: result.exitCode === 0,
1695
+ failureReason,
1696
+ },
1697
+ validator: (data) => data.exitCode === 0,
1698
+ });
1699
+
1582
1700
  // Commit channel activity cursors after successful processing
1583
1701
  // If the task failed, cursors stay uncommitted so messages are re-seen on next poll
1584
1702
  if (cursorUpdates && cursorUpdates.length > 0 && result.exitCode === 0) {
@@ -1665,6 +1783,9 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1665
1783
  const { defaultPrompt, metadataType } = config;
1666
1784
  let role = config.role;
1667
1785
 
1786
+ // Initialize Business-Use SDK for worker-side instrumentation
1787
+ initialize();
1788
+
1668
1789
  // Create provider adapter based on HARNESS_PROVIDER env var (default: claude)
1669
1790
  const adapter = createProviderAdapter(process.env.HARNESS_PROVIDER || "claude");
1670
1791
 
@@ -2308,6 +2429,24 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2308
2429
  if (trigger) {
2309
2430
  console.log(`[${role}] Trigger received: ${trigger.type}`);
2310
2431
 
2432
+ if (
2433
+ trigger.taskId &&
2434
+ (trigger.type === "task_assigned" || trigger.type === "task_offered")
2435
+ ) {
2436
+ ensure({
2437
+ id: "worker_received",
2438
+ flow: "task",
2439
+ runId: trigger.taskId,
2440
+ depIds: ["started"],
2441
+ data: {
2442
+ taskId: trigger.taskId,
2443
+ agentId,
2444
+ triggerType: trigger.type,
2445
+ role,
2446
+ },
2447
+ });
2448
+ }
2449
+
2311
2450
  // Build prompt based on trigger
2312
2451
  let triggerPrompt = await buildPromptForTrigger(
2313
2452
  trigger,
@@ -2524,6 +2663,19 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2524
2663
  continue;
2525
2664
  }
2526
2665
 
2666
+ ensure({
2667
+ id: "worker_process_spawned",
2668
+ flow: "task",
2669
+ runId: runningTask.taskId,
2670
+ depIds: ["worker_received"],
2671
+ data: {
2672
+ taskId: runningTask.taskId,
2673
+ agentId,
2674
+ role,
2675
+ model: taskModel,
2676
+ },
2677
+ });
2678
+
2527
2679
  // Attach trigger metadata for logging
2528
2680
  runningTask.triggerType = trigger.type;
2529
2681
 
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { ensure } from "@desplega.ai/business-use";
2
3
  import { z } from "zod";
3
4
  import {
4
5
  createAgent,
@@ -179,6 +180,34 @@ export async function handleAgentRegister(
179
180
  return { agent, created: true };
180
181
  })();
181
182
 
183
+ if (result.created) {
184
+ ensure({
185
+ id: "registered",
186
+ flow: "agent",
187
+ runId: agentId,
188
+ data: {
189
+ agentId,
190
+ name: parsed.body.name,
191
+ isLead: parsed.body.isLead ?? false,
192
+ },
193
+ });
194
+ } else {
195
+ ensure({
196
+ id: "reconnected",
197
+ flow: "agent",
198
+ runId: agentId,
199
+ depIds: ["registered"],
200
+ data: {
201
+ agentId,
202
+ name: parsed.body.name,
203
+ },
204
+ validator: (_data, ctx) => {
205
+ // Validates that registered happened before reconnected
206
+ return ctx.deps.length > 0;
207
+ },
208
+ });
209
+ }
210
+
182
211
  json(res, result.agent, result.created ? 201 : 200);
183
212
  return true;
184
213
  }
@@ -2,10 +2,13 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import {
4
4
  createApprovalRequest,
5
+ createTaskExtended,
5
6
  getApprovalRequestById,
7
+ getTaskById,
6
8
  listApprovalRequests,
7
9
  resolveApprovalRequest,
8
10
  } from "../be/db";
11
+ import { resolveTemplate } from "../prompts/resolver";
9
12
  import { workflowEventBus } from "../workflows/event-bus";
10
13
  import { route } from "./route-def";
11
14
  import { json, jsonError } from "./utils";
@@ -186,6 +189,40 @@ export async function handleApprovalRequests(
186
189
  });
187
190
  }
188
191
 
192
+ // For standalone (non-workflow) requests, create a follow-up task
193
+ // so the requesting agent is notified of the human's response
194
+ if (!updated.workflowRunId && updated.sourceTaskId) {
195
+ const sourceTask = getTaskById(updated.sourceTaskId);
196
+ if (sourceTask) {
197
+ // Format responses for the template
198
+ const formattedResponses = formatResponses(
199
+ updated.questions as Array<{ id: string; type: string; label: string }>,
200
+ updated.responses as Record<string, unknown>,
201
+ );
202
+
203
+ const { text: taskText } = resolveTemplate("hitl.follow_up", {
204
+ request_id: updated.id,
205
+ title: updated.title,
206
+ status: updated.status,
207
+ responses: formattedResponses,
208
+ });
209
+
210
+ createTaskExtended(taskText, {
211
+ agentId: sourceTask.agentId,
212
+ parentTaskId: updated.sourceTaskId,
213
+ source: "system",
214
+ taskType: "hitl-follow-up",
215
+ tags: ["hitl", "follow-up"],
216
+ // Explicit Slack metadata — parentTaskId auto-inherits too,
217
+ // but being explicit ensures the follow-up task always gets
218
+ // the right thread context even if inheritance logic changes.
219
+ slackChannelId: sourceTask.slackChannelId ?? undefined,
220
+ slackThreadTs: sourceTask.slackThreadTs ?? undefined,
221
+ slackUserId: sourceTask.slackUserId ?? undefined,
222
+ });
223
+ }
224
+ }
225
+
189
226
  json(res, { approvalRequest: updated });
190
227
  return true;
191
228
  }
@@ -245,3 +282,29 @@ export async function handleApprovalRequests(
245
282
 
246
283
  return false;
247
284
  }
285
+
286
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
287
+
288
+ function formatResponses(
289
+ questions: Array<{ id: string; type: string; label: string }>,
290
+ responses: Record<string, unknown>,
291
+ ): string {
292
+ return questions
293
+ .map((q) => {
294
+ const answer = responses[q.id];
295
+ let answerText: string;
296
+ if (answer == null) {
297
+ answerText = "(no answer)";
298
+ } else if (q.type === "approval") {
299
+ const a = answer as { approved?: boolean; comment?: string };
300
+ answerText = a.approved ? "Approved" : "Rejected";
301
+ if (a.comment) answerText += ` — ${a.comment}`;
302
+ } else if (typeof answer === "object") {
303
+ answerText = JSON.stringify(answer);
304
+ } else {
305
+ answerText = String(answer);
306
+ }
307
+ return `- ${q.label}: ${answerText}`;
308
+ })
309
+ .join("\n");
310
+ }
package/src/http/index.ts CHANGED
@@ -4,8 +4,9 @@ import {
4
4
  type Server,
5
5
  type ServerResponse,
6
6
  } from "node:http";
7
+ import { assert, initialize } from "@desplega.ai/business-use";
7
8
  import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
- import { hasCapability } from "@/server";
9
+ import { getEnabledCapabilities, hasCapability } from "@/server";
9
10
  import { initAgentMail } from "../agentmail";
10
11
  import { closeDb } from "../be/db";
11
12
  import { initGitHub } from "../github";
@@ -45,6 +46,7 @@ const globalState = globalThis as typeof globalThis & {
45
46
  __httpServer?: Server<typeof IncomingMessage, typeof ServerResponse>;
46
47
  __transports?: Record<string, StreamableHTTPServerTransport>;
47
48
  __sigintRegistered?: boolean;
49
+ __runId?: string;
48
50
  };
49
51
 
50
52
  // Clean up previous server on hot reload
@@ -167,10 +169,26 @@ if (!globalState.__sigintRegistered) {
167
169
  process.on("SIGTERM", shutdown);
168
170
  }
169
171
 
172
+ if (!globalState.__runId) {
173
+ globalState.__runId = `run_${Date.now()}`;
174
+ }
175
+
176
+ // business-use initialization (no-op if envs not set)
177
+ initialize();
178
+
170
179
  httpServer
171
180
  .listen(port, async () => {
172
181
  console.log(`MCP HTTP server running on http://localhost:${port}/mcp`);
173
182
 
183
+ assert({
184
+ id: "listen",
185
+ flow: "api",
186
+ runId: globalState.__runId!,
187
+ data: {
188
+ capabilities: getEnabledCapabilities(),
189
+ },
190
+ });
191
+
174
192
  // Load global swarm configs into process.env (so integrations can read them)
175
193
  // Infrastructure-level env vars take precedence — only missing keys are filled.
176
194
  try {
@@ -205,7 +223,9 @@ httpServer
205
223
  const { startScheduler } = await import("../scheduler");
206
224
  const { getExecutorRegistry } = await import("../workflows");
207
225
  const intervalMs = Number(process.env.SCHEDULER_INTERVAL_MS) || 10000;
208
- startScheduler(getExecutorRegistry(), intervalMs);
226
+ startScheduler(getExecutorRegistry(), intervalMs, {
227
+ runId: globalState.__runId!,
228
+ });
209
229
  }
210
230
 
211
231
  // Start heartbeat triage (unless disabled)