impel-cli 0.18.15 → 0.18.16

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/src/apps.js CHANGED
@@ -37,6 +37,17 @@ const APP_DEFINITIONS = {
37
37
  executableNames: ["ChatGPT", "Codex"],
38
38
  },
39
39
  };
40
+ const BUNDLE_ARTIFACT_REMOVE_OPTIONS = Object.freeze({
41
+ recursive: true,
42
+ force: true,
43
+ maxRetries: 5,
44
+ retryDelay: 25,
45
+ });
46
+ const BUNDLE_LOCK_WAIT_MS = 120_000;
47
+ const BUNDLE_LOCK_POLL_MS = 100;
48
+ const BUNDLE_LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
49
+ const MACOS_O_EXLOCK = 0x00000020;
50
+ const portableBundleMutationLocks = new Set();
40
51
 
41
52
  // Vendor updates can change minified renderer contracts without notice. Each
42
53
  // CLI release therefore installs and accepts only the exact app builds tested
@@ -81,17 +92,29 @@ export const PINNED_VENDOR_APPS = Object.freeze({
81
92
  }),
82
93
  }),
83
94
  chatgpt: Object.freeze({
84
- version: "26.721.41059",
85
- codexVersion: "0.146.0-alpha.3.1",
95
+ version: "26.727.40816",
96
+ codexVersion: "0.146.0-alpha.9.2",
86
97
  bundleName: "ChatGPT.app",
98
+ windows: Object.freeze({
99
+ storeProductId: "9PLM9XGG6VKS",
100
+ packageName: "OpenAI.Codex",
101
+ // The Microsoft Store manifest identifies this exact reviewed build.
102
+ // Keep one Windows package version so the manifest contract and local
103
+ // AppX validation cannot silently drift apart again.
104
+ packageVersion: "26.727.6591.0",
105
+ codexVersion: "0.146.0-alpha.9.2",
106
+ publisherId: "2p2nqsd0c76g0",
107
+ executable: "app\\ChatGPT.exe",
108
+ updateManifestUrl: "https://persistent.oaistatic.com/codex-app-prod/windows-store-update.json",
109
+ }),
87
110
  downloads: Object.freeze({
88
111
  arm64: Object.freeze({
89
- url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-arm64-26.721.41059.zip",
90
- sha256: "e2b45056f3d1f8ab90f7f16249bfb5a40d09d0f8099f12ca0d8d7a8fdf9108ce",
112
+ url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-arm64-26.727.40816.zip",
113
+ sha256: "fdbede9b8a28b5bf3bbf1213fa04291724faa6ed2d1d61bb04853bbdfebce219",
91
114
  }),
92
115
  x64: Object.freeze({
93
- url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-x64-26.721.41059.zip",
94
- sha256: "f00b09d9e1f5138f055fa972e5243991b493423c41f5d78951dc2e21a84089f4",
116
+ url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-x64-26.727.40816.zip",
117
+ sha256: "12622d916a3993d61fb52a318dcff7af7449eea5070cdd7e094da29f380e3374",
95
118
  }),
96
119
  }),
97
120
  }),
@@ -236,7 +259,10 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
236
259
  // and the tenant manifest so unsupported hosts keep Chat and Code usable.
237
260
  // 25: remove the unproven Windows Cowork capability gate from beta.0 profiles
238
261
  // and restore the reviewed always-enabled managed policy.
239
- export const CURRENT_CONFIG_VERSION = 25;
262
+ // 26: remember the managed provider defaults and move legacy GPT-5.5 profiles
263
+ // onto GPT-5.6 Sol so ChatGPT's compact Work picker does not fall back to its
264
+ // unsupported-selection "Reset to default" treatment.
265
+ export const CURRENT_CONFIG_VERSION = 26;
240
266
 
241
267
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
242
268
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -245,7 +271,7 @@ export const CURRENT_CONFIG_VERSION = 25;
245
271
  // — which is what made every `impel update` re-trigger macOS permission
246
272
  // prompts. Bump this ONLY when a code change alters the bytes of a built
247
273
  // bundle; leave it alone for changes that don't touch bundle contents.
248
- export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-25.1";
274
+ export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-31.2";
249
275
 
250
276
  /** Parse the tenant's install manifest, or null when absent/corrupt. */
251
277
  export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
@@ -323,7 +349,7 @@ export function bundleIsCurrent(status, {
323
349
  compatibility.vendorSignaturesPreserved === true
324
350
  && compatibility.resourceRoot === CHATGPT_RESOURCE_ROOT
325
351
  && compatibility.patches?.fastModeForGatewayAuth > 0
326
- && compatibility.patches?.tenantDisplayNamePreload === 1
352
+ && compatibility.patches?.tenantDisplayNamePreload === 2
327
353
  )),
328
354
  );
329
355
  }
@@ -679,7 +705,7 @@ export function migrateLegacyClaudeAppSessions(userData, homeDir = os.homedir())
679
705
  return copied;
680
706
  }
681
707
 
682
- export async function fetchGatewayModels(config, fetchImpl = fetch) {
708
+ export async function fetchGatewayModels(config, fetchImpl = fetch, { allowEmpty = false } = {}) {
683
709
  const controller = new AbortController();
684
710
  const timeout = setTimeout(() => controller.abort(), 20000);
685
711
  try {
@@ -697,7 +723,9 @@ export async function fetchGatewayModels(config, fetchImpl = fetch) {
697
723
  }
698
724
  if (!Array.isArray(payload?.data)) throw new Error("response has no model data array");
699
725
  const models = payload.data.filter(isGatewayModel);
700
- if (models.length === 0) throw new Error("gateway returned no supported models");
726
+ if (models.length === 0 && !(allowEmpty && isExplicitNoSeatCatalog(payload))) {
727
+ throw new Error("gateway returned no supported models");
728
+ }
701
729
  return { models, source: "gateway", version: payload.version ?? null };
702
730
  } finally {
703
731
  clearTimeout(timeout);
@@ -733,6 +761,7 @@ export function installManagedAppFiles({
733
761
  target,
734
762
  vendorPaths[target] || findVendorApp(target, homeDir),
735
763
  ]));
764
+ const previousManifest = readTenantManifest(homeDir, config.tenantId);
736
765
  const vendorCodexModels = targets.includes("chatgpt")
737
766
  ? readVendorCodexModels(resolvedVendorPaths.chatgpt)
738
767
  : new Map();
@@ -783,6 +812,7 @@ export function installManagedAppFiles({
783
812
  vendorCodexModels,
784
813
  chatgptInvocations,
785
814
  generatedAt,
815
+ previousManifest,
786
816
  );
787
817
  if (RUNTIME_BRAND.features.sessions) ensureCodexSessionHooks(paths.chatgpt.codexHome, config.tenantId, "codex_desktop");
788
818
  }
@@ -794,7 +824,7 @@ export function installManagedAppFiles({
794
824
  }
795
825
 
796
826
  writeAtomic(path.join(paths.tenantRoot, "manifest.json"), JSON.stringify({
797
- schemaVersion: 6,
827
+ schemaVersion: 7,
798
828
  configVersion: CURRENT_CONFIG_VERSION,
799
829
  modelCatalogVersion: 3,
800
830
  gatewayUrl: config.gatewayUrl,
@@ -807,6 +837,10 @@ export function installManagedAppFiles({
807
837
  models,
808
838
  config,
809
839
  ),
840
+ modelDefaults: {
841
+ claude: models.find((model) => model.provider === "claude" && model.default)?.id ?? null,
842
+ chatgpt: models.find((model) => model.provider === "codex" && model.default)?.id ?? null,
843
+ },
810
844
  models: models.map((model) => model.id),
811
845
  updatedAt: generatedAt,
812
846
  }, null, 2) + "\n", 0o600);
@@ -960,7 +994,6 @@ function installPinnedVendorApp(target, homeDir) {
960
994
  homeDir, ".config", RUNTIME_BRAND.cli.configNamespace, "vendor", target, pin.version, pin.bundleName,
961
995
  );
962
996
  fs.mkdirSync(path.dirname(destination), { recursive: true });
963
- sweepStaleBundleArtifacts(path.dirname(destination), path.basename(destination));
964
997
  replaceDirectory(destination, extractedApp);
965
998
  return destination;
966
999
  } finally {
@@ -1141,6 +1174,7 @@ function writeChatGPTConfig(
1141
1174
  vendorCodexModels,
1142
1175
  invocations = null,
1143
1176
  generatedAt = new Date().toISOString(),
1177
+ previousManifest = null,
1144
1178
  ) {
1145
1179
  const experimental = crossAppModelsEnabled(config);
1146
1180
  const orderedModels = experimental
@@ -1156,8 +1190,28 @@ function writeChatGPTConfig(
1156
1190
  const currentToml = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
1157
1191
  const configuredModel = readTopLevelTomlString(currentToml, "model");
1158
1192
  const defaultModelID = orderedModels.find((model) => model.provider === "codex" && model.default)?.id;
1159
- const selectedModel = codexModels.find((model) => model.slug === configuredModel)
1160
- || codexModels.find((model) => model.slug === defaultModelID)
1193
+ const defaultModel = codexModels.find((model) => model.slug === defaultModelID);
1194
+ const previousDefaultModelID = previousManifest?.modelDefaults?.chatgpt;
1195
+ const followsPreviousManagedDefault = (
1196
+ typeof previousDefaultModelID === "string"
1197
+ && configuredModel === previousDefaultModelID
1198
+ && configuredModel !== defaultModelID
1199
+ );
1200
+ // Profiles written before the default-model field existed cannot distinguish
1201
+ // an old managed default from an explicit user choice. GPT-5.5 is the one
1202
+ // observed legacy default that ChatGPT 26.727 renders as an unsupported
1203
+ // compact-picker selection; migrate it once, while preserving every other
1204
+ // explicit model choice.
1205
+ const hasLegacyCompactPickerDefault = (
1206
+ previousDefaultModelID == null
1207
+ && Number(previousManifest?.configVersion ?? 0) < CURRENT_CONFIG_VERSION
1208
+ && configuredModel === "gpt-5.5"
1209
+ && defaultModelID === "gpt-5.6-sol"
1210
+ );
1211
+ const selectedModel = (followsPreviousManagedDefault || hasLegacyCompactPickerDefault
1212
+ ? defaultModel
1213
+ : codexModels.find((model) => model.slug === configuredModel))
1214
+ || defaultModel
1161
1215
  || codexModels[0];
1162
1216
  if (!selectedModel) throw new Error("no Codex models are available for Impel ChatGPT");
1163
1217
  const configuredEffort = readTopLevelTomlString(currentToml, "model_reasoning_effort");
@@ -1517,6 +1571,12 @@ exec "$NODE" "$CLI" token${tenantArgs}
1517
1571
  }
1518
1572
 
1519
1573
  function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageName) {
1574
+ return withBundleMutationLock(paths.claude.launcher, () => (
1575
+ writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStorageName)
1576
+ ));
1577
+ }
1578
+
1579
+ function writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStorageName) {
1520
1580
  if (!vendorPath) throw new Error("Claude vendor app is unavailable");
1521
1581
  if (!isVendableVendorApp("claude", vendorPath)) {
1522
1582
  const bundleId = readBundleIdentifier(vendorPath);
@@ -1530,9 +1590,8 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1530
1590
  if (!executableName) throw new Error(`Claude executable is missing from ${vendorPath}`);
1531
1591
 
1532
1592
  const bundle = paths.claude.launcher;
1533
- const staging = `${bundle}.tmp-${process.pid}`;
1534
1593
  sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1535
- fs.rmSync(staging, { recursive: true, force: true });
1594
+ const staging = uniqueBundleArtifactPath(bundle, "tmp");
1536
1595
  cloneAppBundle(vendorPath, staging);
1537
1596
  try {
1538
1597
  const plistPath = path.join(staging, "Contents", "Info.plist");
@@ -1614,9 +1673,9 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1614
1673
  }, null, 2) + "\n", 0o644);
1615
1674
 
1616
1675
  signVendoredApp(staging, vendorExecutable, "Claude", signingIdentity);
1617
- replaceDirectory(bundle, staging);
1676
+ replaceDirectoryUnlocked(bundle, staging);
1618
1677
  } catch (error) {
1619
- fs.rmSync(staging, { recursive: true, force: true });
1678
+ removeBundleArtifact(staging);
1620
1679
  throw error;
1621
1680
  }
1622
1681
  }
@@ -1630,6 +1689,12 @@ function claudeUsageCompatibilityEnabled(gatewayUrl) {
1630
1689
  }
1631
1690
 
1632
1691
  function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1692
+ return withBundleMutationLock(paths.chatgpt.launcher, () => (
1693
+ writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir)
1694
+ ));
1695
+ }
1696
+
1697
+ function writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir) {
1633
1698
  if (!vendorPath) throw new Error("ChatGPT/Codex vendor app is unavailable");
1634
1699
  const definition = APP_DEFINITIONS.chatgpt;
1635
1700
  const executableName = definition.executableNames.find((name) => (
@@ -1638,11 +1703,10 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1638
1703
  if (!executableName) throw new Error(`ChatGPT/Codex executable is missing from ${vendorPath}`);
1639
1704
 
1640
1705
  const bundle = paths.chatgpt.launcher;
1641
- const staging = `${bundle}.tmp-${process.pid}`;
1706
+ sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1707
+ const staging = uniqueBundleArtifactPath(bundle, "tmp");
1642
1708
  const vendorBundleName = path.basename(vendorPath);
1643
1709
  const vendorBundle = path.join(staging, "Contents", "Resources", vendorBundleName);
1644
- sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1645
- fs.rmSync(staging, { recursive: true, force: true });
1646
1710
  cloneAppBundle(vendorPath, vendorBundle);
1647
1711
  try {
1648
1712
  const asarPath = path.join(vendorBundle, "Contents", "Resources", "app.asar");
@@ -1710,7 +1774,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1710
1774
  patches: {
1711
1775
  fastModeForGatewayAuth: fastModePatchCount,
1712
1776
  desktopAPIForGatewayAuth: 0,
1713
- tenantDisplayNamePreload: 1,
1777
+ tenantDisplayNamePreload: 2,
1714
1778
  },
1715
1779
  vendorSignaturesPreserved: true,
1716
1780
  resourceRoot: CHATGPT_RESOURCE_ROOT,
@@ -1718,9 +1782,9 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1718
1782
  }, null, 2) + "\n", 0o644);
1719
1783
 
1720
1784
  signVendoredApp(staging, vendorExecutable, "ChatGPT", signingIdentity);
1721
- replaceDirectory(bundle, staging);
1785
+ replaceDirectoryUnlocked(bundle, staging);
1722
1786
  } catch (error) {
1723
- fs.rmSync(staging, { recursive: true, force: true });
1787
+ removeBundleArtifact(staging);
1724
1788
  throw error;
1725
1789
  }
1726
1790
  }
@@ -2213,6 +2277,7 @@ HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
2213
2277
  VENDOR_APP="$HERE/../Resources/"${shellQuote(vendorBundleName)}
2214
2278
  export CODEX_HOME=${shellQuote(paths.chatgpt.codexHome)}
2215
2279
  export CODEX_AUTHAPI_BASE_URL=${shellQuote(codexAccountBaseUrl)}
2280
+ export CODEX_SPARKLE_ENABLED=false
2216
2281
  unset CODEX_ACCESS_TOKEN CODEX_API_KEY OPENAI_API_KEY OPENAI_BASE_URL
2217
2282
  secure_codex_home() {
2218
2283
  [ ! -L "$CODEX_HOME" ] || exit 1
@@ -2236,6 +2301,7 @@ secure_codex_home
2236
2301
  BROWSER_DATA=${shellQuote(paths.chatgpt.browserData)}
2237
2302
  mkdir -p "$BROWSER_DATA"
2238
2303
  export IMPEL_APP_DISPLAY_NAME=${shellQuote(paths.chatgpt.displayName)}
2304
+ export IMPEL_APP_BUNDLE_ID=${shellQuote(paths.chatgpt.bundleIdentifier)}
2239
2305
  PRELOAD="$HERE/../Resources/impel-chatgpt-preload.cjs"
2240
2306
  export NODE_OPTIONS="--require=\\\"$PRELOAD\\\""
2241
2307
  # Electron resolves resources from the Impel wrapper when the nested app is
@@ -2250,13 +2316,14 @@ function vendoredChatGPTDisplayNamePreload() {
2250
2316
  return `"use strict";
2251
2317
 
2252
2318
  const displayName = process.env.IMPEL_APP_DISPLAY_NAME;
2253
- if (process.type === "browser" && displayName) {
2319
+ const bundleIdentifier = process.env.IMPEL_APP_BUNDLE_ID;
2320
+ if (process.type === "browser" && displayName && bundleIdentifier) {
2254
2321
  const Module = require("node:module");
2255
2322
  const path = require("node:path");
2256
2323
  const load = Module._load;
2257
2324
  let objc;
2258
2325
  let appKit;
2259
- let launchServicesNamed = false;
2326
+ let launchServicesIdentified = false;
2260
2327
  let activationPolicyRefreshed = false;
2261
2328
 
2262
2329
  const loadNativeBridge = () => {
@@ -2268,20 +2335,31 @@ if (process.type === "browser" && displayName) {
2268
2335
  return { objc, appKit };
2269
2336
  };
2270
2337
 
2271
- const setLaunchServicesApplicationName = () => {
2272
- if (launchServicesNamed) return;
2338
+ const setLaunchServicesApplicationIdentity = () => {
2339
+ if (launchServicesIdentified) return;
2273
2340
  try {
2274
2341
  const native = loadNativeBridge();
2275
2342
  new native.objc.NobjcLibrary(
2276
2343
  "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/ApplicationServices",
2277
2344
  );
2278
2345
  const title = native.appKit.NSString.stringWithUTF8String$(displayName);
2346
+ const identifier = native.appKit.NSString.stringWithUTF8String$(bundleIdentifier);
2279
2347
  const info = native.appKit.NSMutableDictionary.dictionaryWithDictionary$(
2280
2348
  native.appKit.NSBundle.mainBundle().infoDictionary(),
2281
2349
  );
2282
2350
  for (const key of ["CFBundleName", "CFBundleDisplayName"]) {
2283
2351
  info.setObject$forKey$(title, native.appKit.NSString.stringWithUTF8String$(key));
2284
2352
  }
2353
+ // The signed vendor executable runs from the nested ChatGPT.app, so
2354
+ // LaunchServices initially checks it in as com.openai.codex. When the
2355
+ // native app is also open, Dock groups both processes under ChatGPT even
2356
+ // though LSDisplayName was already tenant-branded. Check the process in
2357
+ // again with the unique outer-wrapper identity to keep each tenant's
2358
+ // Dock item distinct while preserving the vendor executable signature.
2359
+ info.setObject$forKey$(
2360
+ identifier,
2361
+ native.appKit.NSString.stringWithUTF8String$("CFBundleIdentifier"),
2362
+ );
2285
2363
  native.objc.callFunction(
2286
2364
  "_LSSetApplicationLaunchServicesServerConnectionStatus",
2287
2365
  { returns: "v", args: ["Q", "@"] },
@@ -2305,7 +2383,7 @@ if (process.type === "browser" && displayName) {
2305
2383
  title,
2306
2384
  null,
2307
2385
  );
2308
- launchServicesNamed = status === 0;
2386
+ launchServicesIdentified = status === 0;
2309
2387
  } catch {
2310
2388
  // Keep the signed vendor app usable if LaunchServices changes this
2311
2389
  // private-but-established process-title API in a future macOS release.
@@ -2340,7 +2418,7 @@ if (process.type === "browser" && displayName) {
2340
2418
  const setName = loaded.app.setName.bind(loaded.app);
2341
2419
  setName(displayName);
2342
2420
  loaded.app.setName = () => setName(displayName);
2343
- setLaunchServicesApplicationName();
2421
+ setLaunchServicesApplicationIdentity();
2344
2422
  setNativeApplicationMenuTitle();
2345
2423
  const setApplicationMenu = loaded.Menu.setApplicationMenu.bind(loaded.Menu);
2346
2424
  loaded.Menu.setApplicationMenu = (menu) => {
@@ -2351,11 +2429,11 @@ if (process.type === "browser" && displayName) {
2351
2429
  loaded.app.setActivationPolicy("accessory");
2352
2430
  setTimeout(() => {
2353
2431
  loaded.app.setActivationPolicy("regular");
2354
- launchServicesNamed = false;
2355
- setLaunchServicesApplicationName();
2432
+ launchServicesIdentified = false;
2433
+ setLaunchServicesApplicationIdentity();
2356
2434
  setApplicationMenu(menu);
2357
2435
  setNativeApplicationMenuTitle();
2358
- }, 0);
2436
+ }, 100);
2359
2437
  }
2360
2438
  setTimeout(setNativeApplicationMenuTitle, 0);
2361
2439
  return result;
@@ -2523,30 +2601,122 @@ export function sweepStaleBundleArtifacts(dir, baseName) {
2523
2601
  throw error;
2524
2602
  }
2525
2603
  const prefixes = [`${baseName}.tmp-`, `${baseName}.previous-`];
2526
- const removed = entries.filter((entry) => prefixes.some((prefix) => entry.startsWith(prefix)));
2527
- for (const entry of removed) {
2528
- fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
2604
+ const removed = [];
2605
+ for (const entry of entries.filter((candidate) => prefixes.some((prefix) => candidate.startsWith(prefix)))) {
2606
+ if (removeBundleArtifact(path.join(dir, entry))) removed.push(entry);
2529
2607
  }
2530
2608
  return removed;
2531
2609
  }
2532
2610
 
2533
- function replaceDirectory(destination, staging) {
2534
- const backup = `${destination}.previous-${process.pid}`;
2535
- fs.rmSync(backup, { recursive: true, force: true });
2536
- if (fs.existsSync(destination)) fs.renameSync(destination, backup);
2611
+ function uniqueBundleArtifactPath(destination, kind) {
2612
+ return `${destination}.${kind}-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
2613
+ }
2614
+
2615
+ function removeBundleArtifact(candidate) {
2616
+ try {
2617
+ fs.rmSync(candidate, BUNDLE_ARTIFACT_REMOVE_OPTIONS);
2618
+ return !fs.existsSync(candidate);
2619
+ } catch {
2620
+ return false;
2621
+ }
2622
+ }
2623
+
2624
+ export function withBundleMutationLock(destination, run) {
2625
+ const lockPath = path.join(
2626
+ path.dirname(destination),
2627
+ `.${path.basename(destination)}.impel-install.lock`,
2628
+ );
2629
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
2630
+
2631
+ if (process.platform !== "darwin") {
2632
+ if (portableBundleMutationLocks.has(lockPath)) {
2633
+ throw new Error(`another Impel app update is already replacing ${path.basename(destination)}`);
2634
+ }
2635
+ const descriptor = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_RDWR, 0o600);
2636
+ portableBundleMutationLocks.add(lockPath);
2637
+ try {
2638
+ return run();
2639
+ } finally {
2640
+ portableBundleMutationLocks.delete(lockPath);
2641
+ fs.closeSync(descriptor);
2642
+ }
2643
+ }
2644
+
2645
+ const deadline = Date.now() + BUNDLE_LOCK_WAIT_MS;
2646
+ let descriptor;
2647
+ while (descriptor === undefined) {
2648
+ try {
2649
+ descriptor = fs.openSync(
2650
+ lockPath,
2651
+ fs.constants.O_CREAT | fs.constants.O_RDWR | fs.constants.O_NONBLOCK | MACOS_O_EXLOCK,
2652
+ 0o600,
2653
+ );
2654
+ } catch (error) {
2655
+ if (!["EAGAIN", "EWOULDBLOCK"].includes(error?.code)) throw error;
2656
+ if (Date.now() >= deadline) {
2657
+ throw new Error(`timed out waiting for another Impel app update to replace ${path.basename(destination)}`);
2658
+ }
2659
+ Atomics.wait(BUNDLE_LOCK_SLEEP, 0, 0, BUNDLE_LOCK_POLL_MS);
2660
+ }
2661
+ }
2662
+ try {
2663
+ return run();
2664
+ } finally {
2665
+ fs.closeSync(descriptor);
2666
+ }
2667
+ }
2668
+
2669
+ function replaceDirectoryUnlocked(destination, staging, {
2670
+ removeArtifact = removeBundleArtifact,
2671
+ } = {}) {
2672
+ const backup = fs.existsSync(destination)
2673
+ ? uniqueBundleArtifactPath(destination, "previous")
2674
+ : null;
2675
+ if (backup) fs.renameSync(destination, backup);
2537
2676
  try {
2538
2677
  fs.renameSync(staging, destination);
2539
- fs.rmSync(backup, { recursive: true, force: true });
2540
2678
  } catch (error) {
2541
- if (!fs.existsSync(destination) && fs.existsSync(backup)) fs.renameSync(backup, destination);
2679
+ if (backup && !fs.existsSync(destination) && fs.existsSync(backup)) {
2680
+ fs.renameSync(backup, destination);
2681
+ }
2542
2682
  throw error;
2543
2683
  }
2684
+
2685
+ // The new bundle is committed once staging has been renamed into place.
2686
+ // Finder, LaunchServices, or a just-closed helper can briefly keep changing
2687
+ // the old tree and make recursive removal end with ENOTEMPTY. Do not turn
2688
+ // that post-commit housekeeping race into a failed tenant update: the next
2689
+ // sweep will retry any backup that remains.
2690
+ if (backup) {
2691
+ try {
2692
+ removeArtifact(backup);
2693
+ } catch {
2694
+ // Cleanup is best-effort after the committed rename.
2695
+ }
2696
+ }
2697
+ }
2698
+
2699
+ export function replaceDirectory(destination, staging, options = {}) {
2700
+ return withBundleMutationLock(destination, () => (
2701
+ replaceDirectoryUnlocked(destination, staging, options)
2702
+ ));
2544
2703
  }
2545
2704
 
2546
2705
  function isGatewayModel(model) {
2547
2706
  return model && typeof model.id === "string" && (model.provider === "claude" || model.provider === "codex");
2548
2707
  }
2549
2708
 
2709
+ function isExplicitNoSeatCatalog(payload) {
2710
+ const statuses = payload?.provider_status;
2711
+ return Boolean(statuses
2712
+ && typeof statuses === "object"
2713
+ && !Array.isArray(statuses)
2714
+ && ["claude", "codex"].every((provider) => (
2715
+ statuses[provider]?.state === "no_seat"
2716
+ && statuses[provider]?.routable === false
2717
+ )));
2718
+ }
2719
+
2550
2720
  function writeAtomic(target, contents, mode) {
2551
2721
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
2552
2722
  if (fs.existsSync(target) && fs.readFileSync(target, "utf8") === contents) {
@@ -76,7 +76,7 @@ export function selectCatalogAppTargets(
76
76
  requestedTargets,
77
77
  models,
78
78
  config,
79
- { log = console.log } = {},
79
+ { log = console.log, allowNone = false } = {},
80
80
  ) {
81
81
  const supported = appTargetsSupportedByModels(
82
82
  requestedTargets,
@@ -87,6 +87,14 @@ export function selectCatalogAppTargets(
87
87
  const unavailable = requestedTargets.filter((target) => !supportedSet.has(target));
88
88
  if (unavailable.length === 0) return supported;
89
89
 
90
+ if (allowNone && supported.length === 0) {
91
+ for (const target of unavailable) {
92
+ const capability = APP_CAPABILITIES[target];
93
+ log(`Skipping ${capability.label}: the selected tenant has no ${capability.provider} models.`);
94
+ }
95
+ return supported;
96
+ }
97
+
90
98
  if (requestedTargets.length === 1 || supported.length === 0) {
91
99
  const target = unavailable[0];
92
100
  const capability = APP_CAPABILITIES[target];
@@ -297,9 +305,9 @@ export function openManagedLauncher(launcher, {
297
305
  return false;
298
306
  }
299
307
 
300
- async function fetchWindowsCatalog(config, io) {
308
+ async function fetchWindowsCatalog(config, io, { allowEmpty = false } = {}) {
301
309
  try {
302
- return await io.fetchModels(config);
310
+ return await io.fetchModels(config, undefined, { allowEmpty });
303
311
  } catch (error) {
304
312
  throw new Error(`tenant model catalog is unavailable (${redactSecretText(error.message)}); the Impel app profiles were not changed`);
305
313
  }
@@ -374,12 +382,25 @@ export async function reconcileWindowsTenantApps({
374
382
  ...overrides,
375
383
  };
376
384
  const config = explicitTenantAppConfig(baseConfig, tenant, targets);
377
- const catalog = await fetchWindowsCatalog(config, io);
378
- const actionTargets = selectCatalogAppTargets(targets, catalog.models, config, { log: io.log });
385
+ const catalog = await fetchWindowsCatalog(config, io, { allowEmpty: true });
386
+ const actionTargets = selectCatalogAppTargets(targets, catalog.models, config, {
387
+ log: io.log,
388
+ allowNone: true,
389
+ });
379
390
  const supported = new Set(actionTargets);
380
391
  const unsupported = targets.filter((target) => !supported.has(target));
381
392
  const userData = io.claudeUserData(environment, config.tenantId);
382
393
  const paths = appPaths(homeDir, config.tenantId, { claudeUserData: userData, tenantName: config.tenantName });
394
+ if (actionTargets.length === 0) {
395
+ return {
396
+ tenantId: config.tenantId,
397
+ targets: [],
398
+ unsupported,
399
+ paths,
400
+ verification: {},
401
+ failed: [],
402
+ };
403
+ }
383
404
  const vendorPaths = {};
384
405
  const preparedTargets = new Set(skipVendor ? actionTargets : skipVendorTargets);
385
406
 
@@ -501,13 +522,28 @@ export async function reconcileMacTenantApps({
501
522
  const config = explicitTenantAppConfig(baseConfig, tenant, targets);
502
523
  let catalog;
503
524
  try {
504
- catalog = await io.fetchModels(config);
525
+ catalog = await io.fetchModels(config, undefined, { allowEmpty: true });
505
526
  } catch (error) {
506
527
  throw new Error(`tenant model catalog is unavailable (${redactSecretText(error?.message || error)}); no Impel app files were changed`);
507
528
  }
508
- const actionTargets = selectCatalogAppTargets(targets, catalog.models, config, { log: io.log });
529
+ const actionTargets = selectCatalogAppTargets(targets, catalog.models, config, {
530
+ log: io.log,
531
+ allowNone: true,
532
+ });
509
533
  const supported = new Set(actionTargets);
510
534
  const unsupported = targets.filter((target) => !supported.has(target));
535
+ const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
536
+ if (actionTargets.length === 0) {
537
+ return {
538
+ tenantId: config.tenantId,
539
+ targets: [],
540
+ unsupported,
541
+ paths,
542
+ verification: {},
543
+ installed: [],
544
+ failed: [],
545
+ };
546
+ }
511
547
  const vendorPaths = {};
512
548
  const statuses = io.status(actionTargets, homeDir, config.tenantId, config.tenantName);
513
549
  for (const status of statuses) vendorPaths[status.target] ||= status.vendorPath;
@@ -549,7 +585,6 @@ export async function reconcileMacTenantApps({
549
585
  vendorPaths,
550
586
  writeBundles: staleTargets,
551
587
  }) : [];
552
- const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
553
588
  const agentItems = [];
554
589
  for (const item of installed) {
555
590
  const { client, env, label } = appSkillTarget(item.target, paths);
@@ -13,6 +13,8 @@ import { promptText } from "../prompt.js";
13
13
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
14
14
  import { restoreNativeProfiles } from "./use.js";
15
15
 
16
+ const RETRYABLE_TENANT_DISCOVERY_ERROR = /could not reach|request timed out|fetch failed|ECONNRESET|ECONNABORTED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|network error|socket/iu;
17
+
16
18
  function confirmed(answer) {
17
19
  return /^(?:y|yes)$/iu.test(String(answer || "").trim());
18
20
  }
@@ -44,6 +46,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
44
46
  platform: process.platform,
45
47
  environment: process.env,
46
48
  preparePlatformClis,
49
+ discoveryLogger: console,
47
50
  ...overrides,
48
51
  };
49
52
  if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
@@ -58,10 +61,22 @@ export async function cmdConverge(argv = [], overrides = {}) {
58
61
  let listing;
59
62
  try {
60
63
  listing = await io.fetchTenants(config);
64
+ // Tenant discovery is the entry gate for the whole convergence. A single
65
+ // transient timeout should not force a human to rerun every update step.
61
66
  } catch (error) {
62
- console.error(`impel update: tenant discovery failed (${redactSecretText(error?.message || error)})`);
63
- process.exitCode = 1;
64
- return false;
67
+ if (!RETRYABLE_TENANT_DISCOVERY_ERROR.test(String(error?.message || error))) {
68
+ console.error(`impel update: tenant discovery failed (${redactSecretText(error?.message || error)})`);
69
+ process.exitCode = 1;
70
+ return false;
71
+ }
72
+ io.discoveryLogger.log("Tenants: discovery request failed transiently; retrying once…");
73
+ try {
74
+ listing = await io.fetchTenants(config);
75
+ } catch (retryError) {
76
+ console.error(`impel update: tenant discovery failed (${redactSecretText(retryError?.message || retryError)})`);
77
+ process.exitCode = 1;
78
+ return false;
79
+ }
65
80
  }
66
81
  const selected = selectDefaultTenant(listing, { currentTenantId: config.tenantId });
67
82
  config.tenantId = selected.id;