agent-relay-server 0.42.0 → 0.43.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 (76) hide show
  1. package/docs/openapi.json +85 -1
  2. package/package.json +2 -2
  3. package/public/assets/{activity-G6l1nOLj.js → activity--LYUyJtT.js} +2 -2
  4. package/public/assets/{activity-G6l1nOLj.js.map → activity--LYUyJtT.js.map} +1 -1
  5. package/public/assets/{agents-DBuCRS5A.js → agents-6mhIWvF-.js} +2 -2
  6. package/public/assets/{agents-DBuCRS5A.js.map → agents-6mhIWvF-.js.map} +1 -1
  7. package/public/assets/{analytics-BhwMtd0_.js → analytics-W-oZ8bsY.js} +2 -2
  8. package/public/assets/{analytics-BhwMtd0_.js.map → analytics-W-oZ8bsY.js.map} +1 -1
  9. package/public/assets/{automation-B7jnsYhH.js → automation-DouRxEN7.js} +2 -2
  10. package/public/assets/{automation-B7jnsYhH.js.map → automation-DouRxEN7.js.map} +1 -1
  11. package/public/assets/chat-DVu6bXmE.js +2 -0
  12. package/public/assets/chat-DVu6bXmE.js.map +1 -0
  13. package/public/assets/display-ConJ9cJB.js.map +1 -1
  14. package/public/assets/{formatted-body-impl-BXuR2gfL.js → formatted-body-impl-CthzMcxz.js} +2 -2
  15. package/public/assets/{formatted-body-impl-BXuR2gfL.js.map → formatted-body-impl-CthzMcxz.js.map} +1 -1
  16. package/public/assets/{index-CAnJGSBl.js → index-B1s-hGRR.js} +5 -5
  17. package/public/assets/{index-CAnJGSBl.js.map → index-B1s-hGRR.js.map} +1 -1
  18. package/public/assets/index-Ryvn1efN.css +2 -0
  19. package/public/assets/{maintenance-B1bX9ety.js → maintenance-CdDtYHE9.js} +2 -2
  20. package/public/assets/{maintenance-B1bX9ety.js.map → maintenance-CdDtYHE9.js.map} +1 -1
  21. package/public/assets/{managed-agents-D12-nGKd.js → managed-agents-DXigb0pj.js} +2 -2
  22. package/public/assets/{managed-agents-D12-nGKd.js.map → managed-agents-DXigb0pj.js.map} +1 -1
  23. package/public/assets/{markdown-preview-impl-BWd3swt8.js → markdown-preview-impl-BVES_oc1.js} +2 -2
  24. package/public/assets/{markdown-preview-impl-BWd3swt8.js.map → markdown-preview-impl-BVES_oc1.js.map} +1 -1
  25. package/public/assets/{memory-4ERBFJBZ.js → memory-DWAF794H.js} +2 -2
  26. package/public/assets/{memory-4ERBFJBZ.js.map → memory-DWAF794H.js.map} +1 -1
  27. package/public/assets/{messages-B3ZES72c.js → messages-D63r1IRE.js} +2 -2
  28. package/public/assets/{messages-B3ZES72c.js.map → messages-D63r1IRE.js.map} +1 -1
  29. package/public/assets/{orchestrators-qKPntM-a.js → orchestrators-rbEOs7pu.js} +2 -2
  30. package/public/assets/{orchestrators-qKPntM-a.js.map → orchestrators-rbEOs7pu.js.map} +1 -1
  31. package/public/assets/{overview-ekmeyPaO.js → overview-CeUZzyTa.js} +2 -2
  32. package/public/assets/{overview-ekmeyPaO.js.map → overview-CeUZzyTa.js.map} +1 -1
  33. package/public/assets/{pairs-B2QdTF0M.js → pairs-DJ_e1R5S.js} +2 -2
  34. package/public/assets/{pairs-B2QdTF0M.js.map → pairs-DJ_e1R5S.js.map} +1 -1
  35. package/public/assets/{security-WkMkEhb7.js → security-DyeypCyX.js} +2 -2
  36. package/public/assets/{security-WkMkEhb7.js.map → security-DyeypCyX.js.map} +1 -1
  37. package/public/assets/{settings-C5aXX3od.js → settings-CVW329oA.js} +2 -2
  38. package/public/assets/{settings-C5aXX3od.js.map → settings-CVW329oA.js.map} +1 -1
  39. package/public/assets/{tasks-ppuVeXkO.js → tasks-CR8BxsPc.js} +2 -2
  40. package/public/assets/{tasks-ppuVeXkO.js.map → tasks-CR8BxsPc.js.map} +1 -1
  41. package/public/assets/{terminal-viewer-impl-DXycIJKe.js → terminal-viewer-impl-CISUjIl9.js} +2 -2
  42. package/public/assets/{terminal-viewer-impl-DXycIJKe.js.map → terminal-viewer-impl-CISUjIl9.js.map} +1 -1
  43. package/public/assets/{work-queue-C8UzeekT.js → work-queue-ipT9bYqc.js} +2 -2
  44. package/public/assets/{work-queue-C8UzeekT.js.map → work-queue-ipT9bYqc.js.map} +1 -1
  45. package/public/index.html +2 -2
  46. package/src/agent-ref.ts +7 -4
  47. package/src/channel-target.ts +1 -0
  48. package/src/db/agent-search.ts +6 -0
  49. package/src/db/agents.ts +11 -2
  50. package/src/db/channels.ts +2 -1
  51. package/src/db/delivery.ts +4 -1
  52. package/src/db/index.ts +1 -0
  53. package/src/db/mappers.ts +1 -1
  54. package/src/db/message-reads.ts +10 -1
  55. package/src/db/migrations.ts +34 -1
  56. package/src/db/plans.ts +17 -2
  57. package/src/db/schema.ts +23 -2
  58. package/src/db/teams.ts +151 -0
  59. package/src/mcp-plan-tools.ts +1 -0
  60. package/src/mcp-team-tools.ts +87 -0
  61. package/src/mcp.ts +6 -1
  62. package/src/routes/index.ts +4 -0
  63. package/src/routes/integrations.ts +2 -1
  64. package/src/routes/messages.ts +1 -1
  65. package/src/routes/teams.ts +60 -0
  66. package/src/runtime-tokens.ts +9 -3
  67. package/src/security.ts +7 -1
  68. package/src/services/plan-graph.ts +2 -0
  69. package/src/services/register-agent.ts +3 -1
  70. package/src/services/spawn-agent.ts +4 -1
  71. package/src/services/team.ts +69 -0
  72. package/src/token-db.ts +3 -3
  73. package/src/validation.ts +2 -0
  74. package/public/assets/chat-KaK5_x65.js +0 -2
  75. package/public/assets/chat-KaK5_x65.js.map +0 -1
  76. package/public/assets/index-DzRKGPXS.css +0 -2
@@ -0,0 +1,87 @@
1
+ import { McpAuthError, McpNotFoundError } from "./mcp-errors";
2
+ import { authContextFromMcp } from "./services/auth-context";
3
+ import { ValidationError } from "./db";
4
+ import { dissolveCallerTeam, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError } from "./services/team";
5
+ import { cleanString, optionalEnum } from "./validation";
6
+ import type { Team } from "./types";
7
+
8
+ const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
9
+
10
+ export const TEAM_TOOLS = [
11
+ {
12
+ name: "relay_team_get",
13
+ description: "Return the caller's team row, live roster, shared brief/definition of done, and any bound plan.",
14
+ requiredScopes: ["teams:read"],
15
+ inputSchema: {
16
+ type: "object",
17
+ properties: { channel: { type: "string" } },
18
+ additionalProperties: false,
19
+ },
20
+ },
21
+ {
22
+ name: "relay_team_set_brief",
23
+ description: "Create/update the caller's team brief, title, and definition of done. Lazily mints a team when needed.",
24
+ requiredScopes: ["teams:write"],
25
+ inputSchema: {
26
+ type: "object",
27
+ properties: {
28
+ title: { type: "string" },
29
+ brief: { type: "string" },
30
+ definitionOfDone: { type: "string" },
31
+ },
32
+ additionalProperties: false,
33
+ },
34
+ },
35
+ {
36
+ name: "relay_team_dissolve",
37
+ description: "Dissolve the caller's team and freeze the composition/outcome snapshot. Does not kill members.",
38
+ requiredScopes: ["teams:write"],
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: {
42
+ outcomeStatus: { type: "string", enum: OUTCOME_STATUSES },
43
+ outcomeSummary: { type: "string" },
44
+ landsClean: { type: "boolean" },
45
+ },
46
+ additionalProperties: false,
47
+ },
48
+ },
49
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
50
+
51
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
52
+
53
+ function mapTeamError(error: unknown): never {
54
+ if (error instanceof TeamAuthError) throw new McpAuthError(error.message);
55
+ if (error instanceof TeamNotFoundError) throw new McpNotFoundError(error.message);
56
+ throw error;
57
+ }
58
+
59
+ export function relayTeamTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
60
+ const ctx = authContextFromMcp(auth);
61
+ try {
62
+ if (name === "relay_team_get") {
63
+ return getCallerTeam(ctx, cleanString(args.channel, "channel", { max: 120 })) as unknown as Record<string, unknown>;
64
+ }
65
+ if (name === "relay_team_set_brief") {
66
+ return setCallerTeamBrief(ctx, {
67
+ title: cleanString(args.title, "title", { max: 240 }),
68
+ brief: cleanString(args.brief, "brief", { max: 16_000 }),
69
+ definitionOfDone: cleanString(args.definitionOfDone, "definitionOfDone", { max: 4000 }),
70
+ }) as unknown as Record<string, unknown>;
71
+ }
72
+ if (name === "relay_team_dissolve") {
73
+ return dissolveCallerTeam(ctx, {
74
+ outcomeStatus: optionalEnum(args.outcomeStatus, "outcomeStatus", OUTCOME_STATUSES) as Team["outcomeStatus"] | undefined,
75
+ outcomeSummary: cleanString(args.outcomeSummary, "outcomeSummary", { max: 4000 }),
76
+ landsClean: args.landsClean === undefined || args.landsClean === null
77
+ ? undefined
78
+ : typeof args.landsClean === "boolean"
79
+ ? args.landsClean
80
+ : (() => { throw new ValidationError("landsClean must be a boolean"); })(),
81
+ }) as unknown as Record<string, unknown>;
82
+ }
83
+ } catch (error) {
84
+ mapTeamError(error);
85
+ }
86
+ throw new ValidationError(`unknown tool: ${name}`);
87
+ }
package/src/mcp.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  import { sendMessageService } from "./services/send-message";
48
48
  import { ServiceAuthError } from "./services/errors";
49
49
  import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
50
+ import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
50
51
  import type { ActivityKind, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
51
52
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
52
53
  import { AUTO_MERGE_POLICIES } from "./workspace-merge";
@@ -101,7 +102,7 @@ const TOOLS: ToolDefinition[] = [
101
102
  type: "object",
102
103
  properties: {
103
104
  from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
104
- to: { type: "string", description: "Target: an agent id, label, name, or a unique id segment (e.g. the number part) — resolved automatically. Or a fan-out selector: tag:, cap:, label:, policy:, or broadcast." },
105
+ to: { type: "string", description: "Target: an agent id, label, name, or a unique id segment (e.g. the number part) — resolved automatically. Or a fan-out selector: tag:, cap:, label:, team:, policy:, or broadcast." },
105
106
  body: { type: "string" },
106
107
  subject: { type: "string" },
107
108
  channel: { type: "string" },
@@ -232,6 +233,7 @@ const TOOLS: ToolDefinition[] = [
232
233
  kind: { type: ["string", "array"], items: { type: "string" } },
233
234
  status: { type: ["string", "array"], items: { type: "string" }, description: "online/idle/busy/offline/stale, the 'running' shorthand, or 'all'. Default 'running'." },
234
235
  spawnedBy: { type: "string", description: "Parent agent id, or 'me' for your own spawned children (#221)." },
236
+ teamId: { type: "string", description: "Durable team id for roster filtering." },
235
237
  sortBy: { type: "string", enum: ["lastActive", "created", "label"] },
236
238
  order: { type: "string", enum: ["asc", "desc"] },
237
239
  limit: { type: "integer", minimum: 1, maximum: 200 },
@@ -456,6 +458,7 @@ const TOOLS: ToolDefinition[] = [
456
458
  additionalProperties: false,
457
459
  },
458
460
  },
461
+ ...TEAM_TOOLS,
459
462
  ...PLAN_TOOLS,
460
463
  ];
461
464
 
@@ -568,6 +571,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
568
571
  else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
569
572
  else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
570
573
  else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
574
+ else if (name.startsWith("relay_team_")) result = relayTeamTool(auth, name, args);
571
575
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
572
576
  else throw new ValidationError(`unknown tool: ${name}`);
573
577
 
@@ -831,6 +835,7 @@ function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): R
831
835
  machine: optionalStringOrArray(args.machine, "machine"),
832
836
  kind: optionalStringOrArray(args.kind, "kind"),
833
837
  spawnedBy,
838
+ teamId: optionalString(args.teamId, "teamId", 120),
834
839
  // Default to running — you can only act on a live agent (consistent with #218).
835
840
  status: optionalStringOrArray(args.status, "status") ?? "running",
836
841
  };
@@ -30,6 +30,7 @@ import { getPlanByIdRoute, getPlansRoute } from "./plans";
30
30
  import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
31
31
  import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
32
32
  import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
33
+ import { getMyTeamRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
33
34
  import { postMcp } from "../mcp";
34
35
  import { postOrchestratorBootstrap, postOrchestratorBootstrapExchange } from "./orchestrator-bootstrap";
35
36
 
@@ -135,6 +136,9 @@ const routes: Route[] = [
135
136
  route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),
136
137
 
137
138
  route("POST", "/api/system/broadcast", postSystemBroadcast),
139
+ route("GET", "/api/teams/me", getMyTeamRoute),
140
+ route("PUT", "/api/teams/me/brief", putMyTeamBriefRoute),
141
+ route("POST", "/api/teams/me/dissolve", postMyTeamDissolveRoute),
138
142
  route("GET", "/api/recipes", getRecipes),
139
143
  route("POST", "/api/recipes/start", postRecipeStart),
140
144
  route("GET", "/api/recipes/instances", getRecipeInstances),
@@ -9,7 +9,7 @@ import { isRecord } from "agent-relay-sdk";
9
9
  import { parseChannelRouteTarget } from "../channel-target.ts";
10
10
  import { type ChannelBinding, type ChannelBindingMode, type ChannelRouteTarget, type ChannelSummary, type IntegrationEventInput, type IntegrationSummary } from "../types";
11
11
 
12
- const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
12
+ const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "team", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
13
13
 
14
14
  const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
15
15
 
@@ -59,6 +59,7 @@ function messageTargetForChannelTarget(target: ChannelRouteTarget, binding?: Cha
59
59
  return binding.poolAgentId;
60
60
  }
61
61
  if (target.type === "label") return `label:${target.id}`;
62
+ if (target.type === "team") return `team:${target.id}`;
62
63
  if (target.type === "tag") return `tag:${target.id}`;
63
64
  if (target.type === "capability") return `cap:${target.id}`;
64
65
  if (target.type === "broadcast") return "broadcast";
@@ -66,7 +66,7 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
66
66
  return input;
67
67
  }
68
68
 
69
- const FANOUT_PREFIXES = ["tag:", "cap:", "label:"];
69
+ const FANOUT_PREFIXES = ["tag:", "cap:", "label:", "team:"];
70
70
 
71
71
  function isDirectTarget(to: string): boolean {
72
72
  return to !== "broadcast" && !FANOUT_PREFIXES.some((p) => to.startsWith(p));
@@ -0,0 +1,60 @@
1
+ import { McpAuthError, McpNotFoundError } from "../mcp-errors";
2
+ import { authContextFromRequest } from "../services/auth-context";
3
+ import { ValidationError } from "../db";
4
+ import { dissolveCallerTeam, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError } from "../services/team";
5
+ import { cleanString, optionalEnum } from "../validation";
6
+ import { error, json, parseBody, type Handler } from "./_shared";
7
+ import type { Team } from "../types";
8
+
9
+ const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
10
+
11
+ function routeError(err: unknown): Response {
12
+ if (err instanceof TeamAuthError || err instanceof McpAuthError) return error(err.message, 403);
13
+ if (err instanceof TeamNotFoundError || err instanceof McpNotFoundError) return error(err.message, 404);
14
+ if (err instanceof Error) return error(err.message);
15
+ return error("team route failed");
16
+ }
17
+
18
+ export const getMyTeamRoute: Handler = (req) => {
19
+ try {
20
+ const channel = cleanString(new URL(req.url).searchParams.get("channel") ?? undefined, "channel", { max: 120 });
21
+ return json(getCallerTeam(authContextFromRequest(req), channel));
22
+ } catch (err) {
23
+ return routeError(err);
24
+ }
25
+ };
26
+
27
+ export const putMyTeamBriefRoute: Handler = async (req) => {
28
+ const parsed = await parseBody<Record<string, unknown>>(req);
29
+ if (!parsed.ok) return error(parsed.error, parsed.status);
30
+ try {
31
+ const body = parsed.body ?? {};
32
+ return json(setCallerTeamBrief(authContextFromRequest(req), {
33
+ title: cleanString(body.title, "title", { max: 240 }),
34
+ brief: cleanString(body.brief, "brief", { max: 16_000 }),
35
+ definitionOfDone: cleanString(body.definitionOfDone, "definitionOfDone", { max: 4000 }),
36
+ }));
37
+ } catch (err) {
38
+ return routeError(err);
39
+ }
40
+ };
41
+
42
+ export const postMyTeamDissolveRoute: Handler = async (req) => {
43
+ const parsed = await parseBody<Record<string, unknown>>(req);
44
+ if (!parsed.ok) return error(parsed.error, parsed.status);
45
+ try {
46
+ const body = parsed.body ?? {};
47
+ const landsClean = body.landsClean;
48
+ return json(dissolveCallerTeam(authContextFromRequest(req), {
49
+ outcomeStatus: optionalEnum(body.outcomeStatus, "outcomeStatus", OUTCOME_STATUSES) as Team["outcomeStatus"] | undefined,
50
+ outcomeSummary: cleanString(body.outcomeSummary, "outcomeSummary", { max: 4000 }),
51
+ landsClean: landsClean === undefined || landsClean === null
52
+ ? undefined
53
+ : typeof landsClean === "boolean"
54
+ ? landsClean
55
+ : (() => { throw new ValidationError("landsClean must be a boolean"); })(),
56
+ }));
57
+ } catch (err) {
58
+ return routeError(err);
59
+ }
60
+ };
@@ -10,9 +10,10 @@ import type { TokenRecord } from "./types";
10
10
  // purpose — that scope must stay for heartbeat/status without implying spawn rights.
11
11
  const SPAWN_SCOPES = ["command:spawn", "command:shutdown"] as const;
12
12
 
13
- function runnerScopeWithSpawn(canSpawn: boolean): string[] | undefined {
14
- if (!canSpawn) return undefined; // undefined → createToken falls back to the profile's scope
13
+ function runnerScopeWithSpawn(canSpawn: boolean, agentInitiated?: boolean): string[] | undefined {
15
14
  const base = getTokenProfile("provider-agent")?.scope ?? [];
15
+ if (agentInitiated) return base.filter((scope) => scope !== "teams:write");
16
+ if (!canSpawn) return undefined; // undefined → createToken falls back to the profile's scope
16
17
  return [...base, ...SPAWN_SCOPES];
17
18
  }
18
19
 
@@ -101,6 +102,8 @@ export function issueRunnerRuntimeToken(input: {
101
102
  agentInitiated?: boolean;
102
103
  /** Parent agent id — stamped so the child registers with an authoritative `spawnedBy`. */
103
104
  spawnedBy?: string;
105
+ /** Durable team id — stamped so the child registers into the coordinator's team. */
106
+ teamId?: string;
104
107
  }): RuntimeTokenResult {
105
108
  const grant = spawnGrantForProfile(input.profile, input.agentInitiated);
106
109
  const subject = input.policyName
@@ -112,7 +115,7 @@ export function issueRunnerRuntimeToken(input: {
112
115
  profileId: "provider-agent",
113
116
  sub: subject,
114
117
  role: "provider",
115
- scope: runnerScopeWithSpawn(grant.canSpawn),
118
+ scope: runnerScopeWithSpawn(grant.canSpawn, input.agentInitiated),
116
119
  constraints: {
117
120
  orchestrators: [input.orchestratorId],
118
121
  cwdPrefixes: [input.cwd],
@@ -120,6 +123,7 @@ export function issueRunnerRuntimeToken(input: {
120
123
  ...(input.spawnRequestId ? { spawnRequestIds: [input.spawnRequestId] } : {}),
121
124
  ...(grant.canSpawn && grant.maxSpawnedAgents ? { maxSpawnedAgents: grant.maxSpawnedAgents } : {}),
122
125
  ...(input.spawnedBy ? { spawnedBy: input.spawnedBy } : {}),
126
+ ...(input.teamId ? { teamId: input.teamId } : {}),
123
127
  },
124
128
  createdBy: input.createdBy ?? "runtime",
125
129
  });
@@ -133,6 +137,7 @@ function issueChildRunnerRuntimeToken(input: {
133
137
  label?: string;
134
138
  policyName?: string;
135
139
  spawnRequestId: string;
140
+ teamId?: string;
136
141
  createdBy?: string;
137
142
  }): RuntimeTokenResult {
138
143
  return createToken({
@@ -146,6 +151,7 @@ function issueChildRunnerRuntimeToken(input: {
146
151
  cwdPrefixes: [input.cwd],
147
152
  spawnRequestIds: [input.spawnRequestId],
148
153
  canDelegate: false,
154
+ ...(input.teamId ? { teamId: input.teamId } : {}),
149
155
  ...(input.policyName ? { policies: [input.policyName] } : {}),
150
156
  },
151
157
  createdBy: input.createdBy ?? input.parentAgentId,
package/src/security.ts CHANGED
@@ -204,6 +204,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
204
204
  if (pathname.startsWith("/api/maintenance")) return "system:admin";
205
205
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
206
206
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
207
+ if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
207
208
  // Insights config (the feature toggle) stays admin-only via the default; only the
208
209
  // mechanical observation feed is writable by lower-privilege callers.
209
210
  if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
@@ -278,6 +279,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
278
279
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
279
280
  if (pathname.startsWith("/api/orchestrators")) return method === "GET" ? "agent:read" : "command:write";
280
281
  if (pathname.startsWith("/api/plans")) return method === "GET" ? "plans:read" : "plans:write";
282
+ if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
281
283
  // The Runner posts the #184 context-gathering signal here (source:"server") via its
282
284
  // provider token. Without this case the path fell through to the system:admin default
283
285
  // below and every observation was 403-dropped — the whole Insights feed stayed empty.
@@ -415,6 +417,10 @@ export function resolveSpawnLineage(constraints: TokenConstraints | undefined):
415
417
  return constraints?.spawnedBy ?? constraints?.parentAgents?.[0];
416
418
  }
417
419
 
420
+ export function resolveTeamLineage(constraints: TokenConstraints | undefined): string | undefined {
421
+ return constraints?.teamId;
422
+ }
423
+
418
424
  function constraintsAllow(constraints: TokenConstraints | undefined, resource: AuthorizationResource | undefined): boolean {
419
425
  if (!constraints || !resource) return true;
420
426
  const hasConstraints = Object.keys(constraints).length > 0;
@@ -462,7 +468,7 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
462
468
  for (const [key, item] of Object.entries(record)) {
463
469
  if (["terminalAttach", "logsRead", "canDelegate"].includes(key)) {
464
470
  if (typeof item !== "boolean") return false;
465
- } else if (key === "cwd" || key === "spawnedBy") {
471
+ } else if (key === "cwd" || key === "spawnedBy" || key === "teamId") {
466
472
  if (typeof item !== "string") return false;
467
473
  } else if (key === "maxSpawnedAgents") {
468
474
  if (typeof item !== "number") return false;
@@ -30,6 +30,7 @@ export interface PlanNodePatch {
30
30
 
31
31
  export interface PlanCreateInput {
32
32
  threadId: unknown;
33
+ teamId?: unknown;
33
34
  channel?: unknown;
34
35
  title?: unknown;
35
36
  objective?: unknown;
@@ -186,6 +187,7 @@ export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
186
187
  const actor = actorId(ctx);
187
188
  const plan = createPlanRow({
188
189
  threadId,
190
+ teamId: cleanString(input.teamId, "teamId", { max: 120 }),
189
191
  channel,
190
192
  title: cleanString(input.title, "title", { max: 240 }),
191
193
  objective: cleanString(input.objective, "objective", { max: 4000 }),
@@ -13,7 +13,7 @@ import { createActivityEvent, getAgent, upsertAgent } from "../db";
13
13
  import { emitAgentStatus } from "../sse";
14
14
  import { noteAgentTimelineEvent } from "../compaction-watch";
15
15
  import { notifyAgentReady } from "../agent-lifecycle-events";
16
- import { resolveSpawnLineage } from "../security";
16
+ import { resolveSpawnLineage, resolveTeamLineage } from "../security";
17
17
  import { markManagedAgentRunning } from "./managed-running";
18
18
  import type { AuthContext } from "./auth-context";
19
19
  import type { AgentCard, RegisterAgentInput } from "../types";
@@ -28,6 +28,8 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
28
28
  // the client-sent body (a child can't forge its own parent). Set by relay at spawn.
29
29
  const lineage = resolveSpawnLineage(ctx.constraints);
30
30
  if (lineage) input.spawnedBy = lineage;
31
+ const teamLineage = resolveTeamLineage(ctx.constraints);
32
+ if (teamLineage) input.teamId = teamLineage;
31
33
 
32
34
  const existing = getAgent(input.id);
33
35
  const agent = upsertAgent(input);
@@ -25,7 +25,7 @@
25
25
  // deliberately out of scope here — this slice converges the two external TRANSPORTS.
26
26
  import type { SpawnProvider } from "agent-relay-sdk";
27
27
  import { stringValue } from "agent-relay-sdk";
28
- import { countLiveSpawnedAgents, createActivityEvent, getAgent, listAgents, ValidationError } from "../db";
28
+ import { countLiveSpawnedAgents, createActivityEvent, ensureTeam, getAgent, listAgents, setAgentTeam, ValidationError } from "../db";
29
29
  import { createCommand } from "../commands-db";
30
30
  import { emitCommandEvent } from "../command-events";
31
31
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
@@ -178,6 +178,8 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
178
178
  // Provenance actor: the resolved caller agent when known, else the transport's label, else
179
179
  // the raw token actor. Recorded in the command payload, the token mint, and the audit row.
180
180
  const requestedBy = callerId ?? input.requestedBy ?? ctx.actor.id;
181
+ const callerTeam = callerId ? (caller?.teamId ? { id: caller.teamId } : ensureTeam(callerId)) : undefined;
182
+ if (callerId && callerTeam && !caller?.teamId) setAgentTeam(callerId, callerTeam.id);
181
183
 
182
184
  // Child runner token. `agentInitiated` (set whenever an agent is the caller) forces a
183
185
  // non-spawn-capable child (no grandchildren); `spawnedBy` stamps authoritative lineage the
@@ -197,6 +199,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
197
199
  // child regardless of profile (no grandchildren).
198
200
  profile: input.profile || undefined,
199
201
  ...(callerId ? { agentInitiated: true, spawnedBy: callerId } : {}),
202
+ ...(callerTeam ? { teamId: callerTeam.id } : {}),
200
203
  });
201
204
 
202
205
  const command = createCommand({
@@ -0,0 +1,69 @@
1
+ import { dissolveTeam, ensureTeam, getAgent, getPlanByTeam, getTeam, getTeamRoster, setTeamBrief } from "../db";
2
+ import type { AuthContext } from "./auth-context";
3
+ import type { Team } from "../types";
4
+
5
+ export class TeamAuthError extends Error {}
6
+ export class TeamNotFoundError extends Error {}
7
+
8
+ export interface TeamPayload {
9
+ team: Team;
10
+ roster: ReturnType<typeof getTeamRoster>;
11
+ brief: string | null;
12
+ definitionOfDone: string | null;
13
+ plan: ReturnType<typeof getPlanByTeam>;
14
+ }
15
+
16
+ export interface TeamBriefInput {
17
+ title?: string;
18
+ brief?: string;
19
+ definitionOfDone?: string;
20
+ }
21
+
22
+ export interface TeamDissolveServiceInput {
23
+ outcomeStatus?: Team["outcomeStatus"];
24
+ outcomeSummary?: string;
25
+ landsClean?: boolean;
26
+ }
27
+
28
+ function requireCallerAgentId(ctx: AuthContext): string {
29
+ if (!ctx.callerAgentId) throw new TeamAuthError("team operations require a single caller agent identity");
30
+ return ctx.callerAgentId;
31
+ }
32
+
33
+ function payloadForAgent(agentId: string, channel?: string): TeamPayload {
34
+ const agent = getAgent(agentId);
35
+ const team = agent?.teamId ? getTeam(agent.teamId) : null;
36
+ if (!team) throw new TeamNotFoundError("caller has no team");
37
+ return {
38
+ team,
39
+ roster: getTeamRoster(team.id),
40
+ brief: team.brief ?? null,
41
+ definitionOfDone: team.definitionOfDone ?? null,
42
+ plan: getPlanByTeam(team.id, channel) ?? null,
43
+ };
44
+ }
45
+
46
+ export function getCallerTeam(ctx: AuthContext, channel?: string): TeamPayload {
47
+ return payloadForAgent(requireCallerAgentId(ctx), channel);
48
+ }
49
+
50
+ export function setCallerTeamBrief(ctx: AuthContext, input: TeamBriefInput): TeamPayload {
51
+ const agentId = requireCallerAgentId(ctx);
52
+ const team = ensureTeam(agentId);
53
+ setTeamBrief({ teamId: team.id, ...input });
54
+ return payloadForAgent(agentId);
55
+ }
56
+
57
+ export function dissolveCallerTeam(ctx: AuthContext, input: TeamDissolveServiceInput): TeamPayload {
58
+ const agentId = requireCallerAgentId(ctx);
59
+ const agent = getAgent(agentId);
60
+ if (!agent?.teamId) throw new TeamNotFoundError("caller has no team");
61
+ const team = dissolveTeam(agent.teamId, input);
62
+ return {
63
+ team,
64
+ roster: getTeamRoster(team.id),
65
+ brief: team.brief ?? null,
66
+ definitionOfDone: team.definitionOfDone ?? null,
67
+ plan: getPlanByTeam(team.id) ?? null,
68
+ };
69
+ }
package/src/token-db.ts CHANGED
@@ -57,7 +57,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
57
57
  name: "Provider Agent",
58
58
  description: "Coding-agent runtime access for messages, commands, tasks, and scoped memory reads.",
59
59
  role: "provider",
60
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "insights:write"],
60
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "teams:read", "teams:write", "insights:write"],
61
61
  ttlSeconds: 24 * 60 * 60,
62
62
  builtIn: true,
63
63
  createdBy: "system",
@@ -67,7 +67,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
67
67
  name: "Provider Child Agent",
68
68
  description: "Delegated child-agent runtime access, constrained to its parent and spawn request.",
69
69
  role: "provider",
70
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "insights:write"],
70
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "insights:write"],
71
71
  constraints: { canDelegate: false },
72
72
  ttlSeconds: 2 * 60 * 60,
73
73
  builtIn: true,
@@ -78,7 +78,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
78
78
  name: "Provider Interactive Agent",
79
79
  description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
80
80
  role: "provider",
81
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "insights:write"],
81
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "insights:write"],
82
82
  constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
83
83
  ttlSeconds: 30 * 24 * 60 * 60,
84
84
  builtIn: true,
package/src/validation.ts CHANGED
@@ -122,6 +122,8 @@ export function cleanTokenConstraints(value: unknown): TokenConstraints | undefi
122
122
  }
123
123
  const cwd = cleanString(value.cwd, "constraints.cwd", { max: 500 });
124
124
  if (cwd) constraints.cwd = cwd;
125
+ const teamId = cleanString(value.teamId, "constraints.teamId", { max: 120 });
126
+ if (teamId) constraints.teamId = teamId;
125
127
  for (const key of ["terminalAttach", "logsRead", "canDelegate"] as const) {
126
128
  if (value[key] !== undefined) {
127
129
  if (typeof value[key] !== "boolean") throw new ValidationError(`constraints.${key} must be a boolean`);