impel-cli 0.9.2 → 0.11.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.
package/README.md CHANGED
@@ -118,7 +118,8 @@ impel setup [--pat <pat>] [--tenant <org>] [--skip-apps] [--skip-clis]
118
118
  apps, then installs the vendored Impel apps on macOS;
119
119
  installs missing vendor CLIs on Windows)
120
120
  impel update [--check] [--skip-apps] Update EVERYTHING: reinstall the CLI from npm, then
121
- cascade to `app update all` and `skills sync all`
121
+ cascade to `app update all`, `skills sync all`, and
122
+ `agents sync all`
122
123
  impel auth [--pat <pat>] [--gateway <url>] [--app <url>]
123
124
  Store your Impel PAT + URLs
124
125
  impel pat create --label <label> [options] Mint a personal PAT through the control plane
@@ -147,11 +148,12 @@ impel use account [claude|codex|all] Revert to your own login (defaul
147
148
  impel off [claude|codex|all] Alias for `impel use account`
148
149
 
149
150
  impel skills sync [claude|codex|all] Sync Bifrost shared skills into managed clients (default: all)
151
+ impel agents sync [claude|codex|all] Sync explicit tenant agents into native clients
150
152
 
151
153
  impel status Launcher readiness + native mode + gateway reachability
152
154
  impel app install [target] [--tenant <org>] Install isolated apps for one tenant
153
155
  impel app update [target] [--tenant <org>] Refresh that tenant's apps, configs, and models
154
- impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
156
+ impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
155
157
  (--stale-only: no-op unless 6h+ since last sync)
156
158
  impel app status [target] [--tenant <org>] Show tenant launcher/vendor status
157
159
  impel app open [target] [--tenant <org>] Install/configure a tenant on first use, then launch
@@ -236,7 +238,8 @@ One command brings everything current, in dependency order: the CLI itself
236
238
  (`npm install -g impel-cli@latest` from npm, with no GitHub credentials),
237
239
  then — re-executing the freshly installed build — `impel app update all`
238
240
  (close running apps, install the CLI-pinned vendor builds, rebuild the vendored Impel apps)
239
- and `impel skills sync all` across every managed profile.
241
+ followed by `impel skills sync all` and `impel agents sync all` across every
242
+ managed profile.
240
243
 
241
244
  Update discovery compares the installed package version with npm's public
242
245
  `latest` metadata, cached for 6 hours in
@@ -248,8 +251,8 @@ remote is ahead, and refresh the cache in a detached background process when
248
251
  it's stale (TTY-only, never blocks a launch; set `IMPEL_SKIP_UPDATE_CHECK=1`
249
252
  to silence). The Impel desktop apps keep themselves current too: their token
250
253
  helper kicks off a detached `impel app refresh --stale-only` on use, which
251
- re-syncs configs, the model catalog, and skills at most every 6 hours — safe
252
- while the apps are running because it never swaps an app bundle.
254
+ re-syncs configs, the model catalog, skills, and native agents at most every six
255
+ hours — safe while the apps are running because it never swaps an app bundle.
253
256
 
254
257
  ### `impel tasks ...` (aliases `impel task`, `impel tickets`, `impel ticket`)
255
258
 
@@ -389,7 +392,9 @@ MCP entry point, reads the PAT from the owner-only Impel
389
392
  config, and proxies JSON-RPC to `<gateway>/mcp`; the PAT is never written into
390
393
  either client's MCP configuration. Available specialists are discovered at
391
394
  runtime, so publishing a new opted-in specialist does not require another CLI
392
- release.
395
+ release. The same authenticated catalog is rendered into native Claude Code
396
+ and Codex agent definitions. Each definition carries only the direct
397
+ `impel mcp --tenant <org>` subprocess invocation, never the PAT itself.
393
398
 
394
399
  ### Isolated CLI launchers
395
400
 
@@ -584,6 +589,55 @@ best-effort: if a client CLI, its `plugin` subcommand, or the gateway is
584
589
  unavailable, it logs a warning and moves on — it never fails the parent command.
585
590
  Set `IMPEL_SKIP_SKILL_SYNC=1` to disable it entirely (offline/CI).
586
591
 
592
+ ### `impel agents sync [claude|codex|all]`
593
+
594
+ Fetches the selected organization's explicit native-agent catalog through the
595
+ authenticated Impel MCP endpoint, then generates native custom-agent files for
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.
599
+
600
+ - Claude Code: `<CLAUDE_CONFIG_DIR>/agents/impel-managed/*.md`
601
+ - Codex / ChatGPT Codex mode: `<CODEX_HOME>/agents/impel-managed/*.toml`
602
+
603
+ Each generated agent is bound to one exact `agentId` and
604
+ `scopeParam`. It verifies that the exact agent is still available, starts one
605
+ idempotent run, polls the durable run to a terminal state, and returns
606
+ `result.finalText` without repeating the work. The files include a tenant-bound
607
+ MCP subprocess definition so they also work in isolated desktop profiles. They
608
+ never contain the PAT; the subprocess reads it from Impel's owner-only config
609
+ when invoked.
610
+
611
+ The sync writes atomically and records a manifest beside the generated files.
612
+ It removes only stale filenames from that manifest, so user-authored agents are
613
+ never deleted. Automatic refreshes are best-effort and retain the last good
614
+ catalog when the gateway is unavailable; the explicit command exits non-zero
615
+ on failure. Set `IMPEL_SKIP_AGENT_SYNC=1` to keep the current generated catalog
616
+ without contacting the gateway.
617
+
618
+ Start a new task after syncing. Claude Code detects updates to an existing
619
+ `agents/` directory within a few seconds, but if the sync created that directory
620
+ for the first time while Claude was already running, restart Claude once.
621
+
622
+ Invocation uses the clients' native agent behavior:
623
+
624
+ ```text
625
+ # Claude Code / Claude Desktop Code tab: guaranteed explicit selection
626
+ @agent-impel-acme-research-agent investigate the dependency change
627
+
628
+ # Claude blocking CLI
629
+ claude --agent impel-acme-research-agent -p "investigate the dependency change"
630
+
631
+ # Codex CLI / ChatGPT desktop Codex task
632
+ Explicitly use the configured impel-acme-research-agent for this request and wait for it.
633
+ ```
634
+
635
+ Claude exposes native custom agents in its `@` typeahead. Current Codex releases
636
+ load and display the generated native agents but do not provide an `@agent`
637
+ mention target; named spawning remains model-mediated and may also be limited by
638
+ the active MultiAgentV2 tool schema. Use `/agent` in Codex to inspect spawned
639
+ agent threads.
640
+
587
641
  ### `impel status`
588
642
 
589
643
  Shows whether the isolated CLI launchers are ready, and whether each native
@@ -675,7 +729,7 @@ successful tenant-specific rebuild.
675
729
 
676
730
  The official Claude and ChatGPT apps remain separate installations and keep
677
731
  their normal accounts, updater, profiles, and bundle identities. Each CLI
678
- release pins exact, tested vendor builds (v0.9.0 pins Claude `1.20186.9` and
732
+ release pins exact, tested vendor builds (v0.10.0 pins Claude `1.20186.9` and
679
733
  ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
680
734
  an existing official app only when its version exactly matches; otherwise it
681
735
  downloads the pinned signed archive into Impel's managed vendor cache under
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.9.2",
3
+ "version": "0.11.0",
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 ADDED
@@ -0,0 +1,504 @@
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.
6
+
7
+ import crypto from "node:crypto";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+
12
+ import { normalizeGatewayUrl, redactSecretText } from "./config.js";
13
+ import { impelMcpInvocation } from "./selfInvocation.js";
14
+ import { normalizeTenantId } from "./tenants.js";
15
+
16
+ export const AGENT_SYNC_TTL_MS = 6 * 60 * 60 * 1000;
17
+ export const MANAGED_AGENT_DIRECTORY = "impel-managed";
18
+ export const MANAGED_AGENT_MANIFEST = ".manifest.json";
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";
22
+ export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
23
+
24
+ const NATIVE_AGENT_TOOL_NAMES = [
25
+ NATIVE_AGENT_LIST_TOOL,
26
+ NATIVE_AGENT_START_TOOL,
27
+ NATIVE_AGENT_READ_TOOL,
28
+ ];
29
+ const SAFE_AGENT_ID_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
30
+ const SAFE_SCOPE_PARAM_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
31
+ const MAX_CATALOG_ITEMS = 500;
32
+
33
+ function privateDirectory(directory) {
34
+ if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) {
35
+ throw new Error(`refusing to use symlinked managed-agent directory ${directory}`);
36
+ }
37
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
38
+ try {
39
+ fs.chmodSync(directory, 0o700);
40
+ } catch {
41
+ // Best effort on Windows.
42
+ }
43
+ }
44
+
45
+ function atomicPrivateWrite(filePath, contents) {
46
+ privateDirectory(path.dirname(filePath));
47
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
48
+ try {
49
+ fs.writeFileSync(temporaryPath, contents, { mode: 0o600 });
50
+ fs.renameSync(temporaryPath, filePath);
51
+ try {
52
+ fs.chmodSync(filePath, 0o600);
53
+ } catch {
54
+ // Best effort on Windows.
55
+ }
56
+ } finally {
57
+ try {
58
+ fs.rmSync(temporaryPath, { force: true });
59
+ } catch {
60
+ // The normal rename path already removed it.
61
+ }
62
+ }
63
+ }
64
+
65
+ function boundedString(value, field, { max = 1024, pattern = null } = {}) {
66
+ if (typeof value !== "string" || !value.trim()) {
67
+ throw new Error(`native-agent catalog returned an invalid ${field}`);
68
+ }
69
+ const normalized = value.trim();
70
+ if (normalized.length > max || (pattern && !pattern.test(normalized))) {
71
+ throw new Error(`native-agent catalog returned an invalid ${field}`);
72
+ }
73
+ return normalized;
74
+ }
75
+
76
+ function stringList(value, field) {
77
+ if (!Array.isArray(value) || value.length > 100) {
78
+ throw new Error(`native-agent catalog returned an invalid ${field}`);
79
+ }
80
+ return value.map((item) => boundedString(item, field, { max: 512 }));
81
+ }
82
+
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) {
92
+ const tenantId = normalizeTenantId(expectedTenantId);
93
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
94
+ throw new Error("native-agent catalog returned an invalid response");
95
+ }
96
+ if (normalizeTenantId(payload.orgId) !== tenantId) {
97
+ throw new Error("native-agent catalog returned the wrong organization");
98
+ }
99
+ if (!Array.isArray(payload.agents) || payload.agents.length > MAX_CATALOG_ITEMS) {
100
+ throw new Error("native-agent catalog returned an invalid agents list");
101
+ }
102
+
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");
107
+ }
108
+ const agentId = boundedString(agent.agentId, "agentId", {
109
+ max: 160,
110
+ pattern: SAFE_AGENT_ID_RE,
111
+ });
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);
121
+ return {
122
+ agentId,
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"]),
131
+ };
132
+ });
133
+ return { orgId: tenantId, agents };
134
+ }
135
+
136
+ function decodeMcpResponse(contentType, body) {
137
+ if (!body.trim()) return [];
138
+ if (!String(contentType || "").toLowerCase().includes("text/event-stream")) {
139
+ return [JSON.parse(body)];
140
+ }
141
+ return body
142
+ .split(/\r?\n/u)
143
+ .filter((line) => line.startsWith("data:"))
144
+ .map((line) => line.slice(5).trim())
145
+ .filter((line) => line && line !== "[DONE]")
146
+ .map((line) => JSON.parse(line));
147
+ }
148
+
149
+ async function postMcp({ endpoint, credential, sessionId, message, fetchImpl, timeoutMs }) {
150
+ const controller = new AbortController();
151
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
152
+ let response;
153
+ try {
154
+ response = await fetchImpl(endpoint, {
155
+ method: "POST",
156
+ headers: {
157
+ Authorization: `Bearer ${credential}`,
158
+ "Content-Type": "application/json",
159
+ Accept: "application/json, text/event-stream",
160
+ ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
161
+ },
162
+ body: JSON.stringify(message),
163
+ signal: controller.signal,
164
+ });
165
+ } catch (error) {
166
+ const detail = error?.name === "AbortError" ? "request timed out" : error?.message || error;
167
+ throw new Error(`native-agent MCP request failed: ${redactSecretText(detail)}`);
168
+ } finally {
169
+ clearTimeout(timeout);
170
+ }
171
+ const body = await response.text();
172
+ if (!response.ok) {
173
+ throw new Error(`native-agent MCP returned HTTP ${response.status}`);
174
+ }
175
+ return {
176
+ sessionId: response.headers.get("mcp-session-id") || sessionId || null,
177
+ messages: decodeMcpResponse(response.headers.get("content-type"), body),
178
+ };
179
+ }
180
+
181
+ function responseForId(messages, id) {
182
+ return messages.find((message) => message?.id === id) || null;
183
+ }
184
+
185
+ function nativeAgentPayloadFromToolResult(message) {
186
+ if (!message) throw new Error("native-agent MCP returned no tool result");
187
+ if (message.error) {
188
+ throw new Error(redactSecretText(message.error.message || "native-agent MCP tool failed"));
189
+ }
190
+ const result = message.result;
191
+ if (result?.isError) throw new Error("native-agent MCP list tool returned an error");
192
+ if (result?.structuredContent && typeof result.structuredContent === "object") {
193
+ return result.structuredContent;
194
+ }
195
+ const text = result?.content?.find((item) => item?.type === "text" && typeof item.text === "string")?.text;
196
+ if (!text) throw new Error("native-agent MCP list tool returned no catalog");
197
+ try {
198
+ return JSON.parse(text);
199
+ } catch {
200
+ throw new Error("native-agent MCP list tool returned invalid JSON");
201
+ }
202
+ }
203
+
204
+ export async function fetchNativeAgentCatalog({
205
+ gatewayUrl,
206
+ credential,
207
+ tenantId,
208
+ fetchImpl = fetch,
209
+ timeoutMs = 20_000,
210
+ }) {
211
+ if (!credential) throw new Error("a tenant credential is required for agent sync");
212
+ const endpoint = `${normalizeGatewayUrl(gatewayUrl)}/mcp`;
213
+ let sessionId = null;
214
+ const initialized = await postMcp({
215
+ endpoint,
216
+ credential,
217
+ sessionId,
218
+ fetchImpl,
219
+ timeoutMs,
220
+ message: {
221
+ jsonrpc: "2.0",
222
+ id: 1,
223
+ method: "initialize",
224
+ params: {
225
+ protocolVersion: "2025-06-18",
226
+ capabilities: {},
227
+ clientInfo: { name: "impel-cli-agent-sync", version: "1.0.0" },
228
+ },
229
+ },
230
+ });
231
+ sessionId = initialized.sessionId;
232
+ const initializeMessage = responseForId(initialized.messages, 1);
233
+ if (initializeMessage?.error || !initializeMessage?.result) {
234
+ throw new Error("native-agent MCP initialization failed");
235
+ }
236
+
237
+ const ready = await postMcp({
238
+ endpoint,
239
+ credential,
240
+ sessionId,
241
+ fetchImpl,
242
+ timeoutMs,
243
+ message: { jsonrpc: "2.0", method: "notifications/initialized", params: {} },
244
+ });
245
+ sessionId = ready.sessionId;
246
+ const listed = await postMcp({
247
+ endpoint,
248
+ credential,
249
+ sessionId,
250
+ fetchImpl,
251
+ timeoutMs,
252
+ message: {
253
+ jsonrpc: "2.0",
254
+ id: 2,
255
+ method: "tools/call",
256
+ params: { name: NATIVE_AGENT_LIST_TOOL, arguments: {} },
257
+ },
258
+ });
259
+ return normalizeNativeAgentCatalog(
260
+ nativeAgentPayloadFromToolResult(responseForId(listed.messages, 2)),
261
+ tenantId,
262
+ );
263
+ }
264
+
265
+ function slug(value) {
266
+ return String(value)
267
+ .toLowerCase()
268
+ .replace(/[^a-z0-9]+/gu, "-")
269
+ .replace(/^-+|-+$/gu, "")
270
+ .slice(0, 48) || "agent";
271
+ }
272
+
273
+ function generatedAgentNames(tenantId, agents) {
274
+ const used = new Map();
275
+ return agents.map((agent) => {
276
+ const base = `impel-${slug(tenantId)}-${slug(agent.agentId)}`.slice(0, 63).replace(/-+$/u, "");
277
+ const previous = used.get(base);
278
+ used.set(base, (previous || 0) + 1);
279
+ if (!previous) return base;
280
+ const suffix = crypto.createHash("sha256")
281
+ .update(`${agent.scopeParam}:${agent.agentId}`)
282
+ .digest("hex")
283
+ .slice(0, 8);
284
+ return `${base.slice(0, 54)}-${suffix}`;
285
+ });
286
+ }
287
+
288
+ function adapterInstructions(tenantId, agent) {
289
+ const toolName = nativeToolName;
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.`
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.`;
296
+ return [
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.`,
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.`,
304
+ ].join(" ");
305
+ }
306
+
307
+ function nativeToolName(toolName) {
308
+ return `mcp__${MANAGED_AGENT_MCP_SERVER}__${toolName}`;
309
+ }
310
+
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);
313
+ const lines = [
314
+ "---",
315
+ `name: ${JSON.stringify(name)}`,
316
+ `description: ${JSON.stringify(description)}`,
317
+ "model: inherit",
318
+ "tools:",
319
+ ...NATIVE_AGENT_TOOL_NAMES.map((tool) => ` - ${JSON.stringify(nativeToolName(tool))}`),
320
+ "mcpServers:",
321
+ ` - ${MANAGED_AGENT_MCP_SERVER}:`,
322
+ " type: stdio",
323
+ ` command: ${JSON.stringify(invocation.command)}`,
324
+ " args:",
325
+ ...invocation.args.map((argument) => ` - ${JSON.stringify(argument)}`),
326
+ " env:",
327
+ ...Object.entries(invocation.env || {}).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`),
328
+ "---",
329
+ "",
330
+ adapterInstructions(tenantId, agent),
331
+ "",
332
+ ];
333
+ return lines.join("\n");
334
+ }
335
+
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);
338
+ const envEntries = Object.entries(invocation.env || {})
339
+ .map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`)
340
+ .join(", ");
341
+ const lines = [
342
+ `name = ${JSON.stringify(name)}`,
343
+ `description = ${JSON.stringify(description)}`,
344
+ 'sandbox_mode = "read-only"',
345
+ `developer_instructions = ${JSON.stringify(adapterInstructions(tenantId, agent))}`,
346
+ "",
347
+ `[mcp_servers.${MANAGED_AGENT_MCP_SERVER}]`,
348
+ `command = ${JSON.stringify(invocation.command)}`,
349
+ `args = [${invocation.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
350
+ `enabled_tools = [${NATIVE_AGENT_TOOL_NAMES.map((tool) => JSON.stringify(tool)).join(", ")}]`,
351
+ ...(envEntries ? [`env = { ${envEntries} }`] : []),
352
+ "",
353
+ ];
354
+ for (const tool of NATIVE_AGENT_TOOL_NAMES) {
355
+ lines.push(`[mcp_servers.${MANAGED_AGENT_MCP_SERVER}.tools.${JSON.stringify(tool)}]`, 'approval_mode = "approve"', "");
356
+ }
357
+ return lines.join("\n");
358
+ }
359
+
360
+ export function renderManagedAgents(client, tenantId, agents, invocation = impelMcpInvocation(["--tenant", tenantId])) {
361
+ if (client !== "claude" && client !== "codex") throw new Error(`unknown agent client ${client}`);
362
+ const normalizedTenant = normalizeTenantId(tenantId);
363
+ const names = generatedAgentNames(normalizedTenant, agents);
364
+ return agents.map((agent, index) => {
365
+ const name = names[index];
366
+ const extension = client === "claude" ? ".md" : ".toml";
367
+ const contents = client === "claude"
368
+ ? renderClaudeAgent({ tenantId: normalizedTenant, agent, name, invocation })
369
+ : renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation });
370
+ return { agentId: agent.agentId, name, fileName: `${name}${extension}`, contents };
371
+ });
372
+ }
373
+
374
+ export function agentProfileRoot(client, {
375
+ env = {},
376
+ environment = process.env,
377
+ homeDir = os.homedir(),
378
+ } = {}) {
379
+ if (client === "claude") {
380
+ return env.CLAUDE_CONFIG_DIR || environment.CLAUDE_CONFIG_DIR || path.join(homeDir, ".claude");
381
+ }
382
+ if (client === "codex") {
383
+ return env.CODEX_HOME || environment.CODEX_HOME || path.join(homeDir, ".codex");
384
+ }
385
+ throw new Error(`unknown agent client ${client}`);
386
+ }
387
+
388
+ function readManifest(manifestPath) {
389
+ try {
390
+ const value = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
391
+ if (!value || typeof value !== "object" || !Array.isArray(value.files)) return null;
392
+ return value;
393
+ } catch (error) {
394
+ if (error?.code === "ENOENT") return null;
395
+ return null;
396
+ }
397
+ }
398
+
399
+ function profileIsFresh(profile, tenantId, now, ttlMs) {
400
+ const manifest = readManifest(path.join(profile.root, "agents", MANAGED_AGENT_DIRECTORY, MANAGED_AGENT_MANIFEST));
401
+ if (!manifest || manifest.tenantId !== tenantId) return false;
402
+ const syncedAt = Date.parse(manifest.syncedAt || "");
403
+ if (!Number.isFinite(syncedAt) || now - syncedAt >= ttlMs) return false;
404
+ return manifest.files.every((fileName) =>
405
+ typeof fileName === "string"
406
+ && path.basename(fileName) === fileName
407
+ && fs.existsSync(path.join(profile.root, "agents", MANAGED_AGENT_DIRECTORY, fileName))
408
+ );
409
+ }
410
+
411
+ export function syncAgentProfile({ client, root, label, tenantId, agents, now = Date.now() }) {
412
+ const agentsDir = path.join(root, "agents");
413
+ for (const candidate of [root, agentsDir]) {
414
+ if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink()) {
415
+ throw new Error(`refusing to use symlinked native-agent path ${candidate}`);
416
+ }
417
+ }
418
+ const managedDir = path.join(agentsDir, MANAGED_AGENT_DIRECTORY);
419
+ privateDirectory(managedDir);
420
+ const manifestPath = path.join(managedDir, MANAGED_AGENT_MANIFEST);
421
+ const prior = readManifest(manifestPath);
422
+ const rendered = renderManagedAgents(client, tenantId, agents);
423
+
424
+ for (const agent of rendered) {
425
+ atomicPrivateWrite(path.join(managedDir, agent.fileName), agent.contents);
426
+ }
427
+ const currentFiles = new Set(rendered.map((agent) => agent.fileName));
428
+ for (const stale of prior?.files || []) {
429
+ if (
430
+ typeof stale === "string"
431
+ && path.basename(stale) === stale
432
+ && !currentFiles.has(stale)
433
+ && (stale.endsWith(".md") || stale.endsWith(".toml"))
434
+ ) {
435
+ fs.rmSync(path.join(managedDir, stale), { force: true });
436
+ }
437
+ }
438
+ atomicPrivateWrite(manifestPath, `${JSON.stringify({
439
+ version: 1,
440
+ tenantId,
441
+ client,
442
+ syncedAt: new Date(now).toISOString(),
443
+ files: [...currentFiles].sort(),
444
+ agents: rendered.map(({ agentId, name }, index) => ({
445
+ agentId,
446
+ scopeParam: agents[index].scopeParam,
447
+ name,
448
+ })),
449
+ }, null, 2)}\n`);
450
+ return { client, root, label, synced: true, count: rendered.length, files: [...currentFiles] };
451
+ }
452
+
453
+ export async function syncAgentProfiles({
454
+ profiles,
455
+ gatewayUrl,
456
+ credential,
457
+ tenantId,
458
+ staleOnly = false,
459
+ ttlMs = AGENT_SYNC_TTL_MS,
460
+ now = Date.now(),
461
+ fetchCatalog = fetchNativeAgentCatalog,
462
+ logger = console,
463
+ }) {
464
+ if (process.env.IMPEL_SKIP_AGENT_SYNC === "1" || process.env.IMPEL_SKIP_AGENT_SYNC === "true") {
465
+ return profiles.map((profile) => ({ ...profile, skipped: true, reason: "disabled" }));
466
+ }
467
+ const normalizedTenant = normalizeTenantId(tenantId);
468
+ const pending = staleOnly
469
+ ? profiles.filter((profile) => !profileIsFresh(profile, normalizedTenant, now, ttlMs))
470
+ : profiles;
471
+ const skipped = profiles
472
+ .filter((profile) => !pending.includes(profile))
473
+ .map((profile) => ({ ...profile, skipped: true, reason: "fresh" }));
474
+ if (pending.length === 0) return skipped;
475
+
476
+ const catalog = await fetchCatalog({ gatewayUrl, credential, tenantId: normalizedTenant });
477
+ const results = [];
478
+ for (const profile of pending) {
479
+ const result = syncAgentProfile({
480
+ ...profile,
481
+ tenantId: normalizedTenant,
482
+ agents: catalog.agents,
483
+ now,
484
+ });
485
+ logger.log(`Agents: ${profile.label || profile.client} up to date (${result.count} explicit tenant agent${result.count === 1 ? "" : "s"}).`);
486
+ results.push(result);
487
+ }
488
+ return [...results, ...skipped];
489
+ }
490
+
491
+ export async function syncAgentProfilesSafe(options) {
492
+ try {
493
+ return await syncAgentProfiles(options);
494
+ } catch (error) {
495
+ const logger = options?.logger || console;
496
+ logger.warn(`impel: agent sync failed (${redactSecretText(error?.message || error)}); keeping the last good native-agent catalog.`);
497
+ return (options?.profiles || []).map((profile) => ({
498
+ ...profile,
499
+ synced: false,
500
+ reason: "error",
501
+ error: redactSecretText(error?.message || error),
502
+ }));
503
+ }
504
+ }
package/src/cli.js CHANGED
@@ -11,6 +11,7 @@ import { cmdApps } from "./commands/apps.js";
11
11
  import { cmdMcp } from "./commands/mcp.js";
12
12
  import { cmdLaunch } from "./commands/launch.js";
13
13
  import { cmdSkills } from "./commands/skills.js";
14
+ import { cmdAgents } from "./commands/agents.js";
14
15
  import { cmdTenant } from "./commands/tenant.js";
15
16
  import { cmdDoctor } from "./commands/doctor.js";
16
17
  import { cmdSetup } from "./commands/setup.js";
@@ -24,7 +25,7 @@ Get started:
24
25
  platform clients, gateway verification
25
26
  (--pat <pat> --tenant <org> --skip-apps/--skip-clis)
26
27
  impel update Update everything: the CLI itself, then the
27
- Impel apps and skills (--check, --skip-apps)
28
+ Impel apps, skills, and agents (--check, --skip-apps)
28
29
 
29
30
  Run:
30
31
  impel claude [args...] Launch Claude Code with an isolated Impel profile
@@ -43,12 +44,13 @@ Manage:
43
44
  impel token [--tenant <org>] Print the selected-tenant bearer
44
45
  impel mcp Run the local Impel MCP stdio bridge
45
46
  impel skills sync [claude|codex|all] Sync shared skills into managed clients
47
+ impel agents sync [claude|codex|all] Sync explicit tenant agents into native clients
46
48
 
47
49
  Desktop apps (macOS and Windows):
48
50
  target is claude, chatgpt/codex, or all (default: all)
49
51
  impel app install [target] [--tenant <org>] Install that tenant's isolated app/profile
50
52
  impel app update [target] [--tenant <org>] Update vendor apps and managed profiles
51
- impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
53
+ impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
52
54
  impel app status [target] [--tenant <org>] Show isolated app and vendor versions
53
55
  impel app uninstall [target] [--tenant <org>] [--keep-data]
54
56
  Remove only that tenant's managed apps/data
@@ -65,6 +67,7 @@ Env:
65
67
  IMPEL_GATEWAY_URL Default gateway URL when --gateway isn't passed.
66
68
  IMPEL_APP_URL Default app/control-plane URL for setup, auth, PATs, and tasks.
67
69
  IMPEL_SKIP_UPDATE_CHECK=1 Silence launch-time update notices.
70
+ IMPEL_SKIP_AGENT_SYNC=1 Keep the last native-agent catalog without refreshing it.
68
71
 
69
72
  Config file:
70
73
  ~/.config/impel/config.json (mode 0600)
@@ -144,6 +147,10 @@ export async function main(argv) {
144
147
  case "skill":
145
148
  return cmdSkills(rest);
146
149
 
150
+ case "agents":
151
+ case "agent":
152
+ return cmdAgents(rest);
153
+
147
154
  case "on":
148
155
  return runUse("gateway", rest[0]);
149
156
 
@@ -0,0 +1,97 @@
1
+ // `impel agents sync [claude|codex|all]` — synchronize the selected tenant's
2
+ // explicitly invocable Impel agents into every managed native registry.
3
+
4
+ import os from "node:os";
5
+
6
+ import { agentProfileRoot, syncAgentProfiles } from "../agents.js";
7
+ import { loadConfig, normalizeGatewayUrl, redactSecretText, resolveDefaultGateway } from "../config.js";
8
+ import { ensureTenantSelection, tenantCredential } from "../tenants.js";
9
+ import { managedSkillProfiles } from "./skills.js";
10
+
11
+ const HELP = `impel agents - sync explicit tenant agents into native clients
12
+
13
+ Usage:
14
+ impel agents sync [claude|codex|all]
15
+
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.`;
19
+
20
+ function normalizeAgentTarget(token) {
21
+ if (token === undefined || token === "all") return "all";
22
+ if (token === "claude") return "claude";
23
+ if (token === "codex" || token === "codex-cli" || token === "codex-app" || token === "chatgpt") {
24
+ return "codex";
25
+ }
26
+ return null;
27
+ }
28
+
29
+ export function managedAgentProfiles(client, options = {}) {
30
+ const homeDir = options.homeDir || os.homedir();
31
+ const environment = options.environment || process.env;
32
+ return managedSkillProfiles(client, options).map((profile) => ({
33
+ client,
34
+ label: profile.label,
35
+ root: agentProfileRoot(client, {
36
+ env: profile.env,
37
+ environment,
38
+ homeDir,
39
+ }),
40
+ }));
41
+ }
42
+
43
+ export async function cmdAgents(argv, overrides = {}) {
44
+ const [action = "sync", targetToken] = argv;
45
+ if (["help", "--help", "-h"].includes(action)) {
46
+ console.log(HELP);
47
+ return;
48
+ }
49
+ if (action !== "sync") {
50
+ console.error(`impel agents: unknown action "${action}". Try \`impel agents sync [claude|codex|all]\`.`);
51
+ process.exitCode = 1;
52
+ return;
53
+ }
54
+ const target = normalizeAgentTarget(targetToken);
55
+ if (target === null) {
56
+ console.error(`impel agents: unknown target "${targetToken}". Use \`claude\`, \`codex\`, or \`all\`.`);
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+
61
+ const io = {
62
+ loadConfig,
63
+ ensureTenantSelection,
64
+ syncAgentProfiles,
65
+ managedAgentProfiles,
66
+ environment: process.env,
67
+ homeDir: os.homedir(),
68
+ ...overrides,
69
+ };
70
+ const config = io.loadConfig();
71
+ if (!config?.pat) {
72
+ console.error("impel agents: not authenticated; run `impel setup` (or `impel auth`) first.");
73
+ process.exitCode = 1;
74
+ return;
75
+ }
76
+
77
+ try {
78
+ const selected = await io.ensureTenantSelection(config);
79
+ const tenantId = selected.tenantId;
80
+ const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
81
+ const clients = target === "all" ? ["claude", "codex"] : [target];
82
+ const profiles = clients.flatMap((client) => io.managedAgentProfiles(client, {
83
+ tenantId,
84
+ environment: io.environment,
85
+ homeDir: io.homeDir,
86
+ }));
87
+ await io.syncAgentProfiles({
88
+ profiles,
89
+ gatewayUrl,
90
+ credential: tenantCredential(config.pat, tenantId),
91
+ tenantId,
92
+ });
93
+ } catch (error) {
94
+ console.error(`impel agents: ${redactSecretText(error?.message || error)}`);
95
+ process.exitCode = 1;
96
+ }
97
+ }
@@ -37,6 +37,7 @@ import {
37
37
  removeManagedApps,
38
38
  } from "../apps.js";
39
39
  import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
40
+ import { syncAgentProfilesSafe } from "../agents.js";
40
41
  import { secureManagedCodexHome } from "../codexSecurity.js";
41
42
  import { impelCliInvocation } from "../selfInvocation.js";
42
43
  import {
@@ -63,6 +64,13 @@ function appSkillTarget(target, paths) {
63
64
  return { client: "codex", env: { CODEX_HOME: paths.chatgpt.codexHome }, label: "Impel ChatGPT app" };
64
65
  }
65
66
 
67
+ function appAgentProfile(target, paths) {
68
+ if (target === "claude") {
69
+ return { client: "claude", root: paths.claude.userData, label: "Impel Claude app" };
70
+ }
71
+ return { client: "codex", root: paths.chatgpt.codexHome, label: "Impel ChatGPT app" };
72
+ }
73
+
66
74
  function windowsAppTargets(targetToken) {
67
75
  if (targetToken === undefined || targetToken === "all") return ["claude", "chatgpt"];
68
76
  if (targetToken === "claude") return ["claude"];
@@ -137,6 +145,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
137
145
  installFiles: installManagedAppFiles,
138
146
  removeApps: removeManagedApps,
139
147
  syncSkills: syncSkillsSafe,
148
+ syncAgents: syncAgentProfilesSafe,
140
149
  selectedConfig: selectedAppConfig,
141
150
  ...overrides,
142
151
  };
@@ -277,6 +286,12 @@ export async function cmdWindowsApps(argv, overrides = {}) {
277
286
  await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
278
287
  if (target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
279
288
  }
289
+ await io.syncAgents({
290
+ profiles: targets.map((target) => appAgentProfile(target, tenantPaths)),
291
+ gatewayUrl: config.gatewayUrl,
292
+ credential: config.pat,
293
+ tenantId: config.tenantId,
294
+ });
280
295
  console.log(`Models: ${catalog.models.length} from ${catalog.source}. The signed vendor apps and their normal profiles were not changed.`);
281
296
  }
282
297
  if (action === "open") {
@@ -379,7 +394,7 @@ export async function cmdApps(argv, overrides = {}) {
379
394
  targets,
380
395
  statuses,
381
396
  homeDir: os.homedir(),
382
- });
397
+ }, overrides);
383
398
  return;
384
399
  }
385
400
  if (action !== "install" && action !== "update") {
@@ -428,6 +443,13 @@ export async function cmdApps(argv, overrides = {}) {
428
443
  await syncSkillsSafe({ client, gatewayUrl, env, label });
429
444
  if (item.target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
430
445
  }
446
+ const syncAgents = overrides.syncAgents || syncAgentProfilesSafe;
447
+ await syncAgents({
448
+ profiles: installed.map((item) => appAgentProfile(item.target, paths)),
449
+ gatewayUrl: config.gatewayUrl,
450
+ credential: config.pat,
451
+ tenantId: config.tenantId,
452
+ });
431
453
  }
432
454
 
433
455
  /**
@@ -450,6 +472,7 @@ export async function provisionAndOpenManagedApps({
450
472
  quitApps: quitBlockingApps,
451
473
  installFiles: installManagedAppFiles,
452
474
  syncSkills: syncSkillsSafe,
475
+ syncAgents: syncAgentProfilesSafe,
453
476
  secureCodexHome: secureManagedCodexHome,
454
477
  openLauncher: (launcher) => spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" }),
455
478
  log: (message) => console.log(message),
@@ -519,6 +542,12 @@ export async function provisionAndOpenManagedApps({
519
542
  await io.syncSkills({ client, gatewayUrl, env, label });
520
543
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
521
544
  }
545
+ await io.syncAgents({
546
+ profiles: installed.map((item) => appAgentProfile(item.target, paths)),
547
+ gatewayUrl: config.gatewayUrl,
548
+ credential: config.pat,
549
+ tenantId: config.tenantId,
550
+ });
522
551
  for (const item of installed) await io.openLauncher(item.launcher);
523
552
  return installed;
524
553
  }
@@ -630,6 +659,12 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
630
659
  await syncSkillsSafe({ client, gatewayUrl, env, label });
631
660
  if (status.target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
632
661
  }
662
+ await syncAgentProfilesSafe({
663
+ profiles: installedTargets.map((status) => appAgentProfile(status.target, tenantPaths)),
664
+ gatewayUrl: config.gatewayUrl,
665
+ credential: config.pat,
666
+ tenantId: config.tenantId,
667
+ });
633
668
  // Keep the update-notice cache warm from the same background slot.
634
669
  try {
635
670
  refreshUpdateCache();
@@ -5,6 +5,7 @@ import {
5
5
  ensureImpelClaudeProfile,
6
6
  ensureImpelCodexProfile,
7
7
  } from "../cliProfiles.js";
8
+ import { syncAgentProfilesSafe } from "../agents.js";
8
9
  import {
9
10
  crossAppModelsEnabled,
10
11
  loadConfig,
@@ -134,9 +135,11 @@ export async function cmdLaunch(tool, argv) {
134
135
  const gatewayCredential = tenantCredential(config.pat, tenantId);
135
136
  const environment = { ...process.env };
136
137
  environment.IMPEL_TENANT_ID = tenantId;
138
+ let agentProfile;
137
139
 
138
140
  if (tool === "claude") {
139
141
  const profile = ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels });
142
+ agentProfile = { client: "claude", root: profile.configDir, label: "Impel isolated Claude (impel claude)" };
140
143
  deleteEnvironmentKeys(environment, CLAUDE_DIRECT_AUTH_ENV);
141
144
  delete environment.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY;
142
145
  environment.CLAUDE_CONFIG_DIR = profile.configDir;
@@ -150,12 +153,23 @@ export async function cmdLaunch(tool, argv) {
150
153
  environment.ANTHROPIC_AUTH_TOKEN = gatewayCredential;
151
154
  } else if (tool === "codex") {
152
155
  const profile = ensureImpelCodexProfile(gatewayUrl, tenantId);
156
+ agentProfile = { client: "codex", root: profile.codexHome, label: "Impel isolated Codex (impel codex)" };
153
157
  deleteEnvironmentKeys(environment, CODEX_DIRECT_AUTH_ENV);
154
158
  environment.CODEX_HOME = profile.codexHome;
155
159
  } else {
156
160
  throw new Error(`unsupported CLI launcher: ${tool}`);
157
161
  }
158
162
 
163
+ // Refresh at most every six hours. A network/catalog failure must not block
164
+ // the isolated client; the last successfully generated agents remain usable.
165
+ await syncAgentProfilesSafe({
166
+ profiles: [agentProfile],
167
+ gatewayUrl,
168
+ credential: gatewayCredential,
169
+ tenantId,
170
+ staleOnly: true,
171
+ });
172
+
159
173
  const exitCode = await runNativeCli(tool, impelLaunchArguments(tool, argv), environment);
160
174
  if (exitCode !== 0) process.exitCode = exitCode;
161
175
  }
@@ -1,6 +1,6 @@
1
1
  // `impel update` — bring everything current in one command: the CLI itself
2
2
  // (npm reinstall from the public package), then the managed desktop apps/profiles, then
3
- // skills. The app step re-executes the freshly installed CLI so the new code
3
+ // skills and native tenant agents. The app step re-executes the freshly installed CLI so the new code
4
4
  // performs it.
5
5
 
6
6
  import fs from "node:fs";
@@ -29,11 +29,11 @@ const HELP = `impel update - update everything Impel in one command
29
29
 
30
30
  Reinstalls impel-cli from npm, then cascades with the NEW build:
31
31
  \`impel app update all\` (update the platform's vendor apps and managed app
32
- profiles) when Impel apps are installed, then \`impel skills sync all\` across
33
- every managed profile.
32
+ profiles) when Impel apps are installed, then \`impel skills sync all\` and
33
+ \`impel agents sync all\` across every managed profile.
34
34
 
35
35
  Usage:
36
- impel update Update the CLI, then cascade to apps and skills
36
+ impel update Update the CLI, then cascade to apps, skills, and agents
37
37
  impel update --check Report whether an update is available; change nothing
38
38
  impel update --skip-apps Skip the desktop-app step of the cascade
39
39
  `;
@@ -102,6 +102,13 @@ function defaultRunSkillsSync() {
102
102
  return result.status === 0;
103
103
  }
104
104
 
105
+ function defaultRunAgentsSync() {
106
+ const result = spawnSync(process.execPath, [CLI_BIN, "agents", "sync", "all"], {
107
+ stdio: "inherit",
108
+ });
109
+ return result.status === 0;
110
+ }
111
+
105
112
  function anyAppInstalled(homeDir = os.homedir()) {
106
113
  const config = loadConfig();
107
114
  const claudeUserData = process.platform === "win32"
@@ -136,6 +143,7 @@ export async function cmdUpdate(argv, overrides = {}) {
136
143
  selfUpdate: defaultSelfUpdate,
137
144
  runAppsUpdate: defaultRunAppsUpdate,
138
145
  runSkillsSync: defaultRunSkillsSync,
146
+ runAgentsSync: defaultRunAgentsSync,
139
147
  appsInstalled: anyAppInstalled,
140
148
  platform: process.platform,
141
149
  ...overrides,
@@ -213,6 +221,12 @@ export async function cmdUpdate(argv, overrides = {}) {
213
221
  cascadeFailed = true;
214
222
  }
215
223
 
224
+ console.log("Agents: syncing the selected tenant into every managed profile…");
225
+ if (!io.runAgentsSync()) {
226
+ console.error("impel update: agent sync failed; re-run `impel agents sync` after fixing the issue.");
227
+ cascadeFailed = true;
228
+ }
229
+
216
230
  if (cascadeFailed) {
217
231
  process.exitCode = 1;
218
232
  return;
@@ -8,12 +8,15 @@
8
8
  // model_provider) into the impel config, so turning it OFF restores exactly
9
9
  // that — never a guess.
10
10
 
11
- import { loadConfig, saveConfig, resolveDefaultGateway } from "../config.js";
11
+ import { loadConfig, saveConfig, redactSecretText, resolveDefaultGateway } from "../config.js";
12
+ import { syncAgentProfilesSafe } from "../agents.js";
13
+ import { ensureTenantSelection, tenantCredential } from "../tenants.js";
12
14
  import {
13
15
  applyClaudeGateway,
14
16
  revertClaudeGateway,
15
17
  isImpelApiKeyHelper,
16
18
  isImpelClaudeBaseUrl,
19
+ CLAUDE_DIR,
17
20
  } from "../claudeSetup.js";
18
21
  import {
19
22
  applyCodexGateway,
@@ -110,6 +113,25 @@ async function useGateway({ target, app }) {
110
113
  if (wantsCodex(target)) {
111
114
  await syncSkillsSafe({ client: "codex", gatewayUrl, env: { CODEX_HOME }, label: "Codex CLI (gateway)" });
112
115
  }
116
+ try {
117
+ const selected = await ensureTenantSelection(config);
118
+ const agentProfiles = [
119
+ ...(wantsClaude(target)
120
+ ? [{ client: "claude", root: CLAUDE_DIR, label: "Claude Code (gateway)" }]
121
+ : []),
122
+ ...(wantsCodex(target)
123
+ ? [{ client: "codex", root: CODEX_HOME, label: "Codex CLI (gateway)" }]
124
+ : []),
125
+ ];
126
+ await syncAgentProfilesSafe({
127
+ profiles: agentProfiles,
128
+ gatewayUrl,
129
+ credential: tenantCredential(config.pat, selected.tenantId),
130
+ tenantId: selected.tenantId,
131
+ });
132
+ } catch (error) {
133
+ console.warn(`impel: agent sync could not resolve the selected tenant (${redactSecretText(error?.message || error)}); continuing.`);
134
+ }
113
135
 
114
136
  console.log("");
115
137
  printGatewayNextSteps({ target, app });