agent-relay-server 0.42.0 → 0.44.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 (97) hide show
  1. package/docs/openapi.json +85 -1
  2. package/package.json +2 -2
  3. package/public/assets/{activity-G6l1nOLj.js → activity-Dkzmef0Z.js} +2 -2
  4. package/public/assets/{activity-G6l1nOLj.js.map → activity-Dkzmef0Z.js.map} +1 -1
  5. package/public/assets/{agent-profiles-BWKpVoNw.js → agent-profiles-DQYHMAyu.js} +2 -2
  6. package/public/assets/{agent-profiles-BWKpVoNw.js.map → agent-profiles-DQYHMAyu.js.map} +1 -1
  7. package/public/assets/{agents-DBuCRS5A.js → agents-atr6HZ1e.js} +2 -2
  8. package/public/assets/{agents-DBuCRS5A.js.map → agents-atr6HZ1e.js.map} +1 -1
  9. package/public/assets/{analytics-BhwMtd0_.js → analytics-azFfAy4R.js} +2 -2
  10. package/public/assets/{analytics-BhwMtd0_.js.map → analytics-azFfAy4R.js.map} +1 -1
  11. package/public/assets/{automation-B7jnsYhH.js → automation-Bk4umHIm.js} +2 -2
  12. package/public/assets/{automation-B7jnsYhH.js.map → automation-Bk4umHIm.js.map} +1 -1
  13. package/public/assets/{branch-state-badge-dN2mQ2gs.js → branch-state-badge-DymObp8d.js} +2 -2
  14. package/public/assets/{branch-state-badge-dN2mQ2gs.js.map → branch-state-badge-DymObp8d.js.map} +1 -1
  15. package/public/assets/channels-B_0wDy0s.js +2 -0
  16. package/public/assets/{channels-CQy-JL7o.js.map → channels-B_0wDy0s.js.map} +1 -1
  17. package/public/assets/chat-DxV4MyYF.js +2 -0
  18. package/public/assets/chat-DxV4MyYF.js.map +1 -0
  19. package/public/assets/{connectors-ByNxzq2k.js → connectors-DLqYF2_3.js} +2 -2
  20. package/public/assets/{connectors-ByNxzq2k.js.map → connectors-DLqYF2_3.js.map} +1 -1
  21. package/public/assets/display-ConJ9cJB.js.map +1 -1
  22. package/public/assets/{formatted-body-impl-BXuR2gfL.js → formatted-body-impl-LK0QcFLC.js} +2 -2
  23. package/public/assets/{formatted-body-impl-BXuR2gfL.js.map → formatted-body-impl-LK0QcFLC.js.map} +1 -1
  24. package/public/assets/{index-CAnJGSBl.js → index-CT10XH5Y.js} +8 -8
  25. package/public/assets/{index-CAnJGSBl.js.map → index-CT10XH5Y.js.map} +1 -1
  26. package/public/assets/index-Ryvn1efN.css +2 -0
  27. package/public/assets/{integrations-BBmL-PnQ.js → integrations-CdeQuRSQ.js} +2 -2
  28. package/public/assets/{integrations-BBmL-PnQ.js.map → integrations-CdeQuRSQ.js.map} +1 -1
  29. package/public/assets/{maintenance-B1bX9ety.js → maintenance-DbqSE_ps.js} +2 -2
  30. package/public/assets/{maintenance-B1bX9ety.js.map → maintenance-DbqSE_ps.js.map} +1 -1
  31. package/public/assets/{managed-agents-D12-nGKd.js → managed-agents-BxhRiBVn.js} +2 -2
  32. package/public/assets/{managed-agents-D12-nGKd.js.map → managed-agents-BxhRiBVn.js.map} +1 -1
  33. package/public/assets/{markdown-preview-impl-BWd3swt8.js → markdown-preview-impl-Dz2Spmat.js} +2 -2
  34. package/public/assets/{markdown-preview-impl-BWd3swt8.js.map → markdown-preview-impl-Dz2Spmat.js.map} +1 -1
  35. package/public/assets/{memory-4ERBFJBZ.js → memory-BTjUKTyA.js} +2 -2
  36. package/public/assets/{memory-4ERBFJBZ.js.map → memory-BTjUKTyA.js.map} +1 -1
  37. package/public/assets/{messages-B3ZES72c.js → messages-C8z05Y3O.js} +2 -2
  38. package/public/assets/{messages-B3ZES72c.js.map → messages-C8z05Y3O.js.map} +1 -1
  39. package/public/assets/{orchestrators-qKPntM-a.js → orchestrators-DQqtYryc.js} +2 -2
  40. package/public/assets/{orchestrators-qKPntM-a.js.map → orchestrators-DQqtYryc.js.map} +1 -1
  41. package/public/assets/{overview-ekmeyPaO.js → overview-CGYu6qQr.js} +2 -2
  42. package/public/assets/{overview-ekmeyPaO.js.map → overview-CGYu6qQr.js.map} +1 -1
  43. package/public/assets/pairs-BgqXzCto.js +2 -0
  44. package/public/assets/{pairs-B2QdTF0M.js.map → pairs-BgqXzCto.js.map} +1 -1
  45. package/public/assets/{security-WkMkEhb7.js → security-BqfRVw-p.js} +2 -2
  46. package/public/assets/{security-WkMkEhb7.js.map → security-BqfRVw-p.js.map} +1 -1
  47. package/public/assets/{settings-C5aXX3od.js → settings-63whklIq.js} +3 -3
  48. package/public/assets/{settings-C5aXX3od.js.map → settings-63whklIq.js.map} +1 -1
  49. package/public/assets/store-B6qT5_de.js +9 -0
  50. package/public/assets/store-B6qT5_de.js.map +1 -0
  51. package/public/assets/tasks-DiZ_4TYW.js +2 -0
  52. package/public/assets/{tasks-ppuVeXkO.js.map → tasks-DiZ_4TYW.js.map} +1 -1
  53. package/public/assets/{terminal-viewer-impl-DXycIJKe.js → terminal-viewer-impl-D3T1F77z.js} +2 -2
  54. package/public/assets/{terminal-viewer-impl-DXycIJKe.js.map → terminal-viewer-impl-D3T1F77z.js.map} +1 -1
  55. package/public/assets/work-queue-C1p2Hybt.js +2 -0
  56. package/public/assets/{work-queue-C8UzeekT.js.map → work-queue-C1p2Hybt.js.map} +1 -1
  57. package/public/assets/{workspaces-BD1P6J21.js → workspaces-DqVr0uN7.js} +2 -2
  58. package/public/assets/{workspaces-BD1P6J21.js.map → workspaces-DqVr0uN7.js.map} +1 -1
  59. package/public/index.html +3 -3
  60. package/src/agent-ref.ts +7 -4
  61. package/src/channel-target.ts +1 -0
  62. package/src/db/agent-search.ts +6 -0
  63. package/src/db/agents.ts +11 -2
  64. package/src/db/channels.ts +2 -1
  65. package/src/db/delivery.ts +4 -1
  66. package/src/db/index.ts +1 -0
  67. package/src/db/mappers.ts +1 -1
  68. package/src/db/message-reads.ts +10 -1
  69. package/src/db/migrations.ts +54 -2
  70. package/src/db/plans.ts +17 -2
  71. package/src/db/schema.ts +24 -3
  72. package/src/db/teams.ts +151 -0
  73. package/src/mcp-plan-tools.ts +7 -3
  74. package/src/mcp-team-tools.ts +87 -0
  75. package/src/mcp.ts +6 -1
  76. package/src/routes/index.ts +4 -0
  77. package/src/routes/integrations.ts +2 -1
  78. package/src/routes/messages.ts +1 -1
  79. package/src/routes/plans.ts +9 -3
  80. package/src/routes/teams.ts +60 -0
  81. package/src/runtime-tokens.ts +9 -3
  82. package/src/security.ts +7 -1
  83. package/src/services/plan-graph.ts +17 -3
  84. package/src/services/register-agent.ts +3 -1
  85. package/src/services/spawn-agent.ts +4 -1
  86. package/src/services/team.ts +69 -0
  87. package/src/token-db.ts +3 -3
  88. package/src/validation.ts +2 -0
  89. package/public/assets/channels-CQy-JL7o.js +0 -2
  90. package/public/assets/chat-KaK5_x65.js +0 -2
  91. package/public/assets/chat-KaK5_x65.js.map +0 -1
  92. package/public/assets/index-DzRKGPXS.css +0 -2
  93. package/public/assets/pairs-B2QdTF0M.js +0 -2
  94. package/public/assets/store-CtKmqMfx.js +0 -9
  95. package/public/assets/store-CtKmqMfx.js.map +0 -1
  96. package/public/assets/tasks-ppuVeXkO.js +0 -2
  97. package/public/assets/work-queue-C8UzeekT.js +0 -2
@@ -174,6 +174,10 @@ export function matchingDeliveryAgents(target: string): AgentCard[] {
174
174
  const label = target.slice(6);
175
175
  return candidates.filter((agent) => agent.label === label);
176
176
  }
177
+ if (target.startsWith("team:")) {
178
+ const teamId = target.slice(5);
179
+ return candidates.filter((agent) => agent.teamId === teamId);
180
+ }
177
181
  return [];
178
182
  }
179
183
 
@@ -392,4 +396,3 @@ export function expireQueuedMessages(now: number = Date.now()): Message[] {
392
396
  })();
393
397
  return ids.map((id) => getMessage(id)).filter((message): message is Message => Boolean(message));
394
398
  }
395
-
package/src/db/index.ts CHANGED
@@ -24,3 +24,4 @@ export * from "./merge-lease.ts";
24
24
  export * from "./continuations.ts";
25
25
  export * from "./continuation-archives.ts";
26
26
  export * from "./plans.ts";
27
+ export * from "./teams.ts";
package/src/db/mappers.ts CHANGED
@@ -130,6 +130,7 @@ export function rowToAgent(row: any): AgentCard {
130
130
  context: parseJson<ContextState | undefined>(row.context_state, undefined),
131
131
  meta: parseJson(row.meta, {}),
132
132
  spawnedBy: row.spawned_by ?? undefined,
133
+ teamId: row.team_id ?? undefined,
133
134
  lastSeen: row.last_seen,
134
135
  createdAt: row.created_at,
135
136
  };
@@ -394,4 +395,3 @@ export const MSG_SELECT = `SELECT m.*, (
394
395
  ) AS reactions_json FROM messages m`;
395
396
 
396
397
  // --- Agents ---
397
-
@@ -162,6 +162,7 @@ export function pollMessages(query: PollQuery): Message[] {
162
162
  const agentTags = agent?.tags ?? [];
163
163
  const agentCaps = agent?.capabilities ?? [];
164
164
  const agentLabel = agent?.label;
165
+ const agentTeamId = agent?.teamId;
165
166
  const agentLegacyChannelTargets = legacyChannelTargets(agent);
166
167
 
167
168
  const conditions: string[] = [];
@@ -186,11 +187,19 @@ export function pollMessages(query: PollQuery): Message[] {
186
187
  targetClauses.push("to_target = ?");
187
188
  params.push(`label:${agentLabel}`);
188
189
  }
190
+ if (agentTeamId) {
191
+ targetClauses.push("to_target = ?");
192
+ params.push(`team:${agentTeamId}`);
193
+ }
189
194
  for (const target of agentLegacyChannelTargets) {
190
195
  targetClauses.push("to_target = ?");
191
196
  params.push(target);
192
197
  }
193
198
  conditions.push(`(${targetClauses.join(" OR ")})`);
199
+ if (agentTeamId) {
200
+ conditions.push("(from_agent != ? OR to_target != ?)");
201
+ params.push(query.for, `team:${agentTeamId}`);
202
+ }
194
203
  conditions.push("delivery_status != 'queued' AND delivery_status NOT IN ('failed', 'dead')");
195
204
 
196
205
  // Hide active claims held by someone else, but let expired claims surface so
@@ -323,7 +332,7 @@ export function markRead(messageId: number, agentId: string): boolean {
323
332
  delivery_next_retry_at = NULL,
324
333
  delivery_poison_reason = NULL,
325
334
  delivery_updated_at = ?
326
- WHERE id = ? AND (to_target = ? OR resolved_to_agent = ? OR to_target = 'broadcast' OR to_target LIKE 'tag:%' OR to_target LIKE 'cap:%' OR to_target LIKE 'label:%')
335
+ WHERE id = ? AND (to_target = ? OR resolved_to_agent = ? OR to_target = 'broadcast' OR to_target LIKE 'tag:%' OR to_target LIKE 'cap:%' OR to_target LIKE 'label:%' OR to_target LIKE 'team:%')
327
336
  `).run(Date.now(), messageId, agentId, agentId);
328
337
  return true;
329
338
  }
@@ -279,22 +279,70 @@ export function applyMigrations(): void {
279
279
  // which runs per persisted session step. Added here (not the base schema) because the column
280
280
  // is migration-added for pre-hardening DBs. Without it that lookup full-scans messages.
281
281
  getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key)");
282
+ getDb().run(`
283
+ CREATE TABLE IF NOT EXISTS teams (
284
+ id TEXT PRIMARY KEY,
285
+ coordinator_id TEXT,
286
+ root_coordinator_id TEXT,
287
+ title TEXT,
288
+ brief TEXT,
289
+ definition_of_done TEXT,
290
+ status TEXT NOT NULL DEFAULT 'active',
291
+ outcome_status TEXT,
292
+ outcome_summary TEXT,
293
+ composition TEXT,
294
+ cost_tokens INTEGER,
295
+ cost_usd REAL,
296
+ wall_clock_ms INTEGER,
297
+ lands_clean INTEGER,
298
+ created_at INTEGER NOT NULL,
299
+ updated_at INTEGER NOT NULL,
300
+ dissolved_at INTEGER
301
+ )
302
+ `);
303
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_teams_coordinator ON teams(coordinator_id)");
304
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_teams_status ON teams(status)");
282
305
  getDb().run(`
283
306
  CREATE TABLE IF NOT EXISTS plans (
284
- id TEXT PRIMARY KEY, thread_id INTEGER NOT NULL, channel TEXT, version INTEGER NOT NULL DEFAULT 1,
307
+ id TEXT PRIMARY KEY, thread_id INTEGER NOT NULL, team_id TEXT, channel TEXT, version INTEGER NOT NULL DEFAULT 1,
285
308
  title TEXT, objective TEXT, nodes TEXT NOT NULL DEFAULT '[]', edges TEXT NOT NULL DEFAULT '[]',
286
309
  created_by TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
287
310
  )
288
311
  `);
312
+ const planCols = getDb().query("PRAGMA table_info(plans)").all() as any[];
313
+ const planColNames = planCols.map((c: any) => c.name);
314
+ if (!planColNames.includes("team_id")) {
315
+ getDb().run("ALTER TABLE plans ADD COLUMN team_id TEXT");
316
+ }
289
317
  getDb().run("CREATE UNIQUE INDEX IF NOT EXISTS idx_plans_thread_channel ON plans(thread_id, coalesce(channel, ''))");
290
318
  getDb().run("CREATE INDEX IF NOT EXISTS idx_plans_updated ON plans(updated_at)");
319
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_plans_team ON plans(team_id)");
291
320
  getDb().run(`
292
321
  CREATE TABLE IF NOT EXISTS plan_versions (
293
- id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
322
+ id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id TEXT NOT NULL,
294
323
  version INTEGER NOT NULL, title TEXT, objective TEXT, nodes TEXT NOT NULL, edges TEXT NOT NULL,
295
324
  changed_by TEXT, changed_at INTEGER NOT NULL
296
325
  )
297
326
  `);
327
+ const planVersionFks = getDb().query("PRAGMA foreign_key_list(plan_versions)").all() as any[];
328
+ if (planVersionFks.some((fk) => fk.table === "plans" && String(fk.on_delete).toUpperCase() === "CASCADE")) {
329
+ getDb().transaction(() => {
330
+ getDb().run("ALTER TABLE plan_versions RENAME TO plan_versions_old");
331
+ getDb().run(`
332
+ CREATE TABLE plan_versions (
333
+ id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id TEXT NOT NULL,
334
+ version INTEGER NOT NULL, title TEXT, objective TEXT, nodes TEXT NOT NULL, edges TEXT NOT NULL,
335
+ changed_by TEXT, changed_at INTEGER NOT NULL
336
+ )
337
+ `);
338
+ getDb().run(`
339
+ INSERT INTO plan_versions (id, plan_id, version, title, objective, nodes, edges, changed_by, changed_at)
340
+ SELECT id, plan_id, version, title, objective, nodes, edges, changed_by, changed_at
341
+ FROM plan_versions_old
342
+ `);
343
+ getDb().run("DROP TABLE plan_versions_old");
344
+ })();
345
+ }
298
346
  getDb().run("CREATE INDEX IF NOT EXISTS idx_plan_versions_plan ON plan_versions(plan_id, version DESC)");
299
347
 
300
348
  // Backfill thread_id for pre-migration rows (self-threaded).
@@ -372,6 +420,10 @@ export function applyMigrations(): void {
372
420
  if (!agentColNames.includes("spawned_by")) {
373
421
  getDb().run("ALTER TABLE agents ADD COLUMN spawned_by TEXT");
374
422
  }
423
+ if (!agentColNames.includes("team_id")) {
424
+ getDb().run("ALTER TABLE agents ADD COLUMN team_id TEXT");
425
+ }
426
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_team ON agents(team_id)");
375
427
  getDb().run(`
376
428
  CREATE TABLE IF NOT EXISTS context_snapshots (
377
429
  id INTEGER PRIMARY KEY AUTOINCREMENT,
package/src/db/plans.ts CHANGED
@@ -6,6 +6,7 @@ import type { Plan, PlanEdge, PlanNode } from "../types";
6
6
  interface PlanRow {
7
7
  id: string;
8
8
  thread_id: number;
9
+ team_id: string | null;
9
10
  channel: string | null;
10
11
  version: number;
11
12
  title: string | null;
@@ -49,6 +50,7 @@ function rowToPlan(row: PlanRow): Plan {
49
50
  return {
50
51
  id: row.id,
51
52
  threadId: String(row.thread_id),
53
+ ...(row.team_id ? { teamId: row.team_id } : {}),
52
54
  ...(row.channel ? { channel: row.channel } : {}),
53
55
  version: row.version,
54
56
  ...(row.title ? { title: row.title } : {}),
@@ -82,6 +84,7 @@ export function planThreadExists(threadId: number): boolean {
82
84
  export function createPlanRow(input: {
83
85
  id?: string;
84
86
  threadId: number;
87
+ teamId?: string;
85
88
  channel?: string;
86
89
  title?: string;
87
90
  objective?: string;
@@ -93,11 +96,12 @@ export function createPlanRow(input: {
93
96
  const now = Date.now();
94
97
  try {
95
98
  getDb().query(`
96
- INSERT INTO plans (id, thread_id, channel, version, title, objective, nodes, edges, created_by, created_at, updated_at)
97
- VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
99
+ INSERT INTO plans (id, thread_id, team_id, channel, version, title, objective, nodes, edges, created_by, created_at, updated_at)
100
+ VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
98
101
  `).run(
99
102
  id,
100
103
  input.threadId,
104
+ input.teamId ?? null,
101
105
  input.channel ?? null,
102
106
  input.title ?? null,
103
107
  input.objective ?? null,
@@ -128,6 +132,17 @@ export function getPlanByThread(threadId: number, channel?: string): Plan | null
128
132
  return row ? rowToPlan(row) : null;
129
133
  }
130
134
 
135
+ export function getPlanByTeam(teamId: string, channel?: string): Plan | null {
136
+ const row = channel === undefined
137
+ ? getDb()
138
+ .query("SELECT * FROM plans WHERE team_id = ? ORDER BY updated_at DESC LIMIT 1")
139
+ .get(teamId) as PlanRow | undefined
140
+ : getDb()
141
+ .query("SELECT * FROM plans WHERE team_id = ? AND coalesce(channel, '') = ? ORDER BY updated_at DESC LIMIT 1")
142
+ .get(teamId, channel) as PlanRow | undefined;
143
+ return row ? rowToPlan(row) : null;
144
+ }
145
+
131
146
  export function replacePlan(input: Plan, changedBy?: string): Plan {
132
147
  const existing = getPlan(input.id);
133
148
  if (!existing) throw new ValidationError(`plan ${input.id} not found`);
package/src/db/schema.ts CHANGED
@@ -106,10 +106,10 @@ export function initDb(path: string = "agent-relay.db"): Database {
106
106
  context_state TEXT,
107
107
  meta TEXT NOT NULL DEFAULT '{}',
108
108
  spawned_by TEXT,
109
+ team_id TEXT,
109
110
  last_seen INTEGER NOT NULL,
110
111
  created_at INTEGER NOT NULL
111
112
  );
112
-
113
113
  CREATE TABLE IF NOT EXISTS messages (
114
114
  id INTEGER PRIMARY KEY AUTOINCREMENT,
115
115
  from_agent TEXT NOT NULL,
@@ -150,15 +150,36 @@ export function initDb(path: string = "agent-relay.db"): Database {
150
150
  -- per candidate row (O(n^2)): ~8.6s at 4k rows, which blew the 5s Stop-hook
151
151
  -- timeout and wedged turns in "busy" (#199).
152
152
  CREATE INDEX IF NOT EXISTS idx_msg_reply_to ON messages(reply_to, from_agent);
153
+ CREATE TABLE IF NOT EXISTS teams (
154
+ id TEXT PRIMARY KEY,
155
+ coordinator_id TEXT,
156
+ root_coordinator_id TEXT,
157
+ title TEXT,
158
+ brief TEXT,
159
+ definition_of_done TEXT,
160
+ status TEXT NOT NULL DEFAULT 'active',
161
+ outcome_status TEXT,
162
+ outcome_summary TEXT,
163
+ composition TEXT,
164
+ cost_tokens INTEGER,
165
+ cost_usd REAL,
166
+ wall_clock_ms INTEGER,
167
+ lands_clean INTEGER,
168
+ created_at INTEGER NOT NULL,
169
+ updated_at INTEGER NOT NULL,
170
+ dissolved_at INTEGER
171
+ );
172
+ CREATE INDEX IF NOT EXISTS idx_teams_coordinator ON teams(coordinator_id);
173
+ CREATE INDEX IF NOT EXISTS idx_teams_status ON teams(status);
153
174
  CREATE TABLE IF NOT EXISTS plans (
154
- id TEXT PRIMARY KEY, thread_id INTEGER NOT NULL, channel TEXT, version INTEGER NOT NULL DEFAULT 1,
175
+ id TEXT PRIMARY KEY, thread_id INTEGER NOT NULL, team_id TEXT, channel TEXT, version INTEGER NOT NULL DEFAULT 1,
155
176
  title TEXT, objective TEXT, nodes TEXT NOT NULL DEFAULT '[]', edges TEXT NOT NULL DEFAULT '[]',
156
177
  created_by TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
157
178
  );
158
179
  CREATE UNIQUE INDEX IF NOT EXISTS idx_plans_thread_channel ON plans(thread_id, coalesce(channel, ''));
159
180
  CREATE INDEX IF NOT EXISTS idx_plans_updated ON plans(updated_at);
160
181
  CREATE TABLE IF NOT EXISTS plan_versions (
161
- id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
182
+ id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id TEXT NOT NULL,
162
183
  version INTEGER NOT NULL, title TEXT, objective TEXT, nodes TEXT NOT NULL, edges TEXT NOT NULL,
163
184
  changed_by TEXT, changed_at INTEGER NOT NULL
164
185
  );
@@ -0,0 +1,151 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getAgent, setAgentTeam } from "./agents.ts";
3
+ import { getDb, ValidationError } from "./connection.ts";
4
+ import { rowToAgent } from "./mappers.ts";
5
+ import type { AgentCard, Team } from "../types";
6
+
7
+ interface TeamRow {
8
+ id: string;
9
+ coordinator_id: string | null;
10
+ root_coordinator_id: string | null;
11
+ title: string | null;
12
+ brief: string | null;
13
+ definition_of_done: string | null;
14
+ status: Team["status"];
15
+ outcome_status: Team["outcomeStatus"] | null;
16
+ outcome_summary: string | null;
17
+ composition: string | null;
18
+ cost_tokens: number | null;
19
+ cost_usd: number | null;
20
+ wall_clock_ms: number | null;
21
+ lands_clean: number | null;
22
+ created_at: number;
23
+ updated_at: number;
24
+ dissolved_at: number | null;
25
+ }
26
+
27
+ export interface TeamDissolveInput {
28
+ outcomeStatus?: Team["outcomeStatus"];
29
+ outcomeSummary?: string;
30
+ landsClean?: boolean;
31
+ }
32
+
33
+ export function teamId(): string {
34
+ return `team_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
35
+ }
36
+
37
+ function rowToTeam(row: TeamRow): Team {
38
+ return {
39
+ id: row.id,
40
+ ...(row.coordinator_id ? { coordinatorId: row.coordinator_id } : {}),
41
+ ...(row.root_coordinator_id ? { rootCoordinatorId: row.root_coordinator_id } : {}),
42
+ ...(row.title ? { title: row.title } : {}),
43
+ ...(row.brief ? { brief: row.brief } : {}),
44
+ ...(row.definition_of_done ? { definitionOfDone: row.definition_of_done } : {}),
45
+ status: row.status,
46
+ ...(row.outcome_status ? { outcomeStatus: row.outcome_status } : {}),
47
+ ...(row.outcome_summary ? { outcomeSummary: row.outcome_summary } : {}),
48
+ ...(row.composition ? { composition: row.composition } : {}),
49
+ ...(row.cost_tokens !== null ? { costTokens: row.cost_tokens } : {}),
50
+ ...(row.cost_usd !== null ? { costUsd: row.cost_usd } : {}),
51
+ ...(row.wall_clock_ms !== null ? { wallClockMs: row.wall_clock_ms } : {}),
52
+ ...(row.lands_clean !== null ? { landsClean: row.lands_clean === 1 } : {}),
53
+ createdAt: row.created_at,
54
+ updatedAt: row.updated_at,
55
+ ...(row.dissolved_at !== null ? { dissolvedAt: row.dissolved_at } : {}),
56
+ };
57
+ }
58
+
59
+ export function getTeam(id: string): Team | null {
60
+ const row = getDb().query("SELECT * FROM teams WHERE id = ?").get(id) as TeamRow | undefined;
61
+ return row ? rowToTeam(row) : null;
62
+ }
63
+
64
+ export function getTeamRoster(teamId: string): AgentCard[] {
65
+ const rows = getDb()
66
+ .query("SELECT * FROM agents WHERE team_id = ? ORDER BY created_at ASC, id ASC")
67
+ .all(teamId) as any[];
68
+ return rows.map(rowToAgent);
69
+ }
70
+
71
+ export function ensureTeam(coordinatorId: string): Team {
72
+ const coordinator = getAgent(coordinatorId);
73
+ if (!coordinator) throw new ValidationError(`coordinator agent ${coordinatorId} not found`);
74
+ if (coordinator.teamId) {
75
+ const existing = getTeam(coordinator.teamId);
76
+ if (existing) return existing;
77
+ }
78
+
79
+ const id = teamId();
80
+ const now = Date.now();
81
+ getDb().transaction(() => {
82
+ getDb().query(`
83
+ INSERT INTO teams (id, coordinator_id, root_coordinator_id, status, created_at, updated_at)
84
+ VALUES (?, ?, ?, 'active', ?, ?)
85
+ `).run(id, coordinatorId, coordinatorId, now, now);
86
+ setAgentTeam(coordinatorId, id);
87
+ })();
88
+ return getTeam(id)!;
89
+ }
90
+
91
+ export function setTeamBrief(input: {
92
+ teamId: string;
93
+ title?: string;
94
+ brief?: string;
95
+ definitionOfDone?: string;
96
+ }): Team {
97
+ const existing = getTeam(input.teamId);
98
+ if (!existing) throw new ValidationError(`team ${input.teamId} not found`);
99
+ const now = Date.now();
100
+ getDb().query(`
101
+ UPDATE teams
102
+ SET title = ?, brief = ?, definition_of_done = ?, updated_at = ?
103
+ WHERE id = ?
104
+ `).run(
105
+ input.title ?? null,
106
+ input.brief ?? null,
107
+ input.definitionOfDone ?? null,
108
+ now,
109
+ input.teamId,
110
+ );
111
+ return getTeam(input.teamId)!;
112
+ }
113
+
114
+ function compositionForMembers(members: AgentCard[]): string {
115
+ return JSON.stringify(members.map((agent) => ({
116
+ id: agent.id,
117
+ label: agent.label,
118
+ role: typeof agent.meta?.role === "string" ? agent.meta.role : undefined,
119
+ provider: agent.providerCapabilities?.model?.provider,
120
+ model: agent.providerCapabilities?.model?.id ?? agent.providerCapabilities?.model?.alias,
121
+ })));
122
+ }
123
+
124
+ export function dissolveTeam(teamId: string, input: TeamDissolveInput = {}): Team {
125
+ const existing = getTeam(teamId);
126
+ if (!existing) throw new ValidationError(`team ${teamId} not found`);
127
+ const members = getTeamRoster(teamId);
128
+ const now = Date.now();
129
+ getDb().query(`
130
+ UPDATE teams
131
+ SET status = 'dissolved',
132
+ outcome_status = ?,
133
+ outcome_summary = ?,
134
+ composition = ?,
135
+ wall_clock_ms = ?,
136
+ lands_clean = ?,
137
+ dissolved_at = ?,
138
+ updated_at = ?
139
+ WHERE id = ?
140
+ `).run(
141
+ input.outcomeStatus ?? null,
142
+ input.outcomeSummary ?? null,
143
+ compositionForMembers(members),
144
+ Math.max(0, now - existing.createdAt),
145
+ input.landsClean === undefined ? null : input.landsClean ? 1 : 0,
146
+ now,
147
+ now,
148
+ teamId,
149
+ );
150
+ return getTeam(teamId)!;
151
+ }
@@ -5,6 +5,7 @@ import {
5
5
  addPlanEdge,
6
6
  addPlanNode,
7
7
  createPlan,
8
+ deletePlanGraph,
8
9
  getPlanGraph,
9
10
  removePlanEdge,
10
11
  removePlanNode,
@@ -47,6 +48,7 @@ export const PLAN_TOOLS = [
47
48
  type: "object",
48
49
  properties: {
49
50
  threadId: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] },
51
+ teamId: { type: "string" },
50
52
  channel: { type: "string" },
51
53
  title: { type: "string" },
52
54
  objective: { type: "string" },
@@ -81,13 +83,14 @@ export const PLAN_TOOLS = [
81
83
  { name: "relay_plan_add_edge", description: "Add a dependency edge to a plan DAG; rejects cycles.", requiredScopes: ["plans:write"], inputSchema: { type: "object", properties: { planId: { type: "string" }, from: { type: "string" }, to: { type: "string" } }, required: ["planId", "from", "to"], additionalProperties: false } },
82
84
  { name: "relay_plan_remove_node", description: "Remove a node and its connected edges from a plan DAG.", requiredScopes: ["plans:write"], inputSchema: { type: "object", properties: { planId: { type: "string" }, nodeId: { type: "string" } }, required: ["planId", "nodeId"], additionalProperties: false } },
83
85
  { name: "relay_plan_remove_edge", description: "Remove a dependency edge from a plan DAG.", requiredScopes: ["plans:write"], inputSchema: { type: "object", properties: { planId: { type: "string" }, from: { type: "string" }, to: { type: "string" } }, required: ["planId", "from", "to"], additionalProperties: false } },
86
+ { name: "relay_plan_delete", description: "Delete a persisted plan DAG and archive its latest version.", requiredScopes: ["plans:write"], inputSchema: { type: "object", properties: { planId: { type: "string" } }, required: ["planId"], additionalProperties: false } },
84
87
  {
85
88
  name: "relay_plan_get",
86
- description: "Fetch a plan by planId or by threadId/channel.",
89
+ description: "Fetch a plan by planId, teamId, or by threadId/channel.",
87
90
  requiredScopes: ["plans:read"],
88
91
  inputSchema: {
89
92
  type: "object",
90
- properties: { planId: { type: "string" }, threadId: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] }, channel: { type: "string" } },
93
+ properties: { planId: { type: "string" }, teamId: { type: "string" }, threadId: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] }, channel: { type: "string" } },
91
94
  additionalProperties: false,
92
95
  },
93
96
  },
@@ -111,8 +114,9 @@ export function relayPlanTool(auth: McpAuthLike, name: string, args: Record<stri
111
114
  if (name === "relay_plan_add_edge") return addPlanEdge(stringArg(args.planId, "planId"), { from: args.from, to: args.to }, ctx) as unknown as Record<string, unknown>;
112
115
  if (name === "relay_plan_remove_node") return removePlanNode(stringArg(args.planId, "planId"), stringArg(args.nodeId, "nodeId"), ctx) as unknown as Record<string, unknown>;
113
116
  if (name === "relay_plan_remove_edge") return removePlanEdge(stringArg(args.planId, "planId"), { from: args.from, to: args.to }, ctx) as unknown as Record<string, unknown>;
117
+ if (name === "relay_plan_delete") return deletePlanGraph(stringArg(args.planId, "planId"), ctx);
114
118
  if (name === "relay_plan_get") {
115
- const plan = getPlanGraph({ planId: args.planId, threadId: args.threadId, channel: args.channel });
119
+ const plan = getPlanGraph({ planId: args.planId, teamId: args.teamId, threadId: args.threadId, channel: args.channel });
116
120
  if (!plan) throw new McpNotFoundError("plan not found");
117
121
  return plan as unknown as Record<string, unknown>;
118
122
  }
@@ -0,0 +1,87 @@
1
+ import { McpAuthError, McpNotFoundError } from "./mcp-errors";
2
+ import { authContextFromMcp } from "./services/auth-context";
3
+ import { ValidationError } from "./db";
4
+ import { dissolveCallerTeam, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError } from "./services/team";
5
+ import { cleanString, optionalEnum } from "./validation";
6
+ import type { Team } from "./types";
7
+
8
+ const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
9
+
10
+ export const TEAM_TOOLS = [
11
+ {
12
+ name: "relay_team_get",
13
+ description: "Return the caller's team row, live roster, shared brief/definition of done, and any bound plan.",
14
+ requiredScopes: ["teams:read"],
15
+ inputSchema: {
16
+ type: "object",
17
+ properties: { channel: { type: "string" } },
18
+ additionalProperties: false,
19
+ },
20
+ },
21
+ {
22
+ name: "relay_team_set_brief",
23
+ description: "Create/update the caller's team brief, title, and definition of done. Lazily mints a team when needed.",
24
+ requiredScopes: ["teams:write"],
25
+ inputSchema: {
26
+ type: "object",
27
+ properties: {
28
+ title: { type: "string" },
29
+ brief: { type: "string" },
30
+ definitionOfDone: { type: "string" },
31
+ },
32
+ additionalProperties: false,
33
+ },
34
+ },
35
+ {
36
+ name: "relay_team_dissolve",
37
+ description: "Dissolve the caller's team and freeze the composition/outcome snapshot. Does not kill members.",
38
+ requiredScopes: ["teams:write"],
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: {
42
+ outcomeStatus: { type: "string", enum: OUTCOME_STATUSES },
43
+ outcomeSummary: { type: "string" },
44
+ landsClean: { type: "boolean" },
45
+ },
46
+ additionalProperties: false,
47
+ },
48
+ },
49
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
50
+
51
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
52
+
53
+ function mapTeamError(error: unknown): never {
54
+ if (error instanceof TeamAuthError) throw new McpAuthError(error.message);
55
+ if (error instanceof TeamNotFoundError) throw new McpNotFoundError(error.message);
56
+ throw error;
57
+ }
58
+
59
+ export function relayTeamTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
60
+ const ctx = authContextFromMcp(auth);
61
+ try {
62
+ if (name === "relay_team_get") {
63
+ return getCallerTeam(ctx, cleanString(args.channel, "channel", { max: 120 })) as unknown as Record<string, unknown>;
64
+ }
65
+ if (name === "relay_team_set_brief") {
66
+ return setCallerTeamBrief(ctx, {
67
+ title: cleanString(args.title, "title", { max: 240 }),
68
+ brief: cleanString(args.brief, "brief", { max: 16_000 }),
69
+ definitionOfDone: cleanString(args.definitionOfDone, "definitionOfDone", { max: 4000 }),
70
+ }) as unknown as Record<string, unknown>;
71
+ }
72
+ if (name === "relay_team_dissolve") {
73
+ return dissolveCallerTeam(ctx, {
74
+ outcomeStatus: optionalEnum(args.outcomeStatus, "outcomeStatus", OUTCOME_STATUSES) as Team["outcomeStatus"] | undefined,
75
+ outcomeSummary: cleanString(args.outcomeSummary, "outcomeSummary", { max: 4000 }),
76
+ landsClean: args.landsClean === undefined || args.landsClean === null
77
+ ? undefined
78
+ : typeof args.landsClean === "boolean"
79
+ ? args.landsClean
80
+ : (() => { throw new ValidationError("landsClean must be a boolean"); })(),
81
+ }) as unknown as Record<string, unknown>;
82
+ }
83
+ } catch (error) {
84
+ mapTeamError(error);
85
+ }
86
+ throw new ValidationError(`unknown tool: ${name}`);
87
+ }
package/src/mcp.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  import { sendMessageService } from "./services/send-message";
48
48
  import { ServiceAuthError } from "./services/errors";
49
49
  import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
50
+ import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
50
51
  import type { ActivityKind, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
51
52
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
52
53
  import { AUTO_MERGE_POLICIES } from "./workspace-merge";
@@ -101,7 +102,7 @@ const TOOLS: ToolDefinition[] = [
101
102
  type: "object",
102
103
  properties: {
103
104
  from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
104
- to: { type: "string", description: "Target: an agent id, label, name, or a unique id segment (e.g. the number part) — resolved automatically. Or a fan-out selector: tag:, cap:, label:, policy:, or broadcast." },
105
+ to: { type: "string", description: "Target: an agent id, label, name, or a unique id segment (e.g. the number part) — resolved automatically. Or a fan-out selector: tag:, cap:, label:, team:, policy:, or broadcast." },
105
106
  body: { type: "string" },
106
107
  subject: { type: "string" },
107
108
  channel: { type: "string" },
@@ -232,6 +233,7 @@ const TOOLS: ToolDefinition[] = [
232
233
  kind: { type: ["string", "array"], items: { type: "string" } },
233
234
  status: { type: ["string", "array"], items: { type: "string" }, description: "online/idle/busy/offline/stale, the 'running' shorthand, or 'all'. Default 'running'." },
234
235
  spawnedBy: { type: "string", description: "Parent agent id, or 'me' for your own spawned children (#221)." },
236
+ teamId: { type: "string", description: "Durable team id for roster filtering." },
235
237
  sortBy: { type: "string", enum: ["lastActive", "created", "label"] },
236
238
  order: { type: "string", enum: ["asc", "desc"] },
237
239
  limit: { type: "integer", minimum: 1, maximum: 200 },
@@ -456,6 +458,7 @@ const TOOLS: ToolDefinition[] = [
456
458
  additionalProperties: false,
457
459
  },
458
460
  },
461
+ ...TEAM_TOOLS,
459
462
  ...PLAN_TOOLS,
460
463
  ];
461
464
 
@@ -568,6 +571,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
568
571
  else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
569
572
  else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
570
573
  else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
574
+ else if (name.startsWith("relay_team_")) result = relayTeamTool(auth, name, args);
571
575
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
572
576
  else throw new ValidationError(`unknown tool: ${name}`);
573
577
 
@@ -831,6 +835,7 @@ function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): R
831
835
  machine: optionalStringOrArray(args.machine, "machine"),
832
836
  kind: optionalStringOrArray(args.kind, "kind"),
833
837
  spawnedBy,
838
+ teamId: optionalString(args.teamId, "teamId", 120),
834
839
  // Default to running — you can only act on a live agent (consistent with #218).
835
840
  status: optionalStringOrArray(args.status, "status") ?? "running",
836
841
  };
@@ -30,6 +30,7 @@ import { getPlanByIdRoute, getPlansRoute } from "./plans";
30
30
  import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
31
31
  import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
32
32
  import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
33
+ import { getMyTeamRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
33
34
  import { postMcp } from "../mcp";
34
35
  import { postOrchestratorBootstrap, postOrchestratorBootstrapExchange } from "./orchestrator-bootstrap";
35
36
 
@@ -135,6 +136,9 @@ const routes: Route[] = [
135
136
  route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),
136
137
 
137
138
  route("POST", "/api/system/broadcast", postSystemBroadcast),
139
+ route("GET", "/api/teams/me", getMyTeamRoute),
140
+ route("PUT", "/api/teams/me/brief", putMyTeamBriefRoute),
141
+ route("POST", "/api/teams/me/dissolve", postMyTeamDissolveRoute),
138
142
  route("GET", "/api/recipes", getRecipes),
139
143
  route("POST", "/api/recipes/start", postRecipeStart),
140
144
  route("GET", "/api/recipes/instances", getRecipeInstances),
@@ -9,7 +9,7 @@ import { isRecord } from "agent-relay-sdk";
9
9
  import { parseChannelRouteTarget } from "../channel-target.ts";
10
10
  import { type ChannelBinding, type ChannelBindingMode, type ChannelRouteTarget, type ChannelSummary, type IntegrationEventInput, type IntegrationSummary } from "../types";
11
11
 
12
- const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
12
+ const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "team", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
13
13
 
14
14
  const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
15
15
 
@@ -59,6 +59,7 @@ function messageTargetForChannelTarget(target: ChannelRouteTarget, binding?: Cha
59
59
  return binding.poolAgentId;
60
60
  }
61
61
  if (target.type === "label") return `label:${target.id}`;
62
+ if (target.type === "team") return `team:${target.id}`;
62
63
  if (target.type === "tag") return `tag:${target.id}`;
63
64
  if (target.type === "capability") return `cap:${target.id}`;
64
65
  if (target.type === "broadcast") return "broadcast";
@@ -66,7 +66,7 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
66
66
  return input;
67
67
  }
68
68
 
69
- const FANOUT_PREFIXES = ["tag:", "cap:", "label:"];
69
+ const FANOUT_PREFIXES = ["tag:", "cap:", "label:", "team:"];
70
70
 
71
71
  function isDirectTarget(to: string): boolean {
72
72
  return to !== "broadcast" && !FANOUT_PREFIXES.some((p) => to.startsWith(p));
@@ -5,10 +5,16 @@ import { error, json, parseQueryInt, type Handler } from "./_shared";
5
5
 
6
6
  export const getPlansRoute: Handler = (req) => {
7
7
  const url = new URL(req.url);
8
- const threadId = parseQueryInt(url.searchParams.get("threadId"), { min: 1, max: Number.MAX_SAFE_INTEGER });
9
- if (threadId === null) return error("threadId required");
10
- if (Number.isNaN(threadId)) return error("threadId must be a positive integer");
11
8
  const channel = cleanString(url.searchParams.get("channel") ?? undefined, "channel", { max: 120 });
9
+ const teamId = cleanString(url.searchParams.get("teamId") ?? undefined, "teamId", { max: 120 });
10
+ if (teamId) {
11
+ const plan = getPlanGraph({ teamId, channel });
12
+ return plan ? json(plan) : error("plan not found", 404);
13
+ }
14
+ const rawThreadId = url.searchParams.get("threadId");
15
+ if (rawThreadId === null) return error("threadId or teamId required");
16
+ const threadId = parseQueryInt(rawThreadId, { min: 1, max: Number.MAX_SAFE_INTEGER });
17
+ if (Number.isNaN(threadId)) return error("threadId must be a positive integer");
12
18
  const plan = getPlanGraph({ threadId, channel });
13
19
  return plan ? json(plan) : error("plan not found", 404);
14
20
  };