impel-cli 0.10.0 → 0.11.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.
package/README.md CHANGED
@@ -148,7 +148,7 @@ impel use account [claude|codex|all] Revert to your own login (defaul
148
148
  impel off [claude|codex|all] Alias for `impel use account`
149
149
 
150
150
  impel skills sync [claude|codex|all] Sync Bifrost shared skills into managed clients (default: all)
151
- impel agents sync [claude|codex|all] Sync the selected tenant's specialists as native agents
151
+ impel agents sync [claude|codex|all] Sync explicit tenant agents into native clients
152
152
 
153
153
  impel status Launcher readiness + native mode + gateway reachability
154
154
  impel app install [target] [--tenant <org>] Install isolated apps for one tenant
@@ -591,15 +591,17 @@ Set `IMPEL_SKIP_SKILL_SYNC=1` to disable it entirely (offline/CI).
591
591
 
592
592
  ### `impel agents sync [claude|codex|all]`
593
593
 
594
- Fetches the selected organization's opted-in specialist catalog through the
594
+ Fetches the selected organization's explicit native-agent catalog through the
595
595
  authenticated Impel MCP endpoint, then generates native custom-agent files for
596
- every managed profile:
596
+ every managed profile. This catalog is separate from automatic specialists: it
597
+ may include truthfully declared write-capable agents, but those agents require
598
+ explicit user selection before the server will start a run.
597
599
 
598
600
  - Claude Code: `<CLAUDE_CONFIG_DIR>/agents/impel-managed/*.md`
599
601
  - Codex / ChatGPT Codex mode: `<CODEX_HOME>/agents/impel-managed/*.toml`
600
602
 
601
- Each generated agent is bound to one exact specialist `agentId` and
602
- `scopeParam`. It verifies that specialist is still available, starts one
603
+ Each generated agent is bound to one exact `agentId` and
604
+ `scopeParam`. It verifies that the exact agent is still available, starts one
603
605
  idempotent run, polls the durable run to a terminal state, and returns
604
606
  `result.finalText` without repeating the work. The files include a tenant-bound
605
607
  MCP subprocess definition so they also work in isolated desktop profiles. They
@@ -627,11 +629,12 @@ Invocation uses the clients' native agent behavior:
627
629
  claude --agent impel-acme-research-agent -p "investigate the dependency change"
628
630
 
629
631
  # Codex CLI / ChatGPT desktop Codex task
630
- Use the configured impel-acme-research-agent subagent for this request and wait for it.
632
+ Explicitly use the configured impel-acme-research-agent for this request and wait for it.
631
633
  ```
632
634
 
633
635
  Claude exposes native custom agents in its `@` typeahead. Current Codex releases
634
- load and display the generated native agents but do not provide an `@agent`
636
+ load the generated standalone definitions directly from `$CODEX_HOME/agents/`
637
+ but do not provide an `@agent`
635
638
  mention target; named spawning remains model-mediated and may also be limited by
636
639
  the active MultiAgentV2 tool schema. Use `/agent` in Codex to inspect spawned
637
640
  agent threads.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agents.js CHANGED
@@ -1,7 +1,8 @@
1
- // Synchronizes tenant-scoped Impel specialists into the native custom-agent
2
- // registries used by Claude Code and Codex. Unlike the public shared-skill
3
- // marketplace, the specialist catalog is authenticated and organization
4
- // specific, so it is fetched through the selected tenant's MCP session.
1
+ // Synchronizes explicitly invocable tenant-scoped Impel agents into the native
2
+ // custom-agent registries used by Claude Code and Codex. Unlike the public
3
+ // shared-skill marketplace, the native-agent catalog is authenticated and
4
+ // organization specific, so it is fetched through the selected tenant's MCP
5
+ // session. Automatic read-only specialists remain a separate catalog.
5
6
 
6
7
  import crypto from "node:crypto";
7
8
  import fs from "node:fs";
@@ -15,17 +16,17 @@ import { normalizeTenantId } from "./tenants.js";
15
16
  export const AGENT_SYNC_TTL_MS = 6 * 60 * 60 * 1000;
16
17
  export const MANAGED_AGENT_DIRECTORY = "impel-managed";
17
18
  export const MANAGED_AGENT_MANIFEST = ".manifest.json";
18
- export const SPECIALIST_LIST_TOOL = "impel_specialists-list_specialists";
19
- export const SPECIALIST_START_TOOL = "impel_specialists-start_specialist_run";
20
- export const SPECIALIST_READ_TOOL = "impel_specialists-read_specialist_run";
19
+ export const NATIVE_AGENT_LIST_TOOL = "impel_specialists-list_native_agents";
20
+ export const NATIVE_AGENT_START_TOOL = "impel_specialists-start_native_agent_run";
21
+ export const NATIVE_AGENT_READ_TOOL = "impel_specialists-read_native_agent_run";
21
22
  export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
22
23
 
23
- const SPECIALIST_TOOL_NAMES = [
24
- SPECIALIST_LIST_TOOL,
25
- SPECIALIST_START_TOOL,
26
- SPECIALIST_READ_TOOL,
24
+ const NATIVE_AGENT_TOOL_NAMES = [
25
+ NATIVE_AGENT_LIST_TOOL,
26
+ NATIVE_AGENT_START_TOOL,
27
+ NATIVE_AGENT_READ_TOOL,
27
28
  ];
28
- const SAFE_SPECIALIST_ID_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
29
+ const SAFE_AGENT_ID_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
29
30
  const SAFE_SCOPE_PARAM_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
30
31
  const MAX_CATALOG_ITEMS = 500;
31
32
 
@@ -63,61 +64,73 @@ function atomicPrivateWrite(filePath, contents) {
63
64
 
64
65
  function boundedString(value, field, { max = 1024, pattern = null } = {}) {
65
66
  if (typeof value !== "string" || !value.trim()) {
66
- throw new Error(`specialist catalog returned an invalid ${field}`);
67
+ throw new Error(`native-agent catalog returned an invalid ${field}`);
67
68
  }
68
69
  const normalized = value.trim();
69
70
  if (normalized.length > max || (pattern && !pattern.test(normalized))) {
70
- throw new Error(`specialist catalog returned an invalid ${field}`);
71
+ throw new Error(`native-agent catalog returned an invalid ${field}`);
71
72
  }
72
73
  return normalized;
73
74
  }
74
75
 
75
76
  function stringList(value, field) {
76
77
  if (!Array.isArray(value) || value.length > 100) {
77
- throw new Error(`specialist catalog returned an invalid ${field}`);
78
+ throw new Error(`native-agent catalog returned an invalid ${field}`);
78
79
  }
79
80
  return value.map((item) => boundedString(item, field, { max: 512 }));
80
81
  }
81
82
 
82
- export function normalizeSpecialistCatalog(payload, expectedTenantId) {
83
+ function enumString(value, field, allowed) {
84
+ const normalized = boundedString(value, field, { max: 80 });
85
+ if (!allowed.includes(normalized)) {
86
+ throw new Error(`native-agent catalog returned an invalid ${field}`);
87
+ }
88
+ return normalized;
89
+ }
90
+
91
+ export function normalizeNativeAgentCatalog(payload, expectedTenantId) {
83
92
  const tenantId = normalizeTenantId(expectedTenantId);
84
93
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
85
- throw new Error("specialist catalog returned an invalid response");
94
+ throw new Error("native-agent catalog returned an invalid response");
86
95
  }
87
96
  if (normalizeTenantId(payload.orgId) !== tenantId) {
88
- throw new Error("specialist catalog returned the wrong organization");
97
+ throw new Error("native-agent catalog returned the wrong organization");
89
98
  }
90
- if (!Array.isArray(payload.specialists) || payload.specialists.length > MAX_CATALOG_ITEMS) {
91
- throw new Error("specialist catalog returned an invalid specialists list");
99
+ if (!Array.isArray(payload.agents) || payload.agents.length > MAX_CATALOG_ITEMS) {
100
+ throw new Error("native-agent catalog returned an invalid agents list");
92
101
  }
93
102
 
94
- const seenIds = new Set();
95
- const specialists = payload.specialists.map((specialist) => {
96
- if (!specialist || typeof specialist !== "object" || Array.isArray(specialist)) {
97
- throw new Error("specialist catalog returned an invalid specialist");
103
+ const seenBindings = new Set();
104
+ const agents = payload.agents.map((agent) => {
105
+ if (!agent || typeof agent !== "object" || Array.isArray(agent)) {
106
+ throw new Error("native-agent catalog returned an invalid agent");
98
107
  }
99
- const agentId = boundedString(specialist.agentId, "agentId", {
108
+ const agentId = boundedString(agent.agentId, "agentId", {
100
109
  max: 160,
101
- pattern: SAFE_SPECIALIST_ID_RE,
110
+ pattern: SAFE_AGENT_ID_RE,
102
111
  });
103
- if (seenIds.has(agentId)) throw new Error(`specialist catalog returned duplicate agentId ${agentId}`);
104
- seenIds.add(agentId);
112
+ const scopeParam = boundedString(agent.scopeParam, "scopeParam", {
113
+ max: 160,
114
+ pattern: SAFE_SCOPE_PARAM_RE,
115
+ });
116
+ const binding = `${scopeParam}:${agentId}`;
117
+ if (seenBindings.has(binding)) {
118
+ throw new Error(`native-agent catalog returned duplicate binding ${binding}`);
119
+ }
120
+ seenBindings.add(binding);
105
121
  return {
106
122
  agentId,
107
- title: boundedString(specialist.title, "title", { max: 160 }),
108
- description: boundedString(specialist.description, "description", { max: 1024 }),
109
- scopeParam: boundedString(specialist.scopeParam, "scopeParam", {
110
- max: 160,
111
- pattern: SAFE_SCOPE_PARAM_RE,
112
- }),
113
- provider: boundedString(specialist.provider, "provider", { max: 80 }),
114
- capabilities: stringList(specialist.capabilities, "capabilities"),
115
- exclusions: stringList(specialist.exclusions, "exclusions"),
116
- requiredContext: stringList(specialist.requiredContext, "requiredContext"),
117
- sideEffects: boundedString(specialist.sideEffects, "sideEffects", { max: 256 }),
123
+ title: boundedString(agent.title, "title", { max: 160 }),
124
+ description: boundedString(agent.description, "description", { max: 1024 }),
125
+ scopeParam,
126
+ provider: enumString(agent.provider, "provider", ["claude_code", "codex"]),
127
+ capabilities: stringList(agent.capabilities, "capabilities"),
128
+ exclusions: stringList(agent.exclusions, "exclusions"),
129
+ requiredContext: stringList(agent.requiredContext, "requiredContext"),
130
+ sideEffects: enumString(agent.sideEffects, "sideEffects", ["read-only", "writes"]),
118
131
  };
119
132
  });
120
- return { orgId: tenantId, specialists };
133
+ return { orgId: tenantId, agents };
121
134
  }
122
135
 
123
136
  function decodeMcpResponse(contentType, body) {
@@ -151,13 +164,13 @@ async function postMcp({ endpoint, credential, sessionId, message, fetchImpl, ti
151
164
  });
152
165
  } catch (error) {
153
166
  const detail = error?.name === "AbortError" ? "request timed out" : error?.message || error;
154
- throw new Error(`specialist MCP request failed: ${redactSecretText(detail)}`);
167
+ throw new Error(`native-agent MCP request failed: ${redactSecretText(detail)}`);
155
168
  } finally {
156
169
  clearTimeout(timeout);
157
170
  }
158
171
  const body = await response.text();
159
172
  if (!response.ok) {
160
- throw new Error(`specialist MCP returned HTTP ${response.status}`);
173
+ throw new Error(`native-agent MCP returned HTTP ${response.status}`);
161
174
  }
162
175
  return {
163
176
  sessionId: response.headers.get("mcp-session-id") || sessionId || null,
@@ -169,26 +182,26 @@ function responseForId(messages, id) {
169
182
  return messages.find((message) => message?.id === id) || null;
170
183
  }
171
184
 
172
- function specialistPayloadFromToolResult(message) {
173
- if (!message) throw new Error("specialist MCP returned no tool result");
185
+ function nativeAgentPayloadFromToolResult(message) {
186
+ if (!message) throw new Error("native-agent MCP returned no tool result");
174
187
  if (message.error) {
175
- throw new Error(redactSecretText(message.error.message || "specialist MCP tool failed"));
188
+ throw new Error(redactSecretText(message.error.message || "native-agent MCP tool failed"));
176
189
  }
177
190
  const result = message.result;
178
- if (result?.isError) throw new Error("specialist MCP list tool returned an error");
191
+ if (result?.isError) throw new Error("native-agent MCP list tool returned an error");
179
192
  if (result?.structuredContent && typeof result.structuredContent === "object") {
180
193
  return result.structuredContent;
181
194
  }
182
195
  const text = result?.content?.find((item) => item?.type === "text" && typeof item.text === "string")?.text;
183
- if (!text) throw new Error("specialist MCP list tool returned no catalog");
196
+ if (!text) throw new Error("native-agent MCP list tool returned no catalog");
184
197
  try {
185
198
  return JSON.parse(text);
186
199
  } catch {
187
- throw new Error("specialist MCP list tool returned invalid JSON");
200
+ throw new Error("native-agent MCP list tool returned invalid JSON");
188
201
  }
189
202
  }
190
203
 
191
- export async function fetchSpecialistCatalog({
204
+ export async function fetchNativeAgentCatalog({
192
205
  gatewayUrl,
193
206
  credential,
194
207
  tenantId,
@@ -218,7 +231,7 @@ export async function fetchSpecialistCatalog({
218
231
  sessionId = initialized.sessionId;
219
232
  const initializeMessage = responseForId(initialized.messages, 1);
220
233
  if (initializeMessage?.error || !initializeMessage?.result) {
221
- throw new Error("specialist MCP initialization failed");
234
+ throw new Error("native-agent MCP initialization failed");
222
235
  }
223
236
 
224
237
  const ready = await postMcp({
@@ -240,11 +253,11 @@ export async function fetchSpecialistCatalog({
240
253
  jsonrpc: "2.0",
241
254
  id: 2,
242
255
  method: "tools/call",
243
- params: { name: SPECIALIST_LIST_TOOL, arguments: {} },
256
+ params: { name: NATIVE_AGENT_LIST_TOOL, arguments: {} },
244
257
  },
245
258
  });
246
- return normalizeSpecialistCatalog(
247
- specialistPayloadFromToolResult(responseForId(listed.messages, 2)),
259
+ return normalizeNativeAgentCatalog(
260
+ nativeAgentPayloadFromToolResult(responseForId(listed.messages, 2)),
248
261
  tenantId,
249
262
  );
250
263
  }
@@ -257,29 +270,36 @@ function slug(value) {
257
270
  .slice(0, 48) || "agent";
258
271
  }
259
272
 
260
- function generatedAgentNames(tenantId, specialists) {
273
+ function generatedAgentNames(tenantId, agents) {
261
274
  const used = new Map();
262
- return specialists.map((specialist) => {
263
- const base = `impel-${slug(tenantId)}-${slug(specialist.agentId)}`.slice(0, 63).replace(/-+$/u, "");
275
+ return agents.map((agent) => {
276
+ const base = `impel-${slug(tenantId)}-${slug(agent.agentId)}`.slice(0, 63).replace(/-+$/u, "");
264
277
  const previous = used.get(base);
265
278
  used.set(base, (previous || 0) + 1);
266
279
  if (!previous) return base;
267
- const suffix = crypto.createHash("sha256").update(specialist.agentId).digest("hex").slice(0, 8);
280
+ const suffix = crypto.createHash("sha256")
281
+ .update(`${agent.scopeParam}:${agent.agentId}`)
282
+ .digest("hex")
283
+ .slice(0, 8);
268
284
  return `${base.slice(0, 54)}-${suffix}`;
269
285
  });
270
286
  }
271
287
 
272
- function adapterInstructions(tenantId, specialist) {
288
+ function adapterInstructions(tenantId, agent) {
273
289
  const toolName = nativeToolName;
274
- const contextRequirement = specialist.requiredContext.length
275
- ? ` Required context keys are ${JSON.stringify(specialist.requiredContext)}; if any are absent, ask for them before starting the run.`
290
+ const contextRequirement = agent.requiredContext.length
291
+ ? ` Required context keys are ${JSON.stringify(agent.requiredContext)}; if any are absent, ask for them before starting the run.`
276
292
  : "";
293
+ const sideEffectInstruction = agent.sideEffects === "writes"
294
+ ? `The catalog declares that this agent writes user or workspace data. Use this adapter only when the user explicitly selected this exact agent for the current request; that explicit selection is the required side-effect confirmation, so pass confirmedSideEffects true. If the agent was chosen automatically or the selection is ambiguous, do not start it and ask the user to select it explicitly.`
295
+ : `The catalog declares that this agent is read-only; omit confirmedSideEffects.`;
277
296
  return [
278
- `You are a thin transport adapter for the exact Impel specialist ${JSON.stringify(specialist.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
279
- `Do not perform the assigned task yourself and do not delegate to any other specialist.`,
280
- `First call ${toolName(SPECIALIST_LIST_TOOL)} and verify that the exact agentId is still available. If it is unavailable or its policy excludes the request, stop with that explicit error.`,
281
- `Call ${toolName(SPECIALIST_START_TOOL)} exactly once with agentId ${JSON.stringify(specialist.agentId)}, scopeParam ${JSON.stringify(specialist.scopeParam)}, task set to the complete assigned task, context and contextKeys containing all supplied context, and one stable idempotencyKey that you reuse for this logical task.${contextRequirement}`,
282
- `Then call ${toolName(SPECIALIST_READ_TOOL)} with the returned runId and waitSeconds 20 until the run reaches a terminal state.`,
297
+ `You are a thin transport adapter for the exact Impel native agent ${JSON.stringify(agent.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
298
+ `Do not perform the assigned task yourself and do not delegate to any other agent.`,
299
+ `First call ${toolName(NATIVE_AGENT_LIST_TOOL)} and verify that the exact agentId is still available with sideEffects ${JSON.stringify(agent.sideEffects)}. If it is unavailable or its policy excludes the request, stop with that explicit error.`,
300
+ sideEffectInstruction,
301
+ `Call ${toolName(NATIVE_AGENT_START_TOOL)} exactly once with agentId ${JSON.stringify(agent.agentId)}, scopeParam ${JSON.stringify(agent.scopeParam)}, task set to the complete assigned task, context and contextKeys containing all supplied context, confirmedSideEffects as directed above, and one stable idempotencyKey that you reuse for this logical task.${contextRequirement}`,
302
+ `Then call ${toolName(NATIVE_AGENT_READ_TOOL)} with the returned runId and waitSeconds 20 until the run reaches a terminal state.`,
283
303
  `When it succeeds, return result.finalText faithfully as the answer. When it fails, return the durable runId, preserved output, and error. Never invent or independently synthesize a replacement result.`,
284
304
  ].join(" ");
285
305
  }
@@ -288,15 +308,15 @@ function nativeToolName(toolName) {
288
308
  return `mcp__${MANAGED_AGENT_MCP_SERVER}__${toolName}`;
289
309
  }
290
310
 
291
- function renderClaudeAgent({ tenantId, specialist, name, invocation }) {
292
- const description = `Runs ${specialist.title} for Impel tenant ${tenantId}: ${specialist.description}`.slice(0, 900);
311
+ function renderClaudeAgent({ tenantId, agent, name, invocation }) {
312
+ const description = `Explicitly runs ${agent.title} for Impel tenant ${tenantId}${agent.sideEffects === "writes" ? " (may write user or workspace data)" : " (read-only)"}: ${agent.description}`.slice(0, 900);
293
313
  const lines = [
294
314
  "---",
295
315
  `name: ${JSON.stringify(name)}`,
296
316
  `description: ${JSON.stringify(description)}`,
297
317
  "model: inherit",
298
318
  "tools:",
299
- ...SPECIALIST_TOOL_NAMES.map((tool) => ` - ${JSON.stringify(nativeToolName(tool))}`),
319
+ ...NATIVE_AGENT_TOOL_NAMES.map((tool) => ` - ${JSON.stringify(nativeToolName(tool))}`),
300
320
  "mcpServers:",
301
321
  ` - ${MANAGED_AGENT_MCP_SERVER}:`,
302
322
  " type: stdio",
@@ -307,14 +327,14 @@ function renderClaudeAgent({ tenantId, specialist, name, invocation }) {
307
327
  ...Object.entries(invocation.env || {}).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`),
308
328
  "---",
309
329
  "",
310
- adapterInstructions(tenantId, specialist),
330
+ adapterInstructions(tenantId, agent),
311
331
  "",
312
332
  ];
313
333
  return lines.join("\n");
314
334
  }
315
335
 
316
- function renderCodexAgent({ tenantId, specialist, name, invocation }) {
317
- const description = `Runs ${specialist.title} for Impel tenant ${tenantId}: ${specialist.description}`.slice(0, 900);
336
+ function renderCodexAgent({ tenantId, agent, name, invocation }) {
337
+ const description = `Explicitly runs ${agent.title} for Impel tenant ${tenantId}${agent.sideEffects === "writes" ? " (may write user or workspace data)" : " (read-only)"}: ${agent.description}`.slice(0, 900);
318
338
  const envEntries = Object.entries(invocation.env || {})
319
339
  .map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`)
320
340
  .join(", ");
@@ -322,32 +342,32 @@ function renderCodexAgent({ tenantId, specialist, name, invocation }) {
322
342
  `name = ${JSON.stringify(name)}`,
323
343
  `description = ${JSON.stringify(description)}`,
324
344
  'sandbox_mode = "read-only"',
325
- `developer_instructions = ${JSON.stringify(adapterInstructions(tenantId, specialist))}`,
345
+ `developer_instructions = ${JSON.stringify(adapterInstructions(tenantId, agent))}`,
326
346
  "",
327
347
  `[mcp_servers.${MANAGED_AGENT_MCP_SERVER}]`,
328
348
  `command = ${JSON.stringify(invocation.command)}`,
329
349
  `args = [${invocation.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
330
- `enabled_tools = [${SPECIALIST_TOOL_NAMES.map((tool) => JSON.stringify(tool)).join(", ")}]`,
350
+ `enabled_tools = [${NATIVE_AGENT_TOOL_NAMES.map((tool) => JSON.stringify(tool)).join(", ")}]`,
331
351
  ...(envEntries ? [`env = { ${envEntries} }`] : []),
332
352
  "",
333
353
  ];
334
- for (const tool of SPECIALIST_TOOL_NAMES) {
354
+ for (const tool of NATIVE_AGENT_TOOL_NAMES) {
335
355
  lines.push(`[mcp_servers.${MANAGED_AGENT_MCP_SERVER}.tools.${JSON.stringify(tool)}]`, 'approval_mode = "approve"', "");
336
356
  }
337
357
  return lines.join("\n");
338
358
  }
339
359
 
340
- export function renderManagedAgents(client, tenantId, specialists, invocation = impelMcpInvocation(["--tenant", tenantId])) {
360
+ export function renderManagedAgents(client, tenantId, agents, invocation = impelMcpInvocation(["--tenant", tenantId])) {
341
361
  if (client !== "claude" && client !== "codex") throw new Error(`unknown agent client ${client}`);
342
362
  const normalizedTenant = normalizeTenantId(tenantId);
343
- const names = generatedAgentNames(normalizedTenant, specialists);
344
- return specialists.map((specialist, index) => {
363
+ const names = generatedAgentNames(normalizedTenant, agents);
364
+ return agents.map((agent, index) => {
345
365
  const name = names[index];
346
366
  const extension = client === "claude" ? ".md" : ".toml";
347
367
  const contents = client === "claude"
348
- ? renderClaudeAgent({ tenantId: normalizedTenant, specialist, name, invocation })
349
- : renderCodexAgent({ tenantId: normalizedTenant, specialist, name, invocation });
350
- return { agentId: specialist.agentId, name, fileName: `${name}${extension}`, contents };
368
+ ? renderClaudeAgent({ tenantId: normalizedTenant, agent, name, invocation })
369
+ : renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation });
370
+ return { agentId: agent.agentId, name, fileName: `${name}${extension}`, contents };
351
371
  });
352
372
  }
353
373
 
@@ -378,17 +398,17 @@ function readManifest(manifestPath) {
378
398
 
379
399
  function profileIsFresh(profile, tenantId, now, ttlMs) {
380
400
  const manifest = readManifest(path.join(profile.root, "agents", MANAGED_AGENT_DIRECTORY, MANAGED_AGENT_MANIFEST));
381
- if (!manifest || manifest.tenantId !== tenantId) return false;
401
+ if (!manifest || manifest.version !== 2 || manifest.tenantId !== tenantId) return false;
382
402
  const syncedAt = Date.parse(manifest.syncedAt || "");
383
403
  if (!Number.isFinite(syncedAt) || now - syncedAt >= ttlMs) return false;
384
404
  return manifest.files.every((fileName) =>
385
405
  typeof fileName === "string"
386
406
  && path.basename(fileName) === fileName
387
- && fs.existsSync(path.join(profile.root, "agents", MANAGED_AGENT_DIRECTORY, fileName))
407
+ && fs.existsSync(path.join(profile.root, "agents", fileName))
388
408
  );
389
409
  }
390
410
 
391
- export function syncAgentProfile({ client, root, label, tenantId, specialists, now = Date.now() }) {
411
+ export function syncAgentProfile({ client, root, label, tenantId, agents, now = Date.now() }) {
392
412
  const agentsDir = path.join(root, "agents");
393
413
  for (const candidate of [root, agentsDir]) {
394
414
  if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink()) {
@@ -399,10 +419,26 @@ export function syncAgentProfile({ client, root, label, tenantId, specialists, n
399
419
  privateDirectory(managedDir);
400
420
  const manifestPath = path.join(managedDir, MANAGED_AGENT_MANIFEST);
401
421
  const prior = readManifest(manifestPath);
402
- const rendered = renderManagedAgents(client, tenantId, specialists);
422
+ const rendered = renderManagedAgents(client, tenantId, agents);
423
+ const priorFiles = new Set(prior?.files || []);
424
+ const priorUsesDiscoveryRoot = prior?.version === 2;
403
425
 
426
+ // Native clients discover standalone definitions directly under `agents/`.
427
+ // Preflight every destination before writing so an unmanaged file with the
428
+ // same generated name is never overwritten.
404
429
  for (const agent of rendered) {
405
- atomicPrivateWrite(path.join(managedDir, agent.fileName), agent.contents);
430
+ const destination = path.join(agentsDir, agent.fileName);
431
+ if (fs.existsSync(destination)) {
432
+ if (!priorUsesDiscoveryRoot || !priorFiles.has(agent.fileName)) {
433
+ throw new Error(`refusing to overwrite unmanaged native-agent file ${destination}`);
434
+ }
435
+ if (fs.lstatSync(destination).isSymbolicLink()) {
436
+ throw new Error(`refusing to overwrite symlinked native-agent file ${destination}`);
437
+ }
438
+ }
439
+ }
440
+ for (const agent of rendered) {
441
+ atomicPrivateWrite(path.join(agentsDir, agent.fileName), agent.contents);
406
442
  }
407
443
  const currentFiles = new Set(rendered.map((agent) => agent.fileName));
408
444
  for (const stale of prior?.files || []) {
@@ -412,16 +448,31 @@ export function syncAgentProfile({ client, root, label, tenantId, specialists, n
412
448
  && !currentFiles.has(stale)
413
449
  && (stale.endsWith(".md") || stale.endsWith(".toml"))
414
450
  ) {
415
- fs.rmSync(path.join(managedDir, stale), { force: true });
451
+ fs.rmSync(path.join(priorUsesDiscoveryRoot ? agentsDir : managedDir, stale), { force: true });
452
+ }
453
+ }
454
+ if (!priorUsesDiscoveryRoot) {
455
+ for (const legacy of prior?.files || []) {
456
+ if (
457
+ typeof legacy === "string"
458
+ && path.basename(legacy) === legacy
459
+ && (legacy.endsWith(".md") || legacy.endsWith(".toml"))
460
+ ) {
461
+ fs.rmSync(path.join(managedDir, legacy), { force: true });
462
+ }
416
463
  }
417
464
  }
418
465
  atomicPrivateWrite(manifestPath, `${JSON.stringify({
419
- version: 1,
466
+ version: 2,
420
467
  tenantId,
421
468
  client,
422
469
  syncedAt: new Date(now).toISOString(),
423
470
  files: [...currentFiles].sort(),
424
- agents: rendered.map(({ agentId, name }) => ({ agentId, name })),
471
+ agents: rendered.map(({ agentId, name }, index) => ({
472
+ agentId,
473
+ scopeParam: agents[index].scopeParam,
474
+ name,
475
+ })),
425
476
  }, null, 2)}\n`);
426
477
  return { client, root, label, synced: true, count: rendered.length, files: [...currentFiles] };
427
478
  }
@@ -434,7 +485,7 @@ export async function syncAgentProfiles({
434
485
  staleOnly = false,
435
486
  ttlMs = AGENT_SYNC_TTL_MS,
436
487
  now = Date.now(),
437
- fetchCatalog = fetchSpecialistCatalog,
488
+ fetchCatalog = fetchNativeAgentCatalog,
438
489
  logger = console,
439
490
  }) {
440
491
  if (process.env.IMPEL_SKIP_AGENT_SYNC === "1" || process.env.IMPEL_SKIP_AGENT_SYNC === "true") {
@@ -455,10 +506,10 @@ export async function syncAgentProfiles({
455
506
  const result = syncAgentProfile({
456
507
  ...profile,
457
508
  tenantId: normalizedTenant,
458
- specialists: catalog.specialists,
509
+ agents: catalog.agents,
459
510
  now,
460
511
  });
461
- logger.log(`Agents: ${profile.label || profile.client} up to date (${result.count} tenant specialist${result.count === 1 ? "" : "s"}).`);
512
+ logger.log(`Agents: ${profile.label || profile.client} up to date (${result.count} explicit tenant agent${result.count === 1 ? "" : "s"}).`);
462
513
  results.push(result);
463
514
  }
464
515
  return [...results, ...skipped];
package/src/cli.js CHANGED
@@ -44,7 +44,7 @@ Manage:
44
44
  impel token [--tenant <org>] Print the selected-tenant bearer
45
45
  impel mcp Run the local Impel MCP stdio bridge
46
46
  impel skills sync [claude|codex|all] Sync shared skills into managed clients
47
- impel agents sync [claude|codex|all] Sync tenant specialists as native subagents
47
+ impel agents sync [claude|codex|all] Sync explicit tenant agents into native clients
48
48
 
49
49
  Desktop apps (macOS and Windows):
50
50
  target is claude, chatgpt/codex, or all (default: all)
@@ -1,5 +1,5 @@
1
1
  // `impel agents sync [claude|codex|all]` — synchronize the selected tenant's
2
- // opted-in Impel specialists into every managed native custom-agent registry.
2
+ // explicitly invocable Impel agents into every managed native registry.
3
3
 
4
4
  import os from "node:os";
5
5
 
@@ -8,13 +8,14 @@ import { loadConfig, normalizeGatewayUrl, redactSecretText, resolveDefaultGatewa
8
8
  import { ensureTenantSelection, tenantCredential } from "../tenants.js";
9
9
  import { managedSkillProfiles } from "./skills.js";
10
10
 
11
- const HELP = `impel agents - sync tenant specialists as native custom agents
11
+ const HELP = `impel agents - sync explicit tenant agents into native clients
12
12
 
13
13
  Usage:
14
14
  impel agents sync [claude|codex|all]
15
15
 
16
- The selected tenant's authenticated specialist catalog is written into every
17
- managed native profile. The default target is all.`;
16
+ The selected tenant's authenticated explicit-agent catalog is written into
17
+ every managed native profile. Write-capable agents require explicit user
18
+ selection before a run can start. The default target is all.`;
18
19
 
19
20
  function normalizeAgentTarget(token) {
20
21
  if (token === undefined || token === "all") return "all";