agent-relay-server 0.51.0 → 0.53.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 (82) hide show
  1. package/docs/openapi.json +165 -1
  2. package/package.json +2 -2
  3. package/public/assets/PlanGraphPanel-C81um9s6.js +2 -0
  4. package/public/assets/PlanGraphPanel-C81um9s6.js.map +1 -0
  5. package/public/assets/{activity-Bt-j5suN.js → activity-DwVgT07D.js} +2 -2
  6. package/public/assets/{activity-Bt-j5suN.js.map → activity-DwVgT07D.js.map} +1 -1
  7. package/public/assets/{agent-profiles-BhVEehP8.js → agent-profiles-BZ_QdiHU.js} +2 -2
  8. package/public/assets/{agent-profiles-BhVEehP8.js.map → agent-profiles-BZ_QdiHU.js.map} +1 -1
  9. package/public/assets/{agents-MmlXjZ8S.js → agents-DV5y_tF1.js} +2 -2
  10. package/public/assets/{agents-MmlXjZ8S.js.map → agents-DV5y_tF1.js.map} +1 -1
  11. package/public/assets/{analytics-dzycu7KU.js → analytics-CzmIT4LJ.js} +2 -2
  12. package/public/assets/{analytics-dzycu7KU.js.map → analytics-CzmIT4LJ.js.map} +1 -1
  13. package/public/assets/{automation-DKmzNNAm.js → automation-Bcx2SOdF.js} +2 -2
  14. package/public/assets/{automation-DKmzNNAm.js.map → automation-Bcx2SOdF.js.map} +1 -1
  15. package/public/assets/{branch-state-badge-Dp6Jk4LH.js → branch-state-badge-CzlsNakd.js} +2 -2
  16. package/public/assets/{branch-state-badge-Dp6Jk4LH.js.map → branch-state-badge-CzlsNakd.js.map} +1 -1
  17. package/public/assets/{channels-CmfUfiV0.js → channels-By6jRWVP.js} +2 -2
  18. package/public/assets/{channels-CmfUfiV0.js.map → channels-By6jRWVP.js.map} +1 -1
  19. package/public/assets/{chat-DKPT2jJj.js → chat-9KhGPR6T.js} +2 -2
  20. package/public/assets/{chat-DKPT2jJj.js.map → chat-9KhGPR6T.js.map} +1 -1
  21. package/public/assets/{connectors-Bf74bw_9.js → connectors-DzhH5WYg.js} +2 -2
  22. package/public/assets/{connectors-Bf74bw_9.js.map → connectors-DzhH5WYg.js.map} +1 -1
  23. package/public/assets/display-CXhnBwGf.js.map +1 -1
  24. package/public/assets/{formatted-body-impl-Bzso46lB.js → formatted-body-impl-tuH3hn4f.js} +2 -2
  25. package/public/assets/{formatted-body-impl-Bzso46lB.js.map → formatted-body-impl-tuH3hn4f.js.map} +1 -1
  26. package/public/assets/{index-uABGD_MX.js → index-BRi2QbMp.js} +6 -6
  27. package/public/assets/index-BRi2QbMp.js.map +1 -0
  28. package/public/assets/{index-C0aWKi0g.css → index-t-8AiaRW.css} +1 -1
  29. package/public/assets/{insights-BczKqmA4.js → insights-Cmkftpne.js} +2 -2
  30. package/public/assets/{insights-BczKqmA4.js.map → insights-Cmkftpne.js.map} +1 -1
  31. package/public/assets/{integrations-By3hMfqC.js → integrations-zGR2Hmle.js} +2 -2
  32. package/public/assets/{integrations-By3hMfqC.js.map → integrations-zGR2Hmle.js.map} +1 -1
  33. package/public/assets/{maintenance-A3dR4k8v.js → maintenance-Dr0Y-QW1.js} +2 -2
  34. package/public/assets/{maintenance-A3dR4k8v.js.map → maintenance-Dr0Y-QW1.js.map} +1 -1
  35. package/public/assets/{managed-agents-65TJRBdv.js → managed-agents-Dd_CLfkU.js} +2 -2
  36. package/public/assets/{managed-agents-65TJRBdv.js.map → managed-agents-Dd_CLfkU.js.map} +1 -1
  37. package/public/assets/{markdown-preview-impl-pevH3csB.js → markdown-preview-impl-CasNfCMM.js} +2 -2
  38. package/public/assets/{markdown-preview-impl-pevH3csB.js.map → markdown-preview-impl-CasNfCMM.js.map} +1 -1
  39. package/public/assets/{memory-B3CXqWQf.js → memory-FCZUrHDG.js} +2 -2
  40. package/public/assets/{memory-B3CXqWQf.js.map → memory-FCZUrHDG.js.map} +1 -1
  41. package/public/assets/{messages-BV48hQsO.js → messages-BVidQ6qj.js} +2 -2
  42. package/public/assets/{messages-BV48hQsO.js.map → messages-BVidQ6qj.js.map} +1 -1
  43. package/public/assets/{orchestrators-CPU7U-s7.js → orchestrators-CDLPID66.js} +2 -2
  44. package/public/assets/{orchestrators-CPU7U-s7.js.map → orchestrators-CDLPID66.js.map} +1 -1
  45. package/public/assets/{overview-mR70u7ea.js → overview-CBBOJR6C.js} +2 -2
  46. package/public/assets/{overview-mR70u7ea.js.map → overview-CBBOJR6C.js.map} +1 -1
  47. package/public/assets/{pairs-B8KJc296.js → pairs-DT1DGrb8.js} +2 -2
  48. package/public/assets/{pairs-B8KJc296.js.map → pairs-DT1DGrb8.js.map} +1 -1
  49. package/public/assets/{security-Dc74nqW8.js → security-BQgWIgl4.js} +2 -2
  50. package/public/assets/{security-Dc74nqW8.js.map → security-BQgWIgl4.js.map} +1 -1
  51. package/public/assets/{select-kIw3Jm8E.js → select-yVL8li9x.js} +2 -2
  52. package/public/assets/{select-kIw3Jm8E.js.map → select-yVL8li9x.js.map} +1 -1
  53. package/public/assets/{settings-XrFz0Q3b.js → settings-C5Mg9eH8.js} +2 -2
  54. package/public/assets/{settings-XrFz0Q3b.js.map → settings-C5Mg9eH8.js.map} +1 -1
  55. package/public/assets/{store-BLZA3u7Y.js → store-BlsqvkSE.js} +3 -3
  56. package/public/assets/store-BlsqvkSE.js.map +1 -0
  57. package/public/assets/{tasks-TY2QGHHg.js → tasks-CySWSOjp.js} +2 -2
  58. package/public/assets/{tasks-TY2QGHHg.js.map → tasks-CySWSOjp.js.map} +1 -1
  59. package/public/assets/teams-DhFfsGOb.js +2 -0
  60. package/public/assets/teams-DhFfsGOb.js.map +1 -0
  61. package/public/assets/{terminal-viewer-impl-D3du9G9E.js → terminal-viewer-impl-BUozTzVL.js} +2 -2
  62. package/public/assets/{terminal-viewer-impl-D3du9G9E.js.map → terminal-viewer-impl-BUozTzVL.js.map} +1 -1
  63. package/public/assets/{work-queue-B2nZp_vj.js → work-queue-CYeuuHjM.js} +2 -2
  64. package/public/assets/{work-queue-B2nZp_vj.js.map → work-queue-CYeuuHjM.js.map} +1 -1
  65. package/public/assets/{workspaces-PdCwiSQx.js → workspaces-DrJwKq9X.js} +2 -2
  66. package/public/assets/{workspaces-PdCwiSQx.js.map → workspaces-DrJwKq9X.js.map} +1 -1
  67. package/public/index.html +3 -3
  68. package/src/config-store.ts +2 -0
  69. package/src/mcp-team-template-tools.ts +133 -0
  70. package/src/mcp-team-tools.ts +4 -1
  71. package/src/mcp.ts +6 -6
  72. package/src/routes/index.ts +6 -0
  73. package/src/routes/team-templates.ts +65 -0
  74. package/src/services/shutdown-agent.ts +24 -7
  75. package/src/services/team-templates.ts +172 -0
  76. package/src/team-template-config.ts +100 -0
  77. package/public/assets/PlanGraphPanel-BnYmYHnY.js +0 -2
  78. package/public/assets/PlanGraphPanel-BnYmYHnY.js.map +0 -1
  79. package/public/assets/index-uABGD_MX.js.map +0 -1
  80. package/public/assets/store-BLZA3u7Y.js.map +0 -1
  81. package/public/assets/teams-DMQN2gCU.js +0 -2
  82. package/public/assets/teams-DMQN2gCU.js.map +0 -1
@@ -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
@@ -334,15 +334,15 @@ const TOOLS: ToolDefinition[] = [
334
334
  },
335
335
  {
336
336
  name: "relay_shutdown_agent",
337
- description: "Shut down an agent through Relay's orchestrator. Gated: requires the command:shutdown scope; an agent caller may only target its OWN live spawned children. Admin tokens keep full reach.",
337
+ description: "Shut down an agent through Relay's orchestrator. Gated: requires the command:shutdown scope; an agent caller may only target its OWN live spawned children, addressed by agentId or spawnRequestId (policyName/tmuxSession are admin-only broad targets). Admin tokens keep full reach.",
338
338
  requiredScopes: ["command:shutdown"],
339
339
  inputSchema: {
340
340
  type: "object",
341
341
  properties: {
342
- agentId: { type: "string" },
343
- policyName: { type: "string" },
344
- spawnRequestId: { type: "string" },
345
- tmuxSession: { type: "string" },
342
+ agentId: { type: "string", description: "Target child by agent id. For an agent caller, agentId or spawnRequestId identifies your spawned child." },
343
+ policyName: { type: "string", description: "Broad target by managed-agent policy — admin/full-reach callers only." },
344
+ spawnRequestId: { type: "string", description: "Target child by the idempotency key relay_spawn_agent returned — resolved to your spawned child server-side." },
345
+ tmuxSession: { type: "string", description: "Broad target by tmux session — admin/full-reach callers only." },
346
346
  orchestratorId: { type: "string" },
347
347
  reason: { type: "string" },
348
348
  timeoutMs: { type: "integer", minimum: 1 },
@@ -570,7 +570,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
570
570
  else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
571
571
  else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
572
572
  else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
573
- else if (name.startsWith("relay_team_")) result = relayTeamTool(auth, name, args);
573
+ else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
574
574
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
575
575
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
576
576
  else throw new ValidationError(`unknown tool: ${name}`);
@@ -33,6 +33,7 @@ import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute,
33
33
  import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
34
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
 
@@ -144,6 +145,11 @@ const routes: Route[] = [
144
145
  route("PUT", "/api/teams/me/brief", putMyTeamBriefRoute),
145
146
  route("POST", "/api/teams/me/dissolve", postMyTeamDissolveRoute),
146
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),
147
153
  route("GET", "/api/recipes", getRecipes),
148
154
  route("POST", "/api/recipes/start", postRecipeStart),
149
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
+ };
@@ -30,6 +30,7 @@ import {
30
30
  createActivityEvent,
31
31
  getAgent,
32
32
  getOrchestrator,
33
+ listAgents,
33
34
  mergeAgentMeta,
34
35
  } from "../db";
35
36
  import { createCommand } from "../commands-db";
@@ -106,16 +107,32 @@ export function authorizeShutdown(ctx: AuthContext, input: ShutdownAgentInput):
106
107
  const callerId = ctx.callerAgentId;
107
108
  // Full reach: admin/server/system (no resolved caller identity) and multi-agent tokens.
108
109
  if (!callerId) return;
109
- // A scoped agent caller may target only its OWN live spawned children, addressed by
110
- // agentId. Broad targeting (policy/spawnRequestId/tmux) and cross-agent kills stay
111
- // full-reach-only otherwise spawn permission silently becomes kill-anyone permission.
112
- if (!input.agentId || input.policyName || input.spawnRequestId || input.tmuxSession) {
113
- throw new ShutdownAuthError("agents may only shut down their own spawned children, addressed by agentId");
110
+ // A scoped agent caller may target only its OWN live spawned children. Two PRECISE
111
+ // self-id keys the parent already holds resolve to a single child and pass the same
112
+ // `spawnedBy` ownership check: `agentId` and `spawnRequestId` (the idempotency key
113
+ // `relay_spawn_agent` accepts + returns; stored on the child's `meta.spawnRequestId`).
114
+ // #115: spawnRequestId was wrongly lumped with the genuinely-broad targets, forcing a
115
+ // parent to look up an agentId it didn't need. Broad targeting (`policyName`/`tmuxSession`)
116
+ // can match agents the caller never spawned, or several at once, so it stays full-reach-only
117
+ // — otherwise spawn permission silently becomes kill-anyone permission.
118
+ if (input.policyName || input.tmuxSession || (!input.agentId && !input.spawnRequestId)) {
119
+ throw new ShutdownAuthError(
120
+ "agents may only shut down their own spawned children, addressed by agentId or spawnRequestId",
121
+ );
114
122
  }
115
- const target = getAgent(input.agentId);
123
+ const target = input.agentId
124
+ ? getAgent(input.agentId)
125
+ : listAgents().find((a) => a.meta?.spawnRequestId === input.spawnRequestId);
116
126
  if (!target || target.spawnedBy !== callerId) {
117
- throw new ShutdownAuthError(`agent ${input.agentId} is not one of your spawned children`);
127
+ throw new ShutdownAuthError(
128
+ input.agentId
129
+ ? `agent ${input.agentId} is not one of your spawned children`
130
+ : `no spawned child of yours matches spawnRequestId ${input.spawnRequestId}`,
131
+ );
118
132
  }
133
+ // Normalize to the resolved agentId so the rest of the service (orchestrator resolution,
134
+ // lifecycle pill, audit row) works on a concrete agent uniformly for both key forms.
135
+ input.agentId = target.id;
119
136
  }
120
137
 
121
138
  /** Resolve the online control orchestrator for the target — the canonical resolver,
@@ -0,0 +1,172 @@
1
+ import { isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, APPROVAL_MODES, VALID_AGENT_LIFECYCLES } from "agent-relay-sdk";
2
+ import { getTeam, ValidationError } from "../db";
3
+ import { deleteConfig, getConfig, listConfig, setConfig } from "../config-store";
4
+ import { TEAM_TEMPLATE_NAMESPACE } from "../team-template-config";
5
+ import { cleanString, optionalEnum } from "../validation";
6
+ import type { AuthContext } from "./auth-context";
7
+ import { setCallerTeamBrief } from "./team";
8
+ import { spawnAgent, type SpawnAgentInput, type SpawnAgentResult } from "./spawn-agent";
9
+ import type { AgentLifecycle, SpawnApprovalMode, SpawnProvider, TeamTemplate, TeamTemplateCompositionEntry, WorkspaceMode } from "../types";
10
+
11
+ export interface TeamTemplateInstantiateInput {
12
+ slug: string;
13
+ orchestratorId?: unknown;
14
+ cwd?: unknown;
15
+ approvalMode?: unknown;
16
+ workspaceMode?: unknown;
17
+ lifecycle?: unknown;
18
+ prompt?: unknown;
19
+ waitForRegistrationMs?: unknown;
20
+ }
21
+
22
+ export interface TeamTemplateInstantiateResult {
23
+ template: TeamTemplate;
24
+ team: ReturnType<typeof setCallerTeamBrief>["team"];
25
+ spawned: SpawnAgentResult[];
26
+ }
27
+
28
+ function slugify(value: string): string {
29
+ const slug = value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120);
30
+ return slug || "team-template";
31
+ }
32
+
33
+ function templateSlug(input: Record<string, unknown>): string {
34
+ return cleanString(input.slug ?? input.id, "slug", { max: 120 }) ?? slugify(cleanString(input.title, "title", { required: true, max: 240 })!);
35
+ }
36
+
37
+ function nowIso(): string {
38
+ return new Date().toISOString();
39
+ }
40
+
41
+ function withTimestamps(template: TeamTemplate, existing?: TeamTemplate): TeamTemplate {
42
+ const now = nowIso();
43
+ return {
44
+ ...template,
45
+ createdAt: existing?.createdAt ?? template.createdAt ?? now,
46
+ updatedAt: now,
47
+ };
48
+ }
49
+
50
+ export function saveTeamTemplate(input: Record<string, unknown>, updatedBy?: string): TeamTemplate {
51
+ if (input.teamId !== undefined && input.composition !== undefined) {
52
+ throw new ValidationError("teamId capture and manual composition are mutually exclusive");
53
+ }
54
+ if (input.teamId !== undefined) return captureTeamTemplateFromTeam(cleanString(input.teamId, "teamId", { required: true, max: 120 })!, input, updatedBy);
55
+ const slug = templateSlug(input);
56
+ const existing = getConfig<TeamTemplate>(TEAM_TEMPLATE_NAMESPACE, slug)?.value;
57
+ const template = withTimestamps({
58
+ id: slug,
59
+ slug,
60
+ title: cleanString(input.title, "title", { required: true, max: 240 })!,
61
+ ...(cleanString(input.description, "description", { max: 2000 }) ? { description: cleanString(input.description, "description", { max: 2000 }) } : {}),
62
+ composition: input.composition as TeamTemplateCompositionEntry[],
63
+ ...(cleanString(input.brief, "brief", { max: 16_000 }) ? { brief: cleanString(input.brief, "brief", { max: 16_000 }) } : {}),
64
+ ...(cleanString(input.definitionOfDone, "definitionOfDone", { max: 4000 }) ? { definitionOfDone: cleanString(input.definitionOfDone, "definitionOfDone", { max: 4000 }) } : {}),
65
+ ...(input.planSkeleton !== undefined ? { planSkeleton: input.planSkeleton as TeamTemplate["planSkeleton"] } : {}),
66
+ }, existing);
67
+ return setConfig(TEAM_TEMPLATE_NAMESPACE, template.slug, template, updatedBy).value as TeamTemplate;
68
+ }
69
+
70
+ export function listTeamTemplates(): TeamTemplate[] {
71
+ return listConfig<TeamTemplate>(TEAM_TEMPLATE_NAMESPACE).map((entry) => entry.value);
72
+ }
73
+
74
+ export function getTeamTemplate(slug: string): TeamTemplate | null {
75
+ return getConfig<TeamTemplate>(TEAM_TEMPLATE_NAMESPACE, slug)?.value ?? null;
76
+ }
77
+
78
+ export function deleteTeamTemplate(slug: string, updatedBy?: string): boolean {
79
+ return deleteConfig(TEAM_TEMPLATE_NAMESPACE, slug, updatedBy);
80
+ }
81
+
82
+ function parseFrozenComposition(raw: string): Array<Record<string, unknown>> {
83
+ try {
84
+ const parsed = JSON.parse(raw) as unknown;
85
+ if (!Array.isArray(parsed)) throw new Error("not array");
86
+ return parsed.filter(isRecord);
87
+ } catch {
88
+ throw new ValidationError("team composition snapshot is invalid JSON");
89
+ }
90
+ }
91
+
92
+ function compositionFromFrozenSnapshot(raw: string): TeamTemplateCompositionEntry[] {
93
+ const grouped = new Map<string, TeamTemplateCompositionEntry>();
94
+ for (const member of parseFrozenComposition(raw)) {
95
+ const provider = cleanString(member.provider, "composition.provider", { max: 40 });
96
+ if (!provider) continue;
97
+ if (!SPAWN_PROVIDERS.includes(provider as SpawnProvider)) throw new ValidationError(`unsupported captured provider: ${provider}`);
98
+ const model = cleanString(member.model, "composition.model", { max: 120 });
99
+ const label = cleanString(member.label ?? member.role, "composition.label", { max: 120 }) ?? provider;
100
+ const key = JSON.stringify({ label, provider, model });
101
+ const existing = grouped.get(key);
102
+ if (existing) existing.count += 1;
103
+ else grouped.set(key, { label, provider: provider as SpawnProvider, ...(model ? { model } : {}), count: 1 });
104
+ }
105
+ const composition = [...grouped.values()];
106
+ if (!composition.length) throw new ValidationError("team composition has no spawnable provider members");
107
+ return composition;
108
+ }
109
+
110
+ export function captureTeamTemplateFromTeam(teamId: string, input: Record<string, unknown> = {}, updatedBy?: string): TeamTemplate {
111
+ const team = getTeam(teamId);
112
+ if (!team) throw new ValidationError(`team ${teamId} not found`);
113
+ if (!team.composition) throw new ValidationError("team has no frozen composition snapshot; dissolve it before capture");
114
+ const title = cleanString(input.title, "title", { max: 240 }) ?? `${team.title ?? team.id} template`;
115
+ const slug = cleanString(input.slug ?? input.id, "slug", { max: 120 }) ?? slugify(title);
116
+ const existing = getConfig<TeamTemplate>(TEAM_TEMPLATE_NAMESPACE, slug)?.value;
117
+ const template = withTimestamps({
118
+ id: slug,
119
+ slug,
120
+ title,
121
+ ...(cleanString(input.description, "description", { max: 2000 }) ?? team.outcomeSummary ? { description: cleanString(input.description, "description", { max: 2000 }) ?? team.outcomeSummary } : {}),
122
+ composition: compositionFromFrozenSnapshot(team.composition),
123
+ ...(team.brief ? { brief: team.brief } : {}),
124
+ ...(team.definitionOfDone ? { definitionOfDone: team.definitionOfDone } : {}),
125
+ }, existing);
126
+ return setConfig(TEAM_TEMPLATE_NAMESPACE, template.slug, template, updatedBy).value as TeamTemplate;
127
+ }
128
+
129
+ function cleanWait(value: unknown): number {
130
+ if (value === undefined || value === null) return 0;
131
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 30_000) {
132
+ throw new ValidationError("waitForRegistrationMs must be an integer between 0 and 30000");
133
+ }
134
+ return value;
135
+ }
136
+
137
+ function spawnInputForEntry(template: TeamTemplate, entry: TeamTemplateCompositionEntry, index: number, count: number, input: TeamTemplateInstantiateInput): SpawnAgentInput {
138
+ return {
139
+ provider: entry.provider,
140
+ orchestratorId: cleanString(input.orchestratorId, "orchestratorId", { max: 200 }),
141
+ cwd: cleanString(input.cwd, "cwd", { max: 500 }),
142
+ model: entry.model,
143
+ effort: entry.effort,
144
+ approvalMode: optionalEnum(input.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
145
+ workspaceMode: optionalEnum(input.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
146
+ lifecycle: optionalEnum(input.lifecycle, "lifecycle", VALID_AGENT_LIFECYCLES) as AgentLifecycle | undefined,
147
+ profile: entry.profile,
148
+ label: count === 1 ? entry.label : `${entry.label} ${index + 1}`,
149
+ prompt: cleanString(input.prompt, "prompt", { max: 16_000 }) ?? template.brief,
150
+ tags: ["team-template", `template:${template.slug}`],
151
+ requestedVia: "team-template",
152
+ waitForRegistrationMs: cleanWait(input.waitForRegistrationMs),
153
+ };
154
+ }
155
+
156
+ export async function instantiateTeamTemplate(input: TeamTemplateInstantiateInput, ctx: AuthContext): Promise<TeamTemplateInstantiateResult> {
157
+ const slug = cleanString(input.slug, "slug", { required: true, max: 120 })!;
158
+ const template = getTeamTemplate(slug);
159
+ if (!template) throw new ValidationError(`team template ${slug} not found`);
160
+ const teamPayload = setCallerTeamBrief(ctx, {
161
+ title: template.title,
162
+ brief: template.brief,
163
+ definitionOfDone: template.definitionOfDone,
164
+ });
165
+ const spawned: SpawnAgentResult[] = [];
166
+ for (const entry of template.composition) {
167
+ for (let i = 0; i < entry.count; i++) {
168
+ spawned.push(await spawnAgent(spawnInputForEntry(template, entry, i, entry.count, input), ctx));
169
+ }
170
+ }
171
+ return { template, team: teamPayload.team, spawned };
172
+ }
@@ -0,0 +1,100 @@
1
+ import { isRecord, SPAWN_PROVIDERS, VALID_EFFORTS } from "agent-relay-sdk";
2
+ import { ValidationError } from "./db";
3
+ import { cleanEnum, cleanString, cleanStringArray } from "./validation";
4
+ import type { PlanOwnerRef, SpawnProvider, TeamTemplate, TeamTemplateCompositionEntry, TeamTemplatePlanSkeleton } from "./types";
5
+
6
+ export const TEAM_TEMPLATE_NAMESPACE = "team-template";
7
+
8
+ function cleanTeamTemplateSlug(value: unknown, field: string): string {
9
+ const slug = cleanString(value, field, { required: true, max: 120 })!;
10
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(slug)) throw new ValidationError(`${field} must be a portable slug`);
11
+ return slug;
12
+ }
13
+
14
+ function cleanPositiveCount(value: unknown, field: string): number {
15
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 50) throw new ValidationError(`${field} must be an integer between 1 and 50`);
16
+ return value;
17
+ }
18
+
19
+ function optionalEffort(value: unknown, field: string): TeamTemplateCompositionEntry["effort"] {
20
+ if (value === undefined || value === null) return undefined;
21
+ return cleanEnum(value, field, VALID_EFFORTS) as TeamTemplateCompositionEntry["effort"];
22
+ }
23
+
24
+ function optionalStringProp<T extends string>(out: object, key: T, value: unknown, max: number): void {
25
+ const cleaned = cleanString(value, key, { max });
26
+ if (cleaned) (out as unknown as Record<string, unknown>)[key] = cleaned;
27
+ }
28
+
29
+ function validateTeamTemplateComposition(value: unknown): TeamTemplateCompositionEntry[] {
30
+ if (!Array.isArray(value)) throw new ValidationError("composition must be an array");
31
+ if (value.length === 0) throw new ValidationError("composition must contain at least one entry");
32
+ if (value.length > 50) throw new ValidationError("composition can contain at most 50 entries");
33
+ return value.map((item, index) => {
34
+ if (!isRecord(item)) throw new ValidationError(`composition[${index}] must be an object`);
35
+ const out: TeamTemplateCompositionEntry = {
36
+ label: cleanString(item.label, `composition[${index}].label`, { required: true, max: 120 })!,
37
+ provider: cleanEnum(item.provider, `composition[${index}].provider`, SPAWN_PROVIDERS) as SpawnProvider,
38
+ count: cleanPositiveCount(item.count, `composition[${index}].count`),
39
+ };
40
+ optionalStringProp(out, "model", item.model, 120);
41
+ optionalStringProp(out, "profile", item.profile, 120);
42
+ const effort = optionalEffort(item.effort, `composition[${index}].effort`);
43
+ if (effort) out.effort = effort;
44
+ return out;
45
+ });
46
+ }
47
+
48
+ function validatePlanSkeleton(value: unknown): TeamTemplatePlanSkeleton | undefined {
49
+ if (value === undefined || value === null) return undefined;
50
+ if (!isRecord(value)) throw new ValidationError("planSkeleton must be an object");
51
+ if (value.nodes !== undefined && !Array.isArray(value.nodes)) throw new ValidationError("planSkeleton.nodes must be an array");
52
+ if (value.edges !== undefined && !Array.isArray(value.edges)) throw new ValidationError("planSkeleton.edges must be an array");
53
+ const skeleton: TeamTemplatePlanSkeleton = {};
54
+ if (Array.isArray(value.nodes)) skeleton.nodes = value.nodes.map((node, index) => {
55
+ if (!isRecord(node)) throw new ValidationError(`planSkeleton.nodes[${index}] must be an object`);
56
+ const out: NonNullable<TeamTemplatePlanSkeleton["nodes"]>[number] = {
57
+ id: cleanString(node.id, `planSkeleton.nodes[${index}].id`, { required: true, max: 120 })!,
58
+ label: cleanString(node.label, `planSkeleton.nodes[${index}].label`, { required: true, max: 240 })!,
59
+ status: cleanEnum(node.status ?? "pending", `planSkeleton.nodes[${index}].status`, ["pending", "in-progress", "landed", "blocked"] as const),
60
+ statusSource: cleanEnum(node.statusSource ?? "manual", `planSkeleton.nodes[${index}].statusSource`, ["manual", "bound"] as const),
61
+ };
62
+ optionalStringProp(out, "scopeBlurb", node.scopeBlurb, 2000);
63
+ optionalStringProp(out, "lane", node.lane, 120);
64
+ const refs = cleanStringArray(node.blackboardRefs, `planSkeleton.nodes[${index}].blackboardRefs`, { itemMax: 120, maxItems: 100 });
65
+ if (refs?.length) out.blackboardRefs = refs;
66
+ if (isRecord(node.ownerRef)) {
67
+ const ownerRef: PlanOwnerRef = {};
68
+ optionalStringProp(ownerRef, "agentId", node.ownerRef.agentId, 200);
69
+ optionalStringProp(ownerRef, "policyName", node.ownerRef.policyName, 120);
70
+ optionalStringProp(ownerRef, "label", node.ownerRef.label, 120);
71
+ if (Object.keys(ownerRef).length) out.ownerRef = ownerRef;
72
+ }
73
+ return out;
74
+ });
75
+ if (Array.isArray(value.edges)) skeleton.edges = value.edges.map((edge, index) => {
76
+ if (!isRecord(edge)) throw new ValidationError(`planSkeleton.edges[${index}] must be an object`);
77
+ return {
78
+ from: cleanString(edge.from, `planSkeleton.edges[${index}].from`, { required: true, max: 120 })!,
79
+ to: cleanString(edge.to, `planSkeleton.edges[${index}].to`, { required: true, max: 120 })!,
80
+ };
81
+ });
82
+ return skeleton;
83
+ }
84
+
85
+ export function validateTeamTemplate(key: string, value: unknown): TeamTemplate {
86
+ if (!isRecord(value)) throw new ValidationError("team template value must be an object");
87
+ const slug = cleanTeamTemplateSlug(value.slug ?? value.id ?? key, "slug");
88
+ if (slug !== key) throw new ValidationError("slug must match config key");
89
+ const id = cleanTeamTemplateSlug(value.id ?? slug, "id");
90
+ if (id !== slug) throw new ValidationError("id must match slug");
91
+ const out: TeamTemplate = { id, slug, title: cleanString(value.title, "title", { required: true, max: 240 })!, composition: validateTeamTemplateComposition(value.composition) };
92
+ optionalStringProp(out, "description", value.description, 2000);
93
+ optionalStringProp(out, "brief", value.brief, 16_000);
94
+ optionalStringProp(out, "definitionOfDone", value.definitionOfDone, 4000);
95
+ optionalStringProp(out, "createdAt", value.createdAt, 80);
96
+ optionalStringProp(out, "updatedAt", value.updatedAt, 80);
97
+ const planSkeleton = validatePlanSkeleton(value.planSkeleton);
98
+ if (planSkeleton) out.planSkeleton = planSkeleton;
99
+ return out;
100
+ }
@@ -1,2 +0,0 @@
1
- import{r as e}from"./chunk-CilyBKbf.js";import{Un as t,jn as n,kn as r,xt as i}from"./lucide-react-ChOGD4vc.js";import{n as a,r as o,t as s}from"./store-BLZA3u7Y.js";var c=e(t(),1),l=n(),u=196,d=68,f=52,p=16,m=14,h=[`landed`,`in-progress`,`pending`,`blocked`],g={landed:{label:`landed`,dot:`bg-emerald-400`,accent:`border-l-emerald-400`,text:`text-emerald-300`,wire:`#34d399`},"in-progress":{label:`in progress`,dot:`bg-sky-400`,accent:`border-l-sky-400`,text:`text-sky-300`,wire:`#38bdf8`},pending:{label:`pending`,dot:`bg-amber-400`,accent:`border-l-amber-400`,text:`text-amber-300`,wire:`#fbbf24`},blocked:{label:`blocked`,dot:`bg-red-400`,accent:`border-l-red-400`,text:`text-red-300`,wire:`#f87171`}};function _(e){return e?e.label||(e.policyName?`policy:${e.policyName}`:e.agentId||``):``}function v(e){let t=new Map(e.nodes.map(e=>[e.id,e])),n=new Map(e.nodes.map((e,t)=>[e.id,t])),r=new Map,i=new Map(e.nodes.map(e=>[e.id,0]));for(let n of e.edges)!t.has(n.from)||!t.has(n.to)||(r.set(n.from,[...r.get(n.from)??[],n.to]),i.set(n.to,(i.get(n.to)??0)+1));let a=new Map(e.nodes.map(e=>[e.id,0])),o=e.nodes.filter(e=>(i.get(e.id)??0)===0).map(e=>e.id),s=new Set;for(;o.length;){let e=o.shift();s.add(e);let t=a.get(e)??0;for(let n of r.get(e)??[])a.set(n,Math.max(a.get(n)??0,t+1)),i.set(n,(i.get(n)??0)-1),(i.get(n)??0)===0&&o.push(n)}let c=Math.max(0,...Array.from(a.values()))+1;for(let t of e.nodes)s.has(t.id)||a.set(t.id,c++);let l=new Map;for(let t of e.nodes){let e=a.get(t.id)??0;l.set(e,[...l.get(e)??[],t])}let h=[...l.entries()].sort(([e],[t])=>e-t),g=Math.max(1,...h.map(([,e])=>e.length)),_=d+p,v=[];h.forEach(([e,t],r)=>{let i=[...t].sort((e,t)=>(e.lane||``).localeCompare(t.lane||``)||(n.get(e.id)??0)-(n.get(t.id)??0)),a=(g-i.length)*_/2;i.forEach((t,n)=>{v.push({node:t,layer:e,x:m+r*(u+f),y:m+a+n*_})})});let y=h.length;return{nodes:v,width:Math.max(320,m*2+y*u+Math.max(0,y-1)*f),height:Math.max(d+m*2,m*2+g*d+Math.max(0,g-1)*p)}}function y(e,t){let n=e.x+u,r=e.y+d/2,i=t.x,a=t.y+d/2,o=n+Math.max(20,(i-n)/2);return`M ${n} ${r} C ${o} ${r}, ${o} ${a}, ${i} ${a}`}function b({threadId:e,teamId:t,channel:n}){let f=t?o(t,n):e?a(e,n):``,p=s(e=>t?e.plansByTeam[f]:f?e.plansByThread[f]:null),m=s(e=>e.fetchPlan);(0,c.useEffect)(()=>{if(t){m({teamId:t,channel:n});return}e&&m({threadId:e,channel:n})},[n,m,t,e]);let b=(0,c.useMemo)(()=>p?v(p):null,[p]),x=(0,c.useMemo)(()=>new Map(b?.nodes.map(e=>[e.node.id,e])??[]),[b]),S=(0,c.useMemo)(()=>{if(!p)return[];let e=new Map;for(let t of p.nodes)e.set(t.status,(e.get(t.status)??0)+1);return h.filter(t=>e.has(t)).map(t=>({status:t,count:e.get(t)??0}))},[p]);return!p||!b?null:(0,l.jsxs)(`section`,{className:`shrink-0 border-b border-border bg-card/45 px-3 py-2`,children:[(0,l.jsxs)(`div`,{className:`mb-2 flex min-w-0 items-center gap-2`,children:[(0,l.jsx)(`span`,{className:`flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted/60`,children:(0,l.jsx)(i,{className:`h-3 w-3 text-muted-foreground`})}),(0,l.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,l.jsx)(`div`,{className:`truncate text-[13px] font-semibold leading-tight text-foreground`,children:p.title||`Plan`}),p.objective&&(0,l.jsx)(`div`,{className:`truncate text-[11px] leading-tight text-muted-foreground`,children:p.objective})]}),S.length>0&&(0,l.jsx)(`div`,{className:`hidden shrink-0 items-center gap-2.5 sm:flex`,children:S.map(({status:e,count:t})=>(0,l.jsxs)(`span`,{className:`flex items-center gap-1 text-[10px] text-muted-foreground`,children:[(0,l.jsx)(`span`,{className:r(`h-1.5 w-1.5 rounded-full`,g[e].dot)}),(0,l.jsx)(`span`,{className:r(`font-medium tabular-nums`,g[e].text),children:t}),(0,l.jsx)(`span`,{children:g[e].label})]},e))}),(0,l.jsxs)(`span`,{className:`shrink-0 rounded bg-muted/60 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground`,children:[`v`,p.version]})]}),p.nodes.length===0?(0,l.jsx)(`div`,{className:`rounded-md border border-dashed border-border px-3 py-3 text-center text-xs text-muted-foreground`,children:`No nodes yet — the plan graph fills in as the team is composed.`}):(0,l.jsx)(`div`,{className:`max-h-[min(46vh,360px)] overflow-auto`,children:(0,l.jsxs)(`div`,{className:`relative`,style:{width:b.width,height:b.height},children:[(0,l.jsxs)(`svg`,{className:`pointer-events-none absolute inset-0`,width:b.width,height:b.height,"aria-hidden":`true`,children:[(0,l.jsx)(`defs`,{children:h.map(e=>(0,l.jsx)(`marker`,{id:`plan-arrow-${e}`,viewBox:`0 0 8 8`,refX:`6.5`,refY:`4`,markerWidth:`6`,markerHeight:`6`,orient:`auto-start-reverse`,children:(0,l.jsx)(`path`,{d:`M0 0 L8 4 L0 8 z`,fill:g[e].wire})},e))}),p.edges.map(e=>{let t=x.get(e.from),n=x.get(e.to);if(!t||!n)return null;let r=g[t.node.status];return(0,l.jsx)(`path`,{d:y(t,n),stroke:r.wire,strokeOpacity:.7,strokeWidth:2,fill:`none`,strokeLinecap:`round`,markerEnd:`url(#plan-arrow-${t.node.status})`},`${e.from}->${e.to}`)})]}),b.nodes.map(({node:e,x:t,y:n})=>{let i=g[e.status],a=_(e.ownerRef);return(0,l.jsxs)(`article`,{className:r(`group absolute flex flex-col justify-between rounded-md border border-l-[3px] bg-background/95 p-2`,`shadow-sm transition-all hover:-translate-y-px hover:border-border hover:shadow-md hover:bg-background`,i.accent),style:{left:t,top:n,width:u,height:d},title:e.scopeBlurb||e.label,children:[(0,l.jsx)(`div`,{className:`truncate text-xs font-semibold leading-tight text-foreground`,children:e.label}),(e.scopeBlurb||e.id)&&(0,l.jsx)(`div`,{className:`truncate text-[10px] leading-tight text-muted-foreground`,children:e.scopeBlurb||e.id}),(0,l.jsxs)(`div`,{className:`flex min-w-0 items-center justify-between gap-1.5`,children:[(0,l.jsxs)(`div`,{className:`flex shrink-0 items-center gap-1.5`,children:[(0,l.jsx)(`span`,{className:r(`h-1.5 w-1.5 shrink-0 rounded-full`,i.dot)}),(0,l.jsx)(`span`,{className:r(`text-[10px] font-medium`,i.text),children:i.label})]}),(0,l.jsxs)(`div`,{className:`flex min-w-0 items-center gap-1.5`,children:[a&&(0,l.jsx)(`span`,{className:`truncate text-[10px] text-muted-foreground`,children:a}),e.lane&&(0,l.jsx)(`span`,{className:`shrink-0 rounded-sm bg-muted/70 px-1 py-px text-[9px] font-medium uppercase tracking-wide text-muted-foreground`,children:e.lane})]})]})]},e.id)})]})})]})}export{b as t};
2
- //# sourceMappingURL=PlanGraphPanel-BnYmYHnY.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"PlanGraphPanel-BnYmYHnY.js","names":[],"sources":["../../dashboard/src/components/chat/PlanGraphPanel.tsx"],"sourcesContent":["import { useEffect, useMemo } from 'react'\nimport { GitBranch } from 'lucide-react'\nimport { useRelayStore } from '@/store'\nimport type { Plan, PlanNode, PlanOwnerRef, PlanStatus } from '@/types'\nimport { cn } from '@/lib/utils'\nimport { planLookupKey, planTeamLookupKey } from '@/store/slices/plans'\n\n// Left-to-right DAG: dependency depth flows along x (sequential gates), nodes\n// that share a depth stack along y (parallel lanes). This keeps the panel SHORT\n// — it's an operator overlay above the chat, not a full view — and lets the\n// gate-vs-lane structure read at a glance. Height is capped + scrollable below.\nconst NODE_W = 196\nconst NODE_H = 68\nconst COL_GAP = 52\nconst ROW_GAP = 16\nconst PAD = 14\n\nconst STATUS_ORDER: PlanStatus[] = ['landed', 'in-progress', 'pending', 'blocked']\n\nconst STATUS_STYLE: Record<\n PlanStatus,\n { label: string; dot: string; accent: string; text: string; wire: string }\n> = {\n landed: { label: 'landed', dot: 'bg-emerald-400', accent: 'border-l-emerald-400', text: 'text-emerald-300', wire: '#34d399' },\n 'in-progress': { label: 'in progress', dot: 'bg-sky-400', accent: 'border-l-sky-400', text: 'text-sky-300', wire: '#38bdf8' },\n pending: { label: 'pending', dot: 'bg-amber-400', accent: 'border-l-amber-400', text: 'text-amber-300', wire: '#fbbf24' },\n blocked: { label: 'blocked', dot: 'bg-red-400', accent: 'border-l-red-400', text: 'text-red-300', wire: '#f87171' },\n}\n\ninterface LayoutNode {\n node: PlanNode\n x: number\n y: number\n layer: number\n}\n\nfunction ownerLabel(owner?: PlanOwnerRef): string {\n if (!owner) return ''\n return owner.label || (owner.policyName ? `policy:${owner.policyName}` : owner.agentId || '')\n}\n\nfunction layerPlan(plan: Plan): { nodes: LayoutNode[]; width: number; height: number } {\n const nodeById = new Map(plan.nodes.map((node) => [node.id, node]))\n const indexById = new Map(plan.nodes.map((node, index) => [node.id, index]))\n const outgoing = new Map<string, string[]>()\n const indegree = new Map(plan.nodes.map((node) => [node.id, 0]))\n for (const edge of plan.edges) {\n if (!nodeById.has(edge.from) || !nodeById.has(edge.to)) continue\n outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge.to])\n indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1)\n }\n\n const layerById = new Map(plan.nodes.map((node) => [node.id, 0]))\n const queue = plan.nodes\n .filter((node) => (indegree.get(node.id) ?? 0) === 0)\n .map((node) => node.id)\n const visited = new Set<string>()\n\n while (queue.length) {\n const id = queue.shift()!\n visited.add(id)\n const fromLayer = layerById.get(id) ?? 0\n for (const to of outgoing.get(id) ?? []) {\n layerById.set(to, Math.max(layerById.get(to) ?? 0, fromLayer + 1))\n indegree.set(to, (indegree.get(to) ?? 0) - 1)\n if ((indegree.get(to) ?? 0) === 0) queue.push(to)\n }\n }\n\n let fallbackLayer = Math.max(0, ...Array.from(layerById.values())) + 1\n for (const node of plan.nodes) {\n if (visited.has(node.id)) continue\n layerById.set(node.id, fallbackLayer++)\n }\n\n const layers = new Map<number, PlanNode[]>()\n for (const node of plan.nodes) {\n const layer = layerById.get(node.id) ?? 0\n layers.set(layer, [...(layers.get(layer) ?? []), node])\n }\n\n const orderedLayers = [...layers.entries()].sort(([a], [b]) => a - b)\n const maxRows = Math.max(1, ...orderedLayers.map(([, nodes]) => nodes.length))\n const rowPitch = NODE_H + ROW_GAP\n\n const layoutNodes: LayoutNode[] = []\n orderedLayers.forEach(([layer, nodes], col) => {\n const ordered = [...nodes].sort((a, b) => {\n const lane = (a.lane || '').localeCompare(b.lane || '')\n return lane || ((indexById.get(a.id) ?? 0) - (indexById.get(b.id) ?? 0))\n })\n // Center each column vertically against the tallest column so the spine\n // runs through the middle and the parallel fan reads as a fan.\n const offset = ((maxRows - ordered.length) * rowPitch) / 2\n ordered.forEach((node, row) => {\n layoutNodes.push({\n node,\n layer,\n x: PAD + col * (NODE_W + COL_GAP),\n y: PAD + offset + row * rowPitch,\n })\n })\n })\n\n const cols = orderedLayers.length\n return {\n nodes: layoutNodes,\n width: Math.max(320, PAD * 2 + cols * NODE_W + Math.max(0, cols - 1) * COL_GAP),\n height: Math.max(NODE_H + PAD * 2, PAD * 2 + maxRows * NODE_H + Math.max(0, maxRows - 1) * ROW_GAP),\n }\n}\n\nfunction edgePath(from: LayoutNode, to: LayoutNode): string {\n const x1 = from.x + NODE_W\n const y1 = from.y + NODE_H / 2\n const x2 = to.x\n const y2 = to.y + NODE_H / 2\n const midX = x1 + Math.max(20, (x2 - x1) / 2)\n return `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`\n}\n\nexport function PlanGraphPanel({ threadId, teamId, channel }: { threadId?: string | number | null; teamId?: string | null; channel?: string }) {\n const key = teamId ? planTeamLookupKey(teamId, channel) : threadId ? planLookupKey(threadId, channel) : ''\n const plan = useRelayStore((s) => teamId ? s.plansByTeam[key] : key ? s.plansByThread[key] : null)\n const fetchPlan = useRelayStore((s) => s.fetchPlan)\n\n useEffect(() => {\n if (teamId) {\n void fetchPlan({ teamId, channel })\n return\n }\n if (threadId) void fetchPlan({ threadId, channel })\n }, [channel, fetchPlan, teamId, threadId])\n\n const layout = useMemo(() => plan ? layerPlan(plan) : null, [plan])\n const nodeById = useMemo(() => new Map(layout?.nodes.map((node) => [node.node.id, node]) ?? []), [layout])\n\n const legend = useMemo(() => {\n if (!plan) return []\n const counts = new Map<PlanStatus, number>()\n for (const node of plan.nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1)\n return STATUS_ORDER.filter((status) => counts.has(status)).map((status) => ({\n status,\n count: counts.get(status) ?? 0,\n }))\n }, [plan])\n\n if (!plan || !layout) return null\n\n return (\n <section className=\"shrink-0 border-b border-border bg-card/45 px-3 py-2\">\n <div className=\"mb-2 flex min-w-0 items-center gap-2\">\n <span className=\"flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted/60\">\n <GitBranch className=\"h-3 w-3 text-muted-foreground\" />\n </span>\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-[13px] font-semibold leading-tight text-foreground\">{plan.title || 'Plan'}</div>\n {plan.objective && <div className=\"truncate text-[11px] leading-tight text-muted-foreground\">{plan.objective}</div>}\n </div>\n {legend.length > 0 && (\n <div className=\"hidden shrink-0 items-center gap-2.5 sm:flex\">\n {legend.map(({ status, count }) => (\n <span key={status} className=\"flex items-center gap-1 text-[10px] text-muted-foreground\">\n <span className={cn('h-1.5 w-1.5 rounded-full', STATUS_STYLE[status].dot)} />\n <span className={cn('font-medium tabular-nums', STATUS_STYLE[status].text)}>{count}</span>\n <span>{STATUS_STYLE[status].label}</span>\n </span>\n ))}\n </div>\n )}\n <span className=\"shrink-0 rounded bg-muted/60 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground\">\n v{plan.version}\n </span>\n </div>\n {plan.nodes.length === 0 ? (\n <div className=\"rounded-md border border-dashed border-border px-3 py-3 text-center text-xs text-muted-foreground\">\n No nodes yet — the plan graph fills in as the team is composed.\n </div>\n ) : (\n <div className=\"max-h-[min(46vh,360px)] overflow-auto\">\n <div className=\"relative\" style={{ width: layout.width, height: layout.height }}>\n <svg className=\"pointer-events-none absolute inset-0\" width={layout.width} height={layout.height} aria-hidden=\"true\">\n <defs>\n {STATUS_ORDER.map((status) => (\n <marker\n key={status}\n id={`plan-arrow-${status}`}\n viewBox=\"0 0 8 8\"\n refX=\"6.5\"\n refY=\"4\"\n markerWidth=\"6\"\n markerHeight=\"6\"\n orient=\"auto-start-reverse\"\n >\n <path d=\"M0 0 L8 4 L0 8 z\" fill={STATUS_STYLE[status].wire} />\n </marker>\n ))}\n </defs>\n {plan.edges.map((edge) => {\n const from = nodeById.get(edge.from)\n const to = nodeById.get(edge.to)\n if (!from || !to) return null\n const wire = STATUS_STYLE[from.node.status]\n return (\n <path\n key={`${edge.from}->${edge.to}`}\n d={edgePath(from, to)}\n stroke={wire.wire}\n strokeOpacity={0.7}\n strokeWidth={2}\n fill=\"none\"\n strokeLinecap=\"round\"\n markerEnd={`url(#plan-arrow-${from.node.status})`}\n />\n )\n })}\n </svg>\n {layout.nodes.map(({ node, x, y }) => {\n const status = STATUS_STYLE[node.status]\n const owner = ownerLabel(node.ownerRef)\n return (\n <article\n key={node.id}\n className={cn(\n 'group absolute flex flex-col justify-between rounded-md border border-l-[3px] bg-background/95 p-2',\n 'shadow-sm transition-all hover:-translate-y-px hover:border-border hover:shadow-md hover:bg-background',\n status.accent,\n )}\n style={{ left: x, top: y, width: NODE_W, height: NODE_H }}\n title={node.scopeBlurb || node.label}\n >\n <div className=\"truncate text-xs font-semibold leading-tight text-foreground\">{node.label}</div>\n {(node.scopeBlurb || node.id) && (\n <div className=\"truncate text-[10px] leading-tight text-muted-foreground\">{node.scopeBlurb || node.id}</div>\n )}\n <div className=\"flex min-w-0 items-center justify-between gap-1.5\">\n <div className=\"flex shrink-0 items-center gap-1.5\">\n <span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', status.dot)} />\n <span className={cn('text-[10px] font-medium', status.text)}>{status.label}</span>\n </div>\n <div className=\"flex min-w-0 items-center gap-1.5\">\n {owner && <span className=\"truncate text-[10px] text-muted-foreground\">{owner}</span>}\n {node.lane && (\n <span className=\"shrink-0 rounded-sm bg-muted/70 px-1 py-px text-[9px] font-medium uppercase tracking-wide text-muted-foreground\">\n {node.lane}\n </span>\n )}\n </div>\n </div>\n </article>\n )\n })}\n </div>\n </div>\n )}\n </section>\n )\n}\n"],"mappings":"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"}