agent-relay-server 0.67.1 → 0.68.1

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 (92) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-CgL7SMC9.js → MessageBubble-DPcJxTJj.js} +2 -2
  4. package/public/assets/{MessageBubble-CgL7SMC9.js.map → MessageBubble-DPcJxTJj.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-C9M1YUH3.js → PlanGraphPanel-BYmE8ldg.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-C9M1YUH3.js.map → PlanGraphPanel-BYmE8ldg.js.map} +1 -1
  7. package/public/assets/{activity-C0ki3CXN.js → activity-BzU196gP.js} +2 -2
  8. package/public/assets/{activity-C0ki3CXN.js.map → activity-BzU196gP.js.map} +1 -1
  9. package/public/assets/agent-profiles-CQuoP8Nr.js +2 -0
  10. package/public/assets/agent-profiles-CQuoP8Nr.js.map +1 -0
  11. package/public/assets/{agents-DVTyhojU.js → agents-qH6q44nK.js} +2 -2
  12. package/public/assets/{agents-DVTyhojU.js.map → agents-qH6q44nK.js.map} +1 -1
  13. package/public/assets/{analytics-D6ffu1ji.js → analytics-EJH9GFID.js} +2 -2
  14. package/public/assets/{analytics-D6ffu1ji.js.map → analytics-EJH9GFID.js.map} +1 -1
  15. package/public/assets/{automation-CKLQvSB-.js → automation-Dahmxg07.js} +2 -2
  16. package/public/assets/{automation-CKLQvSB-.js.map → automation-Dahmxg07.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-DrwCnoKR.js → branch-state-badge-C7H8HY22.js} +2 -2
  18. package/public/assets/{branch-state-badge-DrwCnoKR.js.map → branch-state-badge-C7H8HY22.js.map} +1 -1
  19. package/public/assets/{channels-BuqbbyaO.js → channels-VhlwES_A.js} +2 -2
  20. package/public/assets/{channels-BuqbbyaO.js.map → channels-VhlwES_A.js.map} +1 -1
  21. package/public/assets/{chat-XM7zLIrJ.js → chat-DJqvtoFT.js} +2 -2
  22. package/public/assets/{chat-XM7zLIrJ.js.map → chat-DJqvtoFT.js.map} +1 -1
  23. package/public/assets/{connectors-_LpoKQNb.js → connectors-C03pRoXx.js} +2 -2
  24. package/public/assets/{connectors-_LpoKQNb.js.map → connectors-C03pRoXx.js.map} +1 -1
  25. package/public/assets/{formatted-body-impl-BOtgnE2e.js → formatted-body-impl-DhvR8Z-A.js} +2 -2
  26. package/public/assets/{formatted-body-impl-BOtgnE2e.js.map → formatted-body-impl-DhvR8Z-A.js.map} +1 -1
  27. package/public/assets/index-Dh7NjR-l.css +2 -0
  28. package/public/assets/{index-CARLLWss.js → index-VGRP16ua.js} +6 -6
  29. package/public/assets/index-VGRP16ua.js.map +1 -0
  30. package/public/assets/{insights-BuhwfZK_.js → insights-v9noub5E.js} +2 -2
  31. package/public/assets/{insights-BuhwfZK_.js.map → insights-v9noub5E.js.map} +1 -1
  32. package/public/assets/{integrations-DCyxAv8c.js → integrations-BEa3ArUw.js} +2 -2
  33. package/public/assets/{integrations-DCyxAv8c.js.map → integrations-BEa3ArUw.js.map} +1 -1
  34. package/public/assets/{maintenance-FVXyLhl7.js → maintenance-DyRfVkhp.js} +2 -2
  35. package/public/assets/{maintenance-FVXyLhl7.js.map → maintenance-DyRfVkhp.js.map} +1 -1
  36. package/public/assets/{managed-agents-CaeHrwda.js → managed-agents-BSdMoBgG.js} +2 -2
  37. package/public/assets/{managed-agents-CaeHrwda.js.map → managed-agents-BSdMoBgG.js.map} +1 -1
  38. package/public/assets/{markdown-preview-impl-Brm7jSxL.js → markdown-preview-impl-DidsrMmn.js} +2 -2
  39. package/public/assets/{markdown-preview-impl-Brm7jSxL.js.map → markdown-preview-impl-DidsrMmn.js.map} +1 -1
  40. package/public/assets/{memory-Drj5wn76.js → memory-DM8muGVW.js} +2 -2
  41. package/public/assets/{memory-Drj5wn76.js.map → memory-DM8muGVW.js.map} +1 -1
  42. package/public/assets/{messages-CSSnT5X4.js → messages-BqjAkcBF.js} +2 -2
  43. package/public/assets/{messages-CSSnT5X4.js.map → messages-BqjAkcBF.js.map} +1 -1
  44. package/public/assets/{orchestrators-FdF1O-b_.js → orchestrators-C_mjUDZg.js} +2 -2
  45. package/public/assets/{orchestrators-FdF1O-b_.js.map → orchestrators-C_mjUDZg.js.map} +1 -1
  46. package/public/assets/{overview-tqzRF2zO.js → overview-Cpu2plX-.js} +2 -2
  47. package/public/assets/{overview-tqzRF2zO.js.map → overview-Cpu2plX-.js.map} +1 -1
  48. package/public/assets/{pairs-CJiBSa2o.js → pairs--8_0A0lq.js} +2 -2
  49. package/public/assets/{pairs-CJiBSa2o.js.map → pairs--8_0A0lq.js.map} +1 -1
  50. package/public/assets/{projects-DtJmrhI0.js → projects-nCww9PG1.js} +2 -2
  51. package/public/assets/{projects-DtJmrhI0.js.map → projects-nCww9PG1.js.map} +1 -1
  52. package/public/assets/{security-CItvWUwq.js → security-CwgoEvJT.js} +2 -2
  53. package/public/assets/{security-CItvWUwq.js.map → security-CwgoEvJT.js.map} +1 -1
  54. package/public/assets/{select-CXen1oFN.js → select-Bhx6zSZr.js} +2 -2
  55. package/public/assets/{select-CXen1oFN.js.map → select-Bhx6zSZr.js.map} +1 -1
  56. package/public/assets/{settings-Dm2NtIMK.js → settings-BNQBnKXg.js} +2 -2
  57. package/public/assets/{settings-Dm2NtIMK.js.map → settings-BNQBnKXg.js.map} +1 -1
  58. package/public/assets/store-BB-YW24l.js +9 -0
  59. package/public/assets/store-BB-YW24l.js.map +1 -0
  60. package/public/assets/{tasks-DbyJbQLo.js → tasks-C5Kv18N7.js} +2 -2
  61. package/public/assets/{tasks-DbyJbQLo.js.map → tasks-C5Kv18N7.js.map} +1 -1
  62. package/public/assets/{teams-BtjasnAN.js → teams-D_Ttv7qD.js} +2 -2
  63. package/public/assets/{teams-BtjasnAN.js.map → teams-D_Ttv7qD.js.map} +1 -1
  64. package/public/assets/{terminal-viewer-impl-CbVuN1vi.js → terminal-viewer-impl-5y8v9jNx.js} +2 -2
  65. package/public/assets/{terminal-viewer-impl-CbVuN1vi.js.map → terminal-viewer-impl-5y8v9jNx.js.map} +1 -1
  66. package/public/assets/{work-queue-MP7UUAx7.js → work-queue-BB7bYLIK.js} +2 -2
  67. package/public/assets/{work-queue-MP7UUAx7.js.map → work-queue-BB7bYLIK.js.map} +1 -1
  68. package/public/assets/{workspaces-Bu4uVUcu.js → workspaces-CrtAeVnv.js} +2 -2
  69. package/public/assets/{workspaces-Bu4uVUcu.js.map → workspaces-CrtAeVnv.js.map} +1 -1
  70. package/public/index.html +3 -3
  71. package/runner/src/adapter.ts +11 -0
  72. package/src/agent-card-projection.ts +28 -0
  73. package/src/automations/dispatch.ts +2 -2
  74. package/src/bus.ts +5 -1
  75. package/src/config-store.ts +9 -3
  76. package/src/db/agent-search.ts +22 -8
  77. package/src/db/index.ts +1 -0
  78. package/src/db/migrations.ts +17 -0
  79. package/src/db/provisioning-registry.ts +278 -0
  80. package/src/db/schema.ts +16 -0
  81. package/src/managed-policy.ts +2 -1
  82. package/src/mcp/tools-messaging.ts +72 -3
  83. package/src/project-instructions.ts +41 -1
  84. package/src/routes/agent-sessions.ts +2 -2
  85. package/src/services/spawn-agent.ts +13 -17
  86. package/src/sse.ts +3 -4
  87. package/public/assets/agent-profiles-1Nx8ROA_.js +0 -2
  88. package/public/assets/agent-profiles-1Nx8ROA_.js.map +0 -1
  89. package/public/assets/index-CARLLWss.js.map +0 -1
  90. package/public/assets/index-eCpWfoCA.css +0 -2
  91. package/public/assets/store-BeKcuJ2q.js +0 -9
  92. package/public/assets/store-BeKcuJ2q.js.map +0 -1
@@ -103,6 +103,7 @@ export interface AgentSearchSort {
103
103
  sortBy?: "lastActive" | "created" | "label";
104
104
  order?: "asc" | "desc";
105
105
  limit?: number;
106
+ offset?: number;
106
107
  }
107
108
 
108
109
  const AGENT_SEARCH_MAX_LIMIT = 200;
@@ -112,11 +113,8 @@ function searchValues(value: string | string[] | undefined): string[] {
112
113
  return (Array.isArray(value) ? value : [value]).filter((v): v is string => typeof v === "string" && v.length > 0);
113
114
  }
114
115
 
115
- // Unified fleet search: all filters optional and AND-combined; array values OR within a
116
- // field. One parameterized query (no JS post-filter, no injection). Backs relay_find_agents
117
- // (#219) and the running-only relay_agent_status default (#218). "running" mirrors the
118
- // canonical liveness predicate (see getDb().ts delivery/steward checks + isAgentOnline).
119
- export function searchAgents(filter: AgentSearchFilter = {}, sort: AgentSearchSort = {}): AgentCard[] {
116
+ // Shared clause builder backs both searchAgents (paginated rows) and countAgents (total).
117
+ function buildWhereClauses(filter: AgentSearchFilter): { clauses: string[]; params: any[] } {
120
118
  const clauses: string[] = ["1=1"];
121
119
  const params: any[] = [];
122
120
  const inList = (column: string, values: string[]) => {
@@ -166,15 +164,31 @@ export function searchAgents(filter: AgentSearchFilter = {}, sort: AgentSearchSo
166
164
  if (explicit.length) inList("status", explicit);
167
165
  }
168
166
 
167
+ return { clauses, params };
168
+ }
169
+
170
+ // Unified fleet search: all filters optional and AND-combined; array values OR within a
171
+ // field. One parameterized query (no JS post-filter, no injection). Backs relay_find_agents
172
+ // (#219) and the running-only relay_agent_status default (#218). "running" mirrors the
173
+ // canonical liveness predicate (see getDb().ts delivery/steward checks + isAgentOnline).
174
+ export function searchAgents(filter: AgentSearchFilter = {}, sort: AgentSearchSort = {}): AgentCard[] {
175
+ const { clauses, params } = buildWhereClauses(filter);
169
176
  const sortColumn = sort.sortBy === "created" ? "created_at" : sort.sortBy === "label" ? "label COLLATE NOCASE" : "last_seen";
170
177
  const order = sort.order === "asc" ? "ASC" : "DESC";
171
178
  const limit = Math.max(1, Math.min(sort.limit ?? 50, AGENT_SEARCH_MAX_LIMIT));
172
- params.push(limit);
173
-
174
- const sql = `SELECT * FROM agents WHERE ${clauses.join(" AND ")} ORDER BY ${sortColumn} ${order} LIMIT ?`;
179
+ const offset = Math.max(0, sort.offset ?? 0);
180
+ params.push(limit, offset);
181
+ const sql = `SELECT * FROM agents WHERE ${clauses.join(" AND ")} ORDER BY ${sortColumn} ${order} LIMIT ? OFFSET ?`;
175
182
  return (getDb().query(sql).all(...params) as any[]).map(rowToAgent);
176
183
  }
177
184
 
185
+ // Total matching row count for the same filter — paired with searchAgents for pagination (#548).
186
+ export function countAgents(filter: AgentSearchFilter = {}): number {
187
+ const { clauses, params } = buildWhereClauses(filter);
188
+ const sql = `SELECT COUNT(*) as cnt FROM agents WHERE ${clauses.join(" AND ")}`;
189
+ return ((getDb().query(sql).get(...params) as { cnt: number } | null)?.cnt) ?? 0;
190
+ }
191
+
178
192
  // Count of a parent's currently-LIVE children — the spawn quota numerator (#221). "Live"
179
193
  // mirrors the running predicate (not offline/stale, ready, fresh); a child that exits frees
180
194
  // a slot. Uncapped COUNT (unlike searchAgents' LIMIT) so the quota is exact at any fleet size.
package/src/db/index.ts CHANGED
@@ -28,3 +28,4 @@ export * from "./teams.ts";
28
28
  export * from "./blackboard.ts";
29
29
  export * from "./projects.ts";
30
30
  export * from "./project-entries.ts";
31
+ export * from "./provisioning-registry.ts";
@@ -540,6 +540,23 @@ export function applyMigrations(): void {
540
540
  if (!agentColNames.includes("lifecycle")) {
541
541
  getDb().run("ALTER TABLE agents ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'persistent'");
542
542
  }
543
+
544
+ // Relay-owned provisioning registry (#554/#555/#556) — see schema.ts for the
545
+ // canonical DDL; mirrored here so existing DBs gain the table on upgrade.
546
+ getDb().run(`
547
+ CREATE TABLE IF NOT EXISTS provisioning_assets (
548
+ id TEXT PRIMARY KEY,
549
+ kind TEXT NOT NULL,
550
+ name TEXT NOT NULL,
551
+ provider TEXT NOT NULL DEFAULT 'any',
552
+ definition TEXT NOT NULL,
553
+ enabled INTEGER NOT NULL DEFAULT 1,
554
+ created_at INTEGER NOT NULL,
555
+ updated_at INTEGER NOT NULL,
556
+ UNIQUE(kind, name, provider)
557
+ )
558
+ `);
559
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_provisioning_assets_kind ON provisioning_assets(kind, enabled)");
543
560
  if (!agentColNames.includes("spawned_by")) {
544
561
  getDb().run("ALTER TABLE agents ADD COLUMN spawned_by TEXT");
545
562
  }
@@ -0,0 +1,278 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDb, ValidationError } from "./connection.ts";
3
+ import { parseJson } from "../utils";
4
+ import { isRecord } from "agent-relay-sdk";
5
+ import type {
6
+ AgentProfile,
7
+ AgentProfileAssetRef,
8
+ AgentProfileProvider,
9
+ McpAssetDefinition,
10
+ PluginAssetDefinition,
11
+ ProvisioningAsset,
12
+ ProvisioningAssetKind,
13
+ ProvisioningMcpServer,
14
+ ResolvedAssetDefinition,
15
+ ResolvedProvisioning,
16
+ SkillAssetDefinition,
17
+ SpawnProvider,
18
+ } from "agent-relay-sdk";
19
+
20
+ // Relay-owned provisioning registry (#554/#555/#556). One table, one shape across
21
+ // skills/MCP/plugins: named definitions (the materializable content) keyed by
22
+ // (kind, name), selected by a profile's asset refs. The resolver below turns a
23
+ // profile's refs into a concrete ResolvedProvisioning bundle the runner writes into
24
+ // the launch. #557 (per-profile assembly) is meant to be a thin unifier on top, so
25
+ // this stays the single home for asset definition + resolution — not three modules.
26
+
27
+ const PROVISIONING_ASSET_KINDS = ["skill", "mcp", "plugin"] as const;
28
+ const PROVISIONING_PROVIDERS = ["claude", "codex", "any"] as const;
29
+
30
+ interface ProvisioningAssetRow {
31
+ id: string;
32
+ kind: ProvisioningAssetKind;
33
+ name: string;
34
+ provider: AgentProfileProvider;
35
+ definition: string;
36
+ enabled: number;
37
+ created_at: number;
38
+ updated_at: number;
39
+ }
40
+
41
+ interface UpsertProvisioningAssetInput {
42
+ kind: ProvisioningAssetKind;
43
+ name: string;
44
+ provider?: AgentProfileProvider;
45
+ definition: ResolvedAssetDefinition;
46
+ enabled?: boolean;
47
+ }
48
+
49
+ function assetId(): string {
50
+ return `pa_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
51
+ }
52
+
53
+ function assertKind(kind: ProvisioningAssetKind): void {
54
+ if (!PROVISIONING_ASSET_KINDS.includes(kind)) {
55
+ throw new ValidationError(`kind must be one of: ${PROVISIONING_ASSET_KINDS.join(", ")}`);
56
+ }
57
+ }
58
+
59
+ function assertProvider(provider: AgentProfileProvider): void {
60
+ if (!PROVISIONING_PROVIDERS.includes(provider)) {
61
+ throw new ValidationError(`provider must be one of: ${PROVISIONING_PROVIDERS.join(", ")}`);
62
+ }
63
+ }
64
+
65
+ function assertDefinition(kind: ProvisioningAssetKind, definition: ResolvedAssetDefinition): void {
66
+ if (!isRecord(definition)) throw new ValidationError("definition must be an object");
67
+ if (definition.kind !== kind) throw new ValidationError(`definition.kind (${definition.kind}) must match asset kind (${kind})`);
68
+ if (kind === "mcp" && !isRecord((definition as McpAssetDefinition).server)) {
69
+ throw new ValidationError("mcp definition requires a server object");
70
+ }
71
+ if ((kind === "skill" || kind === "plugin")) {
72
+ const def = definition as SkillAssetDefinition | PluginAssetDefinition;
73
+ if (!def.files?.length && !def.dir) throw new ValidationError(`${kind} definition requires files or dir`);
74
+ }
75
+ }
76
+
77
+ function rowToAsset(row: ProvisioningAssetRow): ProvisioningAsset {
78
+ return {
79
+ id: row.id,
80
+ kind: row.kind,
81
+ name: row.name,
82
+ provider: row.provider,
83
+ definition: parseJson<ResolvedAssetDefinition>(row.definition, { kind: row.kind, name: row.name } as ResolvedAssetDefinition),
84
+ enabled: row.enabled === 1,
85
+ createdAt: row.created_at,
86
+ updatedAt: row.updated_at,
87
+ };
88
+ }
89
+
90
+ // Insert or replace a named definition. Keyed by (kind, name, provider) so the same
91
+ // name can carry provider-specific variants (a claude skill vs a codex skill).
92
+ export function upsertProvisioningAsset(input: UpsertProvisioningAssetInput): ProvisioningAsset {
93
+ assertKind(input.kind);
94
+ const provider = input.provider ?? "any";
95
+ assertProvider(provider);
96
+ const name = input.definition.name || input.name;
97
+ if (!name) throw new ValidationError("name is required");
98
+ assertDefinition(input.kind, { ...input.definition, name });
99
+ const now = Date.now();
100
+ const existing = getDb()
101
+ .query("SELECT id, created_at FROM provisioning_assets WHERE kind = ? AND name = ? AND provider = ?")
102
+ .get(input.kind, name, provider) as { id: string; created_at: number } | undefined;
103
+ const id = existing?.id ?? assetId();
104
+ getDb().query(`
105
+ INSERT INTO provisioning_assets (id, kind, name, provider, definition, enabled, created_at, updated_at)
106
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
107
+ ON CONFLICT(kind, name, provider) DO UPDATE SET
108
+ definition = excluded.definition,
109
+ enabled = excluded.enabled,
110
+ updated_at = excluded.updated_at
111
+ `).run(
112
+ id,
113
+ input.kind,
114
+ name,
115
+ provider,
116
+ JSON.stringify({ ...input.definition, name }),
117
+ input.enabled === false ? 0 : 1,
118
+ existing?.created_at ?? now,
119
+ now,
120
+ );
121
+ return getProvisioningAsset(input.kind, name, provider)!;
122
+ }
123
+
124
+ export function getProvisioningAsset(
125
+ kind: ProvisioningAssetKind,
126
+ name: string,
127
+ provider: AgentProfileProvider = "any",
128
+ ): ProvisioningAsset | null {
129
+ const row = getDb()
130
+ .query("SELECT * FROM provisioning_assets WHERE kind = ? AND name = ? AND provider = ?")
131
+ .get(kind, name, provider) as ProvisioningAssetRow | undefined;
132
+ return row ? rowToAsset(row) : null;
133
+ }
134
+
135
+ export function listProvisioningAssets(opts: { kind?: ProvisioningAssetKind; includeDisabled?: boolean } = {}): ProvisioningAsset[] {
136
+ if (opts.kind) assertKind(opts.kind);
137
+ const conditions: string[] = [];
138
+ const params: (string | number)[] = [];
139
+ if (opts.kind) {
140
+ conditions.push("kind = ?");
141
+ params.push(opts.kind);
142
+ }
143
+ if (!opts.includeDisabled) conditions.push("enabled = 1");
144
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
145
+ const rows = getDb()
146
+ .query(`SELECT * FROM provisioning_assets ${where} ORDER BY kind, name, provider`)
147
+ .all(...params) as ProvisioningAssetRow[];
148
+ return rows.map(rowToAsset);
149
+ }
150
+
151
+ export function deleteProvisioningAsset(
152
+ kind: ProvisioningAssetKind,
153
+ name: string,
154
+ provider: AgentProfileProvider = "any",
155
+ ): boolean {
156
+ const result = getDb()
157
+ .query("DELETE FROM provisioning_assets WHERE kind = ? AND name = ? AND provider = ?")
158
+ .run(kind, name, provider);
159
+ return result.changes > 0;
160
+ }
161
+
162
+ // Resolve a registry ref for a target provider: an exact provider match wins, else
163
+ // the provider-neutral "any" variant. Disabled definitions never resolve.
164
+ function resolveRegistryDefinition(
165
+ kind: ProvisioningAssetKind,
166
+ ref: string,
167
+ provider: SpawnProvider,
168
+ ): ResolvedAssetDefinition | null {
169
+ const specific = getProvisioningAsset(kind, ref, provider);
170
+ if (specific?.enabled) return specific.definition;
171
+ const any = getProvisioningAsset(kind, ref, "any");
172
+ return any?.enabled ? any.definition : null;
173
+ }
174
+
175
+ // A ref applies to a launch iff it's enabled and its provider scope includes the
176
+ // target ("any"/undefined → all providers).
177
+ function refAppliesToProvider(ref: AgentProfileAssetRef, provider: SpawnProvider): boolean {
178
+ return ref.enabled && (!ref.provider || ref.provider === "any" || ref.provider === provider);
179
+ }
180
+
181
+ function inlineFilesFromMeta(meta: Record<string, unknown> | undefined): { path: string; content: string }[] | undefined {
182
+ const raw = meta?.files;
183
+ if (!Array.isArray(raw)) return undefined;
184
+ const files = raw
185
+ .filter(isRecord)
186
+ .filter((f) => typeof f.path === "string" && typeof f.content === "string")
187
+ .map((f) => ({ path: f.path as string, content: f.content as string }));
188
+ return files.length ? files : undefined;
189
+ }
190
+
191
+ // Resolve a skill/plugin asset ref into a concrete definition. relay → registry;
192
+ // inline → built from ref.meta (files or dir); repo → a cwd-relative dir token the
193
+ // runner resolves against the launch cwd; provider → skipped (provider built-in).
194
+ function resolveDirAssetRef(
195
+ kind: "skill" | "plugin",
196
+ ref: AgentProfileAssetRef,
197
+ provider: SpawnProvider,
198
+ ): SkillAssetDefinition | PluginAssetDefinition | null {
199
+ if (ref.source === "relay") {
200
+ const def = resolveRegistryDefinition(kind, ref.ref, provider);
201
+ return def && def.kind === kind ? def : null;
202
+ }
203
+ if (ref.source === "inline") {
204
+ const files = inlineFilesFromMeta(ref.meta);
205
+ const dir = typeof ref.meta?.dir === "string" ? ref.meta.dir : undefined;
206
+ if (!files && !dir) return null;
207
+ return { kind, name: ref.ref, ...(files ? { files } : {}), ...(dir ? { dir } : {}) };
208
+ }
209
+ if (ref.source === "repo") {
210
+ return { kind, name: ref.ref, dir: ref.ref };
211
+ }
212
+ return null;
213
+ }
214
+
215
+ // A profile.mcp.servers entry is either an inline server spec or a registry
216
+ // reference (`{ source: "relay", ref }`). Returns the concrete server spec.
217
+ function resolveMcpEntry(name: string, value: unknown, provider: SpawnProvider): ProvisioningMcpServer | null {
218
+ if (!isRecord(value)) return null;
219
+ if (value.source === "relay" && typeof value.ref === "string") {
220
+ const def = resolveRegistryDefinition("mcp", value.ref, provider);
221
+ return def && def.kind === "mcp" ? def.server : null;
222
+ }
223
+ if (value.enabled === false) return null;
224
+ // Inline spec: keep the recognized server fields only.
225
+ const server: ProvisioningMcpServer = {};
226
+ if (typeof value.type === "string") server.type = value.type as ProvisioningMcpServer["type"];
227
+ if (typeof value.command === "string") server.command = value.command;
228
+ if (Array.isArray(value.args)) server.args = value.args.filter((a): a is string => typeof a === "string");
229
+ if (isRecord(value.env)) server.env = stringRecord(value.env);
230
+ if (typeof value.url === "string") server.url = value.url;
231
+ if (isRecord(value.headers)) server.headers = stringRecord(value.headers);
232
+ return server.command || server.url ? server : null;
233
+ }
234
+
235
+ function stringRecord(value: Record<string, unknown>): Record<string, string> {
236
+ const out: Record<string, string> = {};
237
+ for (const [k, v] of Object.entries(value)) {
238
+ if (typeof v === "string") out[k] = v;
239
+ }
240
+ return out;
241
+ }
242
+
243
+ // Resolve a profile's declared skill/plugin/MCP refs against the registry into the
244
+ // concrete bundle the runner materializes. Relay-side (needs the DB); the result is
245
+ // attached to the in-flight profile (profile.provisioning) before it's serialized
246
+ // into the spawn env. Pure read — never mutates the registry.
247
+ export function resolveProfileProvisioning(profile: AgentProfile, provider: SpawnProvider): ResolvedProvisioning {
248
+ const skills: SkillAssetDefinition[] = [];
249
+ for (const ref of profile.skills ?? []) {
250
+ if (!refAppliesToProvider(ref, provider)) continue;
251
+ const def = resolveDirAssetRef("skill", ref, provider);
252
+ if (def) skills.push(def as SkillAssetDefinition);
253
+ }
254
+
255
+ const plugins: PluginAssetDefinition[] = [];
256
+ for (const ref of profile.plugins ?? []) {
257
+ if (!refAppliesToProvider(ref, provider)) continue;
258
+ const def = resolveDirAssetRef("plugin", ref, provider);
259
+ if (def) plugins.push(def as PluginAssetDefinition);
260
+ }
261
+
262
+ const mcpServers: Record<string, ProvisioningMcpServer> = {};
263
+ for (const [name, value] of Object.entries(profile.mcp?.servers ?? {})) {
264
+ const server = resolveMcpEntry(name, value, provider);
265
+ if (server) mcpServers[name] = server;
266
+ }
267
+
268
+ return { skills, plugins, mcpServers };
269
+ }
270
+
271
+ // Attach the resolved bundle to a spawn-bound profile. Returns a shallow copy so the
272
+ // cached config-store value is never mutated. undefined passes through (an unresolved
273
+ // profile name stays undefined so callers' not-found guards still fire); the runner
274
+ // treats an absent and an empty bundle identically.
275
+ export function withResolvedProvisioning(profile: AgentProfile | undefined, provider: SpawnProvider): AgentProfile | undefined {
276
+ if (!profile) return undefined;
277
+ return { ...profile, provisioning: resolveProfileProvisioning(profile, provider) };
278
+ }
package/src/db/schema.ts CHANGED
@@ -878,6 +878,22 @@ export function initDb(path: string = "agent-relay.db"): Database {
878
878
  CREATE INDEX IF NOT EXISTS idx_insights_obs_project_signal ON insights_observations(project, signal, created_at DESC);
879
879
  CREATE INDEX IF NOT EXISTS idx_insights_obs_signal ON insights_observations(signal, created_at DESC);
880
880
  CREATE INDEX IF NOT EXISTS idx_insights_obs_session ON insights_observations(session_id);
881
+
882
+ -- Relay-owned provisioning registry (#554/#555/#556): named, materializable
883
+ -- skill/MCP/plugin definitions a vanilla profile selects by ref. (kind, name,
884
+ -- provider) is unique so a name can carry provider-specific variants.
885
+ CREATE TABLE IF NOT EXISTS provisioning_assets (
886
+ id TEXT PRIMARY KEY,
887
+ kind TEXT NOT NULL,
888
+ name TEXT NOT NULL,
889
+ provider TEXT NOT NULL DEFAULT 'any',
890
+ definition TEXT NOT NULL,
891
+ enabled INTEGER NOT NULL DEFAULT 1,
892
+ created_at INTEGER NOT NULL,
893
+ updated_at INTEGER NOT NULL,
894
+ UNIQUE(kind, name, provider)
895
+ );
896
+ CREATE INDEX IF NOT EXISTS idx_provisioning_assets_kind ON provisioning_assets(kind, enabled);
881
897
  `);
882
898
 
883
899
  ensureContinuationArchiveSearchSchema();
@@ -1,4 +1,5 @@
1
1
  import { getAgentProfile } from "./config-store";
2
+ import { withResolvedProvisioning } from "./db";
2
3
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
3
4
  import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
4
5
  import type { SpawnPolicy, WorkspaceMode } from "./types";
@@ -28,7 +29,7 @@ interface ManagedSpawnContext {
28
29
  export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string, ctx: ManagedSpawnContext): Record<string, unknown> {
29
30
  const providerArgs = managedPolicyProviderArgs(policy);
30
31
  const prompt = managedPolicyLaunchPrompt(policy);
31
- const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
32
+ const agentProfile = withResolvedProvisioning(policy.profile ? getAgentProfile(policy.profile)?.value : undefined, policy.provider);
32
33
  return buildSpawnCommand({
33
34
  provider: policy.provider,
34
35
  orchestratorId: policy.orchestratorId,
@@ -4,6 +4,7 @@ import { bytesToStream } from "../http-body";
4
4
  import { MAX_BODY_BYTES } from "../config";
5
5
  import { getManagedAgentState, getSpawnPolicy, listSpawnPolicies } from "../config-store";
6
6
  import {
7
+ countAgents,
7
8
  createArtifact,
8
9
  getAgent,
9
10
  getArtifact,
@@ -20,6 +21,7 @@ import {
20
21
  type AgentSearchFilter,
21
22
  type AgentSearchSort,
22
23
  } from "../db";
24
+ import type { AgentCard } from "../types";
23
25
  import { type DeliveryReceipt } from "../agent-ref";
24
26
  import { emitRelayEvent } from "../events";
25
27
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
@@ -31,7 +33,7 @@ import { isRecord } from "agent-relay-sdk";
31
33
  import type { ArtifactKind, ArtifactSensitivity, AttachmentRef, Message, SendMessageInput } from "../types";
32
34
  import type { McpAuthContext, ToolDefinition } from "./types";
33
35
  import { callerAgentId } from "./identity";
34
- import { enumField, optionalAttachments, optionalBoolean, optionalFutureTimestamp, optionalPositiveInt, optionalRecord, optionalString, positiveId, recordField, stringField } from "./validation";
36
+ import { enumField, optionalAttachments, optionalBoolean, optionalFutureTimestamp, optionalNonNegativeInt, optionalPositiveInt, optionalRecord, optionalString, positiveId, recordField, stringField } from "./validation";
35
37
 
36
38
  const VALID_ARTIFACT_KINDS = ["image", "audio", "video", "document", "archive", "other"] as const;
37
39
  const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"] as const;
@@ -165,7 +167,7 @@ export const MESSAGING_TOOLS: ToolDefinition[] = [
165
167
  },
166
168
  {
167
169
  name: "relay_find_agents",
168
- description: "Search the fleet for agents by label, capability, provider, tag, machine, status, or kind, sorted by last-active/created/label. Defaults to running agents only. Use this to find a reachable, capable peer before messaging or pairing — instead of dumping the whole roster.",
170
+ description: "Search the fleet for agents by label, capability, provider, tag, machine, status, or kind, sorted by last-active/created/label. Returns a lean roster summary by default (id, label, status, machine, provider, model, lastSeen, spawnedBy, teamId) — add verbose:true for full agent cards. Defaults to running agents only. Use this to find a reachable, capable peer before messaging or pairing.",
169
171
  requiredScopes: ["agents:read", "agent:read"],
170
172
  inputSchema: {
171
173
  type: "object",
@@ -182,6 +184,8 @@ export const MESSAGING_TOOLS: ToolDefinition[] = [
182
184
  sortBy: { type: "string", enum: ["lastActive", "created", "label"] },
183
185
  order: { type: "string", enum: ["asc", "desc"] },
184
186
  limit: { type: "integer", minimum: 1, maximum: 200 },
187
+ offset: { type: "integer", minimum: 0, description: "Pagination offset (default 0). Use nextOffset from a prior response to page through results." },
188
+ verbose: { type: "boolean", description: "Return full agent cards instead of the lean roster summary. Even verbose results are paginated; use offset+limit to page." },
185
189
  },
186
190
  additionalProperties: false,
187
191
  },
@@ -358,7 +362,42 @@ function optionalStringOrArray(value: unknown, field: string): string | string[]
358
362
  throw new ValidationError(`${field} must be a string or string array`);
359
363
  }
360
364
 
365
+ // Hard character cap for the lean list — a fat-card dump was 286K; lean entries are ~200 chars
366
+ // each so 200 entries ≈ 40K, well inside; this guard catches edge cases (#548).
367
+ const FIND_AGENTS_CHAR_CAP = 50_000;
368
+
369
+ // Fields a coordinator needs to reason about the roster — id, status, routing, parent, team.
370
+ // Drops providerCapabilities (multi-KB), context, meta, tags, capabilities, epoch, etc.
371
+ type AgentRosterEntry = {
372
+ id: string;
373
+ label?: string;
374
+ status: string;
375
+ machine?: string;
376
+ provider?: string;
377
+ model?: string;
378
+ lastSeen: number;
379
+ spawnedBy?: string;
380
+ spawnRequestId?: string;
381
+ teamId?: string;
382
+ };
383
+
384
+ function toLeanEntry(agent: AgentCard): AgentRosterEntry {
385
+ const entry: AgentRosterEntry = { id: agent.id, status: agent.status, lastSeen: agent.lastSeen };
386
+ if (agent.label) entry.label = agent.label;
387
+ if (agent.machine) entry.machine = agent.machine;
388
+ const pc = agent.providerCapabilities;
389
+ if (pc?.model?.provider) entry.provider = pc.model.provider;
390
+ if (pc?.model?.id) entry.model = pc.model.id;
391
+ if (agent.spawnedBy) entry.spawnedBy = agent.spawnedBy;
392
+ const srId = agent.meta?.spawnRequestId;
393
+ if (typeof srId === "string") entry.spawnRequestId = srId;
394
+ if (agent.teamId) entry.teamId = agent.teamId;
395
+ return entry;
396
+ }
397
+
361
398
  export function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
399
+ const verbose = optionalBoolean(args.verbose, "verbose") === true;
400
+ const offset = optionalNonNegativeInt(args.offset, "offset") ?? 0;
362
401
  const spawnedByArg = optionalString(args.spawnedBy, "spawnedBy", 240);
363
402
  const spawnedBy = spawnedByArg === "me" ? callerAgentId(auth) ?? "\0unresolved" : spawnedByArg;
364
403
  const filter: AgentSearchFilter = {
@@ -376,9 +415,39 @@ export function relayFindAgents(auth: McpAuthContext, args: Record<string, unkno
376
415
  sortBy: optionalEnum(args.sortBy, "sortBy", ["lastActive", "created", "label"] as const),
377
416
  order: optionalEnum(args.order, "order", ["asc", "desc"] as const),
378
417
  limit: optionalPositiveInt(args.limit, "limit"),
418
+ offset: offset > 0 ? offset : undefined,
379
419
  };
420
+
421
+ const total = countAgents(filter);
380
422
  const agents = searchAgents(filter, sort);
381
- return { agents, count: agents.length };
423
+ const returned = agents.length;
424
+ const nextOffset = offset + returned < total ? offset + returned : undefined;
425
+
426
+ if (verbose) {
427
+ const result: Record<string, unknown> = { agents, total, count: returned };
428
+ if (nextOffset != null) result.nextOffset = nextOffset;
429
+ return result;
430
+ }
431
+
432
+ // Default: lean projection — drop fat blobs, stay within token budget.
433
+ const lean = agents.map(toLeanEntry);
434
+ const encoded = JSON.stringify(lean);
435
+ if (encoded.length <= FIND_AGENTS_CHAR_CAP) {
436
+ const result: Record<string, unknown> = { agents: lean, total, count: returned };
437
+ if (nextOffset != null) result.nextOffset = nextOffset;
438
+ return result;
439
+ }
440
+
441
+ // Hard cap: truncate lean list and emit explicit truncation hint.
442
+ const safe: AgentRosterEntry[] = [];
443
+ let size = 0;
444
+ for (const entry of lean) {
445
+ const s = JSON.stringify(entry);
446
+ if (size + s.length > FIND_AGENTS_CHAR_CAP) break;
447
+ safe.push(entry);
448
+ size += s.length;
449
+ }
450
+ return { agents: safe, total, count: safe.length, truncated: true, nextOffset: offset + safe.length };
382
451
  }
383
452
 
384
453
  function payloadWithAttachments(
@@ -11,7 +11,7 @@
11
11
  // is that lever: the included-source set is a policy PARAMETER, and the `vanilla`
12
12
  // policy includes `manual`+`promoted` while excluding `ingested`.
13
13
  import { resolveProjectEntries } from "./db";
14
- import { PROJECT_ENTRY_SOURCES, type Project, type ProjectEntryKind, type ProjectEntrySource, type ResolvedProjectEntry } from "./types";
14
+ import { PROJECT_ENTRY_SOURCES, type AgentProfile, type Project, type ProjectEntryKind, type ProjectEntrySource, type ResolvedProjectEntry } from "./types";
15
15
 
16
16
  /** Named source policies — THE additive-from-empty lever (#403 decision 4).
17
17
  * `all` — every source (manual + promoted + ingested host-CLAUDE.md content).
@@ -150,6 +150,46 @@ export function composeProjectInstructions(
150
150
  return out;
151
151
  }
152
152
 
153
+ /**
154
+ * #557 — THE instruction-sources half of the launch-assembly contract (the relay-side
155
+ * composer; the runner-side launch-assembly.ts assembles the provisioned skills/plugins/MCP
156
+ * + vanilla floor). It composes a spawned agent's systemPromptAppend from declared sources,
157
+ * in precedence order: project knowledge preamble (#409, the project-scoped channel) →
158
+ * profile system prompt → profile append entries → transient caller/fork context. Composing
159
+ * from EMPTY and adding (never reading host CLAUDE.md) is the additive-from-empty invariant.
160
+ *
161
+ * Returns the joined block, or the caller's own systemPromptAppend untouched when no
162
+ * profile/project material applies — so a profile-less spawn stays byte-identical.
163
+ *
164
+ * ── #559 SEAM ──────────────────────────────────────────────────────────────────────────
165
+ * Scoped {profile|user} memory does not exist yet (#559 generalizes Projects' anchor to
166
+ * project|profile|user). When it lands, a profile/user-scoped preamble composes in RIGHT
167
+ * HERE, beside the project preamble — same `composeProjectInstructions`-shaped renderer over
168
+ * the {profile|user} scope union, slotted into `appendBlocks` before the profile system
169
+ * prompt. The runner reports the contributing scopes via AssembledInstructions.scopedKnowledge.
170
+ * Until #559 lands, the only knowledge scope is the project preamble below — a thin add, not
171
+ * a refactor.
172
+ */
173
+ export function composeSpawnInstructions(input: {
174
+ callerProject?: Pick<Project, "id" | "slug" | "title">;
175
+ projectInstructions?: ProjectInstructionPolicy;
176
+ profile?: Pick<AgentProfile, "instructions">;
177
+ callerSystemPromptAppend?: string;
178
+ }): string | undefined {
179
+ const appendBlocks: string[] = [];
180
+ if (input.projectInstructions && input.callerProject) {
181
+ const block = composeProjectInstructions(input.callerProject, {
182
+ ...(input.projectInstructions.policy ? { policy: input.projectInstructions.policy } : {}),
183
+ });
184
+ if (block.trim()) appendBlocks.push(block);
185
+ }
186
+ // [#559 seam] profile/user-scoped knowledge preamble composes in here.
187
+ if (input.profile?.instructions.system?.trim()) appendBlocks.push(input.profile.instructions.system);
188
+ appendBlocks.push(...(input.profile?.instructions.append.filter((entry) => entry.trim()) ?? []));
189
+ if (input.callerSystemPromptAppend?.trim()) appendBlocks.push(input.callerSystemPromptAppend);
190
+ return appendBlocks.length ? appendBlocks.join("\n\n") : input.callerSystemPromptAppend;
191
+ }
192
+
153
193
  /**
154
194
  * Parse a spawn-transport's `projectInstructions` wire value into the normalized policy
155
195
  * (or undefined = OFF). Shared by the MCP and HTTP spawn paths so they cannot drift.
@@ -1,7 +1,7 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agent-sessions.
2
2
  import { RELAY_TOKEN_HEADER, isRecord } from "agent-relay-sdk";
3
3
  import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
4
- import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage } from "../db";
4
+ import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage, withResolvedProvisioning } from "../db";
5
5
  import { buildManagedSpawnParams } from "../managed-policy";
6
6
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
7
7
  import { cleanString, optionalEnum } from "../validation";
@@ -111,7 +111,7 @@ function restartSpawnParamsForAgent(
111
111
  const effort = metaString(agent.meta, "effort") as ProviderEffort | undefined;
112
112
  const providerArgs = metaStringArray(agent.meta, "providerArgs");
113
113
  const profileName = metaString(agent.meta, "profile");
114
- const agentProfile = profileName ? getAgentProfile(profileName)?.value : undefined;
114
+ const agentProfile = withResolvedProvisioning(profileName ? getAgentProfile(profileName)?.value : undefined, provider);
115
115
  const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
116
116
  const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
117
117
  const params = buildSpawnCommand({