impel-cli 0.20.56 → 0.20.58

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,4 +1,62 @@
1
1
  # Release notes
2
+ ## 0.20.58 — Provision vendor CLIs on managed enrollment
3
+
4
+ - Ensures freshly enrolled managed devices receive the reviewed Claude Code and Codex CLI binaries without requiring a personal PAT or interactive vendor login.
5
+ - Keeps provisioning isolated from personal Claude/Codex profiles and reports missing vendor runtimes for follow-up convergence.
6
+
7
+ ## 0.20.57 — Live tenant model catalogs for `impel codex` (`impel models sync`)
8
+
9
+ - Adds `impel models sync|list`: the isolated `impel codex` Codex picker now
10
+ follows the tenant's live gateway catalog (`GET /v1/models`) instead of
11
+ being frozen at CLI release time. `sync` rewrites the tenant `models.json`
12
+ atomically (with `--stale-only` TTL gating); `list` shows the picker
13
+ contents, whether they came from a gateway sync or the built-in offline
14
+ floor, and how stale they are.
15
+ - Launches never block on the network: `ensureImpelCodexProfile` stays
16
+ synchronous and keeps writing the built-in catalog as the offline floor,
17
+ while an async, never-throwing `syncCodexModelsSafe` layers the gateway
18
+ catalog after it — at launch, from the detached 6h stale-only app refresh,
19
+ from codex session hooks, and from the `impel token` heartbeat Codex calls
20
+ every ~5 minutes. A valid synced catalog survives relaunches
21
+ (stale-beats-broken); a failed sync keeps the last good file.
22
+ - Records catalog provenance in `models-manifest.json`
23
+ (`{schemaVersion, syncedAt, catalogVersion, digest}` plus tenant/gateway/
24
+ experiment bindings): tampered bytes or changed bindings fall back to the
25
+ floor, and `impel doctor` reports CLI catalog staleness per tenant.
26
+ - Cross-app models (hidden experiment) now reach the isolated CLI: with
27
+ `experimental.crossAppModels` enabled, `impel codex` routes through the
28
+ experimental OpenAI-compatible gateway path and projects tenant Claude
29
+ models into the Codex picker — fail-closed on each model's verified
30
+ `client_capabilities.codex`, exactly like the desktop app.
31
+ - Moves the Claude→Codex projection (`foreignClaudeCodexCatalogEntry`) and
32
+ the gateway catalog fetch (`fetchGatewayModels`) into leaf modules shared
33
+ by the CLI and desktop writers (re-exported from `src/apps.js`), so the
34
+ two surfaces can never fork the projection again. Gateway JSON is still
35
+ reconstructed field-by-field, never written through.
36
+ - Bumps `CURRENT_CONFIG_VERSION` to 45 so existing installs take the slow
37
+ rewrite path once.
38
+ - Release mechanics: the released-fixture corpus for 0.20.57 was re-captured
39
+ on the final release tree (after the projection-convergence merge added
40
+ `additional_speed_tiers` to the shared Codex projection) and is
41
+ byte-identical to the earlier capture — vendor model catalogs
42
+ (`models.json`), the only artifacts that projection change touches, are
43
+ excluded from the corpus by design (offline determinism), so no corpus
44
+ bytes moved.
45
+ - Skipped the green requirement on the `Pinned ChatGPT/Codex Store install`
46
+ vendor-contract job because the Microsoft Store now fulfills
47
+ `OpenAI.Codex 26.818.5229.0` while the reviewed pin tracks `26.818.2441.0`
48
+ (upstream Store drift; the pin bump is tracked as its own follow-up). The
49
+ `Pinned Claude MSIX install and replacement` job is required green on the
50
+ release SHA as usual; the release gate is bypassed only via the documented
51
+ `workflow_dispatch` + `skip_release_gate` escape hatch with a written
52
+ reason naming that drift, after manually verifying every other required
53
+ check green on the tagged SHA.
54
+ - Skipped the prerelease soak (testing-protocol §4.2): 0.20.57 ships stable
55
+ without a `next`-channel soak.
56
+ - Local `pnpm test` on the release machine shows two known environmental
57
+ failures (launch.test.js / managed-apps.test.js, caused by this machine's
58
+ MSC-enrolled managed apps); they reproduce on clean `main` and all six CI
59
+ `Node {18,24}` legs run the same suite green.
2
60
 
3
61
  ## 0.20.56 — Enforce reviewed Windows vendor CLIs, follow the Store pin, harden convergence
4
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.56",
3
+ "version": "0.20.58",
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
@@ -11,7 +11,7 @@ import {
11
11
  secureManagedCodexHome,
12
12
  } from "./codexSecurity.js";
13
13
  import { normalizeTenantId } from "./tenants.js";
14
- import { codexCatalogEntry } from "./modelCatalog.js";
14
+ import { codexCatalogEntry, foreignClaudeCodexCatalogEntry } from "./modelCatalog.js";
15
15
  import {
16
16
  IMPEL_CLI_ENTRYPOINT,
17
17
  IMPEL_TASKS_MCP_SERVER_NAME,
@@ -234,7 +234,11 @@ const LEGACY_TRAPPED_CLAUDE_NAME = new RegExp(`^${escapedDisplayPrefix} Claude(
234
234
  // modelCatalog.js (a leaf module) so cliProfiles.js can consume them
235
235
  // without importing this module's launch-command cycle. Re-exported here
236
236
  // for the many existing consumers.
237
- export { FALLBACK_MODELS, managedCodexCliCatalog } from "./modelCatalog.js";
237
+ // Re-exported from their leaf homes so existing importers keep working;
238
+ // modelCatalog.js and gatewayModels.js must never import from apps.js (the
239
+ // leaf constraint behind the 0.20.39 fork regression).
240
+ export { FALLBACK_MODELS, foreignClaudeCodexCatalogEntry, managedCodexCliCatalog } from "./modelCatalog.js";
241
+ export { fetchGatewayModels } from "./gatewayModels.js";
238
242
 
239
243
  export function normalizeAppTarget(value) {
240
244
  if (value == null || value === "all") return ["claude", "chatgpt"];
@@ -841,33 +845,6 @@ export function migrateLegacyClaudeAppSessions(userData, homeDir = os.homedir())
841
845
  return copied;
842
846
  }
843
847
 
844
- export async function fetchGatewayModels(config, fetchImpl = fetch, { allowEmpty = false } = {}) {
845
- const controller = new AbortController();
846
- const timeout = setTimeout(() => controller.abort(), 20000);
847
- try {
848
- const response = await fetchImpl(`${config.gatewayUrl}/v1/models`, {
849
- headers: { authorization: `Bearer ${config.pat}` },
850
- signal: controller.signal,
851
- });
852
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
853
- const payload = await response.json();
854
- if (config.tenantId && payload?.org_id !== config.tenantId) {
855
- throw new Error("gateway model catalog tenant did not match the selected tenant");
856
- }
857
- if (config.productAccess && payload?.product_access !== config.productAccess) {
858
- throw new Error("gateway model catalog product access did not match the live entitlement");
859
- }
860
- if (!Array.isArray(payload?.data)) throw new Error("response has no model data array");
861
- const models = payload.data.filter(isGatewayModel);
862
- if (models.length === 0 && !(allowEmpty && isExplicitNoSeatCatalog(payload))) {
863
- throw new Error("gateway returned no supported models");
864
- }
865
- return { models, source: "gateway", version: payload.version ?? null };
866
- } finally {
867
- clearTimeout(timeout);
868
- }
869
- }
870
-
871
848
  export function installManagedAppFiles({
872
849
  config,
873
850
  targets,
@@ -1555,66 +1532,6 @@ export function projectClaudeDesktopModels(models) {
1555
1532
  });
1556
1533
  }
1557
1534
 
1558
- export function foreignClaudeCodexCatalogEntry(model, index) {
1559
- const capability = model.client_capabilities?.codex;
1560
- const levels = capability?.supported_reasoning_levels;
1561
- const tiers = capability?.service_tiers;
1562
- const modalities = capability?.input_modalities;
1563
- const valid = capability && typeof capability === "object"
1564
- && Number.isInteger(capability.context_window) && capability.context_window > 0
1565
- && Number.isInteger(capability.max_context_window) && capability.max_context_window >= capability.context_window
1566
- && Array.isArray(levels) && levels.every((level) => (
1567
- level && typeof level.effort === "string" && typeof level.description === "string"
1568
- ))
1569
- && Array.isArray(tiers) && tiers.every((tier) => (
1570
- tier && typeof tier.id === "string" && typeof tier.name === "string" && typeof tier.description === "string"
1571
- ))
1572
- && Array.isArray(modalities) && modalities.every((value) => typeof value === "string")
1573
- && ["supports_parallel_tool_calls", "supports_reasoning_summaries", "supports_verbosity", "supports_search_tool", "supports_image_detail_original"]
1574
- .every((key) => typeof capability[key] === "boolean")
1575
- && (capability.default_reasoning_level == null
1576
- || capability.default_reasoning_level === ""
1577
- || levels.some((level) => level.effort === capability.default_reasoning_level))
1578
- && (capability.apply_patch_tool_type == null || typeof capability.apply_patch_tool_type === "string")
1579
- && (capability.web_search_tool_type == null || typeof capability.web_search_tool_type === "string");
1580
- if (!valid) {
1581
- console.warn(`impel: omitting model ${redactSecretText(model.id)} from Impel ChatGPT: missing verified Codex capabilities`);
1582
- return null;
1583
- }
1584
- return {
1585
- slug: model.id,
1586
- display_name: model.display_name || model.id,
1587
- description: model.description || `Claude model available through the Impel gateway (${model.id}).`,
1588
- ...(capability.default_reasoning_level ? { default_reasoning_level: capability.default_reasoning_level } : {}),
1589
- supported_reasoning_levels: levels,
1590
- service_tiers: tiers,
1591
- shell_type: "shell_command",
1592
- visibility: "list",
1593
- supported_in_api: true,
1594
- priority: Number.isInteger(model.priority) ? model.priority : index + 1,
1595
- base_instructions: "You are an AI coding agent. Follow repository instructions, collaborate with the user, make scoped changes, and verify your work.",
1596
- include_skills_usage_instructions: false,
1597
- supports_reasoning_summaries: capability.supports_reasoning_summaries,
1598
- support_verbosity: capability.supports_verbosity,
1599
- ...(capability.supports_reasoning_summaries ? { default_reasoning_summary: "none" } : {}),
1600
- ...(capability.supports_verbosity ? { default_verbosity: "low" } : {}),
1601
- ...(capability.apply_patch_tool_type ? { apply_patch_tool_type: capability.apply_patch_tool_type } : {}),
1602
- ...(capability.web_search_tool_type ? { web_search_tool_type: capability.web_search_tool_type } : {}),
1603
- truncation_policy: { mode: "tokens", limit: 10000 },
1604
- context_window: capability.context_window,
1605
- max_context_window: capability.max_context_window,
1606
- effective_context_window_percent: 95,
1607
- experimental_supported_tools: [],
1608
- input_modalities: modalities,
1609
- supports_parallel_tool_calls: capability.supports_parallel_tool_calls,
1610
- supports_image_detail_original: capability.supports_image_detail_original,
1611
- supports_search_tool: capability.supports_search_tool,
1612
- use_responses_lite: false,
1613
- tool_mode: null,
1614
- multi_agent_version: null,
1615
- };
1616
- }
1617
-
1618
1535
  function readVendorCodexModels(vendorPath) {
1619
1536
  const binary = vendorPath && path.join(vendorPath, "Contents", "Resources", "codex");
1620
1537
  if (!binary || !fs.existsSync(binary)) return new Map();
@@ -3354,21 +3271,6 @@ export function replaceDirectory(destination, staging, options = {}) {
3354
3271
  ));
3355
3272
  }
3356
3273
 
3357
- function isGatewayModel(model) {
3358
- return model && typeof model.id === "string" && (model.provider === "claude" || model.provider === "codex");
3359
- }
3360
-
3361
- function isExplicitNoSeatCatalog(payload) {
3362
- const statuses = payload?.provider_status;
3363
- return Boolean(statuses
3364
- && typeof statuses === "object"
3365
- && !Array.isArray(statuses)
3366
- && ["claude", "codex"].every((provider) => (
3367
- statuses[provider]?.state === "no_seat"
3368
- && statuses[provider]?.routable === false
3369
- )));
3370
- }
3371
-
3372
3274
  function writeAtomic(target, contents, mode) {
3373
3275
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
3374
3276
  if (fs.existsSync(target) && fs.readFileSync(target, "utf8") === contents) {
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 { cmdModels } from "./commands/models.js";
14
15
  import { cmdAgents } from "./commands/agents.js";
15
16
  import { cmdTenant } from "./commands/tenant.js";
16
17
  import { cmdDoctor } from "./commands/doctor.js";
@@ -21,6 +22,7 @@ import { cmdUpdate } from "./commands/update.js";
21
22
  import { cmdNuke } from "./commands/nuke.js";
22
23
  import { cmdExperimental } from "./commands/experimental.js";
23
24
  import { cmdConverge } from "./commands/converge.js";
25
+ import { cmdProvisionVendorClis } from "./commands/provisionVendorClis.js";
24
26
  import { cmdRemote } from "./commands/remote.js";
25
27
  import { cmdNative } from "./commands/native.js";
26
28
  import { ensureMacDeveloperTools } from "./macDeveloperTools.js";
@@ -68,6 +70,7 @@ Account:
68
70
  Diagnostics:
69
71
  impel status Authentication, current tenant, and local readiness
70
72
  impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
73
+ impel models list|sync Inspect or refresh the isolated Codex model catalog
71
74
  impel report --message "<text>" Send a bug report; you approve the exact payload
72
75
 
73
76
  Reset:
@@ -183,6 +186,10 @@ async function dispatch(cmd, rest) {
183
186
  case "skill":
184
187
  return cmdSkills(rest);
185
188
 
189
+ case "models":
190
+ case "model":
191
+ return cmdModels(rest);
192
+
186
193
  case "agents":
187
194
  case "agent":
188
195
  return cmdAgents(rest);
@@ -219,6 +226,12 @@ async function dispatch(cmd, rest) {
219
226
  case "_converge":
220
227
  return cmdConverge(rest);
221
228
 
229
+ // Hidden, unauthenticated vendor-CLI provisioning, called by the managed
230
+ // enrollment bootstrap. A managed device has no PAT, so it cannot reach
231
+ // `impel setup` — and without this nothing ever installs Claude Code.
232
+ case "_provision-vendor-clis":
233
+ return cmdProvisionVendorClis(rest);
234
+
222
235
  // Hidden OS-shell launcher. Setup/update guarantees the app is ready.
223
236
  case "_app-launch": {
224
237
  const [target = "all", ...args] = rest;
@@ -22,6 +22,7 @@ import { renameWithWindowsRetry } from "./windowsFs.js";
22
22
  import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
23
23
  import { RUNTIME_BRAND } from "./runtimeBrand.js";
24
24
  import { managedCodexCliCatalog } from "./modelCatalog.js";
25
+ import { codexModelsManifestPath, syncedCodexCatalogMatches } from "./modelsManifest.js";
25
26
 
26
27
  export const IMPEL_CLI_PROFILES_DIR = path.join(CONFIG_DIR, "cli");
27
28
  export const CODEX_GATEWAY_TOKEN_ENV = `${RUNTIME_BRAND.cli.providerId
@@ -200,14 +201,19 @@ function splitTomlPreamble(text) {
200
201
  return { preamble: text.slice(0, table.index), rest: text.slice(table.index) };
201
202
  }
202
203
 
203
- function codexManagedBlock(gatewayUrl, tenantId) {
204
+ function codexManagedBlock(gatewayUrl, tenantId, { crossAppModels = false } = {}) {
204
205
  const providerId = RUNTIME_BRAND.cli.providerId;
205
206
  const lines = [
206
207
  CODEX_START_MARK,
207
208
  `# Generated for \`${RUNTIME_BRAND.cli.command} codex\`. Other profile settings outside this block are preserved.`,
208
209
  `[model_providers.${providerId}]`,
209
210
  `name = ${JSON.stringify(`${RUNTIME_BRAND.product.displayName} Gateway`)}`,
210
- `base_url = ${JSON.stringify(impelCodexBaseUrl(gatewayUrl))}`,
211
+ // The experimental OpenAI-compatible route can dispatch both provider
212
+ // families (the desktop ChatGPT profile's cross-app path); the default
213
+ // route is the byte-preserving Codex passthrough.
214
+ `base_url = ${JSON.stringify(crossAppModels
215
+ ? `${gatewayUrl}/experimental/openai/v1`
216
+ : impelCodexBaseUrl(gatewayUrl))}`,
211
217
  'wire_api = "responses"',
212
218
  `env_key = ${JSON.stringify(CODEX_GATEWAY_TOKEN_ENV)}`,
213
219
  ];
@@ -233,7 +239,7 @@ function codexManagedBlock(gatewayUrl, tenantId) {
233
239
  return lines.join("\n");
234
240
  }
235
241
 
236
- export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
242
+ export function ensureImpelCodexProfile(gatewayUrl, tenantId, { crossAppModels = false } = {}) {
237
243
  secureAllManagedCodexHomes({ cliRoot: IMPEL_CLI_PROFILES_DIR });
238
244
  const { codexHome, codexCatalog } = tenantCliProfilePaths(tenantId);
239
245
  secureManagedCodexHome(codexHome);
@@ -269,14 +275,30 @@ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
269
275
  : `${nextPreamble.trimEnd()}\n${catalogLine}\n`;
270
276
  nextPreamble = nextPreamble.replace(/^bypass_hook_trust[ \t]*=[ \t]*(?:true|false)[ \t]*\n?/gm, "");
271
277
  const restText = rest.trim();
272
- const next = [nextPreamble.trimEnd(), codexManagedBlock(gatewayUrl, tenantId), restText]
278
+ const next = [nextPreamble.trimEnd(), codexManagedBlock(gatewayUrl, tenantId, { crossAppModels }), restText]
273
279
  .filter(Boolean)
274
280
  .join("\n\n")
275
281
  .concat("\n");
276
282
 
277
283
  writePrivateFile(configPath, hardenManagedCodexToml(next, configPath));
278
- writePrivateFile(codexCatalog, `${JSON.stringify(managedCodexCliCatalog(), null, 2)}\n`);
284
+ // Launch layering (D13), synchronous half: the FALLBACK_MODELS projection
285
+ // is the offline floor. A catalog that models-manifest.json proves is
286
+ // exactly what the last gateway sync wrote for THESE bindings (tenant,
287
+ // gateway, cross-app state) is kept even when its TTL has lapsed —
288
+ // stale-beats-broken — and syncCodexModelsSafe refreshes it afterwards
289
+ // without ever blocking this writer on the network. Anything else (first
290
+ // run, tampered bytes, changed bindings) is reset to the floor, and the
291
+ // stale manifest is removed so the TTL machinery sees the floor as stale.
292
+ const keepSyncedCatalog = syncedCodexCatalogMatches(codexHome, codexCatalog, {
293
+ tenantId: normalizeTenantId(tenantId),
294
+ gatewayUrl,
295
+ crossAppModels,
296
+ });
297
+ if (!keepSyncedCatalog) {
298
+ writePrivateFile(codexCatalog, `${JSON.stringify(managedCodexCliCatalog(), null, 2)}\n`);
299
+ fs.rmSync(codexModelsManifestPath(codexHome), { force: true });
300
+ }
279
301
  if (RUNTIME_BRAND.features.sessions) ensureCodexSessionHooks(codexHome, tenantId, "codex_cli");
280
302
  secureManagedCodexHome(codexHome);
281
- return { codexHome, configPath, catalogPath: codexCatalog };
303
+ return { codexHome, configPath, catalogPath: codexCatalog, syncedCatalog: keepSyncedCatalog };
282
304
  }
@@ -45,6 +45,7 @@ import {
45
45
  import { resolveSkillCredential } from "../skillBundle.js";
46
46
  import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
47
47
  import { syncAgentProfilesSafe } from "../agents.js";
48
+ import { syncCodexModelsSafe } from "../modelSync.js";
48
49
  import { secureManagedCodexHome } from "../codexSecurity.js";
49
50
  import { impelCliInvocation } from "../selfInvocation.js";
50
51
  import { createProgressLogger, withProgress } from "../progress.js";
@@ -749,6 +750,28 @@ export async function cmdWindowsApps(argv, overrides = {}) {
749
750
 
750
751
  try {
751
752
  if (action === "open" && RUNTIME_BRAND.cli.packageName === "impel-cli") maybePrintUpdateNotice();
753
+ // Windows mirror of refreshApps' CLI-catalog leg: the detached
754
+ // `app refresh --stale-only` slot also converges the isolated
755
+ // `impel codex` model catalog (D13). Runs before the desktop manifest
756
+ // fast-path below because the CLI catalog has its own TTL manifest.
757
+ if (action === "refresh") {
758
+ const stored = loadConfig();
759
+ if (stored?.pat) {
760
+ const cliTenantId = flags.tenant
761
+ ? normalizeTenantId(flags.tenant)
762
+ : stored.tenantId || null;
763
+ if (cliTenantId) {
764
+ await (overrides.syncCliModels || syncCodexModelsSafe)({
765
+ gatewayUrl: normalizeGatewayUrl(stored.gatewayUrl || resolveDefaultGateway()),
766
+ credential: tenantCredential(stored.pat, cliTenantId),
767
+ tenantId: cliTenantId,
768
+ crossAppModels: crossAppModelsEnabled(stored),
769
+ staleOnly: Boolean(flags["stale-only"]),
770
+ logger: { warn: () => {} },
771
+ });
772
+ }
773
+ }
774
+ }
752
775
  // Background token-helper refreshes pass --stale-only; honor the manifest
753
776
  // TTL here like the darwin refresh path does, so every vendor token call
754
777
  // does not become a full catalog fetch + profile rewrite (and its child
@@ -1355,11 +1378,31 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {},
1355
1378
  installFiles: installManagedAppFiles,
1356
1379
  syncSkills: syncSkillsSafe,
1357
1380
  syncAgents: syncAgentProfilesSafe,
1381
+ syncCliModels: syncCodexModelsSafe,
1358
1382
  secureCodexHome: secureManagedCodexHome,
1359
1383
  ...overrides,
1360
1384
  };
1361
1385
  const stored = io.loadStoredConfig();
1362
1386
  if (!stored?.pat) return;
1387
+ // The isolated `impel codex` catalog rides the same detached refresh slot
1388
+ // (D13). Independent of the desktop legs below: a CLI-only machine has no
1389
+ // installed app bundles yet still needs its Codex picker converging.
1390
+ // syncCodexModelsSafe skips uninitialized profiles, honors the 6h manifest
1391
+ // TTL under --stale-only, never throws, and keeps the last good catalog on
1392
+ // failure.
1393
+ {
1394
+ const cliTenantId = tenantId ? normalizeTenantId(tenantId) : stored.tenantId || null;
1395
+ if (cliTenantId) {
1396
+ await io.syncCliModels({
1397
+ gatewayUrl: normalizeGatewayUrl(stored.gatewayUrl || resolveDefaultGateway()),
1398
+ credential: tenantCredential(stored.pat, cliTenantId),
1399
+ tenantId: cliTenantId,
1400
+ crossAppModels: crossAppModelsEnabled(stored),
1401
+ staleOnly,
1402
+ logger: { warn: () => {} },
1403
+ });
1404
+ }
1405
+ }
1363
1406
  const requestedTenantId = tenantId
1364
1407
  ? normalizeTenantId(tenantId)
1365
1408
  : stored.tenantId || null;
@@ -173,6 +173,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
173
173
  credential: resolveSkillCredential({ pat: config.pat, tenantId: selected.id }),
174
174
  platform: io.platform,
175
175
  skipInstall: true,
176
+ crossAppModels: config.experimental?.crossAppModels === true,
176
177
  });
177
178
  let prepared = inspected;
178
179
  if (inspected.missingAfter.length && !inspectOnly && !skipClis) {
@@ -206,6 +207,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
206
207
  platform: io.platform,
207
208
  skipInstall: false,
208
209
  installTools,
210
+ crossAppModels: config.experimental?.crossAppModels === true,
209
211
  });
210
212
  }
211
213
  }
@@ -1,11 +1,12 @@
1
1
  import { parseFlags } from "../args.js";
2
- import { loadConfig, normalizeGatewayUrl, resolveDefaultGateway } from "../config.js";
2
+ import { crossAppModelsEnabled, loadConfig, normalizeGatewayUrl, resolveDefaultGateway } from "../config.js";
3
3
  import {
4
4
  DEFAULT_TTFT_BUDGET_MS,
5
5
  DOCTOR_PROVIDERS,
6
6
  probeTenant,
7
7
  recordSucceeded,
8
8
  } from "../doctor.js";
9
+ import { codexCatalogStatus } from "../modelSync.js";
9
10
  import {
10
11
  assertProviderScopes,
11
12
  fetchTenants,
@@ -85,12 +86,43 @@ function doctorGatewayUrl(value) {
85
86
  return parsed.origin;
86
87
  }
87
88
 
89
+ /**
90
+ * Local, offline staleness of the isolated `impel codex` model catalog
91
+ * (models.json + models-manifest.json). Reported beside the live gateway
92
+ * catalog probe so a device stuck on the offline floor or a months-old sync
93
+ * is visible from the same diagnosis surface that proves the gateway works.
94
+ */
95
+ function cliModelCatalogReport(tenantId, { gatewayUrl, crossAppModels }) {
96
+ try {
97
+ const status = codexCatalogStatus(tenantId, { gatewayUrl, crossAppModels });
98
+ return {
99
+ state: status.catalogError ?? (status.source === "floor" ? "floor" : "synced"),
100
+ syncedAt: status.syncedAt,
101
+ catalogVersion: status.manifest?.catalogVersion ?? null,
102
+ fresh: status.fresh,
103
+ models: status.models.length,
104
+ };
105
+ } catch (error) {
106
+ return { state: "error", error: String(error?.message || error), fresh: false, models: 0 };
107
+ }
108
+ }
109
+
110
+ function cliModelCatalogLine(local) {
111
+ if (local.state === "missing") return "not initialized (run `impel codex` once)";
112
+ if (local.state === "floor") return "offline floor (never synced; run `impel models sync`)";
113
+ if (local.state === "synced") {
114
+ return `gateway sync ${local.syncedAt ?? "unknown"} (${local.fresh ? "fresh" : "stale"}, ${local.models} models)`;
115
+ }
116
+ return `${local.state}${local.error ? ` (${local.error})` : ""}`;
117
+ }
118
+
88
119
  function printHuman(report) {
89
120
  for (const tenant of report.tenants) {
90
121
  console.log(`Tenant: ${tenant.tenantId}`);
91
122
  console.log(`Access: ${productAccessLabel(tenant.productAccess)}`);
92
123
  console.log(`Catalog: ${tenant.catalog.status ?? "network error"} (${tenant.catalog.models} ready models, ${tenant.catalog.durationMs ?? "?"}ms)`);
93
124
  if (tenant.catalog.error) console.log(` ERROR ${tenant.catalog.error}`);
125
+ if (tenant.cliModelCatalog) console.log(`CLI models: ${cliModelCatalogLine(tenant.cliModelCatalog)}`);
94
126
  for (const record of tenant.records) {
95
127
  const ok = recordSucceeded(record);
96
128
  const latency = record.ttftMs === null ? "TTFT n/a" : `TTFT ${record.ttftMs}ms, total ${record.totalMs}ms`;
@@ -194,7 +226,7 @@ export async function cmdDoctor(argv) {
194
226
 
195
227
  const tenantReports = [];
196
228
  for (const tenant of tenants) {
197
- tenantReports.push(await probeTenant({
229
+ const tenantReport = await probeTenant({
198
230
  config: doctorConfig,
199
231
  tenantId: tenant.id,
200
232
  productAccess: listing.productAccess,
@@ -203,7 +235,13 @@ export async function cmdDoctor(argv) {
203
235
  timeoutMs,
204
236
  ttftBudgets,
205
237
  strictLatency: Boolean(flags["strict-latency"]),
206
- }));
238
+ });
239
+ // Offline and advisory: catalog staleness never flips the probe verdict.
240
+ tenantReport.cliModelCatalog = cliModelCatalogReport(tenant.id, {
241
+ gatewayUrl,
242
+ crossAppModels: crossAppModelsEnabled(config),
243
+ });
244
+ tenantReports.push(tenantReport);
207
245
  }
208
246
  const report = {
209
247
  generatedAt: new Date().toISOString(),
@@ -20,6 +20,7 @@ import {
20
20
  saveConfig,
21
21
  } from "../config.js";
22
22
  import { impelClaudeBaseUrl, impelCrossAppClaudeBaseUrl } from "../claudeSetup.js";
23
+ import { syncCodexModelsSafe } from "../modelSync.js";
23
24
  import { parentVerbatimRelayAppendix } from "../verbatimRelay.js";
24
25
  import { withGitEnvironment } from "../skills.js";
25
26
  import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.js";
@@ -501,7 +502,7 @@ export async function cmdLaunch(tool, argv) {
501
502
  if (RUNTIME_BRAND.cli.packageName === "impel-cli") maybePrintUpdateNotice();
502
503
 
503
504
  const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
504
- const crossAppModels = tool === "claude" && crossAppModelsEnabled(config);
505
+ const crossAppModels = crossAppModelsEnabled(config);
505
506
  let tenantId;
506
507
  try {
507
508
  // PAT scopes are immutable. Refresh legacy configs once, then use the
@@ -565,7 +566,7 @@ export async function cmdLaunch(tool, argv) {
565
566
  ? `${environment.ANTHROPIC_CUSTOM_HEADERS}\n${versionHeader}`
566
567
  : versionHeader;
567
568
  } else if (tool === "codex") {
568
- const profile = ensureImpelCodexProfile(gatewayUrl, tenantId);
569
+ const profile = ensureImpelCodexProfile(gatewayUrl, tenantId, { crossAppModels });
569
570
  agentProfile = {
570
571
  client: "codex",
571
572
  root: profile.codexHome,
@@ -575,6 +576,19 @@ export async function cmdLaunch(tool, argv) {
575
576
  deleteEnvironmentKeys(environment, CODEX_DIRECT_AUTH_ENV);
576
577
  environment.CODEX_HOME = profile.codexHome;
577
578
  environment[CODEX_GATEWAY_TOKEN_ENV] = gatewayCredential;
579
+ // Launch layering (D13): the synchronous writer above guaranteed a valid
580
+ // catalog (offline floor, or the intact last sync). Refresh it from the
581
+ // gateway at most every six hours; a network/catalog failure keeps the
582
+ // last good file and never blocks this launch. Codex reads
583
+ // model_catalog_json at startup, so a fresh sync lands next launch.
584
+ await syncCodexModelsSafe({
585
+ gatewayUrl,
586
+ credential: gatewayCredential,
587
+ tenantId,
588
+ crossAppModels,
589
+ staleOnly: true,
590
+ logger: agentSyncLogger(nativeArgv),
591
+ });
578
592
  } else {
579
593
  throw new Error(`unsupported CLI launcher: ${tool}`);
580
594
  }