impel-cli 0.9.1 → 0.10.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,15 +148,16 @@ 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 the selected tenant's specialists as native agents
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
- impel app open [target] [--tenant <org>] Launch isolated instances (fast path: opens
158
- immediately when nothing changed since last open;
159
+ impel app open [target] [--tenant <org>] Install/configure a tenant on first use, then launch
160
+ it (later opens are immediate when nothing changed;
159
161
  a detached stale-only refresh keeps things current)
160
162
  impel app uninstall [target] [--tenant <org>] [--keep-data]
161
163
  Remove one tenant's Impel-managed app files
@@ -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
 
@@ -292,9 +295,13 @@ On macOS, desktop app state is isolated under
292
295
  bundles in `~/Applications`, with names such as `Impel Claude (Acme)` and
293
296
  `Impel ChatGPT (Acme)`, stable tenant-specific bundle identifiers, and fixed
294
297
  tenant profile/auth paths. Different tenants can stay open concurrently.
295
- `impel app open --tenant <org>` refreshes and launches a tenant directly without
296
- changing the globally selected tenant. Switching tenants never deletes local
297
- history or projects; each tenant's preserved profile reopens independently.
298
+ `impel app open --tenant <org>` installs and configures a missing tenant variant
299
+ on first use, then launches it directly without changing the globally selected
300
+ tenant. Later opens use the existing isolated app and profile. Switching tenants
301
+ never deletes local history or projects; each tenant's preserved profile reopens
302
+ independently. The first-use setup still requires an authenticated user PAT and
303
+ live membership in the requested organization; it does not create a PAT per
304
+ tenant.
298
305
  Windows Claude Desktop keeps each
299
306
  tenant under `%LOCALAPPDATA%\Claude-3p\Impel\<org>` and launches the unchanged
300
307
  signed vendor executable with that profile selected. Windows ChatGPT keeps its
@@ -385,7 +392,9 @@ MCP entry point, reads the PAT from the owner-only Impel
385
392
  config, and proxies JSON-RPC to `<gateway>/mcp`; the PAT is never written into
386
393
  either client's MCP configuration. Available specialists are discovered at
387
394
  runtime, so publishing a new opted-in specialist does not require another CLI
388
- 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.
389
398
 
390
399
  ### Isolated CLI launchers
391
400
 
@@ -460,8 +469,9 @@ the selected tenant's 3P gateway configuration beneath Anthropic's reserved
460
469
  normal Claude profile unchanged. `impel app install codex` installs/updates
461
470
  OpenAI's `OpenAI.Codex` Store package, stages its unchanged signed app payload
462
471
  inside Impel's shared app cache, and writes separate Codex and Chromium
463
- profiles. `impel app open claude` and `impel app open codex` launch those
464
- isolated profiles without changing either normal vendor profile.
472
+ profiles. `impel app open claude` and `impel app open codex` install a missing
473
+ official app and configure the selected tenant on first use, then launch the
474
+ isolated profile without changing either normal vendor profile.
465
475
 
466
476
  For Claude, the Impel PAT is supplied only to the launched child process as an
467
477
  authentication token and disappears when that process exits; it is never
@@ -579,6 +589,53 @@ best-effort: if a client CLI, its `plugin` subcommand, or the gateway is
579
589
  unavailable, it logs a warning and moves on — it never fails the parent command.
580
590
  Set `IMPEL_SKIP_SKILL_SYNC=1` to disable it entirely (offline/CI).
581
591
 
592
+ ### `impel agents sync [claude|codex|all]`
593
+
594
+ Fetches the selected organization's opted-in specialist catalog through the
595
+ authenticated Impel MCP endpoint, then generates native custom-agent files for
596
+ every managed profile:
597
+
598
+ - Claude Code: `<CLAUDE_CONFIG_DIR>/agents/impel-managed/*.md`
599
+ - Codex / ChatGPT Codex mode: `<CODEX_HOME>/agents/impel-managed/*.toml`
600
+
601
+ Each generated agent is bound to one exact specialist `agentId` and
602
+ `scopeParam`. It verifies that specialist is still available, starts one
603
+ idempotent run, polls the durable run to a terminal state, and returns
604
+ `result.finalText` without repeating the work. The files include a tenant-bound
605
+ MCP subprocess definition so they also work in isolated desktop profiles. They
606
+ never contain the PAT; the subprocess reads it from Impel's owner-only config
607
+ when invoked.
608
+
609
+ The sync writes atomically and records a manifest beside the generated files.
610
+ It removes only stale filenames from that manifest, so user-authored agents are
611
+ never deleted. Automatic refreshes are best-effort and retain the last good
612
+ catalog when the gateway is unavailable; the explicit command exits non-zero
613
+ on failure. Set `IMPEL_SKIP_AGENT_SYNC=1` to keep the current generated catalog
614
+ without contacting the gateway.
615
+
616
+ Start a new task after syncing. Claude Code detects updates to an existing
617
+ `agents/` directory within a few seconds, but if the sync created that directory
618
+ for the first time while Claude was already running, restart Claude once.
619
+
620
+ Invocation uses the clients' native agent behavior:
621
+
622
+ ```text
623
+ # Claude Code / Claude Desktop Code tab: guaranteed explicit selection
624
+ @agent-impel-acme-research-agent investigate the dependency change
625
+
626
+ # Claude blocking CLI
627
+ claude --agent impel-acme-research-agent -p "investigate the dependency change"
628
+
629
+ # Codex CLI / ChatGPT desktop Codex task
630
+ Use the configured impel-acme-research-agent subagent for this request and wait for it.
631
+ ```
632
+
633
+ 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`
635
+ mention target; named spawning remains model-mediated and may also be limited by
636
+ the active MultiAgentV2 tool schema. Use `/agent` in Codex to inspect spawned
637
+ agent threads.
638
+
582
639
  ### `impel status`
583
640
 
584
641
  Shows whether the isolated CLI launchers are ready, and whether each native
@@ -602,10 +659,8 @@ Codex CLI and the Codex IDE extension / desktop app share the same
602
659
  ### Windows Claude and ChatGPT Desktop
603
660
 
604
661
  ```powershell
605
- impel app install claude
606
662
  impel app open claude
607
663
  impel app status claude
608
- impel app install codex
609
664
  impel app open codex
610
665
  impel app status codex
611
666
  ```
@@ -630,7 +685,8 @@ app-target names.
630
685
 
631
686
  ### macOS Claude and ChatGPT
632
687
 
633
- `impel app install` creates tenant-specific apps such as
688
+ The first `impel app open --tenant <org>` (or an explicit `impel app install`)
689
+ creates tenant-specific apps such as
634
690
  `Impel Claude (acme).app` and `Impel ChatGPT (acme).app` in `~/Applications`.
635
691
  Their visible bundle names use the organization display name, for example
636
692
  `Impel Claude (Acme)` and `Impel ChatGPT (Acme)`. Each tenant/product pair has
@@ -657,11 +713,10 @@ a stable, unique bundle identifier and gateway configuration:
657
713
  - Neither app changes `~/.claude`, `~/.claude.json`, `~/.codex`, or the
658
714
  normal vendor app's browser/profile data.
659
715
 
660
- Install or open several tenants without changing the current selection:
716
+ Open several tenants without changing the current selection. A missing variant
717
+ is installed and configured automatically before it opens:
661
718
 
662
719
  ```sh
663
- impel app install all --tenant acme
664
- impel app install all --tenant globex
665
720
  impel app open all --tenant acme
666
721
  impel app open all --tenant globex
667
722
  ```
@@ -672,7 +727,7 @@ successful tenant-specific rebuild.
672
727
 
673
728
  The official Claude and ChatGPT apps remain separate installations and keep
674
729
  their normal accounts, updater, profiles, and bundle identities. Each CLI
675
- release pins exact, tested vendor builds (v0.9.0 pins Claude `1.20186.9` and
730
+ release pins exact, tested vendor builds (v0.10.0 pins Claude `1.20186.9` and
676
731
  ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
677
732
  an existing official app only when its version exactly matches; otherwise it
678
733
  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.1",
3
+ "version": "0.10.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,480 @@
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.
5
+
6
+ import crypto from "node:crypto";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+
11
+ import { normalizeGatewayUrl, redactSecretText } from "./config.js";
12
+ import { impelMcpInvocation } from "./selfInvocation.js";
13
+ import { normalizeTenantId } from "./tenants.js";
14
+
15
+ export const AGENT_SYNC_TTL_MS = 6 * 60 * 60 * 1000;
16
+ export const MANAGED_AGENT_DIRECTORY = "impel-managed";
17
+ 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";
21
+ export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
22
+
23
+ const SPECIALIST_TOOL_NAMES = [
24
+ SPECIALIST_LIST_TOOL,
25
+ SPECIALIST_START_TOOL,
26
+ SPECIALIST_READ_TOOL,
27
+ ];
28
+ const SAFE_SPECIALIST_ID_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
29
+ const SAFE_SCOPE_PARAM_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
30
+ const MAX_CATALOG_ITEMS = 500;
31
+
32
+ function privateDirectory(directory) {
33
+ if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) {
34
+ throw new Error(`refusing to use symlinked managed-agent directory ${directory}`);
35
+ }
36
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
37
+ try {
38
+ fs.chmodSync(directory, 0o700);
39
+ } catch {
40
+ // Best effort on Windows.
41
+ }
42
+ }
43
+
44
+ function atomicPrivateWrite(filePath, contents) {
45
+ privateDirectory(path.dirname(filePath));
46
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
47
+ try {
48
+ fs.writeFileSync(temporaryPath, contents, { mode: 0o600 });
49
+ fs.renameSync(temporaryPath, filePath);
50
+ try {
51
+ fs.chmodSync(filePath, 0o600);
52
+ } catch {
53
+ // Best effort on Windows.
54
+ }
55
+ } finally {
56
+ try {
57
+ fs.rmSync(temporaryPath, { force: true });
58
+ } catch {
59
+ // The normal rename path already removed it.
60
+ }
61
+ }
62
+ }
63
+
64
+ function boundedString(value, field, { max = 1024, pattern = null } = {}) {
65
+ if (typeof value !== "string" || !value.trim()) {
66
+ throw new Error(`specialist catalog returned an invalid ${field}`);
67
+ }
68
+ const normalized = value.trim();
69
+ if (normalized.length > max || (pattern && !pattern.test(normalized))) {
70
+ throw new Error(`specialist catalog returned an invalid ${field}`);
71
+ }
72
+ return normalized;
73
+ }
74
+
75
+ function stringList(value, field) {
76
+ if (!Array.isArray(value) || value.length > 100) {
77
+ throw new Error(`specialist catalog returned an invalid ${field}`);
78
+ }
79
+ return value.map((item) => boundedString(item, field, { max: 512 }));
80
+ }
81
+
82
+ export function normalizeSpecialistCatalog(payload, expectedTenantId) {
83
+ const tenantId = normalizeTenantId(expectedTenantId);
84
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
85
+ throw new Error("specialist catalog returned an invalid response");
86
+ }
87
+ if (normalizeTenantId(payload.orgId) !== tenantId) {
88
+ throw new Error("specialist catalog returned the wrong organization");
89
+ }
90
+ if (!Array.isArray(payload.specialists) || payload.specialists.length > MAX_CATALOG_ITEMS) {
91
+ throw new Error("specialist catalog returned an invalid specialists list");
92
+ }
93
+
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");
98
+ }
99
+ const agentId = boundedString(specialist.agentId, "agentId", {
100
+ max: 160,
101
+ pattern: SAFE_SPECIALIST_ID_RE,
102
+ });
103
+ if (seenIds.has(agentId)) throw new Error(`specialist catalog returned duplicate agentId ${agentId}`);
104
+ seenIds.add(agentId);
105
+ return {
106
+ 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 }),
118
+ };
119
+ });
120
+ return { orgId: tenantId, specialists };
121
+ }
122
+
123
+ function decodeMcpResponse(contentType, body) {
124
+ if (!body.trim()) return [];
125
+ if (!String(contentType || "").toLowerCase().includes("text/event-stream")) {
126
+ return [JSON.parse(body)];
127
+ }
128
+ return body
129
+ .split(/\r?\n/u)
130
+ .filter((line) => line.startsWith("data:"))
131
+ .map((line) => line.slice(5).trim())
132
+ .filter((line) => line && line !== "[DONE]")
133
+ .map((line) => JSON.parse(line));
134
+ }
135
+
136
+ async function postMcp({ endpoint, credential, sessionId, message, fetchImpl, timeoutMs }) {
137
+ const controller = new AbortController();
138
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
139
+ let response;
140
+ try {
141
+ response = await fetchImpl(endpoint, {
142
+ method: "POST",
143
+ headers: {
144
+ Authorization: `Bearer ${credential}`,
145
+ "Content-Type": "application/json",
146
+ Accept: "application/json, text/event-stream",
147
+ ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
148
+ },
149
+ body: JSON.stringify(message),
150
+ signal: controller.signal,
151
+ });
152
+ } catch (error) {
153
+ const detail = error?.name === "AbortError" ? "request timed out" : error?.message || error;
154
+ throw new Error(`specialist MCP request failed: ${redactSecretText(detail)}`);
155
+ } finally {
156
+ clearTimeout(timeout);
157
+ }
158
+ const body = await response.text();
159
+ if (!response.ok) {
160
+ throw new Error(`specialist MCP returned HTTP ${response.status}`);
161
+ }
162
+ return {
163
+ sessionId: response.headers.get("mcp-session-id") || sessionId || null,
164
+ messages: decodeMcpResponse(response.headers.get("content-type"), body),
165
+ };
166
+ }
167
+
168
+ function responseForId(messages, id) {
169
+ return messages.find((message) => message?.id === id) || null;
170
+ }
171
+
172
+ function specialistPayloadFromToolResult(message) {
173
+ if (!message) throw new Error("specialist MCP returned no tool result");
174
+ if (message.error) {
175
+ throw new Error(redactSecretText(message.error.message || "specialist MCP tool failed"));
176
+ }
177
+ const result = message.result;
178
+ if (result?.isError) throw new Error("specialist MCP list tool returned an error");
179
+ if (result?.structuredContent && typeof result.structuredContent === "object") {
180
+ return result.structuredContent;
181
+ }
182
+ 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");
184
+ try {
185
+ return JSON.parse(text);
186
+ } catch {
187
+ throw new Error("specialist MCP list tool returned invalid JSON");
188
+ }
189
+ }
190
+
191
+ export async function fetchSpecialistCatalog({
192
+ gatewayUrl,
193
+ credential,
194
+ tenantId,
195
+ fetchImpl = fetch,
196
+ timeoutMs = 20_000,
197
+ }) {
198
+ if (!credential) throw new Error("a tenant credential is required for agent sync");
199
+ const endpoint = `${normalizeGatewayUrl(gatewayUrl)}/mcp`;
200
+ let sessionId = null;
201
+ const initialized = await postMcp({
202
+ endpoint,
203
+ credential,
204
+ sessionId,
205
+ fetchImpl,
206
+ timeoutMs,
207
+ message: {
208
+ jsonrpc: "2.0",
209
+ id: 1,
210
+ method: "initialize",
211
+ params: {
212
+ protocolVersion: "2025-06-18",
213
+ capabilities: {},
214
+ clientInfo: { name: "impel-cli-agent-sync", version: "1.0.0" },
215
+ },
216
+ },
217
+ });
218
+ sessionId = initialized.sessionId;
219
+ const initializeMessage = responseForId(initialized.messages, 1);
220
+ if (initializeMessage?.error || !initializeMessage?.result) {
221
+ throw new Error("specialist MCP initialization failed");
222
+ }
223
+
224
+ const ready = await postMcp({
225
+ endpoint,
226
+ credential,
227
+ sessionId,
228
+ fetchImpl,
229
+ timeoutMs,
230
+ message: { jsonrpc: "2.0", method: "notifications/initialized", params: {} },
231
+ });
232
+ sessionId = ready.sessionId;
233
+ const listed = await postMcp({
234
+ endpoint,
235
+ credential,
236
+ sessionId,
237
+ fetchImpl,
238
+ timeoutMs,
239
+ message: {
240
+ jsonrpc: "2.0",
241
+ id: 2,
242
+ method: "tools/call",
243
+ params: { name: SPECIALIST_LIST_TOOL, arguments: {} },
244
+ },
245
+ });
246
+ return normalizeSpecialistCatalog(
247
+ specialistPayloadFromToolResult(responseForId(listed.messages, 2)),
248
+ tenantId,
249
+ );
250
+ }
251
+
252
+ function slug(value) {
253
+ return String(value)
254
+ .toLowerCase()
255
+ .replace(/[^a-z0-9]+/gu, "-")
256
+ .replace(/^-+|-+$/gu, "")
257
+ .slice(0, 48) || "agent";
258
+ }
259
+
260
+ function generatedAgentNames(tenantId, specialists) {
261
+ const used = new Map();
262
+ return specialists.map((specialist) => {
263
+ const base = `impel-${slug(tenantId)}-${slug(specialist.agentId)}`.slice(0, 63).replace(/-+$/u, "");
264
+ const previous = used.get(base);
265
+ used.set(base, (previous || 0) + 1);
266
+ if (!previous) return base;
267
+ const suffix = crypto.createHash("sha256").update(specialist.agentId).digest("hex").slice(0, 8);
268
+ return `${base.slice(0, 54)}-${suffix}`;
269
+ });
270
+ }
271
+
272
+ function adapterInstructions(tenantId, specialist) {
273
+ 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.`
276
+ : "";
277
+ 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.`,
283
+ `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
+ ].join(" ");
285
+ }
286
+
287
+ function nativeToolName(toolName) {
288
+ return `mcp__${MANAGED_AGENT_MCP_SERVER}__${toolName}`;
289
+ }
290
+
291
+ function renderClaudeAgent({ tenantId, specialist, name, invocation }) {
292
+ const description = `Runs ${specialist.title} for Impel tenant ${tenantId}: ${specialist.description}`.slice(0, 900);
293
+ const lines = [
294
+ "---",
295
+ `name: ${JSON.stringify(name)}`,
296
+ `description: ${JSON.stringify(description)}`,
297
+ "model: inherit",
298
+ "tools:",
299
+ ...SPECIALIST_TOOL_NAMES.map((tool) => ` - ${JSON.stringify(nativeToolName(tool))}`),
300
+ "mcpServers:",
301
+ ` - ${MANAGED_AGENT_MCP_SERVER}:`,
302
+ " type: stdio",
303
+ ` command: ${JSON.stringify(invocation.command)}`,
304
+ " args:",
305
+ ...invocation.args.map((argument) => ` - ${JSON.stringify(argument)}`),
306
+ " env:",
307
+ ...Object.entries(invocation.env || {}).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`),
308
+ "---",
309
+ "",
310
+ adapterInstructions(tenantId, specialist),
311
+ "",
312
+ ];
313
+ return lines.join("\n");
314
+ }
315
+
316
+ function renderCodexAgent({ tenantId, specialist, name, invocation }) {
317
+ const description = `Runs ${specialist.title} for Impel tenant ${tenantId}: ${specialist.description}`.slice(0, 900);
318
+ const envEntries = Object.entries(invocation.env || {})
319
+ .map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`)
320
+ .join(", ");
321
+ const lines = [
322
+ `name = ${JSON.stringify(name)}`,
323
+ `description = ${JSON.stringify(description)}`,
324
+ 'sandbox_mode = "read-only"',
325
+ `developer_instructions = ${JSON.stringify(adapterInstructions(tenantId, specialist))}`,
326
+ "",
327
+ `[mcp_servers.${MANAGED_AGENT_MCP_SERVER}]`,
328
+ `command = ${JSON.stringify(invocation.command)}`,
329
+ `args = [${invocation.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
330
+ `enabled_tools = [${SPECIALIST_TOOL_NAMES.map((tool) => JSON.stringify(tool)).join(", ")}]`,
331
+ ...(envEntries ? [`env = { ${envEntries} }`] : []),
332
+ "",
333
+ ];
334
+ for (const tool of SPECIALIST_TOOL_NAMES) {
335
+ lines.push(`[mcp_servers.${MANAGED_AGENT_MCP_SERVER}.tools.${JSON.stringify(tool)}]`, 'approval_mode = "approve"', "");
336
+ }
337
+ return lines.join("\n");
338
+ }
339
+
340
+ export function renderManagedAgents(client, tenantId, specialists, invocation = impelMcpInvocation(["--tenant", tenantId])) {
341
+ if (client !== "claude" && client !== "codex") throw new Error(`unknown agent client ${client}`);
342
+ const normalizedTenant = normalizeTenantId(tenantId);
343
+ const names = generatedAgentNames(normalizedTenant, specialists);
344
+ return specialists.map((specialist, index) => {
345
+ const name = names[index];
346
+ const extension = client === "claude" ? ".md" : ".toml";
347
+ 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 };
351
+ });
352
+ }
353
+
354
+ export function agentProfileRoot(client, {
355
+ env = {},
356
+ environment = process.env,
357
+ homeDir = os.homedir(),
358
+ } = {}) {
359
+ if (client === "claude") {
360
+ return env.CLAUDE_CONFIG_DIR || environment.CLAUDE_CONFIG_DIR || path.join(homeDir, ".claude");
361
+ }
362
+ if (client === "codex") {
363
+ return env.CODEX_HOME || environment.CODEX_HOME || path.join(homeDir, ".codex");
364
+ }
365
+ throw new Error(`unknown agent client ${client}`);
366
+ }
367
+
368
+ function readManifest(manifestPath) {
369
+ try {
370
+ const value = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
371
+ if (!value || typeof value !== "object" || !Array.isArray(value.files)) return null;
372
+ return value;
373
+ } catch (error) {
374
+ if (error?.code === "ENOENT") return null;
375
+ return null;
376
+ }
377
+ }
378
+
379
+ function profileIsFresh(profile, tenantId, now, ttlMs) {
380
+ const manifest = readManifest(path.join(profile.root, "agents", MANAGED_AGENT_DIRECTORY, MANAGED_AGENT_MANIFEST));
381
+ if (!manifest || manifest.tenantId !== tenantId) return false;
382
+ const syncedAt = Date.parse(manifest.syncedAt || "");
383
+ if (!Number.isFinite(syncedAt) || now - syncedAt >= ttlMs) return false;
384
+ return manifest.files.every((fileName) =>
385
+ typeof fileName === "string"
386
+ && path.basename(fileName) === fileName
387
+ && fs.existsSync(path.join(profile.root, "agents", MANAGED_AGENT_DIRECTORY, fileName))
388
+ );
389
+ }
390
+
391
+ export function syncAgentProfile({ client, root, label, tenantId, specialists, now = Date.now() }) {
392
+ const agentsDir = path.join(root, "agents");
393
+ for (const candidate of [root, agentsDir]) {
394
+ if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink()) {
395
+ throw new Error(`refusing to use symlinked native-agent path ${candidate}`);
396
+ }
397
+ }
398
+ const managedDir = path.join(agentsDir, MANAGED_AGENT_DIRECTORY);
399
+ privateDirectory(managedDir);
400
+ const manifestPath = path.join(managedDir, MANAGED_AGENT_MANIFEST);
401
+ const prior = readManifest(manifestPath);
402
+ const rendered = renderManagedAgents(client, tenantId, specialists);
403
+
404
+ for (const agent of rendered) {
405
+ atomicPrivateWrite(path.join(managedDir, agent.fileName), agent.contents);
406
+ }
407
+ const currentFiles = new Set(rendered.map((agent) => agent.fileName));
408
+ for (const stale of prior?.files || []) {
409
+ if (
410
+ typeof stale === "string"
411
+ && path.basename(stale) === stale
412
+ && !currentFiles.has(stale)
413
+ && (stale.endsWith(".md") || stale.endsWith(".toml"))
414
+ ) {
415
+ fs.rmSync(path.join(managedDir, stale), { force: true });
416
+ }
417
+ }
418
+ atomicPrivateWrite(manifestPath, `${JSON.stringify({
419
+ version: 1,
420
+ tenantId,
421
+ client,
422
+ syncedAt: new Date(now).toISOString(),
423
+ files: [...currentFiles].sort(),
424
+ agents: rendered.map(({ agentId, name }) => ({ agentId, name })),
425
+ }, null, 2)}\n`);
426
+ return { client, root, label, synced: true, count: rendered.length, files: [...currentFiles] };
427
+ }
428
+
429
+ export async function syncAgentProfiles({
430
+ profiles,
431
+ gatewayUrl,
432
+ credential,
433
+ tenantId,
434
+ staleOnly = false,
435
+ ttlMs = AGENT_SYNC_TTL_MS,
436
+ now = Date.now(),
437
+ fetchCatalog = fetchSpecialistCatalog,
438
+ logger = console,
439
+ }) {
440
+ if (process.env.IMPEL_SKIP_AGENT_SYNC === "1" || process.env.IMPEL_SKIP_AGENT_SYNC === "true") {
441
+ return profiles.map((profile) => ({ ...profile, skipped: true, reason: "disabled" }));
442
+ }
443
+ const normalizedTenant = normalizeTenantId(tenantId);
444
+ const pending = staleOnly
445
+ ? profiles.filter((profile) => !profileIsFresh(profile, normalizedTenant, now, ttlMs))
446
+ : profiles;
447
+ const skipped = profiles
448
+ .filter((profile) => !pending.includes(profile))
449
+ .map((profile) => ({ ...profile, skipped: true, reason: "fresh" }));
450
+ if (pending.length === 0) return skipped;
451
+
452
+ const catalog = await fetchCatalog({ gatewayUrl, credential, tenantId: normalizedTenant });
453
+ const results = [];
454
+ for (const profile of pending) {
455
+ const result = syncAgentProfile({
456
+ ...profile,
457
+ tenantId: normalizedTenant,
458
+ specialists: catalog.specialists,
459
+ now,
460
+ });
461
+ logger.log(`Agents: ${profile.label || profile.client} up to date (${result.count} tenant specialist${result.count === 1 ? "" : "s"}).`);
462
+ results.push(result);
463
+ }
464
+ return [...results, ...skipped];
465
+ }
466
+
467
+ export async function syncAgentProfilesSafe(options) {
468
+ try {
469
+ return await syncAgentProfiles(options);
470
+ } catch (error) {
471
+ const logger = options?.logger || console;
472
+ logger.warn(`impel: agent sync failed (${redactSecretText(error?.message || error)}); keeping the last good native-agent catalog.`);
473
+ return (options?.profiles || []).map((profile) => ({
474
+ ...profile,
475
+ synced: false,
476
+ reason: "error",
477
+ error: redactSecretText(error?.message || error),
478
+ }));
479
+ }
480
+ }
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,12 +25,12 @@ 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
31
32
  impel codex [args...] Launch Codex with an isolated Impel profile
32
- impel app open [target] [--tenant <org>] Open that tenant's isolated desktop app(s)
33
+ impel app open [target] [--tenant <org>] Install/configure on first use, then open the app(s)
33
34
  impel status Tenant, PAT, per-tool mode, gateway reachability
34
35
  impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
35
36
 
@@ -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 tenant specialists as native subagents
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,96 @@
1
+ // `impel agents sync [claude|codex|all]` — synchronize the selected tenant's
2
+ // opted-in Impel specialists into every managed native custom-agent 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 tenant specialists as native custom agents
12
+
13
+ Usage:
14
+ impel agents sync [claude|codex|all]
15
+
16
+ The selected tenant's authenticated specialist catalog is written into every
17
+ managed native profile. The default target is all.`;
18
+
19
+ function normalizeAgentTarget(token) {
20
+ if (token === undefined || token === "all") return "all";
21
+ if (token === "claude") return "claude";
22
+ if (token === "codex" || token === "codex-cli" || token === "codex-app" || token === "chatgpt") {
23
+ return "codex";
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export function managedAgentProfiles(client, options = {}) {
29
+ const homeDir = options.homeDir || os.homedir();
30
+ const environment = options.environment || process.env;
31
+ return managedSkillProfiles(client, options).map((profile) => ({
32
+ client,
33
+ label: profile.label,
34
+ root: agentProfileRoot(client, {
35
+ env: profile.env,
36
+ environment,
37
+ homeDir,
38
+ }),
39
+ }));
40
+ }
41
+
42
+ export async function cmdAgents(argv, overrides = {}) {
43
+ const [action = "sync", targetToken] = argv;
44
+ if (["help", "--help", "-h"].includes(action)) {
45
+ console.log(HELP);
46
+ return;
47
+ }
48
+ if (action !== "sync") {
49
+ console.error(`impel agents: unknown action "${action}". Try \`impel agents sync [claude|codex|all]\`.`);
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+ const target = normalizeAgentTarget(targetToken);
54
+ if (target === null) {
55
+ console.error(`impel agents: unknown target "${targetToken}". Use \`claude\`, \`codex\`, or \`all\`.`);
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+
60
+ const io = {
61
+ loadConfig,
62
+ ensureTenantSelection,
63
+ syncAgentProfiles,
64
+ managedAgentProfiles,
65
+ environment: process.env,
66
+ homeDir: os.homedir(),
67
+ ...overrides,
68
+ };
69
+ const config = io.loadConfig();
70
+ if (!config?.pat) {
71
+ console.error("impel agents: not authenticated; run `impel setup` (or `impel auth`) first.");
72
+ process.exitCode = 1;
73
+ return;
74
+ }
75
+
76
+ try {
77
+ const selected = await io.ensureTenantSelection(config);
78
+ const tenantId = selected.tenantId;
79
+ const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
80
+ const clients = target === "all" ? ["claude", "codex"] : [target];
81
+ const profiles = clients.flatMap((client) => io.managedAgentProfiles(client, {
82
+ tenantId,
83
+ environment: io.environment,
84
+ homeDir: io.homeDir,
85
+ }));
86
+ await io.syncAgentProfiles({
87
+ profiles,
88
+ gatewayUrl,
89
+ credential: tenantCredential(config.pat, tenantId),
90
+ tenantId,
91
+ });
92
+ } catch (error) {
93
+ console.error(`impel agents: ${redactSecretText(error?.message || error)}`);
94
+ process.exitCode = 1;
95
+ }
96
+ }
@@ -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
  };
@@ -207,13 +216,26 @@ export async function cmdWindowsApps(argv, overrides = {}) {
207
216
  claudeUserData: actionUserData,
208
217
  tenantName: config.tenantName,
209
218
  });
219
+ const profileWasInstalled = {
220
+ claude: fs.existsSync(path.join(
221
+ actionPaths.claude.userData,
222
+ "configLibrary",
223
+ `${CLAUDE_CONFIG_ID}.json`,
224
+ )),
225
+ chatgpt: fs.existsSync(path.join(actionPaths.chatgpt.codexHome, "config.toml")),
226
+ };
210
227
  const vendorPaths = {};
211
228
  for (const target of targets) {
212
229
  const isClaude = target === "claude";
213
230
  const find = isClaude ? io.findClaudeApp : io.findChatGPTApp;
214
231
  const ensure = isClaude ? io.ensureClaudeApp : io.ensureChatGPTApp;
215
232
  let binary = find(io.environment);
216
- if ((action === "install" || action === "update") && !flags["skip-vendor"]) {
233
+ const shouldEnsureVendor = !flags["skip-vendor"] && (
234
+ action === "install"
235
+ || action === "update"
236
+ || (action === "open" && !binary)
237
+ );
238
+ if (shouldEnsureVendor) {
217
239
  const vendor = ensure({ update: action === "update" }, { environment: io.environment });
218
240
  binary = vendor.binary;
219
241
  console.log(`${target}: vendor app ${vendor.action}`);
@@ -252,15 +274,24 @@ export async function cmdWindowsApps(argv, overrides = {}) {
252
274
  claudeUserData: userData,
253
275
  tenantName: config.tenantName,
254
276
  });
255
- if (action === "install" || action === "update" || action === "refresh") {
256
- const verb = action === "install" ? "Installed" : action === "update" ? "Updated" : "Refreshed";
257
- for (const target of targets) {
277
+ const configuredTargets = action === "open"
278
+ ? targets.filter((target) => !profileWasInstalled[target])
279
+ : targets;
280
+ if (action === "install" || action === "update" || action === "refresh" || configuredTargets.length > 0) {
281
+ const verb = action === "update" ? "Updated" : action === "refresh" ? "Refreshed" : "Installed";
282
+ for (const target of configuredTargets) {
258
283
  const profile = target === "claude" ? tenantPaths.claude.userData : tenantPaths.chatgpt.root;
259
284
  console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
260
285
  const { client, env, label } = appSkillTarget(target, tenantPaths);
261
286
  await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
262
287
  if (target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
263
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
+ });
264
295
  console.log(`Models: ${catalog.models.length} from ${catalog.source}. The signed vendor apps and their normal profiles were not changed.`);
265
296
  }
266
297
  if (action === "open") {
@@ -358,43 +389,12 @@ export async function cmdApps(argv, overrides = {}) {
358
389
 
359
390
  const config = await selectedAppConfig(targets, flags.tenant || null);
360
391
  const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
361
- const legacyPaths = appPaths(os.homedir());
362
- for (const status of statuses) {
363
- if (!status.launcherInstalled && !fs.existsSync(legacyPaths[status.target].launcher)) {
364
- throw new Error(`${status.label} is not installed; run \`impel app install ${status.target}\``);
365
- }
366
- if (!status.vendorPath) throw new Error(`${status.label} vendor app is unavailable; reinstall the vendor app first`);
367
- }
368
- let catalog;
369
- try {
370
- catalog = await fetchGatewayModels(config);
371
- } catch (error) {
372
- throw new Error(
373
- `tenant model catalog is unavailable (${redactSecretText(error.message)}); the selected tenant app was not opened`,
374
- );
375
- }
376
- // Only stale bundles pay the clone + codesign rebuild; a bundle swap needs
377
- // its app closed, and quitting is fine here since we relaunch right after.
378
- const staleBundleTargets = statuses
379
- .filter((status) => !bundleIsCurrent(status))
380
- .map((status) => status.target);
381
- if (staleBundleTargets.length > 0) {
382
- await quitBlockingApps(staleBundleTargets, {
383
- tenantId: config.tenantId,
384
- tenantName: config.tenantName,
385
- });
386
- }
387
- installManagedAppFiles({
392
+ await provisionAndOpenManagedApps({
388
393
  config,
389
394
  targets,
390
- models: catalog.models,
395
+ statuses,
391
396
  homeDir: os.homedir(),
392
- vendorPaths: Object.fromEntries(statuses.map((status) => [status.target, status.vendorPath])),
393
- writeBundles: staleBundleTargets,
394
- });
395
- for (const status of appStatus(targets, os.homedir(), config.tenantId, config.tenantName)) {
396
- spawnSync("/usr/bin/open", ["-n", status.launcher], { stdio: "inherit" });
397
- }
397
+ }, overrides);
398
398
  return;
399
399
  }
400
400
  if (action !== "install" && action !== "update") {
@@ -443,6 +443,113 @@ export async function cmdApps(argv, overrides = {}) {
443
443
  await syncSkillsSafe({ client, gatewayUrl, env, label });
444
444
  if (item.target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
445
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
+ });
453
+ }
454
+
455
+ /**
456
+ * Complete the slow `app open` path. A first open is intentionally equivalent
457
+ * to an install for the selected tenant: verify or install the pinned vendor,
458
+ * build the isolated bundle/profile, sync skills, and only then launch it.
459
+ * Existing current installs never reach this helper because fastOpenLaunchers
460
+ * handles them without network or rebuild work.
461
+ */
462
+ export async function provisionAndOpenManagedApps({
463
+ config,
464
+ targets,
465
+ statuses,
466
+ homeDir = os.homedir(),
467
+ }, overrides = {}) {
468
+ const io = {
469
+ fetchModels: fetchGatewayModels,
470
+ ensureVendor: ensureVendorApp,
471
+ bundleCurrent: bundleIsCurrent,
472
+ quitApps: quitBlockingApps,
473
+ installFiles: installManagedAppFiles,
474
+ syncSkills: syncSkillsSafe,
475
+ syncAgents: syncAgentProfilesSafe,
476
+ secureCodexHome: secureManagedCodexHome,
477
+ openLauncher: (launcher) => spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" }),
478
+ log: (message) => console.log(message),
479
+ ...overrides,
480
+ };
481
+
482
+ // Resolve the live tenant catalog before installing or changing anything.
483
+ // This keeps unavailable/unauthorized tenants fail-closed on first use.
484
+ let catalog;
485
+ try {
486
+ catalog = await io.fetchModels(config);
487
+ } catch (error) {
488
+ throw new Error(
489
+ `tenant model catalog is unavailable (${redactSecretText(error?.message || error)}); the selected tenant app was not opened`,
490
+ );
491
+ }
492
+
493
+ const vendorPaths = {};
494
+ for (const status of statuses) {
495
+ let vendorPath = status.vendorPath;
496
+ if (!vendorPath) {
497
+ const result = io.ensureVendor(status.target, { homeDir });
498
+ vendorPath = result.path;
499
+ io.log(`${status.target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
500
+ if (!vendorPath) {
501
+ throw new Error(`${status.target} verified vendor app is unavailable; retry the pinned download`);
502
+ }
503
+ }
504
+ vendorPaths[status.target] = vendorPath;
505
+ }
506
+
507
+ // Only stale or absent bundles pay the clone + codesign rebuild. The model
508
+ // catalog and vendor checks above finish before any running apps are closed.
509
+ const staleBundleTargets = statuses
510
+ .filter((status) => !io.bundleCurrent(status))
511
+ .map((status) => status.target);
512
+ if (staleBundleTargets.length > 0) {
513
+ await io.quitApps(staleBundleTargets, {
514
+ tenantId: config.tenantId,
515
+ tenantName: config.tenantName,
516
+ });
517
+ }
518
+
519
+ const installed = io.installFiles({
520
+ config,
521
+ targets,
522
+ models: catalog.models,
523
+ homeDir,
524
+ vendorPaths,
525
+ writeBundles: staleBundleTargets,
526
+ });
527
+ const newlyInstalled = new Set(
528
+ statuses.filter((status) => !status.launcherInstalled).map((status) => status.target),
529
+ );
530
+ for (const item of installed) {
531
+ if (newlyInstalled.has(item.target)) {
532
+ io.log(`Installed and configured ${item.launcher} for tenant ${config.tenantId}.`);
533
+ }
534
+ }
535
+
536
+ // Match explicit install setup without penalizing repeat opens: this helper
537
+ // only runs when the zero-network fast path has detected drift or first use.
538
+ const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
539
+ const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
540
+ for (const item of installed) {
541
+ const { client, env, label } = appSkillTarget(item.target, paths);
542
+ await io.syncSkills({ client, gatewayUrl, env, label });
543
+ if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
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
+ });
551
+ for (const item of installed) await io.openLauncher(item.launcher);
552
+ return installed;
446
553
  }
447
554
 
448
555
  const REFRESH_TTL_MS = 6 * 60 * 60 * 1000;
@@ -552,6 +659,12 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
552
659
  await syncSkillsSafe({ client, gatewayUrl, env, label });
553
660
  if (status.target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
554
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
+ });
555
668
  // Keep the update-notice cache warm from the same background slot.
556
669
  try {
557
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 });