@desplega.ai/agent-swarm 1.51.2 → 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.
Files changed (55) hide show
  1. package/README.md +131 -0
  2. package/openapi.json +767 -4
  3. package/package.json +3 -1
  4. package/src/be/db.ts +669 -0
  5. package/src/be/migrations/019_skills.sql +65 -0
  6. package/src/be/migrations/020_approval_requests.sql +41 -0
  7. package/src/be/migrations/runner.ts +4 -4
  8. package/src/be/skill-parser.ts +70 -0
  9. package/src/be/skill-sync.ts +106 -0
  10. package/src/commands/runner.ts +299 -52
  11. package/src/http/agents.ts +29 -0
  12. package/src/http/approval-requests.ts +310 -0
  13. package/src/http/config.ts +3 -3
  14. package/src/http/index.ts +26 -2
  15. package/src/http/poll.ts +15 -0
  16. package/src/http/skills.ts +479 -0
  17. package/src/http/tasks.ts +94 -0
  18. package/src/linear/outbound.ts +12 -12
  19. package/src/prompts/base-prompt.ts +8 -0
  20. package/src/providers/claude-adapter.ts +19 -3
  21. package/src/scheduler/scheduler.ts +24 -1
  22. package/src/server.ts +29 -0
  23. package/src/slack/blocks.ts +1 -1
  24. package/src/tests/approval-requests.test.ts +948 -0
  25. package/src/tests/skill-parser.test.ts +178 -0
  26. package/src/tests/skill-sync.test.ts +171 -0
  27. package/src/tests/slack-blocks.test.ts +3 -2
  28. package/src/tests/structured-output.test.ts +1 -0
  29. package/src/tests/tool-annotations.test.ts +2 -1
  30. package/src/tests/tool-call-progress.test.ts +207 -0
  31. package/src/tests/tool-registrar-no-input.test.ts +114 -0
  32. package/src/tests/update-profile-auth.test.ts +1 -0
  33. package/src/tests/workflow-executors.test.ts +4 -2
  34. package/src/tools/request-human-input.ts +117 -0
  35. package/src/tools/skills/index.ts +11 -0
  36. package/src/tools/skills/skill-create.ts +105 -0
  37. package/src/tools/skills/skill-delete.ts +67 -0
  38. package/src/tools/skills/skill-get.ts +75 -0
  39. package/src/tools/skills/skill-install-remote.ts +152 -0
  40. package/src/tools/skills/skill-install.ts +101 -0
  41. package/src/tools/skills/skill-list.ts +77 -0
  42. package/src/tools/skills/skill-publish.ts +123 -0
  43. package/src/tools/skills/skill-search.ts +43 -0
  44. package/src/tools/skills/skill-sync-remote.ts +128 -0
  45. package/src/tools/skills/skill-uninstall.ts +60 -0
  46. package/src/tools/skills/skill-update.ts +128 -0
  47. package/src/tools/store-progress.ts +31 -0
  48. package/src/tools/templates.ts +28 -0
  49. package/src/tools/tool-config.ts +16 -0
  50. package/src/tools/utils.ts +9 -7
  51. package/src/types.ts +54 -0
  52. package/src/workflows/executors/human-in-the-loop.ts +273 -0
  53. package/src/workflows/executors/registry.ts +2 -0
  54. package/src/workflows/recovery.ts +72 -0
  55. package/src/workflows/resume.ts +65 -1
@@ -0,0 +1,479 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import {
4
+ createSkill,
5
+ deleteSkill,
6
+ getAgentSkills,
7
+ getSkillById,
8
+ installSkill,
9
+ listSkills,
10
+ searchSkills,
11
+ uninstallSkill,
12
+ updateSkill,
13
+ } from "../be/db";
14
+ import { parseSkillContent } from "../be/skill-parser";
15
+ import { syncSkillsToFilesystem } from "../be/skill-sync";
16
+ import { route } from "./route-def";
17
+ import { json, jsonError } from "./utils";
18
+
19
+ // ─── Route Definitions ───────────────────────────────────────────────────────
20
+
21
+ const listSkillsRoute = route({
22
+ method: "get",
23
+ path: "/api/skills",
24
+ pattern: ["api", "skills"],
25
+ summary: "List skills with optional filters",
26
+ tags: ["Skills"],
27
+ auth: { apiKey: true },
28
+ query: z.object({
29
+ type: z.string().optional(),
30
+ scope: z.string().optional(),
31
+ agentId: z.string().optional(),
32
+ enabled: z.string().optional(),
33
+ search: z.string().optional(),
34
+ }),
35
+ responses: {
36
+ 200: { description: "Skill list" },
37
+ },
38
+ });
39
+
40
+ const getSkillRoute = route({
41
+ method: "get",
42
+ path: "/api/skills/{id}",
43
+ pattern: ["api", "skills", null],
44
+ summary: "Get skill by ID",
45
+ tags: ["Skills"],
46
+ auth: { apiKey: true },
47
+ params: z.object({ id: z.string() }),
48
+ responses: {
49
+ 200: { description: "Skill details" },
50
+ 404: { description: "Skill not found" },
51
+ },
52
+ });
53
+
54
+ const createSkillRoute = route({
55
+ method: "post",
56
+ path: "/api/skills",
57
+ pattern: ["api", "skills"],
58
+ summary: "Create a new skill",
59
+ tags: ["Skills"],
60
+ auth: { apiKey: true },
61
+ body: z.object({
62
+ content: z.string().min(1),
63
+ type: z.string().optional(),
64
+ scope: z.string().optional(),
65
+ ownerAgentId: z.string().optional(),
66
+ }),
67
+ responses: {
68
+ 201: { description: "Skill created" },
69
+ 400: { description: "Validation error" },
70
+ },
71
+ });
72
+
73
+ const updateSkillRoute = route({
74
+ method: "put",
75
+ path: "/api/skills/{id}",
76
+ pattern: ["api", "skills", null],
77
+ summary: "Update a skill",
78
+ tags: ["Skills"],
79
+ auth: { apiKey: true },
80
+ params: z.object({ id: z.string() }),
81
+ body: z.record(z.string(), z.unknown()),
82
+ responses: {
83
+ 200: { description: "Skill updated" },
84
+ 404: { description: "Skill not found" },
85
+ },
86
+ });
87
+
88
+ const deleteSkillRoute = route({
89
+ method: "delete",
90
+ path: "/api/skills/{id}",
91
+ pattern: ["api", "skills", null],
92
+ summary: "Delete a skill",
93
+ tags: ["Skills"],
94
+ auth: { apiKey: true },
95
+ params: z.object({ id: z.string() }),
96
+ responses: {
97
+ 200: { description: "Skill deleted" },
98
+ 404: { description: "Skill not found" },
99
+ },
100
+ });
101
+
102
+ const installSkillRoute = route({
103
+ method: "post",
104
+ path: "/api/skills/{id}/install",
105
+ pattern: ["api", "skills", null, "install"],
106
+ summary: "Install skill for an agent",
107
+ tags: ["Skills"],
108
+ auth: { apiKey: true },
109
+ params: z.object({ id: z.string() }),
110
+ body: z.object({
111
+ agentId: z.string(),
112
+ }),
113
+ responses: {
114
+ 200: { description: "Skill installed" },
115
+ 404: { description: "Skill not found" },
116
+ },
117
+ });
118
+
119
+ const uninstallSkillRoute = route({
120
+ method: "delete",
121
+ path: "/api/skills/{id}/install/{agentId}",
122
+ pattern: ["api", "skills", null, "install", null],
123
+ summary: "Uninstall skill for an agent",
124
+ tags: ["Skills"],
125
+ auth: { apiKey: true },
126
+ params: z.object({ id: z.string(), agentId: z.string() }),
127
+ responses: {
128
+ 200: { description: "Skill uninstalled" },
129
+ },
130
+ });
131
+
132
+ const installRemoteRoute = route({
133
+ method: "post",
134
+ path: "/api/skills/install-remote",
135
+ pattern: ["api", "skills", "install-remote"],
136
+ summary: "Install a remote skill from GitHub",
137
+ tags: ["Skills"],
138
+ auth: { apiKey: true },
139
+ body: z.object({
140
+ sourceRepo: z.string(),
141
+ sourcePath: z.string().optional(),
142
+ scope: z.string().optional(),
143
+ isComplex: z.boolean().optional(),
144
+ }),
145
+ responses: {
146
+ 201: { description: "Remote skill installed" },
147
+ 400: { description: "Fetch failed" },
148
+ },
149
+ });
150
+
151
+ const syncRemoteRoute = route({
152
+ method: "post",
153
+ path: "/api/skills/sync-remote",
154
+ pattern: ["api", "skills", "sync-remote"],
155
+ summary: "Trigger remote skill sync",
156
+ tags: ["Skills"],
157
+ auth: { apiKey: true },
158
+ body: z.object({
159
+ skillId: z.string().optional(),
160
+ force: z.boolean().optional(),
161
+ }),
162
+ responses: {
163
+ 200: { description: "Sync results" },
164
+ },
165
+ });
166
+
167
+ const syncFilesystemRoute = route({
168
+ method: "post",
169
+ path: "/api/skills/sync-filesystem",
170
+ pattern: ["api", "skills", "sync-filesystem"],
171
+ summary: "Sync installed skills to agent filesystem",
172
+ tags: ["Skills"],
173
+ auth: { apiKey: true, agentId: true },
174
+ responses: {
175
+ 200: { description: "Filesystem sync results" },
176
+ },
177
+ });
178
+
179
+ const getAgentSkillsRoute = route({
180
+ method: "get",
181
+ path: "/api/agents/{id}/skills",
182
+ pattern: ["api", "agents", null, "skills"],
183
+ summary: "Get all skills installed for an agent",
184
+ tags: ["Skills"],
185
+ auth: { apiKey: true },
186
+ params: z.object({ id: z.string() }),
187
+ responses: {
188
+ 200: { description: "Agent skills list" },
189
+ },
190
+ });
191
+
192
+ // ─── Handler ─────────────────────────────────────────────────────────────────
193
+
194
+ export async function handleSkills(
195
+ req: IncomingMessage,
196
+ res: ServerResponse,
197
+ pathSegments: string[],
198
+ queryParams: URLSearchParams,
199
+ myAgentId: string | undefined,
200
+ ): Promise<boolean> {
201
+ // GET /api/agents/:id/skills (must be before /api/skills routes)
202
+ if (getAgentSkillsRoute.match(req.method, pathSegments)) {
203
+ const parsed = await getAgentSkillsRoute.parse(req, res, pathSegments, queryParams);
204
+ if (!parsed) return true;
205
+ const skills = getAgentSkills(parsed.params.id);
206
+ json(res, { skills, total: skills.length });
207
+ return true;
208
+ }
209
+
210
+ // POST /api/skills/install-remote (must be before /api/skills/:id)
211
+ if (installRemoteRoute.match(req.method, pathSegments)) {
212
+ const parsed = await installRemoteRoute.parse(req, res, pathSegments, queryParams);
213
+ if (!parsed) return true;
214
+
215
+ try {
216
+ const branch = "main";
217
+ const filePath = parsed.body.sourcePath ? `${parsed.body.sourcePath}/SKILL.md` : "SKILL.md";
218
+ const rawUrl = `https://raw.githubusercontent.com/${parsed.body.sourceRepo}/${branch}/${filePath}`;
219
+
220
+ let content = "";
221
+ if (!parsed.body.isComplex) {
222
+ const response = await fetch(rawUrl);
223
+ if (!response.ok) {
224
+ jsonError(res, `Failed to fetch SKILL.md: HTTP ${response.status}`, 400);
225
+ return true;
226
+ }
227
+ content = await response.text();
228
+ }
229
+
230
+ let name: string;
231
+ let description: string;
232
+ if (content) {
233
+ const pm = parseSkillContent(content);
234
+ name = pm.name;
235
+ description = pm.description;
236
+ } else {
237
+ name = parsed.body.sourcePath?.split("/").pop() || parsed.body.sourceRepo;
238
+ description = `Complex skill from ${parsed.body.sourceRepo}`;
239
+ }
240
+
241
+ const skill = createSkill({
242
+ name,
243
+ description,
244
+ content,
245
+ type: "remote",
246
+ scope: (parsed.body.scope as "global" | "swarm") ?? "global",
247
+ sourceUrl: rawUrl,
248
+ sourceRepo: parsed.body.sourceRepo,
249
+ sourcePath: parsed.body.sourcePath,
250
+ sourceBranch: branch,
251
+ isComplex: parsed.body.isComplex ?? false,
252
+ });
253
+
254
+ json(res, { skill }, 201);
255
+ } catch (err) {
256
+ jsonError(res, err instanceof Error ? err.message : "Unknown error", 400);
257
+ }
258
+ return true;
259
+ }
260
+
261
+ // POST /api/skills/sync-remote
262
+ if (syncRemoteRoute.match(req.method, pathSegments)) {
263
+ const parsed = await syncRemoteRoute.parse(req, res, pathSegments, queryParams);
264
+ if (!parsed) return true;
265
+
266
+ const remoteSkills = parsed.body.skillId
267
+ ? (() => {
268
+ const s = getSkillById(parsed.body.skillId!);
269
+ return s && s.type === "remote" ? [s] : [];
270
+ })()
271
+ : listSkills({ type: "remote" });
272
+
273
+ let updated = 0;
274
+ const errors: string[] = [];
275
+
276
+ for (const skill of remoteSkills) {
277
+ if (skill.isComplex || !skill.sourceRepo) continue;
278
+ try {
279
+ const fp = skill.sourcePath ? `${skill.sourcePath}/SKILL.md` : "SKILL.md";
280
+ const url = `https://raw.githubusercontent.com/${skill.sourceRepo}/${skill.sourceBranch}/${fp}`;
281
+ const resp = await fetch(url);
282
+ if (!resp.ok) {
283
+ errors.push(`${skill.name}: HTTP ${resp.status}`);
284
+ continue;
285
+ }
286
+ const newContent = await resp.text();
287
+ const newHash = new Bun.CryptoHasher("sha256").update(newContent).digest("hex");
288
+ const now = new Date().toISOString();
289
+
290
+ if (parsed.body.force || newHash !== skill.sourceHash) {
291
+ const pm = parseSkillContent(newContent);
292
+ updateSkill(skill.id, {
293
+ content: newContent,
294
+ name: pm.name,
295
+ description: pm.description,
296
+ allowedTools: pm.allowedTools,
297
+ model: pm.model,
298
+ effort: pm.effort,
299
+ context: pm.context,
300
+ agent: pm.agent,
301
+ disableModelInvocation: pm.disableModelInvocation,
302
+ userInvocable: pm.userInvocable,
303
+ sourceHash: newHash,
304
+ lastFetchedAt: now,
305
+ });
306
+ updated++;
307
+ } else {
308
+ // Content unchanged — still update lastFetchedAt
309
+ updateSkill(skill.id, { lastFetchedAt: now });
310
+ }
311
+ } catch (err) {
312
+ errors.push(`${skill.name}: ${err instanceof Error ? err.message : "Unknown"}`);
313
+ }
314
+ }
315
+
316
+ json(res, { updated, checked: remoteSkills.length, errors });
317
+ return true;
318
+ }
319
+
320
+ // POST /api/skills/sync-filesystem
321
+ if (syncFilesystemRoute.match(req.method, pathSegments)) {
322
+ // This endpoint is called by the runner to sync skills to the filesystem
323
+ const agentId = myAgentId;
324
+ if (!agentId) {
325
+ jsonError(res, "X-Agent-ID required", 400);
326
+ return true;
327
+ }
328
+
329
+ const result = syncSkillsToFilesystem(agentId);
330
+ json(res, {
331
+ synced: result.synced,
332
+ removed: result.removed,
333
+ errors: result.errors,
334
+ message: `Synced ${result.synced} skills, removed ${result.removed} stale entries`,
335
+ });
336
+ return true;
337
+ }
338
+
339
+ // POST /api/skills/:id/install
340
+ if (installSkillRoute.match(req.method, pathSegments)) {
341
+ const parsed = await installSkillRoute.parse(req, res, pathSegments, queryParams);
342
+ if (!parsed) return true;
343
+
344
+ const skill = getSkillById(parsed.params.id);
345
+ if (!skill) {
346
+ jsonError(res, "Skill not found", 404);
347
+ return true;
348
+ }
349
+
350
+ try {
351
+ const agentSkill = installSkill(parsed.body.agentId, parsed.params.id);
352
+ json(res, { agentSkill });
353
+ } catch (err) {
354
+ jsonError(res, err instanceof Error ? err.message : "Install failed", 400);
355
+ }
356
+ return true;
357
+ }
358
+
359
+ // DELETE /api/skills/:id/install/:agentId
360
+ if (uninstallSkillRoute.match(req.method, pathSegments)) {
361
+ const parsed = await uninstallSkillRoute.parse(req, res, pathSegments, queryParams);
362
+ if (!parsed) return true;
363
+
364
+ const removed = uninstallSkill(parsed.params.agentId, parsed.params.id);
365
+ json(res, { success: removed });
366
+ return true;
367
+ }
368
+
369
+ // GET /api/skills
370
+ if (listSkillsRoute.match(req.method, pathSegments)) {
371
+ const parsed = await listSkillsRoute.parse(req, res, pathSegments, queryParams);
372
+ if (!parsed) return true;
373
+
374
+ const skills = parsed.query.search
375
+ ? searchSkills(parsed.query.search)
376
+ : listSkills({
377
+ type: parsed.query.type as "remote" | "personal" | undefined,
378
+ scope: parsed.query.scope as "global" | "swarm" | "agent" | undefined,
379
+ ownerAgentId: parsed.query.agentId,
380
+ isEnabled:
381
+ parsed.query.enabled !== undefined ? parsed.query.enabled === "true" : undefined,
382
+ });
383
+
384
+ json(res, { skills, total: skills.length });
385
+ return true;
386
+ }
387
+
388
+ // GET /api/skills/:id
389
+ if (getSkillRoute.match(req.method, pathSegments)) {
390
+ const parsed = await getSkillRoute.parse(req, res, pathSegments, queryParams);
391
+ if (!parsed) return true;
392
+
393
+ const skill = getSkillById(parsed.params.id);
394
+ if (!skill) {
395
+ jsonError(res, "Skill not found", 404);
396
+ return true;
397
+ }
398
+ json(res, skill);
399
+ return true;
400
+ }
401
+
402
+ // POST /api/skills
403
+ if (createSkillRoute.match(req.method, pathSegments)) {
404
+ const parsed = await createSkillRoute.parse(req, res, pathSegments, queryParams);
405
+ if (!parsed) return true;
406
+
407
+ try {
408
+ const pm = parseSkillContent(parsed.body.content);
409
+ const skill = createSkill({
410
+ name: pm.name,
411
+ description: pm.description,
412
+ content: parsed.body.content,
413
+ type: (parsed.body.type as "remote" | "personal") ?? "personal",
414
+ scope: (parsed.body.scope as "global" | "swarm" | "agent") ?? "agent",
415
+ ownerAgentId: parsed.body.ownerAgentId,
416
+ allowedTools: pm.allowedTools,
417
+ model: pm.model,
418
+ effort: pm.effort,
419
+ context: pm.context,
420
+ agent: pm.agent,
421
+ disableModelInvocation: pm.disableModelInvocation,
422
+ userInvocable: pm.userInvocable,
423
+ });
424
+ json(res, { skill }, 201);
425
+ } catch (err) {
426
+ jsonError(res, err instanceof Error ? err.message : "Create failed", 400);
427
+ }
428
+ return true;
429
+ }
430
+
431
+ // PUT /api/skills/:id
432
+ if (updateSkillRoute.match(req.method, pathSegments)) {
433
+ const parsed = await updateSkillRoute.parse(req, res, pathSegments, queryParams);
434
+ if (!parsed) return true;
435
+
436
+ const updates: Record<string, unknown> = {};
437
+ for (const [key, value] of Object.entries(parsed.body)) {
438
+ updates[key] = value;
439
+ }
440
+
441
+ // If content is being updated, re-parse frontmatter
442
+ if (typeof updates.content === "string") {
443
+ const pm = parseSkillContent(updates.content as string);
444
+ updates.name = pm.name;
445
+ updates.description = pm.description;
446
+ updates.allowedTools = pm.allowedTools;
447
+ updates.model = pm.model;
448
+ updates.effort = pm.effort;
449
+ updates.context = pm.context;
450
+ updates.agent = pm.agent;
451
+ updates.disableModelInvocation = pm.disableModelInvocation;
452
+ updates.userInvocable = pm.userInvocable;
453
+ }
454
+
455
+ const skill = updateSkill(parsed.params.id, updates as Parameters<typeof updateSkill>[1]);
456
+ if (!skill) {
457
+ jsonError(res, "Skill not found", 404);
458
+ return true;
459
+ }
460
+ json(res, { skill });
461
+ return true;
462
+ }
463
+
464
+ // DELETE /api/skills/:id
465
+ if (deleteSkillRoute.match(req.method, pathSegments)) {
466
+ const parsed = await deleteSkillRoute.parse(req, res, pathSegments, queryParams);
467
+ if (!parsed) return true;
468
+
469
+ const deleted = deleteSkill(parsed.params.id);
470
+ if (!deleted) {
471
+ jsonError(res, "Skill not found", 404);
472
+ return true;
473
+ }
474
+ json(res, { success: true });
475
+ return true;
476
+ }
477
+
478
+ return false;
479
+ }
package/src/http/tasks.ts CHANGED
@@ -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
  cancelTask,
@@ -232,6 +233,24 @@ export async function handleTasks(
232
233
  source: (parsed.body.source as import("../types").AgentTaskSource) || "api",
233
234
  outputSchema: parsed.body.outputSchema || undefined,
234
235
  });
236
+
237
+ ensure({
238
+ id: "created",
239
+ flow: "task",
240
+ runId: task.id,
241
+ data: {
242
+ taskId: task.id,
243
+ agentId: task.agentId,
244
+ source: parsed.body.source || "api",
245
+ status: task.status,
246
+ task: task.task.slice(0, 200),
247
+ priority: task.priority,
248
+ tags: task.tags,
249
+ parentTaskId: task.parentTaskId,
250
+ epicId: task.epicId,
251
+ },
252
+ });
253
+
235
254
  json(res, task, 201);
236
255
  } catch (error) {
237
256
  console.error("[HTTP] Failed to create task:", error);
@@ -291,6 +310,36 @@ export async function handleTasks(
291
310
  return true;
292
311
  }
293
312
 
313
+ if (task.status === "pending") {
314
+ ensure({
315
+ id: "cancelled_pending",
316
+ flow: "task",
317
+ runId: parsed.params.id,
318
+ depIds: ["created"],
319
+ data: {
320
+ taskId: parsed.params.id,
321
+ agentId: task.agentId,
322
+ previousStatus: task.status,
323
+ reason,
324
+ },
325
+ validator: (data) => data.previousStatus === "pending",
326
+ });
327
+ } else {
328
+ ensure({
329
+ id: "cancelled_in_progress",
330
+ flow: "task",
331
+ runId: parsed.params.id,
332
+ depIds: ["started"],
333
+ data: {
334
+ taskId: parsed.params.id,
335
+ agentId: task.agentId,
336
+ previousStatus: task.status,
337
+ reason,
338
+ },
339
+ validator: (data) => data.previousStatus === "in_progress",
340
+ });
341
+ }
342
+
294
343
  if (task.agentId) {
295
344
  updateAgentStatusFromCapacity(task.agentId);
296
345
  }
@@ -386,6 +435,25 @@ export async function handleTasks(
386
435
  return true;
387
436
  }
388
437
 
438
+ if (result.task && !("alreadyFinished" in result && result.alreadyFinished)) {
439
+ const finishEventId = parsed.body.status === "completed" ? "completed" : "failed";
440
+ ensure({
441
+ id: finishEventId,
442
+ flow: "task",
443
+ runId: parsed.params.id,
444
+ depIds: ["started"],
445
+ data: {
446
+ taskId: parsed.params.id,
447
+ agentId: myAgentId,
448
+ previousStatus: "in_progress",
449
+ ...(finishEventId === "completed"
450
+ ? { hasOutput: !!parsed.body.output }
451
+ : { failureReason: parsed.body.failureReason }),
452
+ },
453
+ validator: (data) => data.previousStatus === "in_progress",
454
+ });
455
+ }
456
+
389
457
  json(res, {
390
458
  success: true,
391
459
  alreadyFinished: "alreadyFinished" in result ? result.alreadyFinished : false,
@@ -430,6 +498,19 @@ export async function handleTasks(
430
498
  return true;
431
499
  }
432
500
 
501
+ ensure({
502
+ id: "paused",
503
+ flow: "task",
504
+ runId: parsed.params.id,
505
+ depIds: ["started"],
506
+ data: {
507
+ taskId: parsed.params.id,
508
+ agentId: task.agentId,
509
+ previousStatus: task.status,
510
+ },
511
+ validator: (data) => data.previousStatus === "in_progress",
512
+ });
513
+
433
514
  json(res, { success: true, task: pausedTask });
434
515
  return true;
435
516
  }
@@ -460,6 +541,19 @@ export async function handleTasks(
460
541
  return true;
461
542
  }
462
543
 
544
+ ensure({
545
+ id: "resumed",
546
+ flow: "task",
547
+ runId: parsed.params.id,
548
+ depIds: ["paused"],
549
+ data: {
550
+ taskId: parsed.params.id,
551
+ agentId: task.agentId,
552
+ previousStatus: task.status,
553
+ },
554
+ validator: (data) => data.previousStatus === "paused",
555
+ });
556
+
463
557
  json(res, { success: true, task: resumedTask });
464
558
  return true;
465
559
  }
@@ -1,12 +1,7 @@
1
1
  import { getTrackerSync, updateTrackerSync } from "../be/db-queries/tracker";
2
2
  import { workflowEventBus } from "../workflows/event-bus";
3
3
  import { getLinearClient } from "./client";
4
- import {
5
- endAgentSession,
6
- postAgentSessionAction,
7
- postAgentSessionThought,
8
- taskSessionMap,
9
- } from "./sync";
4
+ import { endAgentSession, postAgentSessionAction, taskSessionMap } from "./sync";
10
5
 
11
6
  let subscribed = false;
12
7
 
@@ -60,8 +55,9 @@ async function handleTaskProgress(data: unknown): Promise<void> {
60
55
  const sessionId = taskSessionMap.get(taskId);
61
56
  if (!sessionId) return;
62
57
 
63
- postAgentSessionThought(sessionId, progress).catch((err) => {
64
- console.error(`[Linear Outbound] Failed to post progress thought for task ${taskId}:`, err);
58
+ // Use 'action' activity type — Linear renders it as a structured tool invocation card
59
+ postAgentSessionAction(sessionId, progress).catch((err) => {
60
+ console.error(`[Linear Outbound] Failed to post progress action for task ${taskId}:`, err);
65
61
  });
66
62
  }
67
63
 
@@ -75,7 +71,9 @@ async function handleTaskCompleted(data: unknown): Promise<void> {
75
71
  if (shouldSkipForLoopPrevention(sync)) return;
76
72
 
77
73
  const sessionId = taskSessionMap.get(taskId);
78
- const body = output ? `Task completed.\n\n${output.slice(0, 2000)}` : "Task completed.";
74
+ const body = output
75
+ ? `Task completed.\n\n+++ Output\n${output.slice(0, 2000)}\n+++`
76
+ : "Task completed.";
79
77
 
80
78
  // Prefer AgentSession activity (shows in the agent panel) over issue comment (avoids duplication)
81
79
  if (sessionId) {
@@ -93,7 +91,7 @@ async function handleTaskCompleted(data: unknown): Promise<void> {
93
91
  return;
94
92
  }
95
93
  const comment = output
96
- ? `Task completed by swarm agent.\n\nOutput:\n${output.slice(0, 2000)}`
94
+ ? `Task completed by swarm agent.\n\n+++ Output\n${output.slice(0, 2000)}\n+++`
97
95
  : "Task completed by swarm agent.";
98
96
  await client.createComment({ issueId: sync.externalId, body: comment });
99
97
  console.log(`[Linear Outbound] Posted completion comment for task ${taskId}`);
@@ -121,7 +119,9 @@ async function handleTaskFailed(data: unknown): Promise<void> {
121
119
  if (shouldSkipForLoopPrevention(sync)) return;
122
120
 
123
121
  const sessionId = taskSessionMap.get(taskId);
124
- const body = failureReason ? `Task failed.\n\n${failureReason.slice(0, 2000)}` : "Task failed.";
122
+ const body = failureReason
123
+ ? `Task failed.\n\n+++ Error Details\n${failureReason.slice(0, 2000)}\n+++`
124
+ : "Task failed.";
125
125
 
126
126
  // Prefer AgentSession error activity over issue comment (avoids duplication)
127
127
  if (sessionId) {
@@ -139,7 +139,7 @@ async function handleTaskFailed(data: unknown): Promise<void> {
139
139
  return;
140
140
  }
141
141
  const comment = failureReason
142
- ? `Task failed.\n\nReason:\n${failureReason.slice(0, 2000)}`
142
+ ? `Task failed.\n\n+++ Error Details\n${failureReason.slice(0, 2000)}\n+++`
143
143
  : "Task failed.";
144
144
  await client.createComment({ issueId: sync.externalId, body: comment });
145
145
  console.log(`[Linear Outbound] Posted failure comment for task ${taskId}`);
@@ -38,6 +38,8 @@ export type BasePromptArgs = {
38
38
  clonePath: string;
39
39
  warning?: string | null;
40
40
  };
41
+ /** Pre-fetched skill summaries for the installed skills section */
42
+ skillsSummary?: { name: string; description: string }[];
41
43
  };
42
44
 
43
45
  export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
@@ -68,6 +70,12 @@ export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
68
70
  }
69
71
  }
70
72
 
73
+ // Installed skills section (progressive disclosure — name + description only)
74
+ if (args.skillsSummary && args.skillsSummary.length > 0) {
75
+ const summaries = args.skillsSummary.map((s) => `- /${s.name}: ${s.description}`).join("\n");
76
+ prompt += `\n\n## Installed Skills\n\nThe following skills are available. Use the Skill tool to invoke them by name.\n\n${summaries}\n`;
77
+ }
78
+
71
79
  // Repo context (protected, never truncated)
72
80
  if (args.repoContext) {
73
81
  prompt += "\n\n## Repository Context\n\n";