impel-cli 0.20.56 → 0.20.57

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,59 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.57 — Live tenant model catalogs for `impel codex` (`impel models sync`)
4
+
5
+ - Adds `impel models sync|list`: the isolated `impel codex` Codex picker now
6
+ follows the tenant's live gateway catalog (`GET /v1/models`) instead of
7
+ being frozen at CLI release time. `sync` rewrites the tenant `models.json`
8
+ atomically (with `--stale-only` TTL gating); `list` shows the picker
9
+ contents, whether they came from a gateway sync or the built-in offline
10
+ floor, and how stale they are.
11
+ - Launches never block on the network: `ensureImpelCodexProfile` stays
12
+ synchronous and keeps writing the built-in catalog as the offline floor,
13
+ while an async, never-throwing `syncCodexModelsSafe` layers the gateway
14
+ catalog after it — at launch, from the detached 6h stale-only app refresh,
15
+ from codex session hooks, and from the `impel token` heartbeat Codex calls
16
+ every ~5 minutes. A valid synced catalog survives relaunches
17
+ (stale-beats-broken); a failed sync keeps the last good file.
18
+ - Records catalog provenance in `models-manifest.json`
19
+ (`{schemaVersion, syncedAt, catalogVersion, digest}` plus tenant/gateway/
20
+ experiment bindings): tampered bytes or changed bindings fall back to the
21
+ floor, and `impel doctor` reports CLI catalog staleness per tenant.
22
+ - Cross-app models (hidden experiment) now reach the isolated CLI: with
23
+ `experimental.crossAppModels` enabled, `impel codex` routes through the
24
+ experimental OpenAI-compatible gateway path and projects tenant Claude
25
+ models into the Codex picker — fail-closed on each model's verified
26
+ `client_capabilities.codex`, exactly like the desktop app.
27
+ - Moves the Claude→Codex projection (`foreignClaudeCodexCatalogEntry`) and
28
+ the gateway catalog fetch (`fetchGatewayModels`) into leaf modules shared
29
+ by the CLI and desktop writers (re-exported from `src/apps.js`), so the
30
+ two surfaces can never fork the projection again. Gateway JSON is still
31
+ reconstructed field-by-field, never written through.
32
+ - Bumps `CURRENT_CONFIG_VERSION` to 45 so existing installs take the slow
33
+ rewrite path once.
34
+ - Release mechanics: the released-fixture corpus for 0.20.57 was re-captured
35
+ on the final release tree (after the projection-convergence merge added
36
+ `additional_speed_tiers` to the shared Codex projection) and is
37
+ byte-identical to the earlier capture — vendor model catalogs
38
+ (`models.json`), the only artifacts that projection change touches, are
39
+ excluded from the corpus by design (offline determinism), so no corpus
40
+ bytes moved.
41
+ - Skipped the green requirement on the `Pinned ChatGPT/Codex Store install`
42
+ vendor-contract job because the Microsoft Store now fulfills
43
+ `OpenAI.Codex 26.818.5229.0` while the reviewed pin tracks `26.818.2441.0`
44
+ (upstream Store drift; the pin bump is tracked as its own follow-up). The
45
+ `Pinned Claude MSIX install and replacement` job is required green on the
46
+ release SHA as usual; the release gate is bypassed only via the documented
47
+ `workflow_dispatch` + `skip_release_gate` escape hatch with a written
48
+ reason naming that drift, after manually verifying every other required
49
+ check green on the tagged SHA.
50
+ - Skipped the prerelease soak (testing-protocol §4.2): 0.20.57 ships stable
51
+ without a `next`-channel soak.
52
+ - Local `pnpm test` on the release machine shows two known environmental
53
+ failures (launch.test.js / managed-apps.test.js, caused by this machine's
54
+ MSC-enrolled managed apps); they reproduce on clean `main` and all six CI
55
+ `Node {18,24}` legs run the same suite green.
56
+
3
57
  ## 0.20.56 — Enforce reviewed Windows vendor CLIs, follow the Store pin, harden convergence
4
58
 
5
59
  - Bumps the Windows ChatGPT Store pin to `26.818.2441.0` (embedded Codex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.56",
3
+ "version": "0.20.57",
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";
@@ -68,6 +69,7 @@ Account:
68
69
  Diagnostics:
69
70
  impel status Authentication, current tenant, and local readiness
70
71
  impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
72
+ impel models list|sync Inspect or refresh the isolated Codex model catalog
71
73
  impel report --message "<text>" Send a bug report; you approve the exact payload
72
74
 
73
75
  Reset:
@@ -183,6 +185,10 @@ async function dispatch(cmd, rest) {
183
185
  case "skill":
184
186
  return cmdSkills(rest);
185
187
 
188
+ case "models":
189
+ case "model":
190
+ return cmdModels(rest);
191
+
186
192
  case "agents":
187
193
  case "agent":
188
194
  return cmdAgents(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
  }