impel-cli 0.20.39 → 0.20.40

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/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.40 — Field-report fixes: launcher drift, sync ownership, Codex capability, one-run update
4
+
5
+ - Survives Claude auto-update drift: `impel claude` resolves the exact reviewed
6
+ release from the installer's side-by-side layout instead of exiting 127 when
7
+ the vendor updater moves the shared symlink; setup/update detection shares
8
+ the same resolution, ending the reinstall loop.
9
+ - Makes `impel agents sync` ownership symmetric across Claude and Codex with
10
+ digest-proven reclaim: manifest scope drift self-heals, operator-edited
11
+ files still refuse fail-closed, non-owning manifests can no longer delete
12
+ another tenant's files, and shared roots converge on tenant switch.
13
+ - Unstrands answer-mode adapters: retrying the answer call is permitted only
14
+ before a continuation exists, continuation ids tolerate case drift, and the
15
+ invalid-handle error now says what to pass instead.
16
+ - Restores Codex capability in the managed app and CLI: the generated desktop
17
+ profile enables the Code Mode host (pinned Codex fails closed on
18
+ code_mode_only models without it, hiding the 5.6 family with its max/ultra
19
+ efforts and Fast tier), the CLI model catalog is served from the same
20
+ registry as the desktop (max/ultra/Fast restored), and a dropped
21
+ code_mode_host key now self-heals as drift on current installs.
22
+ - Sizes the Codex verbatim-relay parent wait from the child's attachment
23
+ window so visible working time is no longer truncated, and direct answers
24
+ emit progress notifications.
25
+ - Converges `impel update` in one run: npm-prefix skew hands convergence to
26
+ the verified freshly installed entrypoint instead of aborting after a
27
+ partial update, and install-recovery re-probes live state so completed
28
+ steps are never re-issued.
29
+
3
30
  ## 0.20.39 — Isolate fixed Codex agent adapters
4
31
 
5
32
  - Disables local and remote plugin loading, locally built plugin sharing, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.39",
3
+ "version": "0.20.40",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -5,12 +5,14 @@ import crypto from "node:crypto";
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import {
8
+ enableManagedCodexDesktopCodeModeHost,
8
9
  enableManagedCodexDesktopMcpApps,
9
10
  hardenManagedCodexToml,
10
11
  secureAllManagedCodexHomes,
11
12
  secureManagedCodexHome,
12
13
  } from "./codexSecurity.js";
13
14
  import { normalizeTenantId } from "./tenants.js";
15
+ import { codexCatalogEntry } from "./modelCatalog.js";
14
16
  import {
15
17
  IMPEL_CLI_ENTRYPOINT,
16
18
  IMPEL_TASKS_MCP_SERVER_NAME,
@@ -132,15 +134,6 @@ export const PINNED_VENDOR_APPS = Object.freeze({
132
134
  }),
133
135
  });
134
136
 
135
- const STANDARD_REASONING_LEVELS = [
136
- { effort: "low", description: "Fast responses with lighter reasoning" },
137
- { effort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
138
- { effort: "high", description: "Greater reasoning depth for complex problems" },
139
- { effort: "xhigh", description: "Extra high reasoning depth for complex problems" },
140
- ];
141
- const MAX_REASONING_LEVEL = { effort: "max", description: "Maximum reasoning depth for the hardest problems" };
142
- const ULTRA_REASONING_LEVEL = { effort: "ultra", description: "Maximum reasoning with automatic task delegation" };
143
- const FAST_SERVICE_TIER = { id: "priority", name: "Fast", description: "1.5x speed, increased usage" };
144
137
  const FAST_MODE_AUTH_GATE = /([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)&&!([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)!=null&&\4\?\.requirements\?\.featureRequirements\?\.fast_mode!==!1/gu;
145
138
  const CHATGPT_THREAD_START_PROVIDER = /serviceName:([A-Za-z_$][\w$]*)\.serviceName\?\?this\.options\.defaultServiceName,threadSource:\1\.threadSource===void 0\?`user`:\1\.threadSource/gu;
146
139
  const CHATGPT_THREAD_FORK_PROVIDER = /let ([A-Za-z_$][\w$]*)=\{\.\.\.([A-Za-z_$][\w$]*),threadSource:\2\.threadSource===void 0\?`user`:\2\.threadSource\}/gu;
@@ -213,21 +206,11 @@ function impelClaudeSafeStorageName(tenantId) {
213
206
  const escapedDisplayPrefix = RUNTIME_BRAND.apps.displayPrefix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
214
207
  const LEGACY_TRAPPED_CLAUDE_NAME = new RegExp(`^${escapedDisplayPrefix} Claude( \\[[^\\]]+\\])?$`, "u");
215
208
 
216
- export const FALLBACK_MODELS = [
217
- { id: "claude-opus-5", provider: "claude", display_name: "Claude Opus 5", family: "opus", family_default: true, default: true, supports_1m: true, context_window: 1000000 },
218
- { id: "claude-opus-4-8", provider: "claude", display_name: "Claude Opus 4.8", family: "opus", supports_1m: true, context_window: 1000000 },
219
- { id: "claude-sonnet-5", provider: "claude", display_name: "Claude Sonnet 5", family: "sonnet", family_default: true, context_window: 200000 },
220
- { id: "claude-sonnet-4-6", provider: "claude", display_name: "Claude Sonnet 4.6", family: "sonnet", context_window: 200000 },
221
- { id: "claude-haiku-4-5", provider: "claude", display_name: "Claude Haiku 4.5", family: "haiku", family_default: true, context_window: 200000 },
222
- { id: "claude-fable-5", provider: "claude", display_name: "Claude Fable 5", family: "fable", family_default: true, context_window: 200000 },
223
- { id: "gpt-5.6-sol", provider: "codex", display_name: "GPT-5.6-Sol", description: "Latest frontier agentic coding model.", default: true, priority: 1, default_reasoning_level: "medium", supported_reasoning_levels: [...STANDARD_REASONING_LEVELS, MAX_REASONING_LEVEL, ULTRA_REASONING_LEVEL], service_tiers: [FAST_SERVICE_TIER], context_window: 272000, max_context_window: 272000, use_responses_lite: true, tool_mode: "code_mode_only", multi_agent_version: "v2" },
224
- { id: "gpt-5.6-terra", provider: "codex", display_name: "GPT-5.6-Terra", description: "Balanced agentic coding model for everyday work.", priority: 2, default_reasoning_level: "medium", supported_reasoning_levels: [...STANDARD_REASONING_LEVELS, MAX_REASONING_LEVEL, ULTRA_REASONING_LEVEL], service_tiers: [FAST_SERVICE_TIER], context_window: 272000, max_context_window: 272000, use_responses_lite: true, tool_mode: "code_mode_only", multi_agent_version: "v2" },
225
- { id: "gpt-5.6-luna", provider: "codex", display_name: "GPT-5.6-Luna", description: "Fast and affordable agentic coding model.", priority: 3, default_reasoning_level: "medium", supported_reasoning_levels: [...STANDARD_REASONING_LEVELS, MAX_REASONING_LEVEL], service_tiers: [FAST_SERVICE_TIER], context_window: 272000, max_context_window: 272000, use_responses_lite: true, tool_mode: "code_mode_only", multi_agent_version: "v1" },
226
- { id: "gpt-5.5", provider: "codex", display_name: "GPT-5.5", description: "Frontier model for complex coding, research, and real-world work.", priority: 7, default_reasoning_level: "medium", supported_reasoning_levels: STANDARD_REASONING_LEVELS, service_tiers: [FAST_SERVICE_TIER], context_window: 272000, max_context_window: 272000, use_responses_lite: false, tool_mode: null, multi_agent_version: null },
227
- { id: "gpt-5.4", provider: "codex", display_name: "GPT-5.4", description: "Strong model for everyday coding.", priority: 16, default_reasoning_level: "medium", supported_reasoning_levels: STANDARD_REASONING_LEVELS, service_tiers: [FAST_SERVICE_TIER], context_window: 272000, max_context_window: 1000000, use_responses_lite: false, tool_mode: null, multi_agent_version: null },
228
- { id: "gpt-5.4-mini", provider: "codex", display_name: "GPT-5.4-Mini", description: "Small, fast, and cost-efficient model for simpler coding tasks.", priority: 23, default_reasoning_level: "medium", supported_reasoning_levels: STANDARD_REASONING_LEVELS, service_tiers: [], context_window: 272000, max_context_window: 272000, use_responses_lite: false, tool_mode: null, multi_agent_version: null },
229
- { id: "gpt-5.3-codex-spark", provider: "codex", display_name: "GPT-5.3-Codex-Spark", description: "Ultra-fast coding model.", priority: 26, default_reasoning_level: "high", supported_reasoning_levels: STANDARD_REASONING_LEVELS, service_tiers: [], context_window: 128000, max_context_window: 128000, use_responses_lite: false, tool_mode: null, multi_agent_version: null },
230
- ];
209
+ // The shared model registry and Codex catalog projection live in
210
+ // modelCatalog.js (a leaf module) so cliProfiles.js can consume them
211
+ // without importing this module's launch-command cycle. Re-exported here
212
+ // for the many existing consumers.
213
+ export { FALLBACK_MODELS, managedCodexCliCatalog } from "./modelCatalog.js";
231
214
 
232
215
  export function normalizeAppTarget(value) {
233
216
  if (value == null || value === "all") return ["claude", "chatgpt"];
@@ -315,7 +298,10 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
315
298
  // native-agent attachment window so the visible working span is not
316
299
  // truncated while the child is still running.
317
300
  // 34: isolate fixed-binding Codex profiles from local and remote host plugins.
318
- export const CURRENT_CONFIG_VERSION = 34;
301
+ // 35: enable the Code Mode host in managed desktop profiles (pinned Codex
302
+ // fails closed on code_mode_only models without it) and serve CLI model
303
+ // catalogs from the shared registry so efforts and tiers cannot drift.
304
+ export const CURRENT_CONFIG_VERSION = 35;
319
305
 
320
306
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
321
307
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -1452,7 +1438,10 @@ function writeChatGPTConfig(
1452
1438
  const hardenedToml = hardenManagedCodexToml(mergedToml, configPath);
1453
1439
  writeAtomic(
1454
1440
  configPath,
1455
- enableManagedCodexDesktopMcpApps(hardenedToml, configPath),
1441
+ enableManagedCodexDesktopCodeModeHost(
1442
+ enableManagedCodexDesktopMcpApps(hardenedToml, configPath),
1443
+ configPath,
1444
+ ),
1456
1445
  0o600,
1457
1446
  );
1458
1447
  secureManagedCodexHome(paths.chatgpt.codexHome);
@@ -1541,60 +1530,6 @@ export function foreignClaudeCodexCatalogEntry(model, index) {
1541
1530
  };
1542
1531
  }
1543
1532
 
1544
- function codexCatalogEntry(model, index, vendorModel) {
1545
- const generated = {
1546
- slug: model.id,
1547
- display_name: model.display_name || model.id,
1548
- description: `Available through the Impel gateway (${model.id}).`,
1549
- default_reasoning_level: "medium",
1550
- supported_reasoning_levels: STANDARD_REASONING_LEVELS,
1551
- shell_type: "shell_command",
1552
- visibility: "list",
1553
- supported_in_api: true,
1554
- priority: index + 1,
1555
- base_instructions: "You are Codex, an AI coding agent. Follow repository instructions, collaborate with the user, make scoped changes, and verify your work.",
1556
- include_skills_usage_instructions: false,
1557
- supports_reasoning_summaries: true,
1558
- default_reasoning_summary: "none",
1559
- support_verbosity: true,
1560
- default_verbosity: "low",
1561
- apply_patch_tool_type: "freeform",
1562
- web_search_tool_type: "text_and_image",
1563
- truncation_policy: { mode: "tokens", limit: 10000 },
1564
- context_window: model.context_window || 200000,
1565
- max_context_window: model.context_window || 200000,
1566
- effective_context_window_percent: 95,
1567
- experimental_supported_tools: [],
1568
- input_modalities: ["text", "image"],
1569
- supports_parallel_tool_calls: true,
1570
- supports_image_detail_original: true,
1571
- supports_search_tool: true,
1572
- use_responses_lite: false,
1573
- tool_mode: null,
1574
- multi_agent_version: null,
1575
- };
1576
- const entry = vendorModel ? { ...generated, ...vendorModel } : generated;
1577
- return {
1578
- ...entry,
1579
- slug: model.id,
1580
- display_name: model.display_name || entry.display_name || model.id,
1581
- description: model.description || entry.description,
1582
- default_reasoning_level: model.default_reasoning_level || entry.default_reasoning_level,
1583
- supported_reasoning_levels: Array.isArray(model.supported_reasoning_levels)
1584
- ? model.supported_reasoning_levels
1585
- : entry.supported_reasoning_levels,
1586
- service_tiers: Array.isArray(model.service_tiers) ? model.service_tiers : (entry.service_tiers || []),
1587
- visibility: "list",
1588
- supported_in_api: true,
1589
- priority: Number.isInteger(model.priority) ? model.priority : (entry.priority || index + 1),
1590
- context_window: model.context_window || entry.context_window || 200000,
1591
- max_context_window: model.max_context_window || entry.max_context_window || model.context_window || 200000,
1592
- ...(typeof model.use_responses_lite === "boolean" ? { use_responses_lite: model.use_responses_lite } : {}),
1593
- ...(Object.hasOwn(model, "tool_mode") ? { tool_mode: model.tool_mode } : {}),
1594
- ...(Object.hasOwn(model, "multi_agent_version") ? { multi_agent_version: model.multi_agent_version } : {}),
1595
- };
1596
- }
1597
-
1598
1533
  function readVendorCodexModels(vendorPath) {
1599
1534
  const binary = vendorPath && path.join(vendorPath, "Contents", "Resources", "codex");
1600
1535
  if (!binary || !fs.existsSync(binary)) return new Map();
@@ -1659,9 +1594,31 @@ export function managedChatGPTConfigDrifted(paths) {
1659
1594
  } catch {
1660
1595
  return false; // No managed profile; install/update owns creating one.
1661
1596
  }
1597
+ // The [features] table lives outside the managed block — exactly the
1598
+ // surface the vendor settings writer rewrites. code_mode_host is
1599
+ // load-bearing: without it pinned Codex fails closed on the code_mode_only
1600
+ // default model, so a dropped key must self-heal as drift rather than wait
1601
+ // out the refresh TTL. enable_mcp_apps only degrades rendering but rides
1602
+ // the same check. Configs from generations before the keys existed are
1603
+ // merely stale (the manifest version bump rewrites them), never drift —
1604
+ // require the keys only once this install's manifest proves they were
1605
+ // written.
1606
+ let manifestConfigVersion = 0;
1607
+ try {
1608
+ manifestConfigVersion = Number(JSON.parse(
1609
+ fs.readFileSync(path.join(paths.tenantRoot, "manifest.json"), "utf8"),
1610
+ )?.configVersion) || 0;
1611
+ } catch {
1612
+ // Without a readable manifest the version gate stays closed.
1613
+ }
1614
+ const featuresTable = current.match(/^\[features\]\n(?:[^[\n][^\n]*\n|\n)*/mu)?.[0] || "";
1615
+ const featureKeysDropped = manifestConfigVersion >= 35
1616
+ && (!/^code_mode_host = true$/mu.test(featuresTable)
1617
+ || !/^enable_mcp_apps = true$/mu.test(featuresTable));
1662
1618
  return !current.includes(CHATGPT_CONFIG_START)
1663
1619
  || readTopLevelTomlString(current, "model_provider") !== RUNTIME_BRAND.cli.providerId
1664
- || !readTopLevelTomlString(current, "chatgpt_base_url");
1620
+ || !readTopLevelTomlString(current, "chatgpt_base_url")
1621
+ || featureKeysDropped;
1665
1622
  }
1666
1623
 
1667
1624
  function readTopLevelTomlString(toml, key) {
@@ -21,7 +21,7 @@ import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
21
21
  import { renameWithWindowsRetry } from "./windowsFs.js";
22
22
  import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
23
23
  import { RUNTIME_BRAND } from "./runtimeBrand.js";
24
- import { managedCodexCliCatalog } from "./codexCliCatalog.js";
24
+ import { managedCodexCliCatalog } from "./modelCatalog.js";
25
25
 
26
26
  export const IMPEL_CLI_PROFILES_DIR = path.join(CONFIG_DIR, "cli");
27
27
  export const CODEX_GATEWAY_TOKEN_ENV = `${RUNTIME_BRAND.cli.providerId
@@ -166,6 +166,27 @@ export function enableManagedCodexDesktopMcpApps(
166
166
  );
167
167
  }
168
168
 
169
+ /**
170
+ * Enable the Code Mode execution host in the exact pinned desktop profile.
171
+ * Pinned Codex fails closed when a code_mode_only model is selected while
172
+ * this feature is disabled, and the managed bundle ships the signed
173
+ * codex-code-mode-host companion. CLI launches receive the equivalent
174
+ * `-c features.code_mode_host=true` runtime override; the desktop app spawns
175
+ * Codex directly and must carry the flag in its generated config.
176
+ */
177
+ export function enableManagedCodexDesktopCodeModeHost(
178
+ toml,
179
+ configPath = "managed Codex desktop config",
180
+ ) {
181
+ return upsertManagedScalar(
182
+ toml,
183
+ "features",
184
+ "code_mode_host",
185
+ "true",
186
+ configPath,
187
+ );
188
+ }
189
+
169
190
  function assertSafeManagedPath(target, expectedType) {
170
191
  const stat = lstatOrNull(target);
171
192
  if (!stat) return null;
@@ -214,7 +214,23 @@ export async function cmdConverge(argv = [], overrides = {}) {
214
214
  goals: [{
215
215
  id: "shared-vendor-clis-ready",
216
216
  description: "Shared vendor CLI prerequisites are installed and discoverable",
217
- run: () => sharedReady,
217
+ // Live probe, not a session snapshot: a vendor CLI installed outside
218
+ // this process (for example by the user in another terminal while the
219
+ // recovery session runs) must count as complete the moment the goal
220
+ // is evaluated, so recovery never re-issues an already-finished step.
221
+ run: async () => {
222
+ if (sharedReady) return true;
223
+ const prepared = await io.preparePlatformClis({
224
+ gatewayUrl: config.gatewayUrl,
225
+ tenantId: selected.id,
226
+ platform: io.platform,
227
+ skipInstall: true,
228
+ // Goals re-run before every system-risk step: the probe must be
229
+ // pure discovery — no installs, profile writes, or skill syncs.
230
+ inspectOnly: true,
231
+ });
232
+ return prepared.missingAfter.length === 0;
233
+ },
218
234
  }],
219
235
  explicit: true,
220
236
  noRecovery: false,
@@ -370,7 +370,23 @@ export async function cmdSetup(argv, overrides = {}) {
370
370
  goals: [{
371
371
  id: "shared-vendor-clis-ready",
372
372
  description: "Shared vendor CLI prerequisites are installed and discoverable",
373
- run: () => sharedReady,
373
+ // Live probe, not a session snapshot: a vendor CLI installed outside
374
+ // this process (for example by the user in another terminal while the
375
+ // recovery session runs) must count as complete the moment the goal
376
+ // is evaluated, so recovery never re-issues an already-finished step.
377
+ run: async () => {
378
+ if (sharedReady) return true;
379
+ const prepared = await io.preparePlatformClis({
380
+ gatewayUrl,
381
+ tenantId: selected.id,
382
+ platform: io.platform,
383
+ skipInstall: true,
384
+ // Goals re-run before every system-risk step: the probe must be
385
+ // pure discovery — no installs, profile writes, or skill syncs.
386
+ inspectOnly: true,
387
+ });
388
+ return prepared.missingAfter.length === 0;
389
+ },
374
390
  }],
375
391
  explicit: Boolean(flags.repair),
376
392
  noRecovery: false,
@@ -10,7 +10,7 @@ import { loadConfig, redactSecretText } from "../config.js";
10
10
  import { nativeCommandInvocation } from "../nativeProcess.js";
11
11
  import { withProgress } from "../progress.js";
12
12
  import { runInstallRecovery } from "../installRecovery/engine.js";
13
- import { brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
13
+ import { brandedEnvironmentName, brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
14
14
  import { IMPEL_CLI_ENTRYPOINT } from "../selfInvocation.js";
15
15
  import {
16
16
  fetchRemoteVersion,
@@ -18,12 +18,20 @@ import {
18
18
  isNewerVersion,
19
19
  refreshUpdateCache,
20
20
  updateInstallSpec,
21
+ updatePackage,
21
22
  updateTagForVersion,
22
23
  writeUpdateCache,
23
24
  } from "../updates.js";
24
25
 
25
26
  const CLI_BIN = IMPEL_CLI_ENTRYPOINT;
26
27
 
28
+ /**
29
+ * Loop guard for the fresh-build continuation: `_converge` children run with
30
+ * this marker so a nested `impel update` inside the cascade can never start
31
+ * another self-update (and thus another cascade) recursively.
32
+ */
33
+ export const UPDATE_CASCADE_ENV = brandedEnvironmentName("UPDATE_CASCADE");
34
+
27
35
  /**
28
36
  * The version now on disk at the package that owns CLI_BIN, read FRESH (never
29
37
  * from this process's cached module graph). After `npm install -g` this is the
@@ -39,6 +47,28 @@ export function postInstallCliVersion() {
39
47
  }
40
48
  }
41
49
 
50
+ /**
51
+ * Whether the build at CLI_BIN actually RUNS and reports the expected
52
+ * version. A bare package.json read is not proof after a failed
53
+ * `npm install -g`: the global replace is non-atomic (notably on Windows),
54
+ * so a half-written package can carry the new manifest with broken code.
55
+ */
56
+ export function postInstallCliRunnable(expectedVersion, { spawn = spawnSync, execPath = process.execPath } = {}) {
57
+ if (typeof expectedVersion !== "string" || !expectedVersion.trim()) return false;
58
+ try {
59
+ const run = spawn(execPath, [CLI_BIN, "--version"], {
60
+ encoding: "utf8",
61
+ stdio: ["ignore", "pipe", "pipe"],
62
+ timeout: 15_000,
63
+ windowsHide: true,
64
+ });
65
+ if (run.status !== 0 || run.error) return false;
66
+ return String(run.stdout || "").trim() === expectedVersion.trim();
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
42
72
  const HELP = brandedText(`impel update - update everything Impel in one command
43
73
 
44
74
  Reinstalls impel-cli from npm, then uses the new build to discover every
@@ -101,11 +131,67 @@ function defaultSelfUpdate(spec) {
101
131
  return installUpdateFromRegistry(spec);
102
132
  }
103
133
 
134
+ /**
135
+ * After `npm install -g` exits 0 but the RUNNING CLI's own package still
136
+ * resolves the old version (npm-prefix skew: nvm/Homebrew node transitions,
137
+ * npm 11 dangling prepare-script symlinks), locate the build npm actually
138
+ * installed so the cascade can run under it. Fail-closed: the entry point
139
+ * must live inside the global root of the same `npm` the installer ran, and
140
+ * its package.json must resolve exactly the version npm reported for the
141
+ * update tag; anything else returns null and the caller keeps the hard error.
142
+ */
143
+ export function resolveFreshGlobalEntrypoint(targetVersion, dependencies = {}) {
144
+ const io = {
145
+ spawnSync,
146
+ platform: process.platform,
147
+ environment: process.env,
148
+ readFileSync: fs.readFileSync,
149
+ existsSync: fs.existsSync,
150
+ ...dependencies,
151
+ };
152
+ if (typeof targetVersion !== "string" || !targetVersion.trim()) return null;
153
+ try {
154
+ const invocation = nativeCommandInvocation("npm", ["root", "-g"], io.environment, io.platform);
155
+ const run = io.spawnSync(invocation.command, invocation.args, {
156
+ encoding: "utf8",
157
+ env: io.environment,
158
+ stdio: ["ignore", "pipe", "pipe"],
159
+ timeout: 15_000,
160
+ windowsHide: true,
161
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
162
+ });
163
+ if (run.status !== 0 || run.error) return null;
164
+ const globalRoot = String(run.stdout || "").trim();
165
+ if (!globalRoot || !path.isAbsolute(globalRoot)) return null;
166
+ // Containment is enforced at every hop: the package directory must stay
167
+ // inside the npm global root (an env-supplied package name cannot
168
+ // traverse out), the declared bin must stay inside the package, and the
169
+ // real (symlink-resolved) entry point must still live under the real
170
+ // global root. Anything else fails closed to the caller's hard error.
171
+ const packageRoot = path.resolve(path.join(globalRoot, ...updatePackage().split("/")));
172
+ if (!packageRoot.startsWith(path.resolve(globalRoot) + path.sep)) return null;
173
+ const pkg = JSON.parse(io.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
174
+ if (pkg?.version !== targetVersion) return null;
175
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[RUNTIME_BRAND.cli.command];
176
+ if (typeof bin !== "string" || !bin.trim()) return null;
177
+ const entrypoint = path.resolve(packageRoot, bin);
178
+ if (!entrypoint.startsWith(packageRoot + path.sep)) return null;
179
+ if (!io.existsSync(entrypoint)) return null;
180
+ const realEntrypoint = (io.realpathSync || fs.realpathSync)(entrypoint);
181
+ const realRoot = (io.realpathSync || fs.realpathSync)(globalRoot);
182
+ if (!realEntrypoint.startsWith(realRoot + path.sep)) return null;
183
+ return entrypoint;
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
104
189
  // The cascading steps re-execute the (freshly installed) CLI binary so the
105
190
  // NEW code performs them, not the process that started the update. On
106
191
  // Windows this is also what refreshes the stable %LOCALAPPDATA% entry point:
107
192
  // bin/impel.js rewrites it at startup, so the shim is only ever updated by a
108
- // build that the postInstallCliVersion guard below already verified.
193
+ // build verified either by the postInstallCliVersion guard below or by
194
+ // resolveFreshGlobalEntrypoint's fail-closed skew resolution.
109
195
  export function defaultRunConvergence({
110
196
  skipApps = false,
111
197
  skipClis = false,
@@ -113,6 +199,7 @@ export function defaultRunConvergence({
113
199
  spawn = spawnSync,
114
200
  execPath = process.execPath,
115
201
  cliBin = CLI_BIN,
202
+ environment = process.env,
116
203
  } = {}) {
117
204
  const args = [cliBin, "_converge"];
118
205
  if (skipApps) args.push("--skip-apps");
@@ -120,6 +207,7 @@ export function defaultRunConvergence({
120
207
  if (noRecovery) args.push("--no-recovery");
121
208
  const result = spawn(execPath, args, {
122
209
  stdio: "inherit",
210
+ env: { ...environment, [UPDATE_CASCADE_ENV]: "1" },
123
211
  });
124
212
  return result.status === 0;
125
213
  }
@@ -161,9 +249,12 @@ export async function cmdUpdate(argv, overrides = {}) {
161
249
  runSkillsSync: defaultRunSkillsSync,
162
250
  runAgentsSync: defaultRunAgentsSync,
163
251
  platform: process.platform,
252
+ environment: process.env,
164
253
  progress: withProgress,
165
254
  recoverInstall: runInstallRecovery,
166
255
  postInstallVersion: postInstallCliVersion,
256
+ postInstallRunnable: postInstallCliRunnable,
257
+ resolveFreshInstall: resolveFreshGlobalEntrypoint,
167
258
  loadConfig,
168
259
  ...overrides,
169
260
  };
@@ -225,10 +316,22 @@ export async function cmdUpdate(argv, overrides = {}) {
225
316
  }
226
317
 
227
318
  // ── CLI ────────────────────────────────────────────────────────────────
319
+ let cascadeEntrypoint = null;
228
320
  if (!remote) {
229
321
  console.warn("CLI: npm update check unavailable; keeping the installed build.");
230
322
  } else if (upToDate) {
231
323
  console.log("CLI: already up to date.");
324
+ } else if (io.environment?.[UPDATE_CASCADE_ENV] === "1") {
325
+ // Loop guard: a self-update must never start from inside its own
326
+ // fresh-build continuation, or a buggy cascade could respawn forever.
327
+ // Degrade rather than abort: an inherited marker (e.g. a terminal opened
328
+ // under a converge child) must not permanently brick `impel update` —
329
+ // convergence still runs; only the self-update is withheld.
330
+ console.warn(
331
+ `impel update: skipping the self-update because ${UPDATE_CASCADE_ENV} is set `
332
+ + "(update cascade in progress, or inherited from one). Convergence continues; "
333
+ + `for a self-update, unset ${UPDATE_CASCADE_ENV} or open a fresh terminal.`,
334
+ );
232
335
  } else {
233
336
  console.log(`CLI: installing the npm ${updateTag} build…`);
234
337
  if (!await io.progress(`Installing the npm ${updateTag} impel-cli build`, () => io.selfUpdate(installSpec))) {
@@ -240,8 +343,11 @@ export async function cmdUpdate(argv, overrides = {}) {
240
343
  const config = io.loadConfig();
241
344
  let recovered = false;
242
345
  if (config?.pat && config?.tenantId) {
243
- // The goal tracks the most recent reviewed retry of the npm install:
244
- // recovery may only end "fixed" once that retry actually succeeded.
346
+ // The goal passes on the most recent reviewed retry of the npm
347
+ // install OR on a live disk probe of the package that owns the
348
+ // running CLI: when the user completes the printed manual
349
+ // `npm install --global …` in another terminal mid-recovery, the goal
350
+ // must observe that completion instead of re-issuing the same step.
245
351
  const updateState = { ok: false };
246
352
  try {
247
353
  const recovery = await io.recoverInstall(
@@ -259,7 +365,10 @@ export async function cmdUpdate(argv, overrides = {}) {
259
365
  {
260
366
  id: "cli-update",
261
367
  description: `The global ${RUNTIME_BRAND.cli.packageName} npm update completed successfully`,
262
- run: () => updateState.ok === true,
368
+ run: () => updateState.ok === true
369
+ || (remote != null
370
+ && io.postInstallVersion() === remote
371
+ && io.postInstallRunnable(remote)),
263
372
  },
264
373
  ],
265
374
  explicit: Boolean(flags.repair),
@@ -296,19 +405,35 @@ export async function cmdUpdate(argv, overrides = {}) {
296
405
  console.log("CLI: recovery verified the update path.");
297
406
  }
298
407
  // npm exiting 0 is NOT proof the RUNNING install was updated: with multiple
299
- // Node installs / npm prefixes (common on Windows), the install can land in
300
- // a different global prefix while CLI_BIN the path the cascade re-executes
301
- // still holds the old build. Cascading then runs old code that believes
302
- // an update is still pending, which is how unbounded respawn storms start.
303
- // Probe the version at CLI_BIN fresh from disk and refuse to cascade on skew.
408
+ // Node installs / npm prefixes (nvm/Homebrew node transitions on macOS,
409
+ // prefix skew on Windows), the install can land in a different global
410
+ // prefix while CLI_BIN the path the cascade re-executes still holds
411
+ // the old build. Cascading old code that believes an update is still
412
+ // pending is how unbounded respawn storms start, so the cascade may only
413
+ // ever run a build that provably resolves the target version. Probe the
414
+ // version at CLI_BIN fresh from disk; on skew, locate and verify the
415
+ // build npm actually installed and hand the continuation to THAT binary.
416
+ // (The previous behavior — abort with exit 1 — left the machine
417
+ // half-updated: CLI updated in the other prefix, no tenant/app/skills
418
+ // convergence, and a rerun from the same terminal repeated the abort via
419
+ // the shell's stale command hash. That is the field-reported "run
420
+ // `impel update` twice, sometimes in separate terminals".)
304
421
  const postInstall = io.postInstallVersion();
305
422
  if (remote && postInstall !== remote) {
306
- console.error(`impel update: npm reported success but the running CLI still resolves v${postInstall ?? "?"} (expected v${remote}).`);
307
- console.error(` Running CLI: ${CLI_BIN}`);
308
- console.error(" This usually means the `impel` on PATH belongs to a different npm prefix than `npm prefix -g`.");
309
- console.error(` Fix: run \`npm prefix -g\`, confirm it owns the \`impel\` shim on PATH, then \`npm install --global ${installSpec}\` there.`);
310
- process.exitCode = 1;
311
- return;
423
+ cascadeEntrypoint = io.resolveFreshInstall(remote);
424
+ if (!cascadeEntrypoint) {
425
+ console.error(`impel update: npm reported success but the running CLI still resolves v${postInstall ?? "?"} (expected v${remote}), and the freshly installed build could not be located and verified.`);
426
+ console.error(` Running CLI: ${CLI_BIN}`);
427
+ console.error(" This usually means the `impel` on PATH belongs to a different npm prefix than `npm prefix -g`.");
428
+ console.error(` Fix: run \`npm prefix -g\`, confirm it owns the \`impel\` shim on PATH, then \`npm install --global ${installSpec}\` there.`);
429
+ process.exitCode = 1;
430
+ return;
431
+ }
432
+ console.warn(`impel update: the running CLI at ${CLI_BIN} still resolves v${postInstall ?? "?"}; continuing with the freshly installed v${remote}.`);
433
+ console.warn(` Fresh build: ${cascadeEntrypoint}`);
434
+ console.warn(io.platform === "win32"
435
+ ? " Your current shell may still launch the old install. Open a new terminal, and align the `impel` on PATH with `npm prefix -g`."
436
+ : " Your current shell may still launch the old install. Run `hash -r` (zsh/bash) or open a new terminal, and align the `impel` on PATH with `npm prefix -g`.");
312
437
  }
313
438
  console.log(`CLI: updated${remote ? ` to v${remote}` : ""}.`);
314
439
  }
@@ -318,6 +443,9 @@ export async function cmdUpdate(argv, overrides = {}) {
318
443
  skipApps: Boolean(flags["skip-apps"]),
319
444
  skipClis: Boolean(flags["skip-clis"]),
320
445
  noRecovery: Boolean(flags["no-recovery"]),
446
+ // On npm-prefix skew the continuation must re-exec the verified fresh
447
+ // build; everywhere else CLI_BIN already holds the new bytes.
448
+ ...(cascadeEntrypoint ? { cliBin: cascadeEntrypoint } : {}),
321
449
  };
322
450
  if (!await io.runConvergence(convergenceArgs)) {
323
451
  console.error("impel update: tenant convergence failed; rerun `impel update` after addressing the reported issue.");
@@ -358,6 +358,27 @@ export async function runInstallRecovery(options, overrides = {}) {
358
358
  continue;
359
359
  }
360
360
  }
361
+ // A blocked report gets one live goal pass before it stands: when
362
+ // every health check succeeds (a skipped step, or the user
363
+ // finishing the work in another terminal), the machine is healthy
364
+ // regardless of what the model concluded — mirroring the
365
+ // budget-exhaustion upgrade below.
366
+ if (status === "blocked") {
367
+ goalReport = await runGoals(session.goals);
368
+ if (goalReport.passed) {
369
+ finished = {
370
+ status: "fixed",
371
+ summary: "Every goal health check passes; the reported blocker is no longer present.",
372
+ userAction: null,
373
+ };
374
+ toolResults.push({
375
+ type: "tool_result",
376
+ tool_use_id: call.id,
377
+ content: "Outcome recorded.",
378
+ });
379
+ break;
380
+ }
381
+ }
361
382
  finished = {
362
383
  status,
363
384
  summary: String(call.input?.summary || "").slice(0, 2_000),
@@ -371,6 +392,24 @@ export async function runInstallRecovery(options, overrides = {}) {
371
392
  break;
372
393
  }
373
394
 
395
+ // Live re-probe before any system-risk step: when every goal health
396
+ // check already passes, the state this step would produce is already
397
+ // present on the machine (typically because the user completed the
398
+ // instruction in another terminal mid-session). Skip the step instead
399
+ // of re-prompting for an installer the environment no longer needs.
400
+ if (installRecoveryToolRisk(call.name) === "system" && session.goals.length) {
401
+ const live = await runGoals(session.goals);
402
+ if (live.passed) {
403
+ io.log(`Install recovery: skipping ${call.name} — every goal health check already passes.`);
404
+ toolResults.push({
405
+ type: "tool_result",
406
+ tool_use_id: call.id,
407
+ content: "Skipped: every goal health check now passes — this step is already satisfied on this machine. Verify with check_health and finish with report_outcome.",
408
+ });
409
+ continue;
410
+ }
411
+ }
412
+
374
413
  const result = await executeCall(session, call.name, call.input);
375
414
  if (result.outcome === "declined" && installRecoveryToolRisk(call.name) === "system") {
376
415
  declinedSystemCalls += 1;
@@ -397,6 +436,12 @@ export async function runInstallRecovery(options, overrides = {}) {
397
436
  }
398
437
 
399
438
  if (declinedSystemCalls >= 2) {
439
+ // Same live upgrade as the blocked and budget paths: declining a
440
+ // repair the machine no longer needs must not read as a failure.
441
+ goalReport = await runGoals(session.goals);
442
+ if (goalReport.passed) {
443
+ return terminalReturn(session, "fixed", "The installation passes all health checks.");
444
+ }
400
445
  return terminalReturn(
401
446
  session,
402
447
  "aborted",
package/src/macSetup.js CHANGED
@@ -172,6 +172,7 @@ export async function prepareMacClis({
172
172
  gatewayUrl,
173
173
  tenantId,
174
174
  skipInstall = false,
175
+ inspectOnly = false,
175
176
  installTools = ["claude", "codex"],
176
177
  } = {}, dependencies = {}) {
177
178
  const io = {
@@ -194,6 +195,22 @@ export async function prepareMacClis({
194
195
  if (requested.size !== installTools.length || [...requested].some((tool) => !io.installers[tool])) {
195
196
  throw new Error("installTools must contain unique supported macOS CLI names");
196
197
  }
198
+ // A pure discovery probe: recovery goals and health checks re-evaluate
199
+ // this repeatedly, so it must never install, write profiles, or sync
200
+ // skills — only report what is currently discoverable.
201
+ if (inspectOnly) {
202
+ return {
203
+ binaries: before,
204
+ missingBefore,
205
+ missingAfter: [...missingBefore],
206
+ installAttempted: false,
207
+ installSucceeded: null,
208
+ installFailure: null,
209
+ installations: {},
210
+ installCommands: {},
211
+ profiles: null,
212
+ };
213
+ }
197
214
 
198
215
  const installations = {};
199
216
  if (missingBefore.length && !skipInstall) {
@@ -0,0 +1,103 @@
1
+ // The single model registry shared by every managed Codex surface. This
2
+ // module must stay a leaf (importing only other leaf modules such as
3
+ // runtimeBrand.js) so both the desktop app generation in apps.js and the CLI
4
+ // tenant profiles in cliProfiles.js can consume it without an import cycle —
5
+ // the 0.20.39 regression happened precisely because the CLI carried its own
6
+ // hand-frozen copy of this data.
7
+ import { RUNTIME_BRAND } from "./runtimeBrand.js";
8
+
9
+ const STANDARD_REASONING_LEVELS = [
10
+ { effort: "low", description: "Fast responses with lighter reasoning" },
11
+ { effort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
12
+ { effort: "high", description: "Greater reasoning depth for complex problems" },
13
+ { effort: "xhigh", description: "Extra high reasoning depth for complex problems" },
14
+ ];
15
+ const MAX_REASONING_LEVEL = { effort: "max", description: "Maximum reasoning depth for the hardest problems" };
16
+ const ULTRA_REASONING_LEVEL = { effort: "ultra", description: "Maximum reasoning with automatic task delegation" };
17
+ const FAST_SERVICE_TIER = { id: "priority", name: "Fast", description: "1.5x speed, increased usage" };
18
+
19
+ export const FALLBACK_MODELS = [
20
+ { id: "claude-opus-5", provider: "claude", display_name: "Claude Opus 5", family: "opus", family_default: true, default: true, supports_1m: true, context_window: 1000000 },
21
+ { id: "claude-opus-4-8", provider: "claude", display_name: "Claude Opus 4.8", family: "opus", supports_1m: true, context_window: 1000000 },
22
+ { id: "claude-sonnet-5", provider: "claude", display_name: "Claude Sonnet 5", family: "sonnet", family_default: true, context_window: 200000 },
23
+ { id: "claude-sonnet-4-6", provider: "claude", display_name: "Claude Sonnet 4.6", family: "sonnet", context_window: 200000 },
24
+ { id: "claude-haiku-4-5", provider: "claude", display_name: "Claude Haiku 4.5", family: "haiku", family_default: true, context_window: 200000 },
25
+ { id: "claude-fable-5", provider: "claude", display_name: "Claude Fable 5", family: "fable", family_default: true, context_window: 200000 },
26
+ { id: "gpt-5.6-sol", provider: "codex", display_name: "GPT-5.6-Sol", description: "Latest frontier agentic coding model.", default: true, priority: 1, default_reasoning_level: "medium", supported_reasoning_levels: [...STANDARD_REASONING_LEVELS, MAX_REASONING_LEVEL, ULTRA_REASONING_LEVEL], service_tiers: [FAST_SERVICE_TIER], context_window: 272000, max_context_window: 272000, use_responses_lite: true, tool_mode: "code_mode_only", multi_agent_version: "v2" },
27
+ { id: "gpt-5.6-terra", provider: "codex", display_name: "GPT-5.6-Terra", description: "Balanced agentic coding model for everyday work.", priority: 2, default_reasoning_level: "medium", supported_reasoning_levels: [...STANDARD_REASONING_LEVELS, MAX_REASONING_LEVEL, ULTRA_REASONING_LEVEL], service_tiers: [FAST_SERVICE_TIER], context_window: 272000, max_context_window: 272000, use_responses_lite: true, tool_mode: "code_mode_only", multi_agent_version: "v2" },
28
+ { id: "gpt-5.6-luna", provider: "codex", display_name: "GPT-5.6-Luna", description: "Fast and affordable agentic coding model.", priority: 3, default_reasoning_level: "medium", supported_reasoning_levels: [...STANDARD_REASONING_LEVELS, MAX_REASONING_LEVEL], service_tiers: [FAST_SERVICE_TIER], context_window: 272000, max_context_window: 272000, use_responses_lite: true, tool_mode: "code_mode_only", multi_agent_version: "v1" },
29
+ { id: "gpt-5.5", provider: "codex", display_name: "GPT-5.5", description: "Frontier model for complex coding, research, and real-world work.", priority: 7, default_reasoning_level: "medium", supported_reasoning_levels: STANDARD_REASONING_LEVELS, service_tiers: [FAST_SERVICE_TIER], context_window: 272000, max_context_window: 272000, use_responses_lite: false, tool_mode: null, multi_agent_version: null },
30
+ { id: "gpt-5.4", provider: "codex", display_name: "GPT-5.4", description: "Strong model for everyday coding.", priority: 16, default_reasoning_level: "medium", supported_reasoning_levels: STANDARD_REASONING_LEVELS, service_tiers: [FAST_SERVICE_TIER], context_window: 272000, max_context_window: 1000000, use_responses_lite: false, tool_mode: null, multi_agent_version: null },
31
+ { id: "gpt-5.4-mini", provider: "codex", display_name: "GPT-5.4-Mini", description: "Small, fast, and cost-efficient model for simpler coding tasks.", priority: 23, default_reasoning_level: "medium", supported_reasoning_levels: STANDARD_REASONING_LEVELS, service_tiers: [], context_window: 272000, max_context_window: 272000, use_responses_lite: false, tool_mode: null, multi_agent_version: null },
32
+ { id: "gpt-5.3-codex-spark", provider: "codex", display_name: "GPT-5.3-Codex-Spark", description: "Ultra-fast coding model.", priority: 26, default_reasoning_level: "high", supported_reasoning_levels: STANDARD_REASONING_LEVELS, service_tiers: [], context_window: 128000, max_context_window: 128000, use_responses_lite: false, tool_mode: null, multi_agent_version: null },
33
+ ];
34
+
35
+ export function codexCatalogEntry(model, index, vendorModel) {
36
+ const generated = {
37
+ slug: model.id,
38
+ display_name: model.display_name || model.id,
39
+ description: `Available through the Impel gateway (${model.id}).`,
40
+ default_reasoning_level: "medium",
41
+ supported_reasoning_levels: STANDARD_REASONING_LEVELS,
42
+ shell_type: "shell_command",
43
+ visibility: "list",
44
+ supported_in_api: true,
45
+ priority: index + 1,
46
+ base_instructions: "You are Codex, an AI coding agent. Follow repository instructions, collaborate with the user, make scoped changes, and verify your work.",
47
+ include_skills_usage_instructions: false,
48
+ supports_reasoning_summaries: true,
49
+ default_reasoning_summary: "none",
50
+ support_verbosity: true,
51
+ default_verbosity: "low",
52
+ apply_patch_tool_type: "freeform",
53
+ web_search_tool_type: "text_and_image",
54
+ truncation_policy: { mode: "tokens", limit: 10000 },
55
+ context_window: model.context_window || 200000,
56
+ max_context_window: model.context_window || 200000,
57
+ effective_context_window_percent: 95,
58
+ experimental_supported_tools: [],
59
+ input_modalities: ["text", "image"],
60
+ supports_parallel_tool_calls: true,
61
+ supports_image_detail_original: true,
62
+ supports_search_tool: true,
63
+ use_responses_lite: false,
64
+ tool_mode: null,
65
+ multi_agent_version: null,
66
+ };
67
+ const entry = vendorModel ? { ...generated, ...vendorModel } : generated;
68
+ return {
69
+ ...entry,
70
+ slug: model.id,
71
+ display_name: model.display_name || entry.display_name || model.id,
72
+ description: model.description || entry.description,
73
+ default_reasoning_level: model.default_reasoning_level || entry.default_reasoning_level,
74
+ supported_reasoning_levels: Array.isArray(model.supported_reasoning_levels)
75
+ ? model.supported_reasoning_levels
76
+ : entry.supported_reasoning_levels,
77
+ service_tiers: Array.isArray(model.service_tiers) ? model.service_tiers : (entry.service_tiers || []),
78
+ visibility: "list",
79
+ supported_in_api: true,
80
+ priority: Number.isInteger(model.priority) ? model.priority : (entry.priority || index + 1),
81
+ context_window: model.context_window || entry.context_window || 200000,
82
+ max_context_window: model.max_context_window || entry.max_context_window || model.context_window || 200000,
83
+ ...(typeof model.use_responses_lite === "boolean" ? { use_responses_lite: model.use_responses_lite } : {}),
84
+ ...(Object.hasOwn(model, "tool_mode") ? { tool_mode: model.tool_mode } : {}),
85
+ ...(Object.hasOwn(model, "multi_agent_version") ? { multi_agent_version: model.multi_agent_version } : {}),
86
+ };
87
+ }
88
+
89
+ /**
90
+ * The managed CLI Codex catalog, projected from the same model registry the
91
+ * desktop app uses so reasoning efforts, service tiers, and tool modes can
92
+ * never drift between the two surfaces. CLI profiles pin this file to keep
93
+ * launches offline; vendor enrichment stays a desktop-only concern.
94
+ */
95
+ export function managedCodexCliCatalog(now = new Date()) {
96
+ return {
97
+ fetched_at: now.toISOString(),
98
+ client_version: `${RUNTIME_BRAND.cli.command}-managed-2`,
99
+ models: FALLBACK_MODELS
100
+ .filter((model) => model.provider === "codex")
101
+ .map((model, index) => codexCatalogEntry(model, index)),
102
+ };
103
+ }
@@ -15,10 +15,8 @@ export async function preparePlatformClis(options = {}, dependencies = {}) {
15
15
  claude: find("claude", environment, platform),
16
16
  codex: find("codex", environment, platform),
17
17
  };
18
- const claudeProfile = (dependencies.ensureClaudeProfile || ensureImpelClaudeProfile)(options.gatewayUrl, options.tenantId);
19
- const codexProfile = (dependencies.ensureCodexProfile || ensureImpelCodexProfile)(options.gatewayUrl, options.tenantId);
20
18
  const missingAfter = Object.entries(binaries).filter(([, binary]) => !binary).map(([tool]) => tool);
21
- return {
19
+ const result = {
22
20
  binaries,
23
21
  missingBefore: [...missingAfter],
24
22
  missingAfter,
@@ -27,6 +25,14 @@ export async function preparePlatformClis(options = {}, dependencies = {}) {
27
25
  installFailure: null,
28
26
  installations: {},
29
27
  installCommands: {},
28
+ profiles: null,
29
+ };
30
+ // Discovery-only probes must not write profiles either.
31
+ if (options.inspectOnly) return result;
32
+ const claudeProfile = (dependencies.ensureClaudeProfile || ensureImpelClaudeProfile)(options.gatewayUrl, options.tenantId);
33
+ const codexProfile = (dependencies.ensureCodexProfile || ensureImpelCodexProfile)(options.gatewayUrl, options.tenantId);
34
+ return {
35
+ ...result,
30
36
  profiles: { claude: claudeProfile.configDir, codex: codexProfile.codexHome },
31
37
  };
32
38
  }
@@ -121,6 +121,7 @@ export async function prepareWindowsClis({
121
121
  gatewayUrl,
122
122
  tenantId,
123
123
  skipInstall = false,
124
+ inspectOnly = false,
124
125
  installTools = ["claude", "codex"],
125
126
  } = {}, dependencies = {}) {
126
127
  const io = {
@@ -149,6 +150,25 @@ export async function prepareWindowsClis({
149
150
  throw new Error("installTools must contain unique supported Windows CLI names");
150
151
  }
151
152
 
153
+ // A pure discovery probe: recovery goals and health checks re-evaluate
154
+ // this repeatedly, so it must never download git, install anything, write
155
+ // profiles, or sync skills — only report what is currently discoverable.
156
+ // This short-circuit MUST stay ahead of the git provisioning below, which
157
+ // performs an unprompted network install when git is absent.
158
+ if (inspectOnly) {
159
+ return {
160
+ binaries: before,
161
+ missingBefore,
162
+ missingAfter: [...missingBefore],
163
+ installAttempted: false,
164
+ installSucceeded: null,
165
+ installFailure: null,
166
+ installations: {},
167
+ installCommands: {},
168
+ profiles: null,
169
+ };
170
+ }
171
+
152
172
  // Both vendor CLIs need `git` to install the skills marketplace, and the
153
173
  // target machines are brand new — provision the Impel-managed MinGit before
154
174
  // any skill sync, on every setup/update pass (idempotent once installed).
@@ -1,56 +0,0 @@
1
- const LEVELS = ["low", "medium", "high", "xhigh"].map((effort) => ({
2
- effort,
3
- description: `${effort} reasoning effort`,
4
- }));
5
-
6
- const MODELS = [
7
- ["gpt-5.6-sol", "GPT-5.6-Sol", 1, "medium", "code_mode_only", "v2"],
8
- ["gpt-5.6-terra", "GPT-5.6-Terra", 2, "medium", "code_mode_only", "v2"],
9
- ["gpt-5.6-luna", "GPT-5.6-Luna", 3, "medium", "code_mode_only", "v1"],
10
- ["gpt-5.5", "GPT-5.5", 7, "medium", null, null],
11
- ["gpt-5.4", "GPT-5.4", 16, "medium", null, null],
12
- ["gpt-5.4-mini", "GPT-5.4-Mini", 23, "medium", null, null],
13
- ["gpt-5.3-codex-spark", "GPT-5.3-Codex-Spark", 26, "high", null, null],
14
- ];
15
-
16
- // Reviewed static projection of the exact pinned Codex 0.146 catalog. Keeping
17
- // it process-local prevents the client from issuing unsupported online model
18
- // refreshes during every managed launch.
19
- export function managedCodexCliCatalog(now = new Date()) {
20
- return {
21
- fetched_at: now.toISOString(),
22
- client_version: "impel-managed-codex-0.146",
23
- models: MODELS.map(([slug, displayName, priority, effort, toolMode, multiAgentVersion]) => ({
24
- slug,
25
- display_name: displayName,
26
- description: `Available through the Impel gateway (${slug}).`,
27
- default_reasoning_level: effort,
28
- supported_reasoning_levels: LEVELS,
29
- service_tiers: [],
30
- shell_type: "shell_command",
31
- visibility: "list",
32
- supported_in_api: true,
33
- priority,
34
- base_instructions: "You are Codex, an AI coding agent. Follow repository instructions and verify your work.",
35
- include_skills_usage_instructions: false,
36
- supports_reasoning_summaries: true,
37
- default_reasoning_summary: "none",
38
- support_verbosity: true,
39
- default_verbosity: "low",
40
- apply_patch_tool_type: "freeform",
41
- web_search_tool_type: "text_and_image",
42
- truncation_policy: { mode: "tokens", limit: 10_000 },
43
- context_window: slug === "gpt-5.3-codex-spark" ? 128_000 : 272_000,
44
- max_context_window: slug === "gpt-5.4" ? 1_000_000 : (slug === "gpt-5.3-codex-spark" ? 128_000 : 272_000),
45
- effective_context_window_percent: 95,
46
- experimental_supported_tools: [],
47
- input_modalities: ["text", "image"],
48
- supports_parallel_tool_calls: true,
49
- supports_image_detail_original: true,
50
- supports_search_tool: true,
51
- use_responses_lite: slug.startsWith("gpt-5.6-"),
52
- tool_mode: toolMode,
53
- multi_agent_version: multiAgentVersion,
54
- })),
55
- };
56
- }