agent-relay-server 0.50.0 → 0.51.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 (91) hide show
  1. package/docs/openapi.json +42 -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-Bt-j5suN.js} +2 -2
  6. package/public/assets/{activity-CCOlARUq.js.map → activity-Bt-j5suN.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-MmlXjZ8S.js} +2 -2
  10. package/public/assets/{agents-Cl9a1uBD.js.map → agents-MmlXjZ8S.js.map} +1 -1
  11. package/public/assets/{analytics-DkABnOKb.js → analytics-dzycu7KU.js} +2 -2
  12. package/public/assets/{analytics-DkABnOKb.js.map → analytics-dzycu7KU.js.map} +1 -1
  13. package/public/assets/{automation-CS2V5WAh.js → automation-DKmzNNAm.js} +2 -2
  14. package/public/assets/{automation-CS2V5WAh.js.map → automation-DKmzNNAm.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-DKPT2jJj.js} +2 -2
  20. package/public/assets/{chat-TFFCz8AI.js.map → chat-DKPT2jJj.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-Bzso46lB.js} +2 -2
  25. package/public/assets/{formatted-body-impl-B_mZW3Vj.js.map → formatted-body-impl-Bzso46lB.js.map} +1 -1
  26. package/public/assets/index-C0aWKi0g.css +2 -0
  27. package/public/assets/{index-CWs6iPpQ.js → index-uABGD_MX.js} +6 -6
  28. package/public/assets/{index-CWs6iPpQ.js.map → index-uABGD_MX.js.map} +1 -1
  29. package/public/assets/{insights-D5e1k0IU.js → insights-BczKqmA4.js} +2 -2
  30. package/public/assets/{insights-D5e1k0IU.js.map → insights-BczKqmA4.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-A3dR4k8v.js} +2 -2
  34. package/public/assets/{maintenance-ChmWW3iQ.js.map → maintenance-A3dR4k8v.js.map} +1 -1
  35. package/public/assets/{managed-agents-C6cbTB9d.js → managed-agents-65TJRBdv.js} +2 -2
  36. package/public/assets/{managed-agents-C6cbTB9d.js.map → managed-agents-65TJRBdv.js.map} +1 -1
  37. package/public/assets/{markdown-preview-impl-DjmZIwx5.js → markdown-preview-impl-pevH3csB.js} +2 -2
  38. package/public/assets/{markdown-preview-impl-DjmZIwx5.js.map → markdown-preview-impl-pevH3csB.js.map} +1 -1
  39. package/public/assets/{memory-DYRuGhD9.js → memory-B3CXqWQf.js} +2 -2
  40. package/public/assets/{memory-DYRuGhD9.js.map → memory-B3CXqWQf.js.map} +1 -1
  41. package/public/assets/{messages-q5N9kJ4f.js → messages-BV48hQsO.js} +2 -2
  42. package/public/assets/{messages-q5N9kJ4f.js.map → messages-BV48hQsO.js.map} +1 -1
  43. package/public/assets/{orchestrators-bbIEU7xO.js → orchestrators-CPU7U-s7.js} +2 -2
  44. package/public/assets/{orchestrators-bbIEU7xO.js.map → orchestrators-CPU7U-s7.js.map} +1 -1
  45. package/public/assets/{overview-B3TxMRHV.js → overview-mR70u7ea.js} +2 -2
  46. package/public/assets/{overview-B3TxMRHV.js.map → overview-mR70u7ea.js.map} +1 -1
  47. package/public/assets/{pairs-CF3XEcbG.js → pairs-B8KJc296.js} +2 -2
  48. package/public/assets/{pairs-CF3XEcbG.js.map → pairs-B8KJc296.js.map} +1 -1
  49. package/public/assets/{security-Bffy1FyV.js → security-Dc74nqW8.js} +2 -2
  50. package/public/assets/{security-Bffy1FyV.js.map → security-Dc74nqW8.js.map} +1 -1
  51. package/public/assets/{select-Bz0VV6tj.js → select-kIw3Jm8E.js} +2 -2
  52. package/public/assets/{select-Bz0VV6tj.js.map → select-kIw3Jm8E.js.map} +1 -1
  53. package/public/assets/{settings-DUucPX58.js → settings-XrFz0Q3b.js} +2 -2
  54. package/public/assets/{settings-DUucPX58.js.map → settings-XrFz0Q3b.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-TY2QGHHg.js} +2 -2
  58. package/public/assets/{tasks-Dv1CeteT.js.map → tasks-TY2QGHHg.js.map} +1 -1
  59. package/public/assets/teams-DMQN2gCU.js +2 -0
  60. package/public/assets/teams-DMQN2gCU.js.map +1 -0
  61. package/public/assets/{terminal-viewer-impl-DQEV3e7Z.js → terminal-viewer-impl-D3du9G9E.js} +2 -2
  62. package/public/assets/{terminal-viewer-impl-DQEV3e7Z.js.map → terminal-viewer-impl-D3du9G9E.js.map} +1 -1
  63. package/public/assets/{work-queue-9iUCo-hM.js → work-queue-B2nZp_vj.js} +2 -2
  64. package/public/assets/{work-queue-9iUCo-hM.js.map → work-queue-B2nZp_vj.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 +8 -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.ts +8 -10
  80. package/src/routes/index.ts +2 -1
  81. package/src/routes/teams.ts +14 -2
  82. package/src/services/agent-action.ts +138 -0
  83. package/src/services/plan-graph.ts +49 -11
  84. package/src/services/plan-live-bindings.ts +88 -0
  85. package/src/services/spawn-agent.ts +5 -1
  86. package/src/services/transient-agent-reaper.ts +108 -0
  87. package/src/spawn-command.ts +2 -0
  88. package/public/assets/index-B8ND8v33.css +0 -2
  89. package/public/assets/store-C7dqPL_V.js.map +0 -1
  90. package/public/assets/teams-DEASyStU.js +0 -2
  91. package/public/assets/teams-DEASyStU.js.map +0 -1
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
+ }
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);
@@ -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,7 +31,7 @@ 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
36
  import { postMcp } from "../mcp";
37
37
  import { postOrchestratorBootstrap, postOrchestratorBootstrapExchange } from "./orchestrator-bootstrap";
@@ -139,6 +139,7 @@ const routes: Route[] = [
139
139
 
140
140
  route("POST", "/api/system/broadcast", postSystemBroadcast),
141
141
  route("GET", "/api/teams", getTeamsRoute),
142
+ route("GET", "/api/teams/outcomes", getTeamOutcomesRoute),
142
143
  route("GET", "/api/teams/me", getMyTeamRoute),
143
144
  route("PUT", "/api/teams/me/brief", putMyTeamBriefRoute),
144
145
  route("POST", "/api/teams/me/dissolve", postMyTeamDissolveRoute),
@@ -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);
@@ -0,0 +1,138 @@
1
+ import { createCommand } from "../commands-db";
2
+ import { ValidationError, createActivityEvent, getAgent, sendMessage } from "../db";
3
+ import { emitCommandEvent } from "../command-events";
4
+ import { emitActivityEvent, emitNewMessage } from "../sse";
5
+ import { authorizeShutdown, ShutdownAuthError, ShutdownTargetError } from "./shutdown-agent";
6
+ import type { AuthContext } from "./auth-context";
7
+ import type { AgentCard, Command } from "../types";
8
+
9
+ export type AgentAction = "compact" | "clearContext" | "inject";
10
+
11
+ export interface AgentActionInput {
12
+ agentId: string;
13
+ action: AgentAction;
14
+ message?: string;
15
+ requestedVia: "mcp";
16
+ requestedBy?: string;
17
+ }
18
+
19
+ export interface AgentActionResult {
20
+ action: AgentAction;
21
+ command: Command;
22
+ }
23
+
24
+ export class AgentActionTargetError extends Error {}
25
+
26
+ function commandType(action: AgentAction): "agent.compact" | "agent.clearContext" | "prompt.inject" {
27
+ if (action === "compact") return "agent.compact";
28
+ if (action === "clearContext") return "agent.clearContext";
29
+ return "prompt.inject";
30
+ }
31
+
32
+ function title(action: AgentAction): string {
33
+ if (action === "compact") return "Agent compaction requested";
34
+ if (action === "clearContext") return "Agent context clear requested";
35
+ return "Agent prompt injected";
36
+ }
37
+
38
+ function icon(action: AgentAction): string {
39
+ if (action === "compact") return "ti-fold";
40
+ if (action === "clearContext") return "ti-clear-all";
41
+ return "ti-message-bolt";
42
+ }
43
+
44
+ function compactMemoryParams(agent: AgentCard): Record<string, unknown> {
45
+ const alwaysReload = agent.tags
46
+ .filter((tag) => tag.startsWith("memory-reload:"))
47
+ .map((tag) => tag.slice("memory-reload:".length).trim())
48
+ .filter(Boolean);
49
+ if (!alwaysReload.length) return {};
50
+ return { memory: { alwaysReload } };
51
+ }
52
+
53
+ function assertSupported(agent: AgentCard, action: AgentAction): void {
54
+ if (agent.id === "user" || agent.id === "system" || agent.meta?.kind === "channel" || agent.tags.includes("channel")) {
55
+ throw new ValidationError("agent cannot receive lifecycle actions");
56
+ }
57
+ if (agent.status === "offline") throw new ValidationError("agent is offline");
58
+ if (action === "compact" && agent.providerCapabilities?.context?.compact !== true) {
59
+ throw new ValidationError("agent does not support compact");
60
+ }
61
+ if (action === "clearContext" && agent.providerCapabilities?.context?.clear !== true) {
62
+ throw new ValidationError("agent does not support clearContext");
63
+ }
64
+ if (action === "inject" && agent.providerCapabilities?.liveSession?.inject !== true) {
65
+ throw new ValidationError("agent does not support inject");
66
+ }
67
+ }
68
+
69
+ function sessionMessageForInject(agentId: string, ctx: AuthContext, body: string) {
70
+ const from = ctx.callerAgentId && getAgent(ctx.callerAgentId) ? ctx.callerAgentId : "system";
71
+ const message = sendMessage({
72
+ from,
73
+ to: agentId,
74
+ kind: "session",
75
+ body,
76
+ });
77
+ emitNewMessage(message);
78
+ return message;
79
+ }
80
+
81
+ export function dispatchAgentAction(input: AgentActionInput, ctx: AuthContext): AgentActionResult {
82
+ const agent = getAgent(input.agentId);
83
+ if (!agent) throw new AgentActionTargetError(`agent ${input.agentId} not found`);
84
+
85
+ try {
86
+ authorizeShutdown(ctx, { agentId: input.agentId, requestedVia: input.requestedVia });
87
+ } catch (e) {
88
+ if (e instanceof ShutdownAuthError || e instanceof ShutdownTargetError) throw e;
89
+ throw e;
90
+ }
91
+ assertSupported(agent, input.action);
92
+
93
+ const body = input.action === "inject"
94
+ ? (input.message?.trim() || (() => { throw new ValidationError("message is required for inject"); })())
95
+ : undefined;
96
+ const sessionMessage = body ? sessionMessageForInject(agent.id, ctx, body) : undefined;
97
+ const command = createCommand({
98
+ type: commandType(input.action),
99
+ source: "system",
100
+ target: agent.id,
101
+ correlationId: typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined,
102
+ params: {
103
+ action: input.action,
104
+ agentId: agent.id,
105
+ ...(typeof agent.meta?.sessionName === "string" ? { sessionName: agent.meta.sessionName } : {}),
106
+ ...(typeof agent.meta?.tmuxSession === "string" ? { tmuxSession: agent.meta.tmuxSession } : {}),
107
+ ...(typeof agent.meta?.policyName === "string" ? { policyName: agent.meta.policyName } : {}),
108
+ ...(typeof agent.meta?.spawnRequestId === "string" ? { spawnRequestId: agent.meta.spawnRequestId } : {}),
109
+ ...(input.action === "compact" ? compactMemoryParams(agent) : {}),
110
+ ...(body ? { body, messageId: sessionMessage!.id } : {}),
111
+ requestedBy: input.requestedBy ?? ctx.callerAgentId ?? ctx.actor.id,
112
+ requestedVia: input.requestedVia,
113
+ requestedAt: Date.now(),
114
+ },
115
+ });
116
+ emitCommandEvent(command, "command.requested");
117
+ const event = createActivityEvent({
118
+ clientId: `server-agent-${agent.id}-action-${input.action}-${command.id}`,
119
+ kind: "state",
120
+ title: title(input.action),
121
+ body: input.action,
122
+ meta: agent.id,
123
+ icon: icon(input.action),
124
+ view: "agents",
125
+ agentId: agent.id,
126
+ messageId: sessionMessage?.id,
127
+ metadata: {
128
+ action: input.action,
129
+ commandId: command.id,
130
+ actor: ctx.actor.id,
131
+ actorKind: ctx.actor.kind,
132
+ requestedVia: input.requestedVia,
133
+ source: "server",
134
+ },
135
+ });
136
+ emitActivityEvent(event);
137
+ return { action: input.action, command };
138
+ }
@@ -1,9 +1,9 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
- import { createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, planThreadExists, replacePlan, ValidationError } from "../db";
2
+ import { createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, listPlansWithBoundOwner, planThreadExists, replacePlan, ValidationError } from "../db";
3
3
  import { emitRelayEvent } from "../events";
4
4
  import { cleanString, cleanStringArray, optionalEnum } from "../validation";
5
5
  import type { AuthContext } from "./auth-context";
6
- import type { Plan, PlanEdge, PlanNode, PlanOwnerRef, PlanStatus } from "../types";
6
+ import type { AgentCard, Plan, PlanEdge, PlanNode, PlanOwnerRef, PlanStatus } from "../types";
7
7
 
8
8
  export const PLAN_STATUSES = ["pending", "in-progress", "landed", "blocked"] as const;
9
9
 
@@ -86,10 +86,22 @@ function cleanBlackboardRefs(value: unknown, planTeamId?: string): string[] | un
86
86
  return refs;
87
87
  }
88
88
 
89
- function cleanStatusSource(value: unknown): "manual" {
89
+ function cleanStatusSource(value: unknown): PlanNode["statusSource"] {
90
90
  const statusSource = optionalEnum(value, "statusSource", ["manual", "bound"] as const, "manual");
91
- if (statusSource !== "manual") throw new ValidationError("statusSource must be manual in this slice");
92
- return "manual";
91
+ return statusSource;
92
+ }
93
+
94
+ function boundStatusForAgent(agent: Pick<AgentCard, "status"> | null | undefined): PlanStatus | undefined {
95
+ if (!agent) return undefined;
96
+ if (agent.status === "busy") return "in-progress";
97
+ return "pending";
98
+ }
99
+
100
+ function applyBoundStatus(node: PlanNode): PlanNode {
101
+ if (node.statusSource !== "bound") return node;
102
+ const agentId = node.ownerRef?.agentId;
103
+ if (!agentId) throw new ValidationError("bound plan nodes require ownerRef.agentId");
104
+ return { ...node, status: boundStatusForAgent(getAgent(agentId)) ?? "pending" };
93
105
  }
94
106
 
95
107
  function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
@@ -99,17 +111,18 @@ function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
99
111
  const lane = cleanString(input.lane, "node.lane", { max: 120 });
100
112
  const refs = cleanRefs(input.refs);
101
113
  const blackboardRefs = cleanBlackboardRefs(input.blackboardRefs, planTeamId);
102
- return {
114
+ const statusSource = cleanStatusSource(input.statusSource);
115
+ return applyBoundStatus({
103
116
  id: cleanString(input.id, "node.id", { required: true, max: 120 })!,
104
117
  label: cleanString(input.label, "node.label", { required: true, max: 240 })!,
105
118
  ...(scopeBlurb ? { scopeBlurb } : {}),
106
119
  ...(ownerRef ? { ownerRef } : {}),
107
120
  ...(lane ? { lane } : {}),
108
121
  status: optionalEnum(input.status, "node.status", PLAN_STATUSES, "pending") as PlanStatus,
109
- statusSource: cleanStatusSource(input.statusSource),
122
+ statusSource,
110
123
  ...(refs ? { refs } : {}),
111
124
  ...(blackboardRefs ? { blackboardRefs } : {}),
112
- };
125
+ });
113
126
  }
114
127
 
115
128
  function cleanNodes(value: unknown, planTeamId?: string): PlanNode[] {
@@ -233,8 +246,8 @@ export function updatePlanNode(planId: string, nodeId: string, patch: PlanNodePa
233
246
  const plan = existingPlan(planId);
234
247
  const existing = plan.nodes.find((node) => node.id === nodeId);
235
248
  if (!existing) throw new ValidationError(`node ${nodeId} not found`);
236
- if (patch.statusSource !== undefined) cleanStatusSource(patch.statusSource);
237
- const next: PlanNode = {
249
+ const statusSource = patch.statusSource !== undefined ? cleanStatusSource(patch.statusSource) : existing.statusSource;
250
+ const base: PlanNode = {
238
251
  ...existing,
239
252
  ...(patch.label !== undefined ? { label: cleanString(patch.label, "label", { required: true, max: 240 })! } : {}),
240
253
  ...(patch.scopeBlurb !== undefined ? optionalProp("scopeBlurb", cleanString(patch.scopeBlurb, "scopeBlurb", { max: 2000 })) : {}),
@@ -243,8 +256,9 @@ export function updatePlanNode(planId: string, nodeId: string, patch: PlanNodePa
243
256
  ...(patch.status !== undefined ? { status: optionalEnum(patch.status, "status", PLAN_STATUSES) as PlanStatus } : {}),
244
257
  ...(patch.refs !== undefined ? optionalProp("refs", cleanRefs(patch.refs)) : {}),
245
258
  ...(patch.blackboardRefs !== undefined ? optionalProp("blackboardRefs", cleanBlackboardRefs(patch.blackboardRefs, plan.teamId)) : {}),
246
- statusSource: "manual",
259
+ statusSource,
247
260
  };
261
+ const next = applyBoundStatus(base);
248
262
  const updated = replacePlan({ ...plan, nodes: plan.nodes.map((node) => node.id === nodeId ? next : node) }, actorId(ctx));
249
263
  emitPlan("plan.changed", updated, actorId(ctx));
250
264
  return updated;
@@ -294,6 +308,30 @@ export function deletePlanGraph(planId: string, ctx: AuthContext): { id: string;
294
308
  return { id: plan.id, deleted: true };
295
309
  }
296
310
 
311
+ export function updateBoundPlanNodesForAgent(agentId: string, status: PlanStatus, actor = "server"): Plan[] {
312
+ const updated: Plan[] = [];
313
+ for (const plan of listPlansWithBoundOwner(agentId)) {
314
+ let changed = false;
315
+ const nodes = plan.nodes.map((node) => {
316
+ if (node.statusSource !== "bound" || node.ownerRef?.agentId !== agentId) return node;
317
+ if (node.status === "landed") return node;
318
+ if (node.status === status) return node;
319
+ changed = true;
320
+ return { ...node, status };
321
+ });
322
+ if (!changed) continue;
323
+ const next = replacePlan({ ...plan, nodes }, actor);
324
+ emitPlan("plan.changed", next, actor);
325
+ updated.push(next);
326
+ }
327
+ return updated;
328
+ }
329
+
330
+ export function updateBoundPlanNodesFromAgent(agentId: string, actor = "server"): Plan[] {
331
+ const status = boundStatusForAgent(getAgent(agentId));
332
+ return status ? updateBoundPlanNodesForAgent(agentId, status, actor) : [];
333
+ }
334
+
297
335
  export function getPlanGraph(input: { planId?: unknown; threadId?: unknown; teamId?: unknown; channel?: unknown }): Plan | null {
298
336
  const planId = cleanString(input.planId, "planId", { max: 120 });
299
337
  if (planId) return getPlan(planId);