impel-cli 0.20.55 → 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.
@@ -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,10 +20,11 @@ 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";
26
- import { maybePrintUpdateNotice } from "../updates.js";
27
+ import { installedVersion, maybePrintUpdateNotice } from "../updates.js";
27
28
  import { nativeSpawnInvocation } from "../nativeProcess.js";
28
29
  import { IMPEL_NATIVE_BENCHMARK_ENV } from "../selfInvocation.js";
29
30
  import { resolveReviewedVendorCliBinary } from "../vendorCliBinaries.js";
@@ -392,7 +393,13 @@ async function assertLiveProviderReadiness({ config, gatewayUrl, credential, ten
392
393
  if (!readiness.reachable) {
393
394
  throw new Error(`could not verify live ${label} availability for tenant "${tenantId}" (${readiness.error || "gateway unreachable"})`);
394
395
  }
395
- if (readiness.rejected) throw new Error(`gateway credential was rejected (HTTP ${readiness.status})`);
396
+ if (readiness.rejected) {
397
+ throw new Error(
398
+ `gateway credential was rejected (HTTP ${readiness.status}). `
399
+ + `Your access to tenant "${tenantId}" may have changed; rerun \`${RUNTIME_BRAND.cli.command} setup\` `
400
+ + "to refresh it, or ask a workspace administrator to confirm your gateway seat.",
401
+ );
402
+ }
396
403
  if (!readiness.healthy) {
397
404
  throw new Error(`could not verify live ${label} availability for tenant "${tenantId}" (${readiness.error || `HTTP ${readiness.status}`})`);
398
405
  }
@@ -408,9 +415,13 @@ function runNativeCli(tool, argv, environment) {
408
415
  const binary = resolveReviewedVendorCliBinary(tool, environment);
409
416
  if (!binary) {
410
417
  const version = PINNED_VENDOR_CLI_VERSIONS[tool];
418
+ const repair = process.platform === "win32"
419
+ ? `${RUNTIME_BRAND.cli.command}.cmd update`
420
+ : `${RUNTIME_BRAND.cli.command} update`;
411
421
  console.error(
412
422
  `impel ${tool}: reviewed ${tool === "claude" ? "Claude Code" : "Codex"} CLI `
413
- + `v${version} is not installed. Run \`impel update\` to repair it.`,
423
+ + `v${version} is not installed (an unreviewed or auto-updated build is not launched `
424
+ + `against the gateway). Run \`${repair}\` to repair it.`,
414
425
  );
415
426
  return Promise.resolve(127);
416
427
  }
@@ -491,7 +502,7 @@ export async function cmdLaunch(tool, argv) {
491
502
  if (RUNTIME_BRAND.cli.packageName === "impel-cli") maybePrintUpdateNotice();
492
503
 
493
504
  const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
494
- const crossAppModels = tool === "claude" && crossAppModelsEnabled(config);
505
+ const crossAppModels = crossAppModelsEnabled(config);
495
506
  let tenantId;
496
507
  try {
497
508
  // PAT scopes are immutable. Refresh legacy configs once, then use the
@@ -547,8 +558,15 @@ export async function cmdLaunch(tool, argv) {
547
558
  // Impel PATs are bearer tokens, so keep the token process-scoped instead of
548
559
  // writing it into Claude's isolated profile.
549
560
  environment.ANTHROPIC_AUTH_TOKEN = gatewayCredential;
561
+ // Version attribution for gateway logs: a 403 in production is currently
562
+ // impossible to correlate to a CLI/vendor build. Appended so a user's own
563
+ // custom headers survive.
564
+ const versionHeader = `X-Impel-Cli-Version: ${RUNTIME_BRAND.cli.packageName}/${installedVersion() || "unknown"}`;
565
+ environment.ANTHROPIC_CUSTOM_HEADERS = environment.ANTHROPIC_CUSTOM_HEADERS
566
+ ? `${environment.ANTHROPIC_CUSTOM_HEADERS}\n${versionHeader}`
567
+ : versionHeader;
550
568
  } else if (tool === "codex") {
551
- const profile = ensureImpelCodexProfile(gatewayUrl, tenantId);
569
+ const profile = ensureImpelCodexProfile(gatewayUrl, tenantId, { crossAppModels });
552
570
  agentProfile = {
553
571
  client: "codex",
554
572
  root: profile.codexHome,
@@ -558,6 +576,19 @@ export async function cmdLaunch(tool, argv) {
558
576
  deleteEnvironmentKeys(environment, CODEX_DIRECT_AUTH_ENV);
559
577
  environment.CODEX_HOME = profile.codexHome;
560
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
+ });
561
592
  } else {
562
593
  throw new Error(`unsupported CLI launcher: ${tool}`);
563
594
  }
@@ -0,0 +1,160 @@
1
+ // `impel models sync|list` — the explicit surface over the CLI Codex model
2
+ // catalog layering (D13). `sync` fetches the tenant's gateway catalog and
3
+ // rewrites the isolated `impel codex` models.json (with provenance in
4
+ // models-manifest.json); `list` is near-free diagnostics over the same files:
5
+ // what the picker will show next launch, where it came from (gateway sync or
6
+ // the offline floor), and how stale it is. Launches never depend on this
7
+ // command — the same sync runs launch-layered, TTL-gated, and from the token
8
+ // heartbeat; this is the direct repair/inspection path.
9
+
10
+ import { parseFlags } from "../args.js";
11
+ import {
12
+ crossAppModelsEnabled,
13
+ loadConfig,
14
+ normalizeGatewayUrl,
15
+ redactSecretText,
16
+ resolveDefaultGateway,
17
+ } from "../config.js";
18
+ import { codexCatalogStatus, syncCodexModels } from "../modelSync.js";
19
+ import { MODEL_SYNC_TTL_MS } from "../modelsManifest.js";
20
+ import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
21
+ import { RUNTIME_BRAND } from "../runtimeBrand.js";
22
+
23
+ const HELP = `impel models - inspect or refresh the isolated Codex model catalog
24
+
25
+ Usage:
26
+ impel models list [--tenant <org>] [--json]
27
+ impel models sync [--tenant <org>] [--stale-only]
28
+
29
+ list shows the catalog \`impel codex\` will load next launch: each model, and
30
+ whether the file is the last gateway sync (with its age) or the built-in
31
+ offline floor. sync fetches the tenant's live gateway catalog and rewrites the
32
+ catalog atomically; --stale-only exits without fetching while the last sync is
33
+ inside its ${MODEL_SYNC_TTL_MS / (60 * 60 * 1000)}h window. Launches never block on this: they layer the same
34
+ sync after the offline floor write and keep the last good catalog on failure.
35
+ `;
36
+
37
+ function formatAge(ageMs) {
38
+ const minutes = Math.floor(ageMs / 60_000);
39
+ if (minutes < 1) return "just now";
40
+ if (minutes < 60) return `${minutes}m ago`;
41
+ const hours = Math.floor(minutes / 60);
42
+ if (hours < 48) return `${hours}h ago`;
43
+ return `${Math.floor(hours / 24)}d ago`;
44
+ }
45
+
46
+ export function describeCatalogStatus(status) {
47
+ if (status.catalogError === "missing") {
48
+ return `not initialized (run \`${RUNTIME_BRAND.cli.command} setup\` or \`${RUNTIME_BRAND.cli.command} codex\` first)`;
49
+ }
50
+ if (status.catalogError) return "unreadable (rerun the launcher to regenerate it)";
51
+ if (status.source === "floor") {
52
+ return `offline floor (never synced; run \`${RUNTIME_BRAND.cli.command} models sync\`)`;
53
+ }
54
+ const age = status.ageMs === null ? "unknown age" : formatAge(status.ageMs);
55
+ const version = status.manifest?.catalogVersion == null ? "" : `, catalog v${status.manifest.catalogVersion}`;
56
+ return `gateway sync ${age}${version}, ${status.fresh ? "fresh" : "stale"}`;
57
+ }
58
+
59
+ export async function cmdModels(argv, overrides = {}) {
60
+ const [action, ...rest] = argv;
61
+ if (action === undefined || ["help", "--help", "-h"].includes(action)) {
62
+ console.log(HELP);
63
+ return;
64
+ }
65
+ if (!["sync", "list"].includes(action)) {
66
+ console.error(`impel models: unknown action "${action}". Use \`sync\` or \`list\`.`);
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+ const { flags, positionals } = parseFlags(rest, {
71
+ tenant: { type: "string" },
72
+ "stale-only": { type: "boolean" },
73
+ json: { type: "boolean" },
74
+ });
75
+ if (positionals.length > 0) {
76
+ console.error(`impel models: unexpected argument "${positionals[0]}"`);
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+
81
+ const io = {
82
+ loadConfig,
83
+ ensureTenantSelection,
84
+ syncModels: syncCodexModels,
85
+ catalogStatus: codexCatalogStatus,
86
+ log: (message) => console.log(message),
87
+ ...overrides,
88
+ };
89
+ const config = io.loadConfig();
90
+ if (!config?.pat) {
91
+ console.error(`impel models: not authenticated. Run \`${RUNTIME_BRAND.cli.command} setup\` (or \`${RUNTIME_BRAND.cli.command} auth\`) first.`);
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+
96
+ try {
97
+ const tenantId = flags.tenant
98
+ ? normalizeTenantId(flags.tenant)
99
+ : (await io.ensureTenantSelection(config)).tenantId;
100
+ const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
101
+ const crossAppModels = crossAppModelsEnabled(config);
102
+
103
+ if (action === "sync") {
104
+ const result = await io.syncModels({
105
+ gatewayUrl,
106
+ credential: tenantCredential(config.pat, tenantId),
107
+ tenantId,
108
+ crossAppModels,
109
+ staleOnly: Boolean(flags["stale-only"]),
110
+ });
111
+ if (result.skipped && result.reason === "uninitialized") {
112
+ console.error(
113
+ `impel models: the isolated Codex profile for tenant "${tenantId}" is not initialized. `
114
+ + `Run \`${RUNTIME_BRAND.cli.command} setup\` (or \`${RUNTIME_BRAND.cli.command} codex\`) first.`,
115
+ );
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+ if (result.skipped) {
120
+ io.log(`Models: catalog for tenant ${tenantId} is fresh; nothing to sync.`);
121
+ return;
122
+ }
123
+ io.log(
124
+ `Models: synced ${result.models} model${result.models === 1 ? "" : "s"} for tenant ${tenantId}`
125
+ + `${result.catalogVersion == null ? "" : ` (catalog v${result.catalogVersion})`}. `
126
+ + "The picker updates on the next launch.",
127
+ );
128
+ return;
129
+ }
130
+
131
+ const status = io.catalogStatus(tenantId, { gatewayUrl, crossAppModels });
132
+ if (flags.json) {
133
+ io.log(JSON.stringify({
134
+ tenantId: status.tenantId,
135
+ catalogPath: status.catalogPath,
136
+ source: status.catalogError ? null : status.source,
137
+ catalogError: status.catalogError,
138
+ syncedAt: status.syncedAt,
139
+ catalogVersion: status.manifest?.catalogVersion ?? null,
140
+ fresh: status.fresh,
141
+ crossAppModels,
142
+ models: status.models.map((model) => model.slug),
143
+ }, null, 2));
144
+ return;
145
+ }
146
+ io.log(`Tenant: ${tenantId}`);
147
+ io.log(`Catalog: ${status.catalogPath}`);
148
+ io.log(`Status: ${describeCatalogStatus(status)}`);
149
+ if (crossAppModels) io.log("Experiments: cross-app models enabled");
150
+ if (status.models.length > 0) {
151
+ io.log(`Models (${status.models.length}):`);
152
+ for (const model of status.models) {
153
+ io.log(` ${model.slug}${model.display_name && model.display_name !== model.slug ? ` — ${model.display_name}` : ""}`);
154
+ }
155
+ }
156
+ } catch (error) {
157
+ console.error(`impel models: ${redactSecretText(error?.message || error)}`);
158
+ process.exitCode = 1;
159
+ }
160
+ }
@@ -14,9 +14,17 @@ import {
14
14
  sessionOutboxStatus,
15
15
  touchSessionFlushLock,
16
16
  } from "../sessionCollector.js";
17
- import { loadConfig, redactSecretText } from "../config.js";
17
+ import {
18
+ crossAppModelsEnabled,
19
+ loadConfig,
20
+ normalizeGatewayUrl,
21
+ redactSecretText,
22
+ resolveDefaultGateway,
23
+ } from "../config.js";
24
+ import { tenantCliProfilePaths } from "../cliProfiles.js";
25
+ import { cliCodexCatalogStale } from "../modelSync.js";
18
26
  import { impelCliInvocation } from "../selfInvocation.js";
19
- import { spawnDetachedAppRefresh } from "../updates.js";
27
+ import { spawnDetachedAppRefresh, spawnDetachedModelSync } from "../updates.js";
20
28
  import { brandedEnvironmentName, RUNTIME_BRAND } from "../runtimeBrand.js";
21
29
 
22
30
  const MANAGED_SESSION_HOOK_FLAG = `${RUNTIME_BRAND.cli.command}-managed-session-hook-v1`;
@@ -71,6 +79,45 @@ export function maybeRepairManagedApps(tenantId, {
71
79
  return true;
72
80
  }
73
81
 
82
+ /**
83
+ * The model-catalog half of the token-helper heartbeat: when the isolated CLI
84
+ * Codex catalog for this tenant exists but is past its sync TTL (or its
85
+ * manifest no longer matches the file), kick one detached
86
+ * `models sync --stale-only`. Same shape as maybeRepairManagedApps — the
87
+ * vendor apps and Codex's auth command invoke `impel token` every ~5 minutes,
88
+ * so this is the channel that keeps catalogs converging on machines where
89
+ * nobody relaunches. The heartbeat lock stops repeated token calls from
90
+ * stacking sync children while the gateway is unreachable.
91
+ */
92
+ export function maybeSyncCliModels(tenantId, {
93
+ config = null,
94
+ catalogStale = cliCodexCatalogStale,
95
+ lockIsFresh = sessionFlushLockIsFresh,
96
+ touchLock = touchSessionFlushLock,
97
+ spawnModelSync = spawnDetachedModelSync,
98
+ } = {}) {
99
+ if (!tenantId) return false;
100
+ let stored = config;
101
+ if (stored === null) {
102
+ try {
103
+ stored = loadConfig();
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+ if (!stored?.pat) return false;
109
+ const gatewayUrl = normalizeGatewayUrl(stored.gatewayUrl || resolveDefaultGateway());
110
+ if (!catalogStale(tenantId, {
111
+ gatewayUrl,
112
+ crossAppModels: crossAppModelsEnabled(stored),
113
+ })) return false;
114
+ const lock = path.join(tenantCliProfilePaths(tenantId).codexHome, "models-sync-heartbeat");
115
+ if (lockIsFresh(lock, 60_000)) return false;
116
+ touchLock(lock);
117
+ spawnModelSync(tenantId);
118
+ return true;
119
+ }
120
+
74
121
  /**
75
122
  * The codex session-hook entry point: same repair, scoped to ChatGPT/Codex
76
123
  * drift exactly as before the helper was generalized (the codex hook fires on
@@ -127,7 +174,13 @@ export async function cmdSessions(argv) {
127
174
  config,
128
175
  flush: false,
129
176
  });
130
- if (flags.provider === "codex") maybeRepairManagedCodexApp(flags.tenant);
177
+ if (flags.provider === "codex") {
178
+ maybeRepairManagedCodexApp(flags.tenant);
179
+ // Same guaranteed-to-run channel, second artifact: keep the isolated
180
+ // CLI Codex model catalog converging from codex session events too
181
+ // (TTL + heartbeat-locked, detached `models sync --stale-only`).
182
+ maybeSyncCliModels(flags.tenant);
183
+ }
131
184
  // The Claude mirror of the codex repair above: Claude Desktop rewrites
132
185
  // its co-owned profile files too, and its session hooks are the only
133
186
  // Impel code guaranteed to still run afterward.
@@ -344,6 +344,7 @@ export async function cmdSetup(argv, overrides = {}) {
344
344
  tenantId: selected.id,
345
345
  platform: io.platform,
346
346
  skipInstall: true,
347
+ crossAppModels: config.experimental?.crossAppModels === true,
347
348
  });
348
349
  let prepared = inspected;
349
350
  if (inspected.missingAfter.length && !inspectOnly && !flags["skip-clis"]) {
@@ -376,6 +377,7 @@ export async function cmdSetup(argv, overrides = {}) {
376
377
  platform: io.platform,
377
378
  skipInstall: false,
378
379
  installTools,
380
+ crossAppModels: config.experimental?.crossAppModels === true,
379
381
  });
380
382
  }
381
383
  }
@@ -1,7 +1,7 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { parseFlags } from "../args.js";
3
3
  import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
4
- import { maybeRepairManagedApps } from "./sessions.js";
4
+ import { maybeRepairManagedApps, maybeSyncCliModels } from "./sessions.js";
5
5
  import { RUNTIME_BRAND } from "../runtimeBrand.js";
6
6
 
7
7
  // This is the `apiKeyHelper` / auth-command contract: stdout (and only
@@ -12,6 +12,7 @@ export async function cmdToken(argv = [], overrides = {}) {
12
12
  loadConfig,
13
13
  ensureTenantSelection,
14
14
  repairManagedApps: maybeRepairManagedApps,
15
+ syncCliModels: maybeSyncCliModels,
15
16
  ...overrides,
16
17
  };
17
18
  const { flags } = parseFlags(argv, { tenant: { type: "string" } });
@@ -39,6 +40,15 @@ export async function cmdToken(argv = [], overrides = {}) {
39
40
  } catch {
40
41
  // Opportunistic only; the token contract is already fulfilled.
41
42
  }
43
+ // Same heartbeat, second surface: Codex invokes this helper every ~5
44
+ // minutes, so it is also where a stale isolated-CLI model catalog gets
45
+ // its detached `models sync --stale-only` (D13). Identical contract:
46
+ // after the token bytes, never stdout, never the exit code.
47
+ try {
48
+ io.syncCliModels(tenantId, { config });
49
+ } catch {
50
+ // Opportunistic only; the token contract is already fulfilled.
51
+ }
42
52
  } catch (error) {
43
53
  process.stderr.write(`${RUNTIME_BRAND.cli.command}: ${error.message}\n`);
44
54
  process.exitCode = 1;
@@ -11,6 +11,10 @@ import { nativeCommandInvocation } from "../nativeProcess.js";
11
11
  import { withProgress } from "../progress.js";
12
12
  import { runInstallRecovery } from "../installRecovery/engine.js";
13
13
  import { reportInstallFailure } from "../autoReport.js";
14
+ import {
15
+ convergenceSummaryDiagnostics,
16
+ readRecentConvergenceSummary,
17
+ } from "../convergenceSummary.js";
14
18
  import { brandedEnvironmentName, brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
15
19
  import { IMPEL_CLI_ENTRYPOINT } from "../selfInvocation.js";
16
20
  import {
@@ -512,12 +516,11 @@ export async function cmdUpdate(argv, overrides = {}) {
512
516
  console.error("impel update: tenant convergence failed; rerun `impel update` after addressing the reported issue.");
513
517
  // The child is `impel _converge`, which files no report of its own — and
514
518
  // this parent only sees its exit status, because the child inherits stdio
515
- // so its progress reaches the terminal live. So this report carries what
516
- // the parent does know: that it was an update cascade, and which flags
517
- // shaped the run it failed under. Which tenants failed is visible on the
518
- // user's screen but not in the report; closing that gap needs a channel
519
- // between the two processes, which R2 (one report per invocation) says must
520
- // not simply be a second report from the child.
519
+ // so its progress reaches the terminal live. The child does leave a
520
+ // machine-readable summary on disk, though, so the single report R2
521
+ // allows can carry which tenants failed and why instead of only the
522
+ // flags that shaped the run.
523
+ const summary = (io.readConvergenceSummary || readRecentConvergenceSummary)();
521
524
  await reportUpdateFailure({
522
525
  scope: "shared",
523
526
  platform: io.platform,
@@ -528,6 +531,7 @@ export async function cmdUpdate(argv, overrides = {}) {
528
531
  skipApps: String(Boolean(flags["skip-apps"])),
529
532
  skipClis: String(Boolean(flags["skip-clis"])),
530
533
  cascadedEntrypoint: String(Boolean(cascadeEntrypoint)),
534
+ ...convergenceSummaryDiagnostics(summary),
531
535
  },
532
536
  });
533
537
  process.exitCode = 1;
@@ -0,0 +1,84 @@
1
+ // The `impel update` parent spawns `impel _converge` with inherited stdio and
2
+ // only sees its exit status, and R2 allows one report per invocation — so the
3
+ // auto-filed "Tenant convergence failed" report has carried no tenant detail
4
+ // at all. The child instead leaves a machine-readable summary on disk that the
5
+ // parent folds into the diagnostics of the report it was already filing.
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ import { CONFIG_DIR, redactSecretText } from "./config.js";
11
+ import { renameWithWindowsRetry } from "./windowsFs.js";
12
+
13
+ export const CONVERGENCE_SUMMARY_PATH = path.join(CONFIG_DIR, "reports", ".last-convergence.json");
14
+
15
+ // Wide enough to survive install-recovery sessions between the child's write
16
+ // and the parent's read; narrow enough that yesterday's failure never
17
+ // masquerades as today's.
18
+ export const CONVERGENCE_SUMMARY_FRESHNESS_MS = 15 * 60 * 1_000;
19
+
20
+ const MAX_FAILED_TENANTS = 12;
21
+ const MAX_ERRORS_PER_TENANT = 4;
22
+ const MAX_ERROR_LENGTH = 300;
23
+
24
+ /** Best-effort: losing the summary only costs report detail, never the run. */
25
+ export function writeConvergenceSummary({ passed, sharedFailure = null, failedTenants = [] }, {
26
+ summaryPath = CONVERGENCE_SUMMARY_PATH,
27
+ now = Date.now(),
28
+ } = {}) {
29
+ try {
30
+ const summary = {
31
+ writtenAt: new Date(now).toISOString(),
32
+ passed: Boolean(passed),
33
+ sharedFailure: sharedFailure ? redactSecretText(String(sharedFailure)).slice(0, 2_000) : null,
34
+ failedTenants: failedTenants.slice(0, MAX_FAILED_TENANTS).map((tenant) => ({
35
+ tenantId: String(tenant.tenantId || "").slice(0, 128),
36
+ errors: (tenant.errors || [])
37
+ .slice(0, MAX_ERRORS_PER_TENANT)
38
+ .map((error) => redactSecretText(String(error)).slice(0, MAX_ERROR_LENGTH)),
39
+ })),
40
+ };
41
+ fs.mkdirSync(path.dirname(summaryPath), { recursive: true, mode: 0o700 });
42
+ const temporaryPath = `${summaryPath}.tmp-${process.pid}`;
43
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(summary)}\n`, { mode: 0o600 });
44
+ renameWithWindowsRetry(temporaryPath, summaryPath);
45
+ } catch {
46
+ // Deliberately silent: the summary is diagnostics for a report about a
47
+ // failure that has already been announced on the user's terminal.
48
+ }
49
+ }
50
+
51
+ /** Returns the parsed summary only while it is plausibly from this cascade. */
52
+ export function readRecentConvergenceSummary({
53
+ summaryPath = CONVERGENCE_SUMMARY_PATH,
54
+ now = Date.now(),
55
+ } = {}) {
56
+ try {
57
+ const parsed = JSON.parse(fs.readFileSync(summaryPath, "utf8"));
58
+ const writtenAt = Date.parse(parsed?.writtenAt || "");
59
+ if (!Number.isFinite(writtenAt)) return null;
60
+ if (writtenAt > now + 60_000 || now - writtenAt > CONVERGENCE_SUMMARY_FRESHNESS_MS) return null;
61
+ if (typeof parsed.passed !== "boolean" || !Array.isArray(parsed.failedTenants)) return null;
62
+ return parsed;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /** Flatten a summary into the bounded string map the report envelope accepts. */
69
+ export function convergenceSummaryDiagnostics(summary) {
70
+ if (!summary || summary.passed) return {};
71
+ const failed = summary.failedTenants.filter((tenant) => tenant?.tenantId);
72
+ return {
73
+ ...(summary.sharedFailure ? { sharedFailure: summary.sharedFailure } : {}),
74
+ ...(failed.length
75
+ ? {
76
+ failedTenantIds: failed.map((tenant) => tenant.tenantId).join(","),
77
+ failedTenantErrors: failed
78
+ .map((tenant) => `${tenant.tenantId}: ${(tenant.errors || []).join("; ")}`)
79
+ .join(" | ")
80
+ .slice(0, 1_800),
81
+ }
82
+ : {}),
83
+ };
84
+ }
@@ -12,6 +12,7 @@ const ALIASES = Object.freeze({
12
12
  tenants: "tenant",
13
13
  org: "tenant",
14
14
  skill: "skills",
15
+ model: "models",
15
16
  agent: "agents",
16
17
  upgrade: "update",
17
18
  on: "use",
@@ -39,6 +40,7 @@ function help(version) {
39
40
  if (enabled.has("mcp")) lines.push(` ${command} mcp Run the authenticated MCP transport`);
40
41
  if (enabled.has("sessions")) lines.push(` ${command} sessions ... Run managed session lifecycle hooks`);
41
42
  if (enabled.has("skills")) lines.push(` ${command} skills sync [...] Sync gateway skills into managed clients`);
43
+ if (enabled.has("models")) lines.push(` ${command} models list|sync Inspect or refresh the isolated Codex model catalog`);
42
44
  if (enabled.has("agents")) lines.push(` ${command} agents sync [...] Sync tenant agents into managed clients`);
43
45
  if (enabled.has("status")) lines.push(` ${command} status Show local readiness`);
44
46
  if (enabled.has("doctor")) lines.push(` ${command} doctor [...] Run gateway and provider diagnostics`);
@@ -0,0 +1,58 @@
1
+ // The authenticated tenant model catalog fetch (GET /v1/models), shared by
2
+ // the desktop app writers in apps.js and the CLI catalog sync in
3
+ // modelSync.js. Leaf module (no imports): the model-catalog side of the CLI
4
+ // must be loadable without pulling in the desktop bundle machinery, so this
5
+ // cannot live in apps.js. apps.js re-exports it for existing importers.
6
+
7
+ function isGatewayModel(model) {
8
+ return model && typeof model.id === "string" && (model.provider === "claude" || model.provider === "codex");
9
+ }
10
+
11
+ /**
12
+ * A tenant whose subscription genuinely has no Claude/Codex seats returns an
13
+ * explicitly marked empty catalog. Only lifecycle reconciliation may treat
14
+ * that as valid emptiness (allowEmpty) — every other caller keeps failing
15
+ * closed so a gateway outage can never masquerade as "no models".
16
+ */
17
+ function isExplicitNoSeatCatalog(payload) {
18
+ const statuses = payload?.provider_status;
19
+ return Boolean(statuses
20
+ && typeof statuses === "object"
21
+ && !Array.isArray(statuses)
22
+ && ["claude", "codex"].every((provider) => (
23
+ statuses[provider]?.state === "no_seat"
24
+ && statuses[provider]?.routable === false
25
+ )));
26
+ }
27
+
28
+ export async function fetchGatewayModels(config, fetchImpl = fetch, { allowEmpty = false } = {}) {
29
+ const controller = new AbortController();
30
+ const timeout = setTimeout(() => controller.abort(), 20000);
31
+ try {
32
+ const response = await fetchImpl(`${config.gatewayUrl}/v1/models`, {
33
+ headers: { authorization: `Bearer ${config.pat}` },
34
+ signal: controller.signal,
35
+ });
36
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
37
+ const payload = await response.json();
38
+ if (config.tenantId && payload?.org_id !== config.tenantId) {
39
+ throw new Error("gateway model catalog tenant did not match the selected tenant");
40
+ }
41
+ if (config.productAccess && payload?.product_access !== config.productAccess) {
42
+ throw new Error("gateway model catalog product access did not match the live entitlement");
43
+ }
44
+ if (!Array.isArray(payload?.data)) throw new Error("response has no model data array");
45
+ // The envelope is additive at schema version 3: overlay-aware gateways
46
+ // also send entitlement_available and org_defaults beside the filtered
47
+ // data[], whose per-model default/family_default flags are already
48
+ // rewritten to the tenant's effective defaults. Clients trust the served
49
+ // flags as-is and tolerate unknown envelope fields.
50
+ const models = payload.data.filter(isGatewayModel);
51
+ if (models.length === 0 && !(allowEmpty && isExplicitNoSeatCatalog(payload))) {
52
+ throw new Error("gateway returned no supported models");
53
+ }
54
+ return { models, source: "gateway", version: payload.version ?? null };
55
+ } finally {
56
+ clearTimeout(timeout);
57
+ }
58
+ }