@desplega.ai/agent-swarm 1.2.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.claude/settings.local.json +20 -1
  2. package/.env.docker.example +22 -1
  3. package/.env.example +17 -0
  4. package/.github/workflows/docker-publish.yml +92 -0
  5. package/CONTRIBUTING.md +270 -0
  6. package/DEPLOYMENT.md +391 -0
  7. package/Dockerfile.worker +29 -1
  8. package/FAQ.md +19 -0
  9. package/LICENSE +21 -0
  10. package/MCP.md +249 -0
  11. package/README.md +103 -207
  12. package/assets/agent-swarm-logo-orange.png +0 -0
  13. package/assets/agent-swarm-logo.png +0 -0
  14. package/docker-compose.example.yml +137 -0
  15. package/docker-entrypoint.sh +223 -7
  16. package/package.json +8 -3
  17. package/{cc-plugin → plugin}/.claude-plugin/plugin.json +1 -1
  18. package/plugin/README.md +1 -0
  19. package/plugin/agents/.gitkeep +0 -0
  20. package/plugin/agents/codebase-analyzer.md +143 -0
  21. package/plugin/agents/codebase-locator.md +122 -0
  22. package/plugin/agents/codebase-pattern-finder.md +227 -0
  23. package/plugin/agents/web-search-researcher.md +109 -0
  24. package/plugin/commands/create-plan.md +415 -0
  25. package/plugin/commands/implement-plan.md +89 -0
  26. package/plugin/commands/research.md +200 -0
  27. package/plugin/commands/start-leader.md +101 -0
  28. package/plugin/commands/start-worker.md +56 -0
  29. package/plugin/commands/swarm-chat.md +78 -0
  30. package/plugin/commands/todos.md +66 -0
  31. package/plugin/commands/work-on-task.md +44 -0
  32. package/plugin/skills/.gitkeep +0 -0
  33. package/scripts/generate-mcp-docs.ts +415 -0
  34. package/slack-manifest.json +69 -0
  35. package/src/be/db.ts +1431 -25
  36. package/src/cli.tsx +135 -11
  37. package/src/commands/lead.ts +13 -0
  38. package/src/commands/runner.ts +255 -0
  39. package/src/commands/worker.ts +8 -220
  40. package/src/hooks/hook.ts +102 -14
  41. package/src/http.ts +361 -5
  42. package/src/prompts/base-prompt.ts +131 -0
  43. package/src/server.ts +56 -0
  44. package/src/slack/app.ts +73 -0
  45. package/src/slack/commands.ts +88 -0
  46. package/src/slack/handlers.ts +281 -0
  47. package/src/slack/index.ts +3 -0
  48. package/src/slack/responses.ts +175 -0
  49. package/src/slack/router.ts +170 -0
  50. package/src/slack/types.ts +20 -0
  51. package/src/slack/watcher.ts +119 -0
  52. package/src/tools/create-channel.ts +80 -0
  53. package/src/tools/get-tasks.ts +54 -21
  54. package/src/tools/join-swarm.ts +28 -4
  55. package/src/tools/list-channels.ts +37 -0
  56. package/src/tools/list-services.ts +110 -0
  57. package/src/tools/poll-task.ts +46 -3
  58. package/src/tools/post-message.ts +87 -0
  59. package/src/tools/read-messages.ts +192 -0
  60. package/src/tools/register-service.ts +118 -0
  61. package/src/tools/send-task.ts +80 -7
  62. package/src/tools/store-progress.ts +9 -3
  63. package/src/tools/task-action.ts +211 -0
  64. package/src/tools/unregister-service.ts +110 -0
  65. package/src/tools/update-profile.ts +105 -0
  66. package/src/tools/update-service-status.ts +118 -0
  67. package/src/types.ts +110 -3
  68. package/src/utils/pretty-print.ts +224 -0
  69. package/thoughts/shared/plans/.gitkeep +0 -0
  70. package/thoughts/shared/plans/2025-12-18-inverse-teleport.md +1142 -0
  71. package/thoughts/shared/plans/2025-12-18-slack-integration.md +1195 -0
  72. package/thoughts/shared/plans/2025-12-19-agent-log-streaming.md +732 -0
  73. package/thoughts/shared/plans/2025-12-19-role-based-swarm-plugin.md +361 -0
  74. package/thoughts/shared/plans/2025-12-20-mobile-responsive-ui.md +501 -0
  75. package/thoughts/shared/plans/2025-12-20-startup-team-swarm.md +560 -0
  76. package/thoughts/shared/research/.gitkeep +0 -0
  77. package/thoughts/shared/research/2025-12-18-slack-integration.md +442 -0
  78. package/thoughts/shared/research/2025-12-19-agent-log-streaming.md +339 -0
  79. package/thoughts/shared/research/2025-12-19-agent-secrets-cli-research.md +390 -0
  80. package/thoughts/shared/research/2025-12-21-gemini-cli-integration.md +376 -0
  81. package/thoughts/shared/research/2025-12-22-setup-experience-improvements.md +264 -0
  82. package/tsconfig.json +3 -1
  83. package/ui/bun.lock +692 -0
  84. package/ui/index.html +22 -0
  85. package/ui/package.json +32 -0
  86. package/ui/pnpm-lock.yaml +3034 -0
  87. package/ui/postcss.config.js +6 -0
  88. package/ui/public/logo.png +0 -0
  89. package/ui/src/App.tsx +43 -0
  90. package/ui/src/components/ActivityFeed.tsx +415 -0
  91. package/ui/src/components/AgentDetailPanel.tsx +534 -0
  92. package/ui/src/components/AgentsPanel.tsx +549 -0
  93. package/ui/src/components/ChatPanel.tsx +1820 -0
  94. package/ui/src/components/ConfigModal.tsx +232 -0
  95. package/ui/src/components/Dashboard.tsx +534 -0
  96. package/ui/src/components/Header.tsx +168 -0
  97. package/ui/src/components/ServicesPanel.tsx +612 -0
  98. package/ui/src/components/StatsBar.tsx +288 -0
  99. package/ui/src/components/StatusBadge.tsx +124 -0
  100. package/ui/src/components/TaskDetailPanel.tsx +807 -0
  101. package/ui/src/components/TasksPanel.tsx +575 -0
  102. package/ui/src/hooks/queries.ts +170 -0
  103. package/ui/src/index.css +235 -0
  104. package/ui/src/lib/api.ts +161 -0
  105. package/ui/src/lib/config.ts +35 -0
  106. package/ui/src/lib/theme.ts +214 -0
  107. package/ui/src/lib/utils.ts +48 -0
  108. package/ui/src/main.tsx +32 -0
  109. package/ui/src/types/api.ts +164 -0
  110. package/ui/src/vite-env.d.ts +1 -0
  111. package/ui/tailwind.config.js +35 -0
  112. package/ui/tsconfig.json +31 -0
  113. package/ui/vite.config.ts +22 -0
  114. package/cc-plugin/README.md +0 -49
  115. package/cc-plugin/commands/setup-leader.md +0 -73
  116. package/cc-plugin/commands/start-worker.md +0 -64
  117. package/docker-compose.worker.yml +0 -35
  118. package/example-req-meta.json +0 -24
  119. /package/{cc-plugin → plugin}/hooks/hooks.json +0 -0
package/src/be/db.ts CHANGED
@@ -5,8 +5,14 @@ import type {
5
5
  AgentLogEventType,
6
6
  AgentStatus,
7
7
  AgentTask,
8
+ AgentTaskSource,
8
9
  AgentTaskStatus,
9
10
  AgentWithTasks,
11
+ Channel,
12
+ ChannelMessage,
13
+ ChannelType,
14
+ Service,
15
+ ServiceStatus,
10
16
  } from "../types";
11
17
 
12
18
  let db: Database | null = null;
@@ -26,22 +32,37 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
26
32
  name TEXT NOT NULL,
27
33
  isLead INTEGER NOT NULL DEFAULT 0,
28
34
  status TEXT NOT NULL CHECK(status IN ('idle', 'busy', 'offline')),
35
+ description TEXT,
36
+ role TEXT,
37
+ capabilities TEXT DEFAULT '[]',
29
38
  createdAt TEXT NOT NULL,
30
39
  lastUpdatedAt TEXT NOT NULL
31
40
  );
32
41
 
33
42
  CREATE TABLE IF NOT EXISTS agent_tasks (
34
43
  id TEXT PRIMARY KEY,
35
- agentId TEXT NOT NULL,
44
+ agentId TEXT,
45
+ creatorAgentId TEXT,
36
46
  task TEXT NOT NULL,
37
- status TEXT NOT NULL CHECK(status IN ('pending', 'in_progress', 'completed', 'failed')),
47
+ status TEXT NOT NULL DEFAULT 'pending',
48
+ source TEXT NOT NULL DEFAULT 'mcp',
49
+ taskType TEXT,
50
+ tags TEXT DEFAULT '[]',
51
+ priority INTEGER DEFAULT 50,
52
+ dependsOn TEXT DEFAULT '[]',
53
+ offeredTo TEXT,
54
+ offeredAt TEXT,
55
+ acceptedAt TEXT,
56
+ rejectionReason TEXT,
57
+ slackChannelId TEXT,
58
+ slackThreadTs TEXT,
59
+ slackUserId TEXT,
38
60
  createdAt TEXT NOT NULL,
39
61
  lastUpdatedAt TEXT NOT NULL,
40
62
  finishedAt TEXT,
41
63
  failureReason TEXT,
42
64
  output TEXT,
43
- progress TEXT,
44
- FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE
65
+ progress TEXT
45
66
  );
46
67
 
47
68
  CREATE INDEX IF NOT EXISTS idx_agent_tasks_agentId ON agent_tasks(agentId);
@@ -62,8 +83,237 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
62
83
  CREATE INDEX IF NOT EXISTS idx_agent_log_taskId ON agent_log(taskId);
63
84
  CREATE INDEX IF NOT EXISTS idx_agent_log_eventType ON agent_log(eventType);
64
85
  CREATE INDEX IF NOT EXISTS idx_agent_log_createdAt ON agent_log(createdAt);
86
+
87
+ -- Channels table
88
+ CREATE TABLE IF NOT EXISTS channels (
89
+ id TEXT PRIMARY KEY,
90
+ name TEXT NOT NULL UNIQUE,
91
+ description TEXT,
92
+ type TEXT NOT NULL DEFAULT 'public' CHECK(type IN ('public', 'dm')),
93
+ createdBy TEXT,
94
+ participants TEXT DEFAULT '[]',
95
+ createdAt TEXT NOT NULL,
96
+ FOREIGN KEY (createdBy) REFERENCES agents(id) ON DELETE SET NULL
97
+ );
98
+
99
+ -- Channel messages table
100
+ CREATE TABLE IF NOT EXISTS channel_messages (
101
+ id TEXT PRIMARY KEY,
102
+ channelId TEXT NOT NULL,
103
+ agentId TEXT,
104
+ content TEXT NOT NULL,
105
+ replyToId TEXT,
106
+ mentions TEXT DEFAULT '[]',
107
+ createdAt TEXT NOT NULL,
108
+ FOREIGN KEY (channelId) REFERENCES channels(id) ON DELETE CASCADE,
109
+ FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE,
110
+ FOREIGN KEY (replyToId) REFERENCES channel_messages(id) ON DELETE SET NULL
111
+ );
112
+
113
+ CREATE INDEX IF NOT EXISTS idx_channel_messages_channelId ON channel_messages(channelId);
114
+ CREATE INDEX IF NOT EXISTS idx_channel_messages_agentId ON channel_messages(agentId);
115
+ CREATE INDEX IF NOT EXISTS idx_channel_messages_createdAt ON channel_messages(createdAt);
116
+
117
+ -- Channel read state table
118
+ CREATE TABLE IF NOT EXISTS channel_read_state (
119
+ agentId TEXT NOT NULL,
120
+ channelId TEXT NOT NULL,
121
+ lastReadAt TEXT NOT NULL,
122
+ PRIMARY KEY (agentId, channelId),
123
+ FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE,
124
+ FOREIGN KEY (channelId) REFERENCES channels(id) ON DELETE CASCADE
125
+ );
126
+
127
+ -- Services table (for PM2/background services)
128
+ CREATE TABLE IF NOT EXISTS services (
129
+ id TEXT PRIMARY KEY,
130
+ agentId TEXT NOT NULL,
131
+ name TEXT NOT NULL,
132
+ port INTEGER NOT NULL DEFAULT 3000,
133
+ description TEXT,
134
+ url TEXT,
135
+ healthCheckPath TEXT DEFAULT '/health',
136
+ status TEXT NOT NULL DEFAULT 'starting' CHECK(status IN ('starting', 'healthy', 'unhealthy', 'stopped')),
137
+ -- PM2 configuration for ecosystem-based restart
138
+ script TEXT NOT NULL DEFAULT '',
139
+ cwd TEXT,
140
+ interpreter TEXT,
141
+ args TEXT, -- JSON array
142
+ env TEXT, -- JSON object
143
+ metadata TEXT DEFAULT '{}',
144
+ createdAt TEXT NOT NULL,
145
+ lastUpdatedAt TEXT NOT NULL,
146
+ FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE,
147
+ UNIQUE(agentId, name)
148
+ );
149
+
150
+ CREATE INDEX IF NOT EXISTS idx_services_agentId ON services(agentId);
151
+ CREATE INDEX IF NOT EXISTS idx_services_status ON services(status);
65
152
  `);
66
153
 
154
+ // Seed default general channel if it doesn't exist
155
+ // Use a stable UUID for the general channel so it's consistent across restarts
156
+ const generalChannelId = "00000000-0000-4000-8000-000000000001";
157
+ try {
158
+ // Migration: Fix old 'general' channel ID that wasn't a valid UUID
159
+ db.run(`UPDATE channels SET id = ? WHERE id = 'general'`, [generalChannelId]);
160
+ db.run(`UPDATE channel_messages SET channelId = ? WHERE channelId = 'general'`, [
161
+ generalChannelId,
162
+ ]);
163
+ db.run(`UPDATE channel_read_state SET channelId = ? WHERE channelId = 'general'`, [
164
+ generalChannelId,
165
+ ]);
166
+ } catch {
167
+ /* Migration not needed or already applied */
168
+ }
169
+ try {
170
+ db.run(
171
+ `
172
+ INSERT OR IGNORE INTO channels (id, name, description, type, createdAt)
173
+ VALUES (?, 'general', 'Default channel for all agents', 'public', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
174
+ `,
175
+ [generalChannelId],
176
+ );
177
+ } catch {
178
+ /* Channel already exists */
179
+ }
180
+
181
+ // Migration: Add new columns to existing databases (SQLite doesn't support IF NOT EXISTS for columns)
182
+ // Agent task columns
183
+ try {
184
+ db.run(
185
+ `ALTER TABLE agent_tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'mcp' CHECK(source IN ('mcp', 'slack', 'api'))`,
186
+ );
187
+ } catch {
188
+ /* exists */
189
+ }
190
+ try {
191
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN slackChannelId TEXT`);
192
+ } catch {
193
+ /* exists */
194
+ }
195
+ try {
196
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN slackThreadTs TEXT`);
197
+ } catch {
198
+ /* exists */
199
+ }
200
+ try {
201
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN slackUserId TEXT`);
202
+ } catch {
203
+ /* exists */
204
+ }
205
+ try {
206
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN taskType TEXT`);
207
+ } catch {
208
+ /* exists */
209
+ }
210
+ try {
211
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN tags TEXT DEFAULT '[]'`);
212
+ } catch {
213
+ /* exists */
214
+ }
215
+ try {
216
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN priority INTEGER DEFAULT 50`);
217
+ } catch {
218
+ /* exists */
219
+ }
220
+ try {
221
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN dependsOn TEXT DEFAULT '[]'`);
222
+ } catch {
223
+ /* exists */
224
+ }
225
+ try {
226
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN offeredTo TEXT`);
227
+ } catch {
228
+ /* exists */
229
+ }
230
+ try {
231
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN offeredAt TEXT`);
232
+ } catch {
233
+ /* exists */
234
+ }
235
+ try {
236
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN acceptedAt TEXT`);
237
+ } catch {
238
+ /* exists */
239
+ }
240
+ try {
241
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN rejectionReason TEXT`);
242
+ } catch {
243
+ /* exists */
244
+ }
245
+ try {
246
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN creatorAgentId TEXT`);
247
+ } catch {
248
+ /* exists */
249
+ }
250
+ // Mention-to-task columns
251
+ try {
252
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN mentionMessageId TEXT`);
253
+ } catch {
254
+ /* exists */
255
+ }
256
+ try {
257
+ db.run(`ALTER TABLE agent_tasks ADD COLUMN mentionChannelId TEXT`);
258
+ } catch {
259
+ /* exists */
260
+ }
261
+ // Agent profile columns
262
+ try {
263
+ db.run(`ALTER TABLE agents ADD COLUMN description TEXT`);
264
+ } catch {
265
+ /* exists */
266
+ }
267
+ try {
268
+ db.run(`ALTER TABLE agents ADD COLUMN role TEXT`);
269
+ } catch {
270
+ /* exists */
271
+ }
272
+ try {
273
+ db.run(`ALTER TABLE agents ADD COLUMN capabilities TEXT DEFAULT '[]'`);
274
+ } catch {
275
+ /* exists */
276
+ }
277
+
278
+ // Service PM2 columns migration
279
+ try {
280
+ db.run(`ALTER TABLE services ADD COLUMN script TEXT NOT NULL DEFAULT ''`);
281
+ } catch {
282
+ /* exists */
283
+ }
284
+ try {
285
+ db.run(`ALTER TABLE services ADD COLUMN cwd TEXT`);
286
+ } catch {
287
+ /* exists */
288
+ }
289
+ try {
290
+ db.run(`ALTER TABLE services ADD COLUMN interpreter TEXT`);
291
+ } catch {
292
+ /* exists */
293
+ }
294
+ try {
295
+ db.run(`ALTER TABLE services ADD COLUMN args TEXT`);
296
+ } catch {
297
+ /* exists */
298
+ }
299
+ try {
300
+ db.run(`ALTER TABLE services ADD COLUMN env TEXT`);
301
+ } catch {
302
+ /* exists */
303
+ }
304
+
305
+ // Create indexes on new columns (after migrations add them)
306
+ try {
307
+ db.run(`CREATE INDEX IF NOT EXISTS idx_agent_tasks_offeredTo ON agent_tasks(offeredTo)`);
308
+ } catch {
309
+ /* exists or column missing */
310
+ }
311
+ try {
312
+ db.run(`CREATE INDEX IF NOT EXISTS idx_agent_tasks_taskType ON agent_tasks(taskType)`);
313
+ } catch {
314
+ /* exists or column missing */
315
+ }
316
+
67
317
  return db;
68
318
  }
69
319
 
@@ -90,6 +340,9 @@ type AgentRow = {
90
340
  name: string;
91
341
  isLead: number;
92
342
  status: AgentStatus;
343
+ description: string | null;
344
+ role: string | null;
345
+ capabilities: string | null;
93
346
  createdAt: string;
94
347
  lastUpdatedAt: string;
95
348
  };
@@ -100,6 +353,9 @@ function rowToAgent(row: AgentRow): Agent {
100
353
  name: row.name,
101
354
  isLead: row.isLead === 1,
102
355
  status: row.status,
356
+ description: row.description ?? undefined,
357
+ role: row.role ?? undefined,
358
+ capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
103
359
  createdAt: row.createdAt,
104
360
  lastUpdatedAt: row.lastUpdatedAt,
105
361
  };
@@ -177,9 +433,24 @@ export function deleteAgent(id: string): boolean {
177
433
 
178
434
  type AgentTaskRow = {
179
435
  id: string;
180
- agentId: string;
436
+ agentId: string | null;
437
+ creatorAgentId: string | null;
181
438
  task: string;
182
439
  status: AgentTaskStatus;
440
+ source: AgentTaskSource;
441
+ taskType: string | null;
442
+ tags: string | null;
443
+ priority: number;
444
+ dependsOn: string | null;
445
+ offeredTo: string | null;
446
+ offeredAt: string | null;
447
+ acceptedAt: string | null;
448
+ rejectionReason: string | null;
449
+ slackChannelId: string | null;
450
+ slackThreadTs: string | null;
451
+ slackUserId: string | null;
452
+ mentionMessageId: string | null;
453
+ mentionChannelId: string | null;
183
454
  createdAt: string;
184
455
  lastUpdatedAt: string;
185
456
  finishedAt: string | null;
@@ -192,8 +463,23 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
192
463
  return {
193
464
  id: row.id,
194
465
  agentId: row.agentId,
466
+ creatorAgentId: row.creatorAgentId ?? undefined,
195
467
  task: row.task,
196
468
  status: row.status,
469
+ source: row.source,
470
+ taskType: row.taskType ?? undefined,
471
+ tags: row.tags ? JSON.parse(row.tags) : [],
472
+ priority: row.priority ?? 50,
473
+ dependsOn: row.dependsOn ? JSON.parse(row.dependsOn) : [],
474
+ offeredTo: row.offeredTo ?? undefined,
475
+ offeredAt: row.offeredAt ?? undefined,
476
+ acceptedAt: row.acceptedAt ?? undefined,
477
+ rejectionReason: row.rejectionReason ?? undefined,
478
+ slackChannelId: row.slackChannelId ?? undefined,
479
+ slackThreadTs: row.slackThreadTs ?? undefined,
480
+ slackUserId: row.slackUserId ?? undefined,
481
+ mentionMessageId: row.mentionMessageId ?? undefined,
482
+ mentionChannelId: row.mentionChannelId ?? undefined,
197
483
  createdAt: row.createdAt,
198
484
  lastUpdatedAt: row.lastUpdatedAt,
199
485
  finishedAt: row.finishedAt ?? undefined,
@@ -205,9 +491,21 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
205
491
 
206
492
  export const taskQueries = {
207
493
  insert: () =>
208
- getDb().prepare<AgentTaskRow, [string, string, string, AgentTaskStatus]>(
209
- `INSERT INTO agent_tasks (id, agentId, task, status, createdAt, lastUpdatedAt)
210
- VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *`,
494
+ getDb().prepare<
495
+ AgentTaskRow,
496
+ [
497
+ string,
498
+ string,
499
+ string,
500
+ AgentTaskStatus,
501
+ AgentTaskSource,
502
+ string | null,
503
+ string | null,
504
+ string | null,
505
+ ]
506
+ >(
507
+ `INSERT INTO agent_tasks (id, agentId, task, status, source, slackChannelId, slackThreadTs, slackUserId, createdAt, lastUpdatedAt)
508
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *`,
211
509
  ),
212
510
 
213
511
  getById: () => getDb().prepare<AgentTaskRow, [string]>("SELECT * FROM agent_tasks WHERE id = ?"),
@@ -246,12 +544,39 @@ export const taskQueries = {
246
544
  delete: () => getDb().prepare<null, [string]>("DELETE FROM agent_tasks WHERE id = ?"),
247
545
  };
248
546
 
249
- export function createTask(agentId: string, task: string): AgentTask {
547
+ export function createTask(
548
+ agentId: string,
549
+ task: string,
550
+ options?: {
551
+ source?: AgentTaskSource;
552
+ slackChannelId?: string;
553
+ slackThreadTs?: string;
554
+ slackUserId?: string;
555
+ },
556
+ ): AgentTask {
250
557
  const id = crypto.randomUUID();
251
- const row = taskQueries.insert().get(id, agentId, task, "pending");
558
+ const source = options?.source ?? "mcp";
559
+ const row = taskQueries
560
+ .insert()
561
+ .get(
562
+ id,
563
+ agentId,
564
+ task,
565
+ "pending",
566
+ source,
567
+ options?.slackChannelId ?? null,
568
+ options?.slackThreadTs ?? null,
569
+ options?.slackUserId ?? null,
570
+ );
252
571
  if (!row) throw new Error("Failed to create task");
253
572
  try {
254
- createLogEntry({ eventType: "task_created", agentId, taskId: id, newValue: "pending" });
573
+ createLogEntry({
574
+ eventType: "task_created",
575
+ agentId,
576
+ taskId: id,
577
+ newValue: "pending",
578
+ metadata: { source },
579
+ });
255
580
  } catch {}
256
581
  return rowToAgentTask(row);
257
582
  }
@@ -278,7 +603,7 @@ export function startTask(taskId: string): AgentTask | null {
278
603
  createLogEntry({
279
604
  eventType: "task_status_change",
280
605
  taskId,
281
- agentId: row.agentId,
606
+ agentId: row.agentId ?? undefined,
282
607
  oldValue: oldTask.status,
283
608
  newValue: "in_progress",
284
609
  });
@@ -300,21 +625,127 @@ export function getTasksByStatus(status: AgentTaskStatus): AgentTask[] {
300
625
  return taskQueries.getByStatus().all(status).map(rowToAgentTask);
301
626
  }
302
627
 
303
- export function getAllTasks(status?: AgentTaskStatus): AgentTask[] {
304
- if (status) {
305
- return getDb()
306
- .prepare<AgentTaskRow, [AgentTaskStatus]>(
307
- "SELECT * FROM agent_tasks WHERE status = ? ORDER BY lastUpdatedAt DESC",
308
- )
309
- .all(status)
310
- .map(rowToAgentTask);
628
+ export interface TaskFilters {
629
+ status?: AgentTaskStatus;
630
+ agentId?: string;
631
+ search?: string;
632
+ // New filters
633
+ unassigned?: boolean;
634
+ offeredTo?: string;
635
+ readyOnly?: boolean;
636
+ taskType?: string;
637
+ tags?: string[];
638
+ }
639
+
640
+ export function getAllTasks(filters?: TaskFilters): AgentTask[] {
641
+ const conditions: string[] = [];
642
+ const params: (string | AgentTaskStatus)[] = [];
643
+
644
+ if (filters?.status) {
645
+ conditions.push("status = ?");
646
+ params.push(filters.status);
311
647
  }
648
+
649
+ if (filters?.agentId) {
650
+ conditions.push("agentId = ?");
651
+ params.push(filters.agentId);
652
+ }
653
+
654
+ if (filters?.search) {
655
+ conditions.push("task LIKE ?");
656
+ params.push(`%${filters.search}%`);
657
+ }
658
+
659
+ // New filters
660
+ if (filters?.unassigned) {
661
+ conditions.push("(agentId IS NULL OR status = 'unassigned')");
662
+ }
663
+
664
+ if (filters?.offeredTo) {
665
+ conditions.push("offeredTo = ?");
666
+ params.push(filters.offeredTo);
667
+ }
668
+
669
+ if (filters?.taskType) {
670
+ conditions.push("taskType = ?");
671
+ params.push(filters.taskType);
672
+ }
673
+
674
+ if (filters?.tags && filters.tags.length > 0) {
675
+ // Match any of the tags
676
+ const tagConditions = filters.tags.map(() => "tags LIKE ?");
677
+ conditions.push(`(${tagConditions.join(" OR ")})`);
678
+ for (const tag of filters.tags) {
679
+ params.push(`%"${tag}"%`);
680
+ }
681
+ }
682
+
683
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
684
+ const query = `SELECT * FROM agent_tasks ${whereClause} ORDER BY lastUpdatedAt DESC, priority DESC`;
685
+
686
+ let tasks = getDb()
687
+ .prepare<AgentTaskRow, (string | AgentTaskStatus)[]>(query)
688
+ .all(...params)
689
+ .map(rowToAgentTask);
690
+
691
+ // Filter for ready tasks (dependencies met) if requested
692
+ if (filters?.readyOnly) {
693
+ tasks = tasks.filter((task) => {
694
+ if (!task.dependsOn || task.dependsOn.length === 0) return true;
695
+ return checkDependencies(task.id).ready;
696
+ });
697
+ }
698
+
699
+ return tasks;
700
+ }
701
+
702
+ export function getCompletedSlackTasks(): AgentTask[] {
703
+ return getDb()
704
+ .prepare<AgentTaskRow, []>(
705
+ `SELECT * FROM agent_tasks
706
+ WHERE source = 'slack'
707
+ AND slackChannelId IS NOT NULL
708
+ AND status IN ('completed', 'failed')
709
+ ORDER BY lastUpdatedAt DESC`,
710
+ )
711
+ .all()
712
+ .map(rowToAgentTask);
713
+ }
714
+
715
+ export function getInProgressSlackTasks(): AgentTask[] {
312
716
  return getDb()
313
- .prepare<AgentTaskRow, []>("SELECT * FROM agent_tasks ORDER BY lastUpdatedAt DESC")
717
+ .prepare<AgentTaskRow, []>(
718
+ `SELECT * FROM agent_tasks
719
+ WHERE source = 'slack'
720
+ AND slackChannelId IS NOT NULL
721
+ AND status = 'in_progress'
722
+ ORDER BY lastUpdatedAt DESC`,
723
+ )
314
724
  .all()
315
725
  .map(rowToAgentTask);
316
726
  }
317
727
 
728
+ /**
729
+ * Find an agent that has an active task (in_progress or pending) in a specific Slack thread.
730
+ * Used for routing thread follow-up messages to the same agent.
731
+ */
732
+ export function getAgentWorkingOnThread(channelId: string, threadTs: string): Agent | null {
733
+ const row = getDb()
734
+ .prepare<AgentTaskRow, [string, string]>(
735
+ `SELECT * FROM agent_tasks
736
+ WHERE source = 'slack'
737
+ AND slackChannelId = ?
738
+ AND slackThreadTs = ?
739
+ AND status IN ('in_progress', 'pending')
740
+ ORDER BY createdAt DESC
741
+ LIMIT 1`,
742
+ )
743
+ .get(channelId, threadTs);
744
+
745
+ if (!row || !row.agentId) return null;
746
+ return getAgentById(row.agentId);
747
+ }
748
+
318
749
  export function completeTask(id: string, output?: string): AgentTask | null {
319
750
  const oldTask = getTaskById(id);
320
751
  const finishedAt = new Date().toISOString();
@@ -330,7 +761,7 @@ export function completeTask(id: string, output?: string): AgentTask | null {
330
761
  createLogEntry({
331
762
  eventType: "task_status_change",
332
763
  taskId: id,
333
- agentId: row.agentId,
764
+ agentId: row.agentId ?? undefined,
334
765
  oldValue: oldTask.status,
335
766
  newValue: "completed",
336
767
  });
@@ -349,7 +780,7 @@ export function failTask(id: string, reason: string): AgentTask | null {
349
780
  createLogEntry({
350
781
  eventType: "task_status_change",
351
782
  taskId: id,
352
- agentId: row.agentId,
783
+ agentId: row.agentId ?? undefined,
353
784
  oldValue: oldTask.status,
354
785
  newValue: "failed",
355
786
  metadata: { reason },
@@ -371,7 +802,7 @@ export function updateTaskProgress(id: string, progress: string): AgentTask | nu
371
802
  createLogEntry({
372
803
  eventType: "task_progress",
373
804
  taskId: id,
374
- agentId: row.agentId,
805
+ agentId: row.agentId ?? undefined,
375
806
  newValue: progress,
376
807
  });
377
808
  } catch {}
@@ -507,9 +938,984 @@ export function getLogsByTaskIdChronological(taskId: string): AgentLog[] {
507
938
  export function getAllLogs(limit?: number): AgentLog[] {
508
939
  if (limit) {
509
940
  return getDb()
510
- .prepare<AgentLogRow, [number]>("SELECT * FROM agent_log ORDER BY createdAt DESC LIMIT ?")
941
+ .prepare<AgentLogRow, [number]>(
942
+ "SELECT * FROM agent_log WHERE eventType != 'agent_status_change' ORDER BY createdAt DESC LIMIT ?",
943
+ )
511
944
  .all(limit)
512
945
  .map(rowToAgentLog);
513
946
  }
514
947
  return logQueries.getAll().all().map(rowToAgentLog);
515
948
  }
949
+
950
+ // ============================================================================
951
+ // Task Pool Operations
952
+ // ============================================================================
953
+
954
+ export interface CreateTaskOptions {
955
+ agentId?: string | null;
956
+ creatorAgentId?: string;
957
+ source?: AgentTaskSource;
958
+ taskType?: string;
959
+ tags?: string[];
960
+ priority?: number;
961
+ dependsOn?: string[];
962
+ offeredTo?: string;
963
+ slackChannelId?: string;
964
+ slackThreadTs?: string;
965
+ slackUserId?: string;
966
+ mentionMessageId?: string;
967
+ mentionChannelId?: string;
968
+ }
969
+
970
+ export function createTaskExtended(task: string, options?: CreateTaskOptions): AgentTask {
971
+ const id = crypto.randomUUID();
972
+ const now = new Date().toISOString();
973
+ const status: AgentTaskStatus = options?.offeredTo
974
+ ? "offered"
975
+ : options?.agentId
976
+ ? "pending"
977
+ : "unassigned";
978
+
979
+ const row = getDb()
980
+ .prepare<AgentTaskRow, (string | number | null)[]>(
981
+ `INSERT INTO agent_tasks (
982
+ id, agentId, creatorAgentId, task, status, source,
983
+ taskType, tags, priority, dependsOn, offeredTo, offeredAt,
984
+ slackChannelId, slackThreadTs, slackUserId,
985
+ mentionMessageId, mentionChannelId, createdAt, lastUpdatedAt
986
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
987
+ )
988
+ .get(
989
+ id,
990
+ options?.agentId ?? null,
991
+ options?.creatorAgentId ?? null,
992
+ task,
993
+ status,
994
+ options?.source ?? "mcp",
995
+ options?.taskType ?? null,
996
+ JSON.stringify(options?.tags ?? []),
997
+ options?.priority ?? 50,
998
+ JSON.stringify(options?.dependsOn ?? []),
999
+ options?.offeredTo ?? null,
1000
+ options?.offeredTo ? now : null,
1001
+ options?.slackChannelId ?? null,
1002
+ options?.slackThreadTs ?? null,
1003
+ options?.slackUserId ?? null,
1004
+ options?.mentionMessageId ?? null,
1005
+ options?.mentionChannelId ?? null,
1006
+ now,
1007
+ now,
1008
+ );
1009
+
1010
+ if (!row) throw new Error("Failed to create task");
1011
+
1012
+ try {
1013
+ createLogEntry({
1014
+ eventType: status === "offered" ? "task_offered" : "task_created",
1015
+ agentId: options?.creatorAgentId,
1016
+ taskId: id,
1017
+ newValue: status,
1018
+ metadata: { source: options?.source ?? "mcp" },
1019
+ });
1020
+ } catch {}
1021
+
1022
+ return rowToAgentTask(row);
1023
+ }
1024
+
1025
+ export function claimTask(taskId: string, agentId: string): AgentTask | null {
1026
+ const task = getTaskById(taskId);
1027
+ if (!task) return null;
1028
+ if (task.status !== "unassigned") return null;
1029
+
1030
+ const now = new Date().toISOString();
1031
+ const row = getDb()
1032
+ .prepare<AgentTaskRow, [string, string, string]>(
1033
+ `UPDATE agent_tasks SET agentId = ?, status = 'pending', lastUpdatedAt = ?
1034
+ WHERE id = ? AND status = 'unassigned' RETURNING *`,
1035
+ )
1036
+ .get(agentId, now, taskId);
1037
+
1038
+ if (row) {
1039
+ try {
1040
+ createLogEntry({
1041
+ eventType: "task_claimed",
1042
+ agentId,
1043
+ taskId,
1044
+ oldValue: "unassigned",
1045
+ newValue: "pending",
1046
+ });
1047
+ } catch {}
1048
+ }
1049
+
1050
+ return row ? rowToAgentTask(row) : null;
1051
+ }
1052
+
1053
+ export function releaseTask(taskId: string): AgentTask | null {
1054
+ const task = getTaskById(taskId);
1055
+ if (!task) return null;
1056
+ if (task.status !== "pending") return null;
1057
+
1058
+ const now = new Date().toISOString();
1059
+ const row = getDb()
1060
+ .prepare<AgentTaskRow, [string, string]>(
1061
+ `UPDATE agent_tasks SET agentId = NULL, status = 'unassigned', lastUpdatedAt = ?
1062
+ WHERE id = ? AND status = 'pending' RETURNING *`,
1063
+ )
1064
+ .get(now, taskId);
1065
+
1066
+ if (row) {
1067
+ try {
1068
+ createLogEntry({
1069
+ eventType: "task_released",
1070
+ agentId: task.agentId ?? undefined,
1071
+ taskId,
1072
+ oldValue: "pending",
1073
+ newValue: "unassigned",
1074
+ });
1075
+ } catch {}
1076
+ }
1077
+
1078
+ return row ? rowToAgentTask(row) : null;
1079
+ }
1080
+
1081
+ export function acceptTask(taskId: string, agentId: string): AgentTask | null {
1082
+ const task = getTaskById(taskId);
1083
+ if (!task) return null;
1084
+ if (task.status !== "offered" || task.offeredTo !== agentId) return null;
1085
+
1086
+ const now = new Date().toISOString();
1087
+ const row = getDb()
1088
+ .prepare<AgentTaskRow, [string, string, string, string]>(
1089
+ `UPDATE agent_tasks SET agentId = ?, status = 'pending', acceptedAt = ?, lastUpdatedAt = ?
1090
+ WHERE id = ? AND status = 'offered' RETURNING *`,
1091
+ )
1092
+ .get(agentId, now, now, taskId);
1093
+
1094
+ if (row) {
1095
+ try {
1096
+ createLogEntry({
1097
+ eventType: "task_accepted",
1098
+ agentId,
1099
+ taskId,
1100
+ oldValue: "offered",
1101
+ newValue: "pending",
1102
+ });
1103
+ } catch {}
1104
+ }
1105
+
1106
+ return row ? rowToAgentTask(row) : null;
1107
+ }
1108
+
1109
+ export function rejectTask(taskId: string, agentId: string, reason?: string): AgentTask | null {
1110
+ const task = getTaskById(taskId);
1111
+ if (!task) return null;
1112
+ if (task.status !== "offered" || task.offeredTo !== agentId) return null;
1113
+
1114
+ const now = new Date().toISOString();
1115
+ const row = getDb()
1116
+ .prepare<AgentTaskRow, [string | null, string, string]>(
1117
+ `UPDATE agent_tasks SET
1118
+ status = 'unassigned', offeredTo = NULL, offeredAt = NULL,
1119
+ rejectionReason = ?, lastUpdatedAt = ?
1120
+ WHERE id = ? AND status = 'offered' RETURNING *`,
1121
+ )
1122
+ .get(reason ?? null, now, taskId);
1123
+
1124
+ if (row) {
1125
+ try {
1126
+ createLogEntry({
1127
+ eventType: "task_rejected",
1128
+ agentId,
1129
+ taskId,
1130
+ oldValue: "offered",
1131
+ newValue: "unassigned",
1132
+ metadata: reason ? { reason } : undefined,
1133
+ });
1134
+ } catch {}
1135
+ }
1136
+
1137
+ return row ? rowToAgentTask(row) : null;
1138
+ }
1139
+
1140
+ export function getOfferedTasksForAgent(agentId: string): AgentTask[] {
1141
+ return getDb()
1142
+ .prepare<AgentTaskRow, [string]>(
1143
+ "SELECT * FROM agent_tasks WHERE offeredTo = ? AND status = 'offered' ORDER BY createdAt ASC",
1144
+ )
1145
+ .all(agentId)
1146
+ .map(rowToAgentTask);
1147
+ }
1148
+
1149
+ export function getUnassignedTasksCount(): number {
1150
+ const result = getDb()
1151
+ .prepare<{ count: number }, []>(
1152
+ "SELECT COUNT(*) as count FROM agent_tasks WHERE status = 'unassigned'",
1153
+ )
1154
+ .get();
1155
+ return result?.count ?? 0;
1156
+ }
1157
+
1158
+ // ============================================================================
1159
+ // Dependency Checking
1160
+ // ============================================================================
1161
+
1162
+ export function checkDependencies(taskId: string): {
1163
+ ready: boolean;
1164
+ blockedBy: string[];
1165
+ } {
1166
+ const task = getTaskById(taskId);
1167
+ if (!task || !task.dependsOn || task.dependsOn.length === 0) {
1168
+ return { ready: true, blockedBy: [] };
1169
+ }
1170
+
1171
+ const blockedBy: string[] = [];
1172
+ for (const depId of task.dependsOn) {
1173
+ const depTask = getTaskById(depId);
1174
+ if (!depTask || depTask.status !== "completed") {
1175
+ blockedBy.push(depId);
1176
+ }
1177
+ }
1178
+
1179
+ return { ready: blockedBy.length === 0, blockedBy };
1180
+ }
1181
+
1182
+ // ============================================================================
1183
+ // Agent Profile Operations
1184
+ // ============================================================================
1185
+
1186
+ export function updateAgentProfile(
1187
+ id: string,
1188
+ updates: {
1189
+ description?: string;
1190
+ role?: string;
1191
+ capabilities?: string[];
1192
+ },
1193
+ ): Agent | null {
1194
+ const agent = getAgentById(id);
1195
+ if (!agent) return null;
1196
+
1197
+ const now = new Date().toISOString();
1198
+ const row = getDb()
1199
+ .prepare<AgentRow, [string | null, string | null, string | null, string, string]>(
1200
+ `UPDATE agents SET
1201
+ description = COALESCE(?, description),
1202
+ role = COALESCE(?, role),
1203
+ capabilities = COALESCE(?, capabilities),
1204
+ lastUpdatedAt = ?
1205
+ WHERE id = ? RETURNING *`,
1206
+ )
1207
+ .get(
1208
+ updates.description ?? null,
1209
+ updates.role ?? null,
1210
+ updates.capabilities ? JSON.stringify(updates.capabilities) : null,
1211
+ now,
1212
+ id,
1213
+ );
1214
+
1215
+ return row ? rowToAgent(row) : null;
1216
+ }
1217
+
1218
+ // ============================================================================
1219
+ // Channel Operations
1220
+ // ============================================================================
1221
+
1222
+ type ChannelRow = {
1223
+ id: string;
1224
+ name: string;
1225
+ description: string | null;
1226
+ type: ChannelType;
1227
+ createdBy: string | null;
1228
+ participants: string | null;
1229
+ createdAt: string;
1230
+ };
1231
+
1232
+ function rowToChannel(row: ChannelRow): Channel {
1233
+ return {
1234
+ id: row.id,
1235
+ name: row.name,
1236
+ description: row.description ?? undefined,
1237
+ type: row.type,
1238
+ createdBy: row.createdBy ?? undefined,
1239
+ participants: row.participants ? JSON.parse(row.participants) : [],
1240
+ createdAt: row.createdAt,
1241
+ };
1242
+ }
1243
+
1244
+ type ChannelMessageRow = {
1245
+ id: string;
1246
+ channelId: string;
1247
+ agentId: string | null;
1248
+ content: string;
1249
+ replyToId: string | null;
1250
+ mentions: string | null;
1251
+ createdAt: string;
1252
+ };
1253
+
1254
+ function rowToChannelMessage(row: ChannelMessageRow, agentName?: string): ChannelMessage {
1255
+ return {
1256
+ id: row.id,
1257
+ channelId: row.channelId,
1258
+ agentId: row.agentId,
1259
+ agentName: agentName ?? (row.agentId ? undefined : "Human"),
1260
+ content: row.content,
1261
+ replyToId: row.replyToId ?? undefined,
1262
+ mentions: row.mentions ? JSON.parse(row.mentions) : [],
1263
+ createdAt: row.createdAt,
1264
+ };
1265
+ }
1266
+
1267
+ export function createChannel(
1268
+ name: string,
1269
+ options?: {
1270
+ description?: string;
1271
+ type?: ChannelType;
1272
+ createdBy?: string;
1273
+ participants?: string[];
1274
+ },
1275
+ ): Channel {
1276
+ const id = crypto.randomUUID();
1277
+ const now = new Date().toISOString();
1278
+
1279
+ const row = getDb()
1280
+ .prepare<
1281
+ ChannelRow,
1282
+ [string, string, string | null, ChannelType, string | null, string, string]
1283
+ >(
1284
+ `INSERT INTO channels (id, name, description, type, createdBy, participants, createdAt)
1285
+ VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *`,
1286
+ )
1287
+ .get(
1288
+ id,
1289
+ name,
1290
+ options?.description ?? null,
1291
+ options?.type ?? "public",
1292
+ options?.createdBy ?? null,
1293
+ JSON.stringify(options?.participants ?? []),
1294
+ now,
1295
+ );
1296
+
1297
+ if (!row) throw new Error("Failed to create channel");
1298
+ return rowToChannel(row);
1299
+ }
1300
+
1301
+ export function getMessageById(id: string): ChannelMessage | null {
1302
+ const row = getDb()
1303
+ .prepare<ChannelMessageRow, [string]>("SELECT * FROM channel_messages WHERE id = ?")
1304
+ .get(id);
1305
+ if (!row) return null;
1306
+ const agent = row.agentId ? getAgentById(row.agentId) : null;
1307
+ return rowToChannelMessage(row, agent?.name);
1308
+ }
1309
+
1310
+ export function getChannelById(id: string): Channel | null {
1311
+ const row = getDb().prepare<ChannelRow, [string]>("SELECT * FROM channels WHERE id = ?").get(id);
1312
+ return row ? rowToChannel(row) : null;
1313
+ }
1314
+
1315
+ export function getChannelByName(name: string): Channel | null {
1316
+ const row = getDb()
1317
+ .prepare<ChannelRow, [string]>("SELECT * FROM channels WHERE name = ?")
1318
+ .get(name);
1319
+ return row ? rowToChannel(row) : null;
1320
+ }
1321
+
1322
+ export function getAllChannels(): Channel[] {
1323
+ return getDb()
1324
+ .prepare<ChannelRow, []>("SELECT * FROM channels ORDER BY name")
1325
+ .all()
1326
+ .map(rowToChannel);
1327
+ }
1328
+
1329
+ export function postMessage(
1330
+ channelId: string,
1331
+ agentId: string | null,
1332
+ content: string,
1333
+ options?: {
1334
+ replyToId?: string;
1335
+ mentions?: string[];
1336
+ },
1337
+ ): ChannelMessage {
1338
+ const id = crypto.randomUUID();
1339
+ const now = new Date().toISOString();
1340
+
1341
+ // Detect /task prefix - only create tasks when explicitly requested
1342
+ const isTaskMessage = content.trimStart().startsWith("/task ");
1343
+ const messageContent = isTaskMessage ? content.replace(/^\s*\/task\s+/, "") : content;
1344
+
1345
+ const row = getDb()
1346
+ .prepare<
1347
+ ChannelMessageRow,
1348
+ [string, string, string | null, string, string | null, string, string]
1349
+ >(
1350
+ `INSERT INTO channel_messages (id, channelId, agentId, content, replyToId, mentions, createdAt)
1351
+ VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *`,
1352
+ )
1353
+ .get(
1354
+ id,
1355
+ channelId,
1356
+ agentId,
1357
+ messageContent,
1358
+ options?.replyToId ?? null,
1359
+ JSON.stringify(options?.mentions ?? []),
1360
+ now,
1361
+ );
1362
+
1363
+ if (!row) throw new Error("Failed to post message");
1364
+
1365
+ try {
1366
+ createLogEntry({
1367
+ eventType: "channel_message",
1368
+ agentId: agentId ?? undefined,
1369
+ metadata: { channelId, messageId: id },
1370
+ });
1371
+ } catch {}
1372
+
1373
+ // Determine which agents should receive task notifications
1374
+ let targetMentions = options?.mentions ?? [];
1375
+
1376
+ // Thread follow-up: If no explicit mentions and this is a reply, inherit from parent message
1377
+ // Note: Only for notifications, not for task creation (requires explicit /task)
1378
+ if (targetMentions.length === 0 && options?.replyToId) {
1379
+ const parentMessage = getMessageById(options.replyToId);
1380
+ if (parentMessage?.mentions && parentMessage.mentions.length > 0) {
1381
+ targetMentions = parentMessage.mentions;
1382
+ }
1383
+ }
1384
+
1385
+ // Only create tasks when /task prefix is used
1386
+ if (isTaskMessage && targetMentions.length > 0) {
1387
+ const sender = agentId ? getAgentById(agentId) : null;
1388
+ const channel = getChannelById(channelId);
1389
+ const senderName = sender?.name ?? "Human";
1390
+ const channelName = channel?.name ?? "unknown";
1391
+ const truncated =
1392
+ messageContent.length > 80 ? `${messageContent.slice(0, 80)}...` : messageContent;
1393
+
1394
+ // Dedupe mentions (self-mentions allowed - agents can create tasks for themselves)
1395
+ const uniqueMentions = [...new Set(targetMentions)];
1396
+ const createdTaskIds: string[] = [];
1397
+
1398
+ for (const mentionedAgentId of uniqueMentions) {
1399
+ // Skip if agent doesn't exist
1400
+ const mentionedAgent = getAgentById(mentionedAgentId);
1401
+ if (!mentionedAgent) continue;
1402
+
1403
+ const taskDescription = `Task from ${senderName} in #${channelName}: "${truncated}"`;
1404
+
1405
+ const task = createTaskExtended(taskDescription, {
1406
+ agentId: mentionedAgentId, // Direct assignment
1407
+ creatorAgentId: agentId ?? undefined,
1408
+ source: "mcp",
1409
+ taskType: "task",
1410
+ priority: 50,
1411
+ mentionMessageId: id,
1412
+ mentionChannelId: channelId,
1413
+ });
1414
+ createdTaskIds.push(task.id);
1415
+ }
1416
+
1417
+ // Append task links to message content (markdown format for frontend)
1418
+ if (createdTaskIds.length > 0) {
1419
+ const taskLinks = createdTaskIds
1420
+ .map((taskId) => `[#${taskId.slice(0, 8)}](task:${taskId})`)
1421
+ .join(" ");
1422
+ const updatedContent = `${messageContent}\n\n→ Created: ${taskLinks}`;
1423
+ getDb()
1424
+ .prepare(`UPDATE channel_messages SET content = ? WHERE id = ?`)
1425
+ .run(updatedContent, id);
1426
+ }
1427
+ }
1428
+
1429
+ // Get agent name for the response - re-fetch to get updated content
1430
+ const agent = agentId ? getAgentById(agentId) : null;
1431
+ const updatedRow = getDb()
1432
+ .prepare<ChannelMessageRow, [string]>(
1433
+ `SELECT m.*, a.name as agentName FROM channel_messages m
1434
+ LEFT JOIN agents a ON m.agentId = a.id WHERE m.id = ?`,
1435
+ )
1436
+ .get(id);
1437
+ return rowToChannelMessage(updatedRow ?? row, agent?.name);
1438
+ }
1439
+
1440
+ export function getChannelMessages(
1441
+ channelId: string,
1442
+ options?: {
1443
+ limit?: number;
1444
+ since?: string;
1445
+ before?: string;
1446
+ },
1447
+ ): ChannelMessage[] {
1448
+ let query =
1449
+ "SELECT m.*, a.name as agentName FROM channel_messages m LEFT JOIN agents a ON m.agentId = a.id WHERE m.channelId = ?";
1450
+ const params: (string | number)[] = [channelId];
1451
+
1452
+ if (options?.since) {
1453
+ query += " AND m.createdAt > ?";
1454
+ params.push(options.since);
1455
+ }
1456
+
1457
+ if (options?.before) {
1458
+ query += " AND m.createdAt < ?";
1459
+ params.push(options.before);
1460
+ }
1461
+
1462
+ query += " ORDER BY m.createdAt DESC";
1463
+
1464
+ if (options?.limit) {
1465
+ query += " LIMIT ?";
1466
+ params.push(options.limit);
1467
+ }
1468
+
1469
+ type MessageWithAgentRow = ChannelMessageRow & { agentName: string | null };
1470
+
1471
+ return getDb()
1472
+ .prepare<MessageWithAgentRow, (string | number)[]>(query)
1473
+ .all(...params)
1474
+ .map((row) => rowToChannelMessage(row, row.agentName ?? undefined))
1475
+ .reverse(); // Return in chronological order
1476
+ }
1477
+
1478
+ export function updateReadState(agentId: string, channelId: string): void {
1479
+ const now = new Date().toISOString();
1480
+ getDb().run(
1481
+ `INSERT INTO channel_read_state (agentId, channelId, lastReadAt)
1482
+ VALUES (?, ?, ?)
1483
+ ON CONFLICT(agentId, channelId) DO UPDATE SET lastReadAt = ?`,
1484
+ [agentId, channelId, now, now],
1485
+ );
1486
+ }
1487
+
1488
+ export function getLastReadAt(agentId: string, channelId: string): string | null {
1489
+ const result = getDb()
1490
+ .prepare<{ lastReadAt: string }, [string, string]>(
1491
+ "SELECT lastReadAt FROM channel_read_state WHERE agentId = ? AND channelId = ?",
1492
+ )
1493
+ .get(agentId, channelId);
1494
+ return result?.lastReadAt ?? null;
1495
+ }
1496
+
1497
+ export function getUnreadMessages(agentId: string, channelId: string): ChannelMessage[] {
1498
+ const lastReadAt = getLastReadAt(agentId, channelId);
1499
+
1500
+ let query = `SELECT m.*, a.name as agentName FROM channel_messages m
1501
+ LEFT JOIN agents a ON m.agentId = a.id
1502
+ WHERE m.channelId = ?`;
1503
+ const params: string[] = [channelId];
1504
+
1505
+ if (lastReadAt) {
1506
+ query += " AND m.createdAt > ?";
1507
+ params.push(lastReadAt);
1508
+ }
1509
+
1510
+ query += " ORDER BY m.createdAt ASC";
1511
+
1512
+ type MessageWithAgentRow = ChannelMessageRow & { agentName: string | null };
1513
+
1514
+ return getDb()
1515
+ .prepare<MessageWithAgentRow, string[]>(query)
1516
+ .all(...params)
1517
+ .map((row) => rowToChannelMessage(row, row.agentName ?? undefined));
1518
+ }
1519
+
1520
+ export function getMentionsForAgent(
1521
+ agentId: string,
1522
+ options?: { unreadOnly?: boolean; channelId?: string },
1523
+ ): ChannelMessage[] {
1524
+ let query = `SELECT m.*, a.name as agentName FROM channel_messages m
1525
+ LEFT JOIN agents a ON m.agentId = a.id
1526
+ WHERE m.mentions LIKE ?`;
1527
+ const params: string[] = [`%"${agentId}"%`];
1528
+
1529
+ if (options?.channelId) {
1530
+ query += " AND m.channelId = ?";
1531
+ params.push(options.channelId);
1532
+
1533
+ if (options?.unreadOnly) {
1534
+ const lastReadAt = getLastReadAt(agentId, options.channelId);
1535
+ if (lastReadAt) {
1536
+ query += " AND m.createdAt > ?";
1537
+ params.push(lastReadAt);
1538
+ }
1539
+ }
1540
+ }
1541
+
1542
+ query += " ORDER BY m.createdAt DESC LIMIT 50";
1543
+
1544
+ type MessageWithAgentRow = ChannelMessageRow & { agentName: string | null };
1545
+
1546
+ return getDb()
1547
+ .prepare<MessageWithAgentRow, string[]>(query)
1548
+ .all(...params)
1549
+ .map((row) => rowToChannelMessage(row, row.agentName ?? undefined));
1550
+ }
1551
+
1552
+ // ============================================================================
1553
+ // Inbox Summary (for system tray)
1554
+ // ============================================================================
1555
+
1556
+ export interface MentionPreview {
1557
+ channelName: string;
1558
+ agentName: string;
1559
+ content: string;
1560
+ createdAt: string;
1561
+ }
1562
+
1563
+ export interface InboxSummary {
1564
+ unreadCount: number;
1565
+ mentionsCount: number;
1566
+ offeredTasksCount: number;
1567
+ poolTasksCount: number;
1568
+ inProgressCount: number;
1569
+ recentMentions: MentionPreview[]; // Up to 3 recent @mentions
1570
+ }
1571
+
1572
+ export function getInboxSummary(agentId: string): InboxSummary {
1573
+ const db = getDb();
1574
+ const channels = getAllChannels();
1575
+ let unreadCount = 0;
1576
+ let mentionsCount = 0;
1577
+
1578
+ for (const channel of channels) {
1579
+ const lastReadAt = getLastReadAt(agentId, channel.id);
1580
+ const baseCondition = lastReadAt ? `AND m.createdAt > '${lastReadAt}'` : "";
1581
+
1582
+ // Count unread (excluding own messages)
1583
+ const channelUnread = db
1584
+ .prepare<{ count: number }, [string]>(
1585
+ `SELECT COUNT(*) as count FROM channel_messages m
1586
+ WHERE m.channelId = ? AND (m.agentId != '${agentId}' OR m.agentId IS NULL) ${baseCondition}`,
1587
+ )
1588
+ .get(channel.id);
1589
+ unreadCount += channelUnread?.count ?? 0;
1590
+
1591
+ // Count mentions in unread
1592
+ const channelMentions = db
1593
+ .prepare<{ count: number }, [string, string]>(
1594
+ `SELECT COUNT(*) as count FROM channel_messages m
1595
+ WHERE m.channelId = ? AND m.mentions LIKE ? ${baseCondition}`,
1596
+ )
1597
+ .get(channel.id, `%"${agentId}"%`);
1598
+ mentionsCount += channelMentions?.count ?? 0;
1599
+ }
1600
+
1601
+ // Count offered tasks for this agent
1602
+ const offeredResult = db
1603
+ .prepare<{ count: number }, [string]>(
1604
+ "SELECT COUNT(*) as count FROM agent_tasks WHERE offeredTo = ? AND status = 'offered'",
1605
+ )
1606
+ .get(agentId);
1607
+
1608
+ // Count unassigned tasks in pool
1609
+ const poolResult = db
1610
+ .prepare<{ count: number }, []>(
1611
+ "SELECT COUNT(*) as count FROM agent_tasks WHERE status = 'unassigned'",
1612
+ )
1613
+ .get();
1614
+
1615
+ // Count my in-progress tasks
1616
+ const inProgressResult = db
1617
+ .prepare<{ count: number }, [string]>(
1618
+ "SELECT COUNT(*) as count FROM agent_tasks WHERE agentId = ? AND status = 'in_progress'",
1619
+ )
1620
+ .get(agentId);
1621
+
1622
+ // Get recent unread @mentions (up to 3)
1623
+ const recentMentions: MentionPreview[] = [];
1624
+ const mentionMessages = getMentionsForAgent(agentId, { unreadOnly: false });
1625
+
1626
+ // Filter to only unread mentions and limit to 3
1627
+ for (const msg of mentionMessages) {
1628
+ if (recentMentions.length >= 3) break;
1629
+
1630
+ // Check if message is unread (by checking against read state per channel)
1631
+ const lastReadAt = getLastReadAt(agentId, msg.channelId);
1632
+ if (lastReadAt && new Date(msg.createdAt) <= new Date(lastReadAt)) {
1633
+ continue; // Already read
1634
+ }
1635
+
1636
+ // Get channel name
1637
+ const channel = getChannelById(msg.channelId);
1638
+
1639
+ recentMentions.push({
1640
+ channelName: channel?.name ?? "unknown",
1641
+ agentName: msg.agentName ?? "Unknown",
1642
+ content: msg.content.length > 100 ? `${msg.content.slice(0, 100)}...` : msg.content,
1643
+ createdAt: msg.createdAt,
1644
+ });
1645
+ }
1646
+
1647
+ return {
1648
+ unreadCount,
1649
+ mentionsCount,
1650
+ offeredTasksCount: offeredResult?.count ?? 0,
1651
+ poolTasksCount: poolResult?.count ?? 0,
1652
+ inProgressCount: inProgressResult?.count ?? 0,
1653
+ recentMentions,
1654
+ };
1655
+ }
1656
+
1657
+ // ============================================================================
1658
+ // Service Operations (PM2/background services)
1659
+ // ============================================================================
1660
+
1661
+ type ServiceRow = {
1662
+ id: string;
1663
+ agentId: string;
1664
+ name: string;
1665
+ port: number;
1666
+ description: string | null;
1667
+ url: string | null;
1668
+ healthCheckPath: string | null;
1669
+ status: ServiceStatus;
1670
+ // PM2 configuration
1671
+ script: string;
1672
+ cwd: string | null;
1673
+ interpreter: string | null;
1674
+ args: string | null; // JSON array
1675
+ env: string | null; // JSON object
1676
+ metadata: string | null;
1677
+ createdAt: string;
1678
+ lastUpdatedAt: string;
1679
+ };
1680
+
1681
+ function rowToService(row: ServiceRow): Service {
1682
+ return {
1683
+ id: row.id,
1684
+ agentId: row.agentId,
1685
+ name: row.name,
1686
+ port: row.port,
1687
+ description: row.description ?? undefined,
1688
+ url: row.url ?? undefined,
1689
+ healthCheckPath: row.healthCheckPath ?? "/health",
1690
+ status: row.status,
1691
+ // PM2 configuration
1692
+ script: row.script,
1693
+ cwd: row.cwd ?? undefined,
1694
+ interpreter: row.interpreter ?? undefined,
1695
+ args: row.args ? JSON.parse(row.args) : undefined,
1696
+ env: row.env ? JSON.parse(row.env) : undefined,
1697
+ metadata: row.metadata ? JSON.parse(row.metadata) : {},
1698
+ createdAt: row.createdAt,
1699
+ lastUpdatedAt: row.lastUpdatedAt,
1700
+ };
1701
+ }
1702
+
1703
+ export interface CreateServiceOptions {
1704
+ port?: number;
1705
+ description?: string;
1706
+ url?: string;
1707
+ healthCheckPath?: string;
1708
+ // PM2 configuration
1709
+ script: string; // Required
1710
+ cwd?: string;
1711
+ interpreter?: string;
1712
+ args?: string[];
1713
+ env?: Record<string, string>;
1714
+ metadata?: Record<string, unknown>;
1715
+ }
1716
+
1717
+ export function createService(
1718
+ agentId: string,
1719
+ name: string,
1720
+ options: CreateServiceOptions,
1721
+ ): Service {
1722
+ const id = crypto.randomUUID();
1723
+ const now = new Date().toISOString();
1724
+
1725
+ const row = getDb()
1726
+ .prepare<ServiceRow, (string | number | null)[]>(
1727
+ `INSERT INTO services (id, agentId, name, port, description, url, healthCheckPath, status, script, cwd, interpreter, args, env, metadata, createdAt, lastUpdatedAt)
1728
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
1729
+ )
1730
+ .get(
1731
+ id,
1732
+ agentId,
1733
+ name,
1734
+ options.port ?? 3000,
1735
+ options.description ?? null,
1736
+ options.url ?? null,
1737
+ options.healthCheckPath ?? "/health",
1738
+ options.script,
1739
+ options.cwd ?? null,
1740
+ options.interpreter ?? null,
1741
+ options.args ? JSON.stringify(options.args) : null,
1742
+ options.env ? JSON.stringify(options.env) : null,
1743
+ JSON.stringify(options.metadata ?? {}),
1744
+ now,
1745
+ now,
1746
+ );
1747
+
1748
+ if (!row) throw new Error("Failed to create service");
1749
+
1750
+ try {
1751
+ createLogEntry({
1752
+ eventType: "service_registered",
1753
+ agentId,
1754
+ newValue: name,
1755
+ metadata: { serviceId: id, port: options?.port ?? 3000 },
1756
+ });
1757
+ } catch {}
1758
+
1759
+ return rowToService(row);
1760
+ }
1761
+
1762
+ export function getServiceById(id: string): Service | null {
1763
+ const row = getDb().prepare<ServiceRow, [string]>("SELECT * FROM services WHERE id = ?").get(id);
1764
+ return row ? rowToService(row) : null;
1765
+ }
1766
+
1767
+ export function getServiceByAgentAndName(agentId: string, name: string): Service | null {
1768
+ const row = getDb()
1769
+ .prepare<ServiceRow, [string, string]>("SELECT * FROM services WHERE agentId = ? AND name = ?")
1770
+ .get(agentId, name);
1771
+ return row ? rowToService(row) : null;
1772
+ }
1773
+
1774
+ export function getServicesByAgentId(agentId: string): Service[] {
1775
+ return getDb()
1776
+ .prepare<ServiceRow, [string]>("SELECT * FROM services WHERE agentId = ? ORDER BY name")
1777
+ .all(agentId)
1778
+ .map(rowToService);
1779
+ }
1780
+
1781
+ export interface ServiceFilters {
1782
+ agentId?: string;
1783
+ name?: string;
1784
+ status?: ServiceStatus;
1785
+ }
1786
+
1787
+ export function getAllServices(filters?: ServiceFilters): Service[] {
1788
+ const conditions: string[] = [];
1789
+ const params: string[] = [];
1790
+
1791
+ if (filters?.agentId) {
1792
+ conditions.push("agentId = ?");
1793
+ params.push(filters.agentId);
1794
+ }
1795
+
1796
+ if (filters?.name) {
1797
+ conditions.push("name LIKE ?");
1798
+ params.push(`%${filters.name}%`);
1799
+ }
1800
+
1801
+ if (filters?.status) {
1802
+ conditions.push("status = ?");
1803
+ params.push(filters.status);
1804
+ }
1805
+
1806
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1807
+ const query = `SELECT * FROM services ${whereClause} ORDER BY
1808
+ CASE status
1809
+ WHEN 'healthy' THEN 1
1810
+ WHEN 'starting' THEN 2
1811
+ WHEN 'unhealthy' THEN 3
1812
+ WHEN 'stopped' THEN 4
1813
+ END, name`;
1814
+
1815
+ return getDb()
1816
+ .prepare<ServiceRow, string[]>(query)
1817
+ .all(...params)
1818
+ .map(rowToService);
1819
+ }
1820
+
1821
+ export function updateServiceStatus(id: string, status: ServiceStatus): Service | null {
1822
+ const oldService = getServiceById(id);
1823
+ if (!oldService) return null;
1824
+
1825
+ const now = new Date().toISOString();
1826
+ const row = getDb()
1827
+ .prepare<ServiceRow, [ServiceStatus, string, string]>(
1828
+ `UPDATE services SET status = ?, lastUpdatedAt = ? WHERE id = ? RETURNING *`,
1829
+ )
1830
+ .get(status, now, id);
1831
+
1832
+ if (row && oldService.status !== status) {
1833
+ try {
1834
+ createLogEntry({
1835
+ eventType: "service_status_change",
1836
+ agentId: oldService.agentId,
1837
+ oldValue: oldService.status,
1838
+ newValue: status,
1839
+ metadata: { serviceId: id, serviceName: oldService.name },
1840
+ });
1841
+ } catch {}
1842
+ }
1843
+
1844
+ return row ? rowToService(row) : null;
1845
+ }
1846
+
1847
+ export function deleteService(id: string): boolean {
1848
+ const service = getServiceById(id);
1849
+ if (service) {
1850
+ try {
1851
+ createLogEntry({
1852
+ eventType: "service_unregistered",
1853
+ agentId: service.agentId,
1854
+ oldValue: service.name,
1855
+ metadata: { serviceId: id },
1856
+ });
1857
+ } catch {}
1858
+ }
1859
+
1860
+ const result = getDb().run("DELETE FROM services WHERE id = ?", [id]);
1861
+ return result.changes > 0;
1862
+ }
1863
+
1864
+ /** Upsert a service - update if exists (by agentId + name), create if not */
1865
+ export function upsertService(
1866
+ agentId: string,
1867
+ name: string,
1868
+ options: CreateServiceOptions,
1869
+ ): Service {
1870
+ const existing = getServiceByAgentAndName(agentId, name);
1871
+
1872
+ if (existing) {
1873
+ // Update existing service
1874
+ const now = new Date().toISOString();
1875
+ const row = getDb()
1876
+ .prepare<ServiceRow, (string | number | null)[]>(
1877
+ `UPDATE services SET
1878
+ port = ?, description = ?, url = ?, healthCheckPath = ?,
1879
+ script = ?, cwd = ?, interpreter = ?, args = ?, env = ?,
1880
+ metadata = ?, lastUpdatedAt = ?
1881
+ WHERE id = ? RETURNING *`,
1882
+ )
1883
+ .get(
1884
+ options.port ?? existing.port,
1885
+ options.description ?? existing.description ?? null,
1886
+ options.url ?? existing.url ?? null,
1887
+ options.healthCheckPath ?? existing.healthCheckPath ?? "/health",
1888
+ options.script,
1889
+ options.cwd ?? null,
1890
+ options.interpreter ?? null,
1891
+ options.args ? JSON.stringify(options.args) : null,
1892
+ options.env ? JSON.stringify(options.env) : null,
1893
+ JSON.stringify(options.metadata ?? existing.metadata ?? {}),
1894
+ now,
1895
+ existing.id,
1896
+ );
1897
+
1898
+ if (!row) throw new Error("Failed to update service");
1899
+ return rowToService(row);
1900
+ }
1901
+
1902
+ // Create new service
1903
+ return createService(agentId, name, options);
1904
+ }
1905
+
1906
+ export function deleteServicesByAgentId(agentId: string): number {
1907
+ const services = getServicesByAgentId(agentId);
1908
+ for (const service of services) {
1909
+ try {
1910
+ createLogEntry({
1911
+ eventType: "service_unregistered",
1912
+ agentId,
1913
+ oldValue: service.name,
1914
+ metadata: { serviceId: service.id },
1915
+ });
1916
+ } catch {}
1917
+ }
1918
+
1919
+ const result = getDb().run("DELETE FROM services WHERE agentId = ?", [agentId]);
1920
+ return result.changes;
1921
+ }