agent-relay-server 0.50.0 → 0.52.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 (96) hide show
  1. package/docs/openapi.json +206 -1
  2. package/package.json +2 -2
  3. package/public/assets/{PlanGraphPanel-BYgnvJZR.js → PlanGraphPanel-BnYmYHnY.js} +2 -2
  4. package/public/assets/{PlanGraphPanel-BYgnvJZR.js.map → PlanGraphPanel-BnYmYHnY.js.map} +1 -1
  5. package/public/assets/{activity-CCOlARUq.js → activity-BNLuP717.js} +2 -2
  6. package/public/assets/{activity-CCOlARUq.js.map → activity-BNLuP717.js.map} +1 -1
  7. package/public/assets/{agent-profiles-CGQq2EyX.js → agent-profiles-BhVEehP8.js} +2 -2
  8. package/public/assets/{agent-profiles-CGQq2EyX.js.map → agent-profiles-BhVEehP8.js.map} +1 -1
  9. package/public/assets/{agents-Cl9a1uBD.js → agents-CqQhC2aN.js} +2 -2
  10. package/public/assets/{agents-Cl9a1uBD.js.map → agents-CqQhC2aN.js.map} +1 -1
  11. package/public/assets/{analytics-DkABnOKb.js → analytics-44eqtAsx.js} +2 -2
  12. package/public/assets/{analytics-DkABnOKb.js.map → analytics-44eqtAsx.js.map} +1 -1
  13. package/public/assets/{automation-CS2V5WAh.js → automation-x65cxlk4.js} +2 -2
  14. package/public/assets/{automation-CS2V5WAh.js.map → automation-x65cxlk4.js.map} +1 -1
  15. package/public/assets/{branch-state-badge-DhTKagJn.js → branch-state-badge-Dp6Jk4LH.js} +2 -2
  16. package/public/assets/{branch-state-badge-DhTKagJn.js.map → branch-state-badge-Dp6Jk4LH.js.map} +1 -1
  17. package/public/assets/{channels-BcUZKNmr.js → channels-CmfUfiV0.js} +2 -2
  18. package/public/assets/{channels-BcUZKNmr.js.map → channels-CmfUfiV0.js.map} +1 -1
  19. package/public/assets/{chat-TFFCz8AI.js → chat-nugo0m55.js} +2 -2
  20. package/public/assets/{chat-TFFCz8AI.js.map → chat-nugo0m55.js.map} +1 -1
  21. package/public/assets/{connectors-Dd5gUsM2.js → connectors-Bf74bw_9.js} +2 -2
  22. package/public/assets/{connectors-Dd5gUsM2.js.map → connectors-Bf74bw_9.js.map} +1 -1
  23. package/public/assets/display-CXhnBwGf.js.map +1 -1
  24. package/public/assets/{formatted-body-impl-B_mZW3Vj.js → formatted-body-impl-UeUBqdRz.js} +2 -2
  25. package/public/assets/{formatted-body-impl-B_mZW3Vj.js.map → formatted-body-impl-UeUBqdRz.js.map} +1 -1
  26. package/public/assets/index-C0aWKi0g.css +2 -0
  27. package/public/assets/{index-CWs6iPpQ.js → index-D--CyG66.js} +6 -6
  28. package/public/assets/{index-CWs6iPpQ.js.map → index-D--CyG66.js.map} +1 -1
  29. package/public/assets/{insights-D5e1k0IU.js → insights-C9k7mCoJ.js} +2 -2
  30. package/public/assets/{insights-D5e1k0IU.js.map → insights-C9k7mCoJ.js.map} +1 -1
  31. package/public/assets/{integrations-DVdvwQaZ.js → integrations-By3hMfqC.js} +2 -2
  32. package/public/assets/{integrations-DVdvwQaZ.js.map → integrations-By3hMfqC.js.map} +1 -1
  33. package/public/assets/{maintenance-ChmWW3iQ.js → maintenance-tgb7AMTv.js} +2 -2
  34. package/public/assets/{maintenance-ChmWW3iQ.js.map → maintenance-tgb7AMTv.js.map} +1 -1
  35. package/public/assets/{managed-agents-C6cbTB9d.js → managed-agents-DJ3TfBJ5.js} +2 -2
  36. package/public/assets/{managed-agents-C6cbTB9d.js.map → managed-agents-DJ3TfBJ5.js.map} +1 -1
  37. package/public/assets/{markdown-preview-impl-DjmZIwx5.js → markdown-preview-impl-DcHq1Hto.js} +2 -2
  38. package/public/assets/{markdown-preview-impl-DjmZIwx5.js.map → markdown-preview-impl-DcHq1Hto.js.map} +1 -1
  39. package/public/assets/{memory-DYRuGhD9.js → memory-CMdemdtb.js} +2 -2
  40. package/public/assets/{memory-DYRuGhD9.js.map → memory-CMdemdtb.js.map} +1 -1
  41. package/public/assets/{messages-q5N9kJ4f.js → messages-CLuLC9wC.js} +2 -2
  42. package/public/assets/{messages-q5N9kJ4f.js.map → messages-CLuLC9wC.js.map} +1 -1
  43. package/public/assets/{orchestrators-bbIEU7xO.js → orchestrators-d27zYyT9.js} +2 -2
  44. package/public/assets/{orchestrators-bbIEU7xO.js.map → orchestrators-d27zYyT9.js.map} +1 -1
  45. package/public/assets/{overview-B3TxMRHV.js → overview-BY-cs5SI.js} +2 -2
  46. package/public/assets/{overview-B3TxMRHV.js.map → overview-BY-cs5SI.js.map} +1 -1
  47. package/public/assets/{pairs-CF3XEcbG.js → pairs-CmyQx_4F.js} +2 -2
  48. package/public/assets/{pairs-CF3XEcbG.js.map → pairs-CmyQx_4F.js.map} +1 -1
  49. package/public/assets/{security-Bffy1FyV.js → security-CxCnWTlk.js} +2 -2
  50. package/public/assets/{security-Bffy1FyV.js.map → security-CxCnWTlk.js.map} +1 -1
  51. package/public/assets/{select-Bz0VV6tj.js → select-B6iG4oYX.js} +2 -2
  52. package/public/assets/{select-Bz0VV6tj.js.map → select-B6iG4oYX.js.map} +1 -1
  53. package/public/assets/{settings-DUucPX58.js → settings-vSYPv8kE.js} +2 -2
  54. package/public/assets/{settings-DUucPX58.js.map → settings-vSYPv8kE.js.map} +1 -1
  55. package/public/assets/{store-C7dqPL_V.js → store-BLZA3u7Y.js} +2 -2
  56. package/public/assets/store-BLZA3u7Y.js.map +1 -0
  57. package/public/assets/{tasks-Dv1CeteT.js → tasks-BYUAWEL-.js} +2 -2
  58. package/public/assets/{tasks-Dv1CeteT.js.map → tasks-BYUAWEL-.js.map} +1 -1
  59. package/public/assets/teams-gWvyw7ET.js +2 -0
  60. package/public/assets/teams-gWvyw7ET.js.map +1 -0
  61. package/public/assets/{terminal-viewer-impl-DQEV3e7Z.js → terminal-viewer-impl-CHP0w79T.js} +2 -2
  62. package/public/assets/{terminal-viewer-impl-DQEV3e7Z.js.map → terminal-viewer-impl-CHP0w79T.js.map} +1 -1
  63. package/public/assets/{work-queue-9iUCo-hM.js → work-queue-CBjXlNuG.js} +2 -2
  64. package/public/assets/{work-queue-9iUCo-hM.js.map → work-queue-CBjXlNuG.js.map} +1 -1
  65. package/public/assets/{workspaces-B0zGEd0M.js → workspaces-PdCwiSQx.js} +2 -2
  66. package/public/assets/{workspaces-B0zGEd0M.js.map → workspaces-PdCwiSQx.js.map} +1 -1
  67. package/public/index.html +3 -3
  68. package/src/bus.ts +2 -2
  69. package/src/config-store.ts +10 -16
  70. package/src/db/agents.ts +6 -2
  71. package/src/db/mappers.ts +1 -0
  72. package/src/db/migrations.ts +5 -2
  73. package/src/db/plans.ts +11 -0
  74. package/src/db/schema.ts +1 -1
  75. package/src/db/teams.ts +120 -0
  76. package/src/index.ts +2 -0
  77. package/src/maintenance.ts +3 -3
  78. package/src/mcp-agent-action.ts +45 -0
  79. package/src/mcp-team-template-tools.ts +133 -0
  80. package/src/mcp-team-tools.ts +4 -1
  81. package/src/mcp.ts +9 -11
  82. package/src/routes/index.ts +8 -1
  83. package/src/routes/team-templates.ts +65 -0
  84. package/src/routes/teams.ts +14 -2
  85. package/src/services/agent-action.ts +138 -0
  86. package/src/services/plan-graph.ts +49 -11
  87. package/src/services/plan-live-bindings.ts +88 -0
  88. package/src/services/spawn-agent.ts +5 -1
  89. package/src/services/team-templates.ts +172 -0
  90. package/src/services/transient-agent-reaper.ts +108 -0
  91. package/src/spawn-command.ts +2 -0
  92. package/src/team-template-config.ts +100 -0
  93. package/public/assets/index-B8ND8v33.css +0 -2
  94. package/public/assets/store-C7dqPL_V.js.map +0 -1
  95. package/public/assets/teams-DEASyStU.js +0 -2
  96. package/public/assets/teams-DEASyStU.js.map +0 -1
package/src/db/plans.ts CHANGED
@@ -143,6 +143,17 @@ export function getPlanByTeam(teamId: string, channel?: string): Plan | null {
143
143
  return row ? rowToPlan(row) : null;
144
144
  }
145
145
 
146
+ export function listPlansWithBoundOwner(agentId: string): Plan[] {
147
+ const rows = getDb().query(`
148
+ SELECT DISTINCT p.*
149
+ FROM plans p, json_each(p.nodes) node
150
+ WHERE json_extract(node.value, '$.statusSource') = 'bound'
151
+ AND json_extract(node.value, '$.ownerRef.agentId') = ?
152
+ ORDER BY p.updated_at DESC, p.id ASC
153
+ `).all(agentId) as PlanRow[];
154
+ return rows.map(rowToPlan);
155
+ }
156
+
146
157
  export function replacePlan(input: Plan, changedBy?: string): Plan {
147
158
  const existing = getPlan(input.id);
148
159
  if (!existing) throw new ValidationError(`plan ${input.id} not found`);
package/src/db/schema.ts CHANGED
@@ -104,7 +104,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
104
104
  epoch INTEGER NOT NULL DEFAULT 0,
105
105
  provider_capabilities TEXT,
106
106
  context_state TEXT,
107
- meta TEXT NOT NULL DEFAULT '{}',
107
+ meta TEXT NOT NULL DEFAULT '{}', lifecycle TEXT NOT NULL DEFAULT 'persistent',
108
108
  spawned_by TEXT,
109
109
  team_id TEXT,
110
110
  last_seen INTEGER NOT NULL,
package/src/db/teams.ts CHANGED
@@ -31,6 +31,46 @@ export interface TeamDissolveInput {
31
31
  landsClean?: boolean;
32
32
  }
33
33
 
34
+ type OutcomeBucket = NonNullable<Team["outcomeStatus"]> | "unknown";
35
+
36
+ interface TeamOutcomeRow {
37
+ id: string;
38
+ title: string | null;
39
+ outcome_status: Team["outcomeStatus"] | null;
40
+ outcome_summary: string | null;
41
+ composition: string | null;
42
+ wall_clock_ms: number | null;
43
+ dissolved_at: number | null;
44
+ }
45
+
46
+ export interface TeamOutcomeCompositionAggregate {
47
+ shape: string;
48
+ members: Array<{ provider: string; model: string }>;
49
+ count: number;
50
+ successCount: number;
51
+ successRate: number;
52
+ avgWallClockMs: number | null;
53
+ }
54
+
55
+ export interface TeamOutcomeRecent {
56
+ id: string;
57
+ title?: string;
58
+ outcome: Team["outcomeStatus"] | null;
59
+ outcomeSummary?: string;
60
+ durationMs: number | null;
61
+ memberCount: number;
62
+ dissolvedAt: number | null;
63
+ }
64
+
65
+ export interface TeamOutcomeAggregates {
66
+ totals: {
67
+ total: number;
68
+ byOutcomeStatus: Record<OutcomeBucket, number>;
69
+ };
70
+ compositionShapes: TeamOutcomeCompositionAggregate[];
71
+ recent: TeamOutcomeRecent[];
72
+ }
73
+
34
74
  export function teamId(): string {
35
75
  return `team_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
36
76
  }
@@ -153,6 +193,86 @@ function compositionForMembers(members: AgentCard[]): string {
153
193
  })));
154
194
  }
155
195
 
196
+ function compositionShape(composition: string | null): { key: string; members: Array<{ provider: string; model: string }>; memberCount: number } {
197
+ if (!composition) return { key: "empty", members: [], memberCount: 0 };
198
+ try {
199
+ const parsed = JSON.parse(composition) as unknown;
200
+ if (!Array.isArray(parsed)) return { key: "unknown/unknown", members: [{ provider: "unknown", model: "unknown" }], memberCount: 0 };
201
+ const seen = new Map<string, { provider: string; model: string }>();
202
+ for (const raw of parsed) {
203
+ const member = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
204
+ const provider = typeof member.provider === "string" && member.provider.trim() ? member.provider.trim() : "unknown";
205
+ const model = typeof member.model === "string" && member.model.trim() ? member.model.trim() : "unknown";
206
+ seen.set(`${provider}/${model}`, { provider, model });
207
+ }
208
+ const members = [...seen.values()].sort((a, b) => a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model));
209
+ return { key: members.length ? members.map((member) => `${member.provider}/${member.model}`).join(", ") : "empty", members, memberCount: parsed.length };
210
+ } catch {
211
+ return { key: "unknown/unknown", members: [{ provider: "unknown", model: "unknown" }], memberCount: 0 };
212
+ }
213
+ }
214
+
215
+ export function getTeamOutcomeAggregates(input: { recentLimit?: number } = {}): TeamOutcomeAggregates {
216
+ const recentLimit = Math.max(1, Math.min(50, input.recentLimit ?? 12));
217
+ const rows = getDb().query(`
218
+ SELECT id, title, outcome_status, outcome_summary, composition, wall_clock_ms, dissolved_at
219
+ FROM teams
220
+ WHERE status = 'dissolved'
221
+ ORDER BY dissolved_at DESC, updated_at DESC, id DESC
222
+ `).all() as TeamOutcomeRow[];
223
+
224
+ const byOutcomeStatus: Record<OutcomeBucket, number> = { success: 0, partial: 0, failed: 0, unknown: 0 };
225
+ const byShape = new Map<string, {
226
+ members: Array<{ provider: string; model: string }>;
227
+ count: number;
228
+ successCount: number;
229
+ durationTotal: number;
230
+ durationCount: number;
231
+ }>();
232
+ const recent: TeamOutcomeRecent[] = [];
233
+
234
+ for (const row of rows) {
235
+ byOutcomeStatus[row.outcome_status ?? "unknown"] += 1;
236
+ const shape = compositionShape(row.composition);
237
+ const aggregate = byShape.get(shape.key) ?? { members: shape.members, count: 0, successCount: 0, durationTotal: 0, durationCount: 0 };
238
+ aggregate.count += 1;
239
+ if (row.outcome_status === "success") aggregate.successCount += 1;
240
+ if (row.wall_clock_ms !== null) {
241
+ aggregate.durationTotal += row.wall_clock_ms;
242
+ aggregate.durationCount += 1;
243
+ }
244
+ byShape.set(shape.key, aggregate);
245
+ if (recent.length < recentLimit) {
246
+ recent.push({
247
+ id: row.id,
248
+ ...(row.title ? { title: row.title } : {}),
249
+ outcome: row.outcome_status,
250
+ ...(row.outcome_summary ? { outcomeSummary: row.outcome_summary } : {}),
251
+ durationMs: row.wall_clock_ms,
252
+ memberCount: shape.memberCount,
253
+ dissolvedAt: row.dissolved_at,
254
+ });
255
+ }
256
+ }
257
+
258
+ const compositionShapes = [...byShape.entries()]
259
+ .map(([shape, aggregate]) => ({
260
+ shape,
261
+ members: aggregate.members,
262
+ count: aggregate.count,
263
+ successCount: aggregate.successCount,
264
+ successRate: aggregate.count === 0 ? 0 : aggregate.successCount / aggregate.count,
265
+ avgWallClockMs: aggregate.durationCount === 0 ? null : Math.round(aggregate.durationTotal / aggregate.durationCount),
266
+ }))
267
+ .sort((a, b) => b.count - a.count || b.successRate - a.successRate || a.shape.localeCompare(b.shape));
268
+
269
+ return {
270
+ totals: { total: rows.length, byOutcomeStatus },
271
+ compositionShapes,
272
+ recent,
273
+ };
274
+ }
275
+
156
276
  export function dissolveTeam(teamId: string, input: TeamDissolveInput = {}): Team {
157
277
  const existing = getTeam(teamId);
158
278
  if (!existing) throw new ValidationError(`team ${teamId} not found`);
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { getLifecycleManager } from "./lifecycle-manager";
10
10
  import { getCompactionWatch } from "./compaction-watch";
11
11
  import { getConfig, setConfig } from "./config-store";
12
12
  import { startConnectorStatusPoller } from "./connectors";
13
+ import { startPlanLiveBindings } from "./services/plan-live-bindings";
13
14
  import { resolve, sep } from "path";
14
15
  import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
15
16
  import {
@@ -73,6 +74,7 @@ function startServer(): void {
73
74
 
74
75
  assertSafeNetworkConfig(HOST);
75
76
  initDb(DB_PATH);
77
+ startPlanLiveBindings();
76
78
  getLifecycleManager().start();
77
79
  getCompactionWatch().start();
78
80
  reconcileRelayVersion();
@@ -62,6 +62,7 @@ import {
62
62
  import { notifySystemMessage } from "./notify";
63
63
  import { pruneExpiredTokenRecords } from "./token-db";
64
64
  import { dissolveTeamById } from "./services/team";
65
+ import { reapTransientAgents, resetTransientTrackerForTests } from "./services/transient-agent-reaper";
65
66
  import type { Command, MaintenanceJob, MaintenanceJobRun } from "./types";
66
67
 
67
68
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -134,9 +135,7 @@ const orphanReapEnabled = () => process.env.AGENT_RELAY_ORPHAN_REAP !== "0";
134
135
  // orchestratorId + session identity -> when we first saw it orphaned (and last reaped).
135
136
  const orphanTracker = new Map<string, { firstOrphanedAt: number; lastReapAt?: number }>();
136
137
 
137
- export function resetOrphanTrackerForTests(): void {
138
- orphanTracker.clear();
139
- }
138
+ export function resetOrphanTrackerForTests(): void { orphanTracker.clear(); resetTransientTrackerForTests(); }
140
139
 
141
140
  interface MaintenanceJobDefinition {
142
141
  id: string;
@@ -300,6 +299,7 @@ const definitions: MaintenanceJobDefinition[] = [
300
299
  runOnStart: false,
301
300
  handler: reapOrphanedSessions,
302
301
  },
302
+ { id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: reapTransientAgents },
303
303
  {
304
304
  id: "team-reap",
305
305
  title: "Team lifecycle close-out",
@@ -0,0 +1,45 @@
1
+ import { cleanEnum, cleanString } from "./validation";
2
+ import { AgentActionTargetError, dispatchAgentAction, type AgentAction } from "./services/agent-action";
3
+ import { authContextFromMcp } from "./services/auth-context";
4
+ import { ShutdownAuthError, ShutdownTargetError } from "./services/shutdown-agent";
5
+ import { McpAuthError, McpNotFoundError } from "./mcp-errors";
6
+
7
+ type McpAuthLike = {
8
+ actor: string;
9
+ kind: "component" | "integration" | "server";
10
+ scopes?: string[];
11
+ component?: Parameters<typeof authContextFromMcp>[0]["component"];
12
+ };
13
+
14
+ export const AGENT_ACTION_TOOL = {
15
+ name: "relay_agent_action",
16
+ description: "Compact, clear context, or directly inject a prompt into one of your live spawned children. Gated exactly like relay_shutdown_agent: an agent caller may target only its OWN spawned children by agentId; admin tokens keep full reach.",
17
+ requiredScopes: ["command:shutdown"],
18
+ inputSchema: {
19
+ type: "object",
20
+ properties: {
21
+ agentId: { type: "string" },
22
+ action: { type: "string", enum: ["compact", "clearContext", "inject"] },
23
+ message: { type: "string", description: "Required when action is inject." },
24
+ },
25
+ required: ["agentId", "action"],
26
+ additionalProperties: false,
27
+ },
28
+ };
29
+
30
+ export function relayAgentAction(auth: McpAuthLike, args: Record<string, unknown>): Record<string, unknown> {
31
+ try {
32
+ const result = dispatchAgentAction({
33
+ agentId: cleanString(args.agentId, "agentId", { required: true, max: 240 })!,
34
+ action: cleanEnum(args.action, "action", ["compact", "clearContext", "inject"] as const) as AgentAction,
35
+ message: cleanString(args.message, "message", { max: 16_000 }),
36
+ requestedVia: "mcp",
37
+ requestedBy: auth.actor,
38
+ }, authContextFromMcp(auth));
39
+ return { ok: true, action: result.action, command: result.command };
40
+ } catch (e) {
41
+ if (e instanceof ShutdownAuthError) throw new McpAuthError(e.message);
42
+ if (e instanceof ShutdownTargetError || e instanceof AgentActionTargetError) throw new McpNotFoundError(e.message);
43
+ throw e;
44
+ }
45
+ }
@@ -0,0 +1,133 @@
1
+ import { APPROVAL_MODES, VALID_AGENT_LIFECYCLES, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
2
+ import { McpAuthError } from "./mcp-errors";
3
+ import { authContextFromMcp } from "./services/auth-context";
4
+ import { deleteTeamTemplate, getTeamTemplate, instantiateTeamTemplate, listTeamTemplates, saveTeamTemplate } from "./services/team-templates";
5
+ import { ValidationError } from "./db";
6
+
7
+ const COMPOSITION_ENTRY_SCHEMA = {
8
+ type: "object",
9
+ properties: {
10
+ label: { type: "string" },
11
+ provider: { type: "string", enum: ["claude", "codex"] },
12
+ model: { type: "string" },
13
+ effort: { type: "string" },
14
+ profile: { type: "string" },
15
+ count: { type: "integer", minimum: 1, maximum: 50 },
16
+ },
17
+ required: ["label", "provider", "count"],
18
+ additionalProperties: false,
19
+ } as const;
20
+
21
+ const PLAN_SKELETON_SCHEMA = {
22
+ type: "object",
23
+ properties: {
24
+ nodes: { type: "array", items: { type: "object" } },
25
+ edges: { type: "array", items: { type: "object" } },
26
+ },
27
+ additionalProperties: false,
28
+ } as const;
29
+
30
+ export const TEAM_TEMPLATE_TOOLS = [
31
+ {
32
+ name: "relay_team_template_save",
33
+ description: "Create/update a reusable team template, or capture one from a dissolved team's frozen composition by passing teamId.",
34
+ requiredScopes: ["teams:write"],
35
+ inputSchema: {
36
+ type: "object",
37
+ properties: {
38
+ id: { type: "string" },
39
+ slug: { type: "string" },
40
+ title: { type: "string" },
41
+ description: { type: "string" },
42
+ composition: { type: "array", items: COMPOSITION_ENTRY_SCHEMA },
43
+ brief: { type: "string" },
44
+ definitionOfDone: { type: "string" },
45
+ planSkeleton: PLAN_SKELETON_SCHEMA,
46
+ teamId: { type: "string", description: "Capture from this dissolved team's frozen composition snapshot instead of passing composition." },
47
+ },
48
+ additionalProperties: false,
49
+ },
50
+ },
51
+ {
52
+ name: "relay_team_template_list",
53
+ description: "List saved team templates.",
54
+ requiredScopes: ["teams:read"],
55
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
56
+ },
57
+ {
58
+ name: "relay_team_template_get",
59
+ description: "Fetch one saved team template by slug.",
60
+ requiredScopes: ["teams:read"],
61
+ inputSchema: { type: "object", properties: { slug: { type: "string" } }, required: ["slug"], additionalProperties: false },
62
+ },
63
+ {
64
+ name: "relay_team_template_delete",
65
+ description: "Delete one saved team template by slug.",
66
+ requiredScopes: ["teams:write"],
67
+ inputSchema: { type: "object", properties: { slug: { type: "string" } }, required: ["slug"], additionalProperties: false },
68
+ },
69
+ {
70
+ name: "relay_team_spawn_from_template",
71
+ description: "Instantiate a saved team template: set the caller's team brief and spawn its composition through the shared spawn service.",
72
+ requiredScopes: ["teams:write"],
73
+ inputSchema: {
74
+ type: "object",
75
+ properties: {
76
+ slug: { type: "string" },
77
+ orchestratorId: { type: "string" },
78
+ cwd: { type: "string" },
79
+ approvalMode: { type: "string", enum: APPROVAL_MODES },
80
+ workspaceMode: { type: "string", enum: VALID_WORKSPACE_MODES },
81
+ lifecycle: { type: "string", enum: VALID_AGENT_LIFECYCLES },
82
+ prompt: { type: "string" },
83
+ waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000 },
84
+ },
85
+ required: ["slug"],
86
+ additionalProperties: false,
87
+ },
88
+ },
89
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
90
+
91
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
92
+
93
+ function hasScope(auth: McpAuthLike, scope: string): boolean {
94
+ if (auth.scopes?.includes("*") || auth.scopes?.includes("admin:*")) return true;
95
+ const [category] = scope.split(":");
96
+ return Boolean(auth.scopes?.includes(scope) || (category && auth.scopes?.includes(`${category}:*`)));
97
+ }
98
+
99
+ function requireScope(auth: McpAuthLike, scope: string): void {
100
+ if (!hasScope(auth, scope)) throw new McpAuthError(`tool requires ${scope}`);
101
+ }
102
+
103
+ function slugArg(args: Record<string, unknown>): string {
104
+ const slug = args.slug;
105
+ if (typeof slug !== "string" || !slug.trim()) throw new ValidationError("slug required");
106
+ return slug.trim();
107
+ }
108
+
109
+ export async function relayTeamTemplateTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
110
+ if (name === "relay_team_template_save") {
111
+ requireScope(auth, "teams:write");
112
+ return saveTeamTemplate(args, auth.actor) as unknown as Record<string, unknown>;
113
+ }
114
+ if (name === "relay_team_template_list") return { templates: listTeamTemplates() };
115
+ if (name === "relay_team_template_get") {
116
+ const template = getTeamTemplate(slugArg(args));
117
+ if (!template) throw new ValidationError("team template not found");
118
+ return { template };
119
+ }
120
+ if (name === "relay_team_template_delete") {
121
+ requireScope(auth, "teams:write");
122
+ return { slug: slugArg(args), deleted: deleteTeamTemplate(slugArg(args), auth.actor) };
123
+ }
124
+ if (name === "relay_team_spawn_from_template") {
125
+ requireScope(auth, "teams:write");
126
+ return await instantiateTeamTemplate({ ...args, slug: slugArg(args) }, authContextFromMcp(auth)) as unknown as Record<string, unknown>;
127
+ }
128
+ throw new ValidationError(`unknown tool: ${name}`);
129
+ }
130
+
131
+ export function isTeamTemplateTool(name: string): boolean {
132
+ return name.startsWith("relay_team_template_") || name === "relay_team_spawn_from_template";
133
+ }
@@ -4,6 +4,7 @@ import { ValidationError } from "./db";
4
4
  import { dissolveCallerTeam, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError } from "./services/team";
5
5
  import { cleanString, optionalEnum } from "./validation";
6
6
  import type { Team } from "./types";
7
+ import { TEAM_TEMPLATE_TOOLS, isTeamTemplateTool, relayTeamTemplateTool } from "./mcp-team-template-tools";
7
8
 
8
9
  const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
9
10
 
@@ -46,6 +47,7 @@ export const TEAM_TOOLS = [
46
47
  additionalProperties: false,
47
48
  },
48
49
  },
50
+ ...TEAM_TEMPLATE_TOOLS,
49
51
  ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
50
52
 
51
53
  type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
@@ -56,7 +58,8 @@ function mapTeamError(error: unknown): never {
56
58
  throw error;
57
59
  }
58
60
 
59
- export function relayTeamTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
61
+ export async function relayTeamTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
62
+ if (isTeamTemplateTool(name)) return await relayTeamTemplateTool(auth, name, args);
60
63
  const ctx = authContextFromMcp(auth);
61
64
  try {
62
65
  if (name === "relay_team_get") {
package/src/mcp.ts CHANGED
@@ -49,21 +49,17 @@ import { ServiceAuthError } from "./services/errors";
49
49
  import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
50
50
  import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
51
51
  import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
52
- import type { ActivityKind, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
52
+ import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
53
+ import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
53
54
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
54
55
  import { AUTO_MERGE_POLICIES } from "./workspace-merge";
55
56
  import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
56
57
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
57
- import { errMessage, isRecord, stringValue, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
58
+ import { errMessage, isRecord, stringValue, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
58
59
 
59
60
  type JsonRpcId = string | number | null;
60
61
 
61
- type JsonRpcRequest = {
62
- jsonrpc?: "2.0";
63
- id?: JsonRpcId;
64
- method?: string;
65
- params?: unknown;
66
- };
62
+ type JsonRpcRequest = { jsonrpc?: "2.0"; id?: JsonRpcId; method?: string; params?: unknown };
67
63
 
68
64
  type McpAuthContext = {
69
65
  actor: string;
@@ -92,8 +88,6 @@ const VALID_ARTIFACT_KINDS = ["image", "audio", "video", "document", "archive",
92
88
  const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"] as const;
93
89
  const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
94
90
  const VALID_ARTIFACT_ENTITY_TYPES = ["message", "task", "recipeRun", "recipeStep", "channelEvent"] as const;
95
-
96
-
97
91
  const TOOLS: ToolDefinition[] = [
98
92
  {
99
93
  name: "relay_send_message",
@@ -320,6 +314,7 @@ const TOOLS: ToolDefinition[] = [
320
314
  providerArgs: { type: "array", items: { type: "string" } },
321
315
  policyName: { type: "string" },
322
316
  spawnRequestId: { type: "string" },
317
+ lifecycle: { type: "string", enum: VALID_AGENT_LIFECYCLES, description: "Lifecycle mode. `persistent` keeps the agent until explicitly stopped. `transient` lets Relay auto-shutdown the child after it is idle and, for isolated workers, after its branch has landed. Default: persistent." },
323
318
  workspaceMode: { type: "string", enum: VALID_WORKSPACE_MODES, description: "Git workspace for the worker. `isolated` = its own worktree on an `agent/session-*` branch off main that auto-lands when ready (delegate a unit of work, get a branch worker). `shared` = edits your live checkout (a sidecar). `inherit` = let the orchestrator decide. DEFAULT for agent-initiated spawns is `isolated` so a delegated worker lands to main instead of editing your tree; pass `shared`/`inherit` to override." },
324
319
  waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000, description: "How long to wait for the spawned agent to register before returning, so the response carries its resolved agent id (default 8000; 0 = return immediately with just spawnRequestId)." },
325
320
  },
@@ -355,6 +350,7 @@ const TOOLS: ToolDefinition[] = [
355
350
  additionalProperties: false,
356
351
  },
357
352
  },
353
+ AGENT_ACTION_TOOL,
358
354
  {
359
355
  name: "relay_workspace_status",
360
356
  description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes — use it after relay_workspace_ready to wait for the auto-merge to land your branch on main (status leaves 'ready' → merged/recycled-to-active with a fresh rebased branch) or to surface a conflict/review_requested the steward couldn't auto-resolve. Defaults to the workspace you own; pass workspaceId to target another.",
@@ -566,6 +562,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
566
562
  else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
567
563
  else if (name === "relay_spawn_targets") result = relaySpawnTargets(auth);
568
564
  else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
565
+ else if (name === "relay_agent_action") result = relayAgentAction(auth, args);
569
566
  else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
570
567
  else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
571
568
  else if (name === "relay_workspace_ready") result = relayWorkspaceMutation(auth, "ready", args);
@@ -573,7 +570,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
573
570
  else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
574
571
  else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
575
572
  else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
576
- else if (name.startsWith("relay_team_")) result = relayTeamTool(auth, name, args);
573
+ else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
577
574
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
578
575
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
579
576
  else throw new ValidationError(`unknown tool: ${name}`);
@@ -866,6 +863,7 @@ async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknow
866
863
  effort: optionalEnum(args.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined,
867
864
  approvalMode: optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
868
865
  workspaceMode: optionalEnum(args.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
866
+ lifecycle: (optionalEnum(args.lifecycle, "lifecycle", VALID_AGENT_LIFECYCLES) as AgentLifecycle | undefined) ?? "persistent",
869
867
  label: optionalString(args.label, "label", 120),
870
868
  policyName: optionalString(args.policyName, "policyName", 120),
871
869
  profile: optionalString(args.profile, "profile", 120),
@@ -31,8 +31,9 @@ import { getBlackboardEntryRoute, getBlackboardRoute } from "./blackboard";
31
31
  import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
32
32
  import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
33
33
  import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
34
- import { getMyTeamRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
34
+ import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
35
35
  import { postTeamDissolveByIdRoute } from "./teams";
36
+ import { deleteTeamTemplateRoute, getTeamTemplateRoute, getTeamTemplatesRoute, postTeamTemplateRoute, putTeamTemplateRoute } from "./team-templates";
36
37
  import { postMcp } from "../mcp";
37
38
  import { postOrchestratorBootstrap, postOrchestratorBootstrapExchange } from "./orchestrator-bootstrap";
38
39
 
@@ -139,10 +140,16 @@ const routes: Route[] = [
139
140
 
140
141
  route("POST", "/api/system/broadcast", postSystemBroadcast),
141
142
  route("GET", "/api/teams", getTeamsRoute),
143
+ route("GET", "/api/teams/outcomes", getTeamOutcomesRoute),
142
144
  route("GET", "/api/teams/me", getMyTeamRoute),
143
145
  route("PUT", "/api/teams/me/brief", putMyTeamBriefRoute),
144
146
  route("POST", "/api/teams/me/dissolve", postMyTeamDissolveRoute),
145
147
  route("POST", "/api/teams/:id/dissolve", postTeamDissolveByIdRoute),
148
+ route("GET", "/api/team-templates", getTeamTemplatesRoute),
149
+ route("POST", "/api/team-templates", postTeamTemplateRoute),
150
+ route("GET", "/api/team-templates/:slug", getTeamTemplateRoute),
151
+ route("PUT", "/api/team-templates/:slug", putTeamTemplateRoute),
152
+ route("DELETE", "/api/team-templates/:slug", deleteTeamTemplateRoute),
146
153
  route("GET", "/api/recipes", getRecipes),
147
154
  route("POST", "/api/recipes/start", postRecipeStart),
148
155
  route("GET", "/api/recipes/instances", getRecipeInstances),
@@ -0,0 +1,65 @@
1
+ import { authContextFromRequest } from "../services/auth-context";
2
+ import { deleteTeamTemplate, getTeamTemplate, listTeamTemplates, saveTeamTemplate } from "../services/team-templates";
3
+ import { cleanString } from "../validation";
4
+ import { authorizeRoute, error, json, parseBody, type Handler } from "./_shared";
5
+
6
+ function updatedBy(req: Request): string {
7
+ return authContextFromRequest(req).actor.id;
8
+ }
9
+
10
+ function routeError(err: unknown): Response {
11
+ if (err instanceof Error) return error(err.message);
12
+ return error("team template route failed");
13
+ }
14
+
15
+ function slugParam(params: Record<string, string | undefined>): string {
16
+ return cleanString(params.slug, "slug", { required: true, max: 120 })!;
17
+ }
18
+
19
+ export const getTeamTemplatesRoute: Handler = (req) => {
20
+ const denied = authorizeRoute(req, { scope: "system:admin" });
21
+ if (denied) return denied;
22
+ return json({ templates: listTeamTemplates() });
23
+ };
24
+
25
+ export const getTeamTemplateRoute: Handler = (req, params) => {
26
+ const denied = authorizeRoute(req, { scope: "system:admin" });
27
+ if (denied) return denied;
28
+ try {
29
+ const template = getTeamTemplate(slugParam(params));
30
+ return template ? json(template) : error("team template not found", 404);
31
+ } catch (err) {
32
+ return routeError(err);
33
+ }
34
+ };
35
+
36
+ export const postTeamTemplateRoute: Handler = async (req) => {
37
+ const denied = authorizeRoute(req, { scope: "system:admin" });
38
+ if (denied) return denied;
39
+ const parsed = await parseBody<Record<string, unknown>>(req);
40
+ if (!parsed.ok) return error(parsed.error, parsed.status);
41
+ try {
42
+ return json(saveTeamTemplate(parsed.body ?? {}, updatedBy(req)), 201);
43
+ } catch (err) {
44
+ return routeError(err);
45
+ }
46
+ };
47
+
48
+ export const putTeamTemplateRoute: Handler = async (req, params) => {
49
+ const denied = authorizeRoute(req, { scope: "system:admin" });
50
+ if (denied) return denied;
51
+ const parsed = await parseBody<Record<string, unknown>>(req);
52
+ if (!parsed.ok) return error(parsed.error, parsed.status);
53
+ try {
54
+ return json(saveTeamTemplate({ ...(parsed.body ?? {}), slug: slugParam(params) }, updatedBy(req)));
55
+ } catch (err) {
56
+ return routeError(err);
57
+ }
58
+ };
59
+
60
+ export const deleteTeamTemplateRoute: Handler = (req, params) => {
61
+ const denied = authorizeRoute(req, { scope: "system:admin" });
62
+ if (denied) return denied;
63
+ const slug = slugParam(params);
64
+ return json({ slug, deleted: deleteTeamTemplate(slug, updatedBy(req)) });
65
+ };
@@ -1,9 +1,9 @@
1
1
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
2
2
  import { authContextFromRequest } from "../services/auth-context";
3
- import { getTeam, listAllTeams, ValidationError } from "../db";
3
+ import { getTeam, getTeamOutcomeAggregates, listAllTeams, ValidationError } from "../db";
4
4
  import { dissolveCallerTeam, dissolveTeamById, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError, type TeamDissolveServiceInput } from "../services/team";
5
5
  import { cleanString, optionalEnum } from "../validation";
6
- import { authorizeRoute, error, json, parseBody, type Handler } from "./_shared";
6
+ import { authorizeRoute, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
7
7
  import type { Team } from "../types";
8
8
 
9
9
  const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
@@ -50,6 +50,18 @@ export const getTeamsRoute: Handler = (req) => {
50
50
  }
51
51
  };
52
52
 
53
+ export const getTeamOutcomesRoute: Handler = (req) => {
54
+ const denied = authorizeRoute(req, { scope: "system:admin" });
55
+ if (denied) return denied;
56
+ try {
57
+ const recentLimit = parseQueryInt(new URL(req.url).searchParams.get("recentLimit"), { min: 1, max: 50, clamp: true }) ?? undefined;
58
+ if (Number.isNaN(recentLimit)) return error("invalid recentLimit", 400);
59
+ return json(getTeamOutcomeAggregates({ recentLimit }));
60
+ } catch (err) {
61
+ return routeError(err);
62
+ }
63
+ };
64
+
53
65
  export const putMyTeamBriefRoute: Handler = async (req) => {
54
66
  const parsed = await parseBody<Record<string, unknown>>(req);
55
67
  if (!parsed.ok) return error(parsed.error, parsed.status);