impel-cli 0.20.38 → 0.20.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,8 +32,11 @@ import {
32
32
  ensureVendorApp,
33
33
  fetchGatewayModels,
34
34
  installManagedAppFiles,
35
+ MANAGED_RUNTIME_PREFLIGHT_EXIT_CODE,
35
36
  managedChatGPTConfigDrifted,
37
+ managedClaudeConfigDrifted,
36
38
  managedLauncherName,
39
+ managedRuntimePreflight,
37
40
  normalizeAppTarget,
38
41
  quitBlockingApps,
39
42
  readTenantManifest,
@@ -294,7 +297,18 @@ function windowsProcessFailure(result) {
294
297
  export function openManagedLauncher(launcher, {
295
298
  spawn = spawnSync,
296
299
  reportError = console.error,
300
+ preflight = managedRuntimePreflight,
297
301
  } = {}) {
302
+ // Runtime-presence preflight (the v0.20.32 exit-127 rule): never hand a
303
+ // managed bundle whose vendored runtime is missing or off-pin to the OS —
304
+ // that launch dies with an unactionable bare failure. One actionable line
305
+ // and a distinct exit code instead; unmanaged bundles are not judged here.
306
+ const runtime = preflight(launcher);
307
+ if (runtime && runtime.ok === false) {
308
+ reportError(runtime.message);
309
+ process.exitCode = MANAGED_RUNTIME_PREFLIGHT_EXIT_CODE;
310
+ return false;
311
+ }
298
312
  const result = spawn("/usr/bin/open", ["-n", launcher], { encoding: "utf8" });
299
313
  if (!result?.error && result?.status === 0) return true;
300
314
  const detail = result?.error?.message
@@ -740,7 +754,11 @@ export async function cmdWindowsApps(argv, overrides = {}) {
740
754
  claudeUserData: io.claudeUserData(io.environment, staleTenantId),
741
755
  tenantName: staleTenantId === stored.tenantId ? stored.tenantName : null,
742
756
  });
743
- if (manifestIsFresh(stalePaths, stored) && !managedChatGPTConfigDrifted(stalePaths)) return true;
757
+ if (
758
+ manifestIsFresh(stalePaths, stored)
759
+ && !managedChatGPTConfigDrifted(stalePaths)
760
+ && !managedClaudeConfigDrifted(stalePaths)
761
+ ) return true;
744
762
  }
745
763
  }
746
764
  const config = await io.selectedConfig(targets, flags.tenant || null);
@@ -1329,7 +1347,12 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {},
1329
1347
  ? stored.tenantName || manifest?.tenantName
1330
1348
  : manifest?.tenantName,
1331
1349
  });
1332
- if (staleOnly && manifestIsFresh(paths, stored) && !managedChatGPTConfigDrifted(paths)) return;
1350
+ if (
1351
+ staleOnly
1352
+ && manifestIsFresh(paths, stored)
1353
+ && !managedChatGPTConfigDrifted(paths)
1354
+ && !managedClaudeConfigDrifted(paths)
1355
+ ) return;
1333
1356
 
1334
1357
  let config;
1335
1358
  try {
@@ -214,7 +214,23 @@ export async function cmdConverge(argv = [], overrides = {}) {
214
214
  goals: [{
215
215
  id: "shared-vendor-clis-ready",
216
216
  description: "Shared vendor CLI prerequisites are installed and discoverable",
217
- run: () => sharedReady,
217
+ // Live probe, not a session snapshot: a vendor CLI installed outside
218
+ // this process (for example by the user in another terminal while the
219
+ // recovery session runs) must count as complete the moment the goal
220
+ // is evaluated, so recovery never re-issues an already-finished step.
221
+ run: async () => {
222
+ if (sharedReady) return true;
223
+ const prepared = await io.preparePlatformClis({
224
+ gatewayUrl: config.gatewayUrl,
225
+ tenantId: selected.id,
226
+ platform: io.platform,
227
+ skipInstall: true,
228
+ // Goals re-run before every system-risk step: the probe must be
229
+ // pure discovery — no installs, profile writes, or skill syncs.
230
+ inspectOnly: true,
231
+ });
232
+ return prepared.missingAfter.length === 0;
233
+ },
218
234
  }],
219
235
  explicit: true,
220
236
  noRecovery: false,
@@ -263,7 +263,7 @@ export function runNativeAgentMcpServer({
263
263
  let value;
264
264
  if (name === NATIVE_AGENT_ANSWER_TOOL) {
265
265
  if (mode !== "answer") throw new Error("this native-agent binding cannot answer directly");
266
- value = await transport.answer(args, { signal: controller.signal });
266
+ value = await transport.answer(args, { signal: controller.signal, onProgress });
267
267
  } else if (name === NATIVE_AGENT_RUN_TOOL) {
268
268
  if (mode === "recovery") throw new Error("retired native-agent bindings cannot start new runs");
269
269
  if (mode === "answer") throw new Error("direct-answer native-agent bindings cannot start durable runs");
@@ -2,7 +2,8 @@ import { spawn } from "node:child_process";
2
2
  import path from "node:path";
3
3
 
4
4
  import { parseFlags } from "../args.js";
5
- import { appPaths, managedChatGPTConfigDrifted } from "../apps.js";
5
+ import { appPaths, managedChatGPTConfigDrifted, managedClaudeConfigDrifted } from "../apps.js";
6
+ import { windowsClaudeUserData } from "../windowsApps.js";
6
7
  import {
7
8
  clearSessionFlushLock,
8
9
  collectSessionHook,
@@ -32,23 +33,36 @@ const SPEC = {
32
33
  [MANAGED_SESSION_HOOK_FLAG]: { type: "boolean" },
33
34
  };
34
35
 
36
+ // The Claude drift check reads the Claude app profile, which lives under
37
+ // %LOCALAPPDATA% on Windows rather than the darwin tenant root.
38
+ function managedAppDriftPaths(tenantId) {
39
+ return appPaths(undefined, tenantId, process.platform === "win32"
40
+ ? { claudeUserData: windowsClaudeUserData(process.env, tenantId) }
41
+ : {});
42
+ }
43
+
44
+ function anyManagedAppConfigDrifted(paths) {
45
+ return managedChatGPTConfigDrifted(paths) || managedClaudeConfigDrifted(paths);
46
+ }
47
+
35
48
  /**
36
- * Repair a managed ChatGPT profile the desktop app just rewrote out from under
37
- * the gateway. Hooks are the only Impel code guaranteed to run while the app
38
- * is broken (the app stops calling the provider token helper once the managed
39
- * `model_provider` key is gone), so each codex hook event checks for drift and
40
- * kicks one detached stale-only refresh — which the drift-aware staleness gate
41
- * turns into a real repair. The heartbeat lock keeps repeated hook events from
42
- * stacking refresh children while one is already running.
49
+ * Repair a managed app profile a vendor just rewrote out from under the
50
+ * gateway. Hooks and the token helper are the only Impel code guaranteed to
51
+ * run while an app is broken (a drifted config can stop calling the provider
52
+ * token helper entirely), so both check for drift and kick one detached
53
+ * stale-only refresh — which the drift-aware staleness gate turns into a real
54
+ * repair. The heartbeat lock keeps repeated events from stacking refresh
55
+ * children while one is already running. Content-checks both managed
56
+ * surfaces (ChatGPT/Codex and Claude) by default.
43
57
  */
44
- export function maybeRepairManagedCodexApp(tenantId, {
45
- drifted = managedChatGPTConfigDrifted,
58
+ export function maybeRepairManagedApps(tenantId, {
59
+ drifted = anyManagedAppConfigDrifted,
46
60
  lockIsFresh = sessionFlushLockIsFresh,
47
61
  touchLock = touchSessionFlushLock,
48
62
  spawnRefresh = spawnDetachedAppRefresh,
49
63
  } = {}) {
50
64
  if (!tenantId) return false;
51
- const paths = appPaths(undefined, tenantId);
65
+ const paths = managedAppDriftPaths(tenantId);
52
66
  if (!drifted(paths)) return false;
53
67
  const lock = path.join(paths.tenantRoot, "app-repair-heartbeat");
54
68
  if (lockIsFresh(lock, 60_000)) return false;
@@ -57,6 +71,19 @@ export function maybeRepairManagedCodexApp(tenantId, {
57
71
  return true;
58
72
  }
59
73
 
74
+ /**
75
+ * The codex session-hook entry point: same repair, scoped to ChatGPT/Codex
76
+ * drift exactly as before the helper was generalized (the codex hook fires on
77
+ * codex events; Claude drift is healed by the token helper and the Claude
78
+ * session hooks' own refresh channel).
79
+ */
80
+ export function maybeRepairManagedCodexApp(tenantId, overrides = {}) {
81
+ return maybeRepairManagedApps(tenantId, {
82
+ drifted: managedChatGPTConfigDrifted,
83
+ ...overrides,
84
+ });
85
+ }
86
+
60
87
  function startDetachedFlush({ provider, tenant, session }) {
61
88
  const invocation = impelCliInvocation([
62
89
  "sessions",
@@ -101,6 +128,12 @@ export async function cmdSessions(argv) {
101
128
  flush: false,
102
129
  });
103
130
  if (flags.provider === "codex") maybeRepairManagedCodexApp(flags.tenant);
131
+ // The Claude mirror of the codex repair above: Claude Desktop rewrites
132
+ // its co-owned profile files too, and its session hooks are the only
133
+ // Impel code guaranteed to still run afterward.
134
+ if (flags.provider === "claude_code") {
135
+ maybeRepairManagedApps(flags.tenant, { drifted: managedClaudeConfigDrifted });
136
+ }
104
137
  if (config && (config.tenantId === flags.tenant || environmentValue("SESSIONS_DEV_ORG_ID"))) {
105
138
  // Hooks fire on every session event; only spawn a flush child when no
106
139
  // live one is already polling this session's outbox (heartbeat lock).
@@ -370,7 +370,23 @@ export async function cmdSetup(argv, overrides = {}) {
370
370
  goals: [{
371
371
  id: "shared-vendor-clis-ready",
372
372
  description: "Shared vendor CLI prerequisites are installed and discoverable",
373
- run: () => sharedReady,
373
+ // Live probe, not a session snapshot: a vendor CLI installed outside
374
+ // this process (for example by the user in another terminal while the
375
+ // recovery session runs) must count as complete the moment the goal
376
+ // is evaluated, so recovery never re-issues an already-finished step.
377
+ run: async () => {
378
+ if (sharedReady) return true;
379
+ const prepared = await io.preparePlatformClis({
380
+ gatewayUrl,
381
+ tenantId: selected.id,
382
+ platform: io.platform,
383
+ skipInstall: true,
384
+ // Goals re-run before every system-risk step: the probe must be
385
+ // pure discovery — no installs, profile writes, or skill syncs.
386
+ inspectOnly: true,
387
+ });
388
+ return prepared.missingAfter.length === 0;
389
+ },
374
390
  }],
375
391
  explicit: Boolean(flags.repair),
376
392
  noRecovery: false,
@@ -1,14 +1,21 @@
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
5
  import { RUNTIME_BRAND } from "../runtimeBrand.js";
5
6
 
6
7
  // This is the `apiKeyHelper` / auth-command contract: stdout (and only
7
8
  // stdout) must be exactly the bearer token, nothing else. Both Claude Code's
8
9
  // apiKeyHelper and Codex CLI's `model_providers.<id>.auth.command` call this.
9
- export async function cmdToken(argv = []) {
10
+ export async function cmdToken(argv = [], overrides = {}) {
11
+ const io = {
12
+ loadConfig,
13
+ ensureTenantSelection,
14
+ repairManagedApps: maybeRepairManagedApps,
15
+ ...overrides,
16
+ };
10
17
  const { flags } = parseFlags(argv, { tenant: { type: "string" } });
11
- const config = loadConfig();
18
+ const config = io.loadConfig();
12
19
  if (!config?.pat) {
13
20
  process.stderr.write(`${RUNTIME_BRAND.cli.command}: not authenticated. Run \`${RUNTIME_BRAND.cli.command} setup\` (or \`${RUNTIME_BRAND.cli.command} auth\`) first.\n`);
14
21
  process.exitCode = 1;
@@ -17,8 +24,21 @@ export async function cmdToken(argv = []) {
17
24
  try {
18
25
  const tenantId = flags.tenant
19
26
  ? normalizeTenantId(flags.tenant)
20
- : (await ensureTenantSelection(config)).tenantId;
27
+ : (await io.ensureTenantSelection(config)).tenantId;
21
28
  process.stdout.write(`${tenantCredential(config.pat, tenantId)}\n`);
29
+ // Drift-aware self-heal, strictly AFTER the token bytes are on stdout: the
30
+ // vendor apps call this helper on every auth, which makes it an Impel
31
+ // entry point that keeps running even after a vendor settings-writer
32
+ // rewrites a managed profile (a drifted config once stopped invoking the
33
+ // rest of the heal channel entirely). Kick the same detached,
34
+ // heartbeat-locked stale-only refresh the session hooks use — for both
35
+ // Codex and Claude drift. It must never write to stdout, never block the
36
+ // emission above, and never change this command's exit code.
37
+ try {
38
+ io.repairManagedApps(tenantId);
39
+ } catch {
40
+ // Opportunistic only; the token contract is already fulfilled.
41
+ }
22
42
  } catch (error) {
23
43
  process.stderr.write(`${RUNTIME_BRAND.cli.command}: ${error.message}\n`);
24
44
  process.exitCode = 1;
@@ -10,7 +10,7 @@ import { loadConfig, redactSecretText } from "../config.js";
10
10
  import { nativeCommandInvocation } from "../nativeProcess.js";
11
11
  import { withProgress } from "../progress.js";
12
12
  import { runInstallRecovery } from "../installRecovery/engine.js";
13
- import { brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
13
+ import { brandedEnvironmentName, brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
14
14
  import { IMPEL_CLI_ENTRYPOINT } from "../selfInvocation.js";
15
15
  import {
16
16
  fetchRemoteVersion,
@@ -18,12 +18,20 @@ import {
18
18
  isNewerVersion,
19
19
  refreshUpdateCache,
20
20
  updateInstallSpec,
21
+ updatePackage,
21
22
  updateTagForVersion,
22
23
  writeUpdateCache,
23
24
  } from "../updates.js";
24
25
 
25
26
  const CLI_BIN = IMPEL_CLI_ENTRYPOINT;
26
27
 
28
+ /**
29
+ * Loop guard for the fresh-build continuation: `_converge` children run with
30
+ * this marker so a nested `impel update` inside the cascade can never start
31
+ * another self-update (and thus another cascade) recursively.
32
+ */
33
+ export const UPDATE_CASCADE_ENV = brandedEnvironmentName("UPDATE_CASCADE");
34
+
27
35
  /**
28
36
  * The version now on disk at the package that owns CLI_BIN, read FRESH (never
29
37
  * from this process's cached module graph). After `npm install -g` this is the
@@ -39,6 +47,28 @@ export function postInstallCliVersion() {
39
47
  }
40
48
  }
41
49
 
50
+ /**
51
+ * Whether the build at CLI_BIN actually RUNS and reports the expected
52
+ * version. A bare package.json read is not proof after a failed
53
+ * `npm install -g`: the global replace is non-atomic (notably on Windows),
54
+ * so a half-written package can carry the new manifest with broken code.
55
+ */
56
+ export function postInstallCliRunnable(expectedVersion, { spawn = spawnSync, execPath = process.execPath } = {}) {
57
+ if (typeof expectedVersion !== "string" || !expectedVersion.trim()) return false;
58
+ try {
59
+ const run = spawn(execPath, [CLI_BIN, "--version"], {
60
+ encoding: "utf8",
61
+ stdio: ["ignore", "pipe", "pipe"],
62
+ timeout: 15_000,
63
+ windowsHide: true,
64
+ });
65
+ if (run.status !== 0 || run.error) return false;
66
+ return String(run.stdout || "").trim() === expectedVersion.trim();
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
42
72
  const HELP = brandedText(`impel update - update everything Impel in one command
43
73
 
44
74
  Reinstalls impel-cli from npm, then uses the new build to discover every
@@ -101,11 +131,67 @@ function defaultSelfUpdate(spec) {
101
131
  return installUpdateFromRegistry(spec);
102
132
  }
103
133
 
134
+ /**
135
+ * After `npm install -g` exits 0 but the RUNNING CLI's own package still
136
+ * resolves the old version (npm-prefix skew: nvm/Homebrew node transitions,
137
+ * npm 11 dangling prepare-script symlinks), locate the build npm actually
138
+ * installed so the cascade can run under it. Fail-closed: the entry point
139
+ * must live inside the global root of the same `npm` the installer ran, and
140
+ * its package.json must resolve exactly the version npm reported for the
141
+ * update tag; anything else returns null and the caller keeps the hard error.
142
+ */
143
+ export function resolveFreshGlobalEntrypoint(targetVersion, dependencies = {}) {
144
+ const io = {
145
+ spawnSync,
146
+ platform: process.platform,
147
+ environment: process.env,
148
+ readFileSync: fs.readFileSync,
149
+ existsSync: fs.existsSync,
150
+ ...dependencies,
151
+ };
152
+ if (typeof targetVersion !== "string" || !targetVersion.trim()) return null;
153
+ try {
154
+ const invocation = nativeCommandInvocation("npm", ["root", "-g"], io.environment, io.platform);
155
+ const run = io.spawnSync(invocation.command, invocation.args, {
156
+ encoding: "utf8",
157
+ env: io.environment,
158
+ stdio: ["ignore", "pipe", "pipe"],
159
+ timeout: 15_000,
160
+ windowsHide: true,
161
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
162
+ });
163
+ if (run.status !== 0 || run.error) return null;
164
+ const globalRoot = String(run.stdout || "").trim();
165
+ if (!globalRoot || !path.isAbsolute(globalRoot)) return null;
166
+ // Containment is enforced at every hop: the package directory must stay
167
+ // inside the npm global root (an env-supplied package name cannot
168
+ // traverse out), the declared bin must stay inside the package, and the
169
+ // real (symlink-resolved) entry point must still live under the real
170
+ // global root. Anything else fails closed to the caller's hard error.
171
+ const packageRoot = path.resolve(path.join(globalRoot, ...updatePackage().split("/")));
172
+ if (!packageRoot.startsWith(path.resolve(globalRoot) + path.sep)) return null;
173
+ const pkg = JSON.parse(io.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
174
+ if (pkg?.version !== targetVersion) return null;
175
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[RUNTIME_BRAND.cli.command];
176
+ if (typeof bin !== "string" || !bin.trim()) return null;
177
+ const entrypoint = path.resolve(packageRoot, bin);
178
+ if (!entrypoint.startsWith(packageRoot + path.sep)) return null;
179
+ if (!io.existsSync(entrypoint)) return null;
180
+ const realEntrypoint = (io.realpathSync || fs.realpathSync)(entrypoint);
181
+ const realRoot = (io.realpathSync || fs.realpathSync)(globalRoot);
182
+ if (!realEntrypoint.startsWith(realRoot + path.sep)) return null;
183
+ return entrypoint;
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
104
189
  // The cascading steps re-execute the (freshly installed) CLI binary so the
105
190
  // NEW code performs them, not the process that started the update. On
106
191
  // Windows this is also what refreshes the stable %LOCALAPPDATA% entry point:
107
192
  // bin/impel.js rewrites it at startup, so the shim is only ever updated by a
108
- // build that the postInstallCliVersion guard below already verified.
193
+ // build verified either by the postInstallCliVersion guard below or by
194
+ // resolveFreshGlobalEntrypoint's fail-closed skew resolution.
109
195
  export function defaultRunConvergence({
110
196
  skipApps = false,
111
197
  skipClis = false,
@@ -113,6 +199,7 @@ export function defaultRunConvergence({
113
199
  spawn = spawnSync,
114
200
  execPath = process.execPath,
115
201
  cliBin = CLI_BIN,
202
+ environment = process.env,
116
203
  } = {}) {
117
204
  const args = [cliBin, "_converge"];
118
205
  if (skipApps) args.push("--skip-apps");
@@ -120,6 +207,7 @@ export function defaultRunConvergence({
120
207
  if (noRecovery) args.push("--no-recovery");
121
208
  const result = spawn(execPath, args, {
122
209
  stdio: "inherit",
210
+ env: { ...environment, [UPDATE_CASCADE_ENV]: "1" },
123
211
  });
124
212
  return result.status === 0;
125
213
  }
@@ -161,9 +249,12 @@ export async function cmdUpdate(argv, overrides = {}) {
161
249
  runSkillsSync: defaultRunSkillsSync,
162
250
  runAgentsSync: defaultRunAgentsSync,
163
251
  platform: process.platform,
252
+ environment: process.env,
164
253
  progress: withProgress,
165
254
  recoverInstall: runInstallRecovery,
166
255
  postInstallVersion: postInstallCliVersion,
256
+ postInstallRunnable: postInstallCliRunnable,
257
+ resolveFreshInstall: resolveFreshGlobalEntrypoint,
167
258
  loadConfig,
168
259
  ...overrides,
169
260
  };
@@ -225,10 +316,22 @@ export async function cmdUpdate(argv, overrides = {}) {
225
316
  }
226
317
 
227
318
  // ── CLI ────────────────────────────────────────────────────────────────
319
+ let cascadeEntrypoint = null;
228
320
  if (!remote) {
229
321
  console.warn("CLI: npm update check unavailable; keeping the installed build.");
230
322
  } else if (upToDate) {
231
323
  console.log("CLI: already up to date.");
324
+ } else if (io.environment?.[UPDATE_CASCADE_ENV] === "1") {
325
+ // Loop guard: a self-update must never start from inside its own
326
+ // fresh-build continuation, or a buggy cascade could respawn forever.
327
+ // Degrade rather than abort: an inherited marker (e.g. a terminal opened
328
+ // under a converge child) must not permanently brick `impel update` —
329
+ // convergence still runs; only the self-update is withheld.
330
+ console.warn(
331
+ `impel update: skipping the self-update because ${UPDATE_CASCADE_ENV} is set `
332
+ + "(update cascade in progress, or inherited from one). Convergence continues; "
333
+ + `for a self-update, unset ${UPDATE_CASCADE_ENV} or open a fresh terminal.`,
334
+ );
232
335
  } else {
233
336
  console.log(`CLI: installing the npm ${updateTag} build…`);
234
337
  if (!await io.progress(`Installing the npm ${updateTag} impel-cli build`, () => io.selfUpdate(installSpec))) {
@@ -240,8 +343,11 @@ export async function cmdUpdate(argv, overrides = {}) {
240
343
  const config = io.loadConfig();
241
344
  let recovered = false;
242
345
  if (config?.pat && config?.tenantId) {
243
- // The goal tracks the most recent reviewed retry of the npm install:
244
- // recovery may only end "fixed" once that retry actually succeeded.
346
+ // The goal passes on the most recent reviewed retry of the npm
347
+ // install OR on a live disk probe of the package that owns the
348
+ // running CLI: when the user completes the printed manual
349
+ // `npm install --global …` in another terminal mid-recovery, the goal
350
+ // must observe that completion instead of re-issuing the same step.
245
351
  const updateState = { ok: false };
246
352
  try {
247
353
  const recovery = await io.recoverInstall(
@@ -259,7 +365,10 @@ export async function cmdUpdate(argv, overrides = {}) {
259
365
  {
260
366
  id: "cli-update",
261
367
  description: `The global ${RUNTIME_BRAND.cli.packageName} npm update completed successfully`,
262
- run: () => updateState.ok === true,
368
+ run: () => updateState.ok === true
369
+ || (remote != null
370
+ && io.postInstallVersion() === remote
371
+ && io.postInstallRunnable(remote)),
263
372
  },
264
373
  ],
265
374
  explicit: Boolean(flags.repair),
@@ -296,19 +405,35 @@ export async function cmdUpdate(argv, overrides = {}) {
296
405
  console.log("CLI: recovery verified the update path.");
297
406
  }
298
407
  // npm exiting 0 is NOT proof the RUNNING install was updated: with multiple
299
- // Node installs / npm prefixes (common on Windows), the install can land in
300
- // a different global prefix while CLI_BIN the path the cascade re-executes
301
- // still holds the old build. Cascading then runs old code that believes
302
- // an update is still pending, which is how unbounded respawn storms start.
303
- // Probe the version at CLI_BIN fresh from disk and refuse to cascade on skew.
408
+ // Node installs / npm prefixes (nvm/Homebrew node transitions on macOS,
409
+ // prefix skew on Windows), the install can land in a different global
410
+ // prefix while CLI_BIN the path the cascade re-executes still holds
411
+ // the old build. Cascading old code that believes an update is still
412
+ // pending is how unbounded respawn storms start, so the cascade may only
413
+ // ever run a build that provably resolves the target version. Probe the
414
+ // version at CLI_BIN fresh from disk; on skew, locate and verify the
415
+ // build npm actually installed and hand the continuation to THAT binary.
416
+ // (The previous behavior — abort with exit 1 — left the machine
417
+ // half-updated: CLI updated in the other prefix, no tenant/app/skills
418
+ // convergence, and a rerun from the same terminal repeated the abort via
419
+ // the shell's stale command hash. That is the field-reported "run
420
+ // `impel update` twice, sometimes in separate terminals".)
304
421
  const postInstall = io.postInstallVersion();
305
422
  if (remote && postInstall !== remote) {
306
- console.error(`impel update: npm reported success but the running CLI still resolves v${postInstall ?? "?"} (expected v${remote}).`);
307
- console.error(` Running CLI: ${CLI_BIN}`);
308
- console.error(" This usually means the `impel` on PATH belongs to a different npm prefix than `npm prefix -g`.");
309
- console.error(` Fix: run \`npm prefix -g\`, confirm it owns the \`impel\` shim on PATH, then \`npm install --global ${installSpec}\` there.`);
310
- process.exitCode = 1;
311
- return;
423
+ cascadeEntrypoint = io.resolveFreshInstall(remote);
424
+ if (!cascadeEntrypoint) {
425
+ console.error(`impel update: npm reported success but the running CLI still resolves v${postInstall ?? "?"} (expected v${remote}), and the freshly installed build could not be located and verified.`);
426
+ console.error(` Running CLI: ${CLI_BIN}`);
427
+ console.error(" This usually means the `impel` on PATH belongs to a different npm prefix than `npm prefix -g`.");
428
+ console.error(` Fix: run \`npm prefix -g\`, confirm it owns the \`impel\` shim on PATH, then \`npm install --global ${installSpec}\` there.`);
429
+ process.exitCode = 1;
430
+ return;
431
+ }
432
+ console.warn(`impel update: the running CLI at ${CLI_BIN} still resolves v${postInstall ?? "?"}; continuing with the freshly installed v${remote}.`);
433
+ console.warn(` Fresh build: ${cascadeEntrypoint}`);
434
+ console.warn(io.platform === "win32"
435
+ ? " Your current shell may still launch the old install. Open a new terminal, and align the `impel` on PATH with `npm prefix -g`."
436
+ : " Your current shell may still launch the old install. Run `hash -r` (zsh/bash) or open a new terminal, and align the `impel` on PATH with `npm prefix -g`.");
312
437
  }
313
438
  console.log(`CLI: updated${remote ? ` to v${remote}` : ""}.`);
314
439
  }
@@ -318,6 +443,9 @@ export async function cmdUpdate(argv, overrides = {}) {
318
443
  skipApps: Boolean(flags["skip-apps"]),
319
444
  skipClis: Boolean(flags["skip-clis"]),
320
445
  noRecovery: Boolean(flags["no-recovery"]),
446
+ // On npm-prefix skew the continuation must re-exec the verified fresh
447
+ // build; everywhere else CLI_BIN already holds the new bytes.
448
+ ...(cascadeEntrypoint ? { cliBin: cascadeEntrypoint } : {}),
321
449
  };
322
450
  if (!await io.runConvergence(convergenceArgs)) {
323
451
  console.error("impel update: tenant convergence failed; rerun `impel update` after addressing the reported issue.");
@@ -358,6 +358,27 @@ export async function runInstallRecovery(options, overrides = {}) {
358
358
  continue;
359
359
  }
360
360
  }
361
+ // A blocked report gets one live goal pass before it stands: when
362
+ // every health check succeeds (a skipped step, or the user
363
+ // finishing the work in another terminal), the machine is healthy
364
+ // regardless of what the model concluded — mirroring the
365
+ // budget-exhaustion upgrade below.
366
+ if (status === "blocked") {
367
+ goalReport = await runGoals(session.goals);
368
+ if (goalReport.passed) {
369
+ finished = {
370
+ status: "fixed",
371
+ summary: "Every goal health check passes; the reported blocker is no longer present.",
372
+ userAction: null,
373
+ };
374
+ toolResults.push({
375
+ type: "tool_result",
376
+ tool_use_id: call.id,
377
+ content: "Outcome recorded.",
378
+ });
379
+ break;
380
+ }
381
+ }
361
382
  finished = {
362
383
  status,
363
384
  summary: String(call.input?.summary || "").slice(0, 2_000),
@@ -371,6 +392,24 @@ export async function runInstallRecovery(options, overrides = {}) {
371
392
  break;
372
393
  }
373
394
 
395
+ // Live re-probe before any system-risk step: when every goal health
396
+ // check already passes, the state this step would produce is already
397
+ // present on the machine (typically because the user completed the
398
+ // instruction in another terminal mid-session). Skip the step instead
399
+ // of re-prompting for an installer the environment no longer needs.
400
+ if (installRecoveryToolRisk(call.name) === "system" && session.goals.length) {
401
+ const live = await runGoals(session.goals);
402
+ if (live.passed) {
403
+ io.log(`Install recovery: skipping ${call.name} — every goal health check already passes.`);
404
+ toolResults.push({
405
+ type: "tool_result",
406
+ tool_use_id: call.id,
407
+ content: "Skipped: every goal health check now passes — this step is already satisfied on this machine. Verify with check_health and finish with report_outcome.",
408
+ });
409
+ continue;
410
+ }
411
+ }
412
+
374
413
  const result = await executeCall(session, call.name, call.input);
375
414
  if (result.outcome === "declined" && installRecoveryToolRisk(call.name) === "system") {
376
415
  declinedSystemCalls += 1;
@@ -397,6 +436,12 @@ export async function runInstallRecovery(options, overrides = {}) {
397
436
  }
398
437
 
399
438
  if (declinedSystemCalls >= 2) {
439
+ // Same live upgrade as the blocked and budget paths: declining a
440
+ // repair the machine no longer needs must not read as a failure.
441
+ goalReport = await runGoals(session.goals);
442
+ if (goalReport.passed) {
443
+ return terminalReturn(session, "fixed", "The installation passes all health checks.");
444
+ }
400
445
  return terminalReturn(
401
446
  session,
402
447
  "aborted",
package/src/macSetup.js CHANGED
@@ -7,7 +7,7 @@ import { spawnSync } from "node:child_process";
7
7
  import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles.js";
8
8
  import { findNativeBinary } from "./nativeProcess.js";
9
9
  import { syncSkillsSafe } from "./skills.js";
10
- import { verifyReviewedMacVendorCli } from "./vendorCliBinaries.js";
10
+ import { findReviewedVendorCliBinary, verifyReviewedMacVendorCli } from "./vendorCliBinaries.js";
11
11
  import { PINNED_VENDOR_CLI_VERSIONS } from "./vendorCliVersions.js";
12
12
 
13
13
  const MAX_INSTALLER_BYTES = 512 * 1024;
@@ -49,13 +49,14 @@ export const MAC_CLI_INSTALLERS = Object.freeze({
49
49
  });
50
50
 
51
51
  function detectMacClis(find, environment, verify) {
52
- const detect = (tool) => find(
53
- tool,
54
- environment,
55
- "darwin",
56
- "IMPEL",
57
- (binary) => verify(tool, binary, environment),
58
- );
52
+ // Detection must share the launcher's reviewed resolution (including the
53
+ // side-by-side fallback after vendor auto-update drift); a raw PATH probe
54
+ // here would classify a drifted-but-launchable install as missing and
55
+ // reinstall it on every setup/update pass.
56
+ const detect = (tool) => findReviewedVendorCliBinary(tool, environment, "darwin", "IMPEL", {
57
+ find,
58
+ verify,
59
+ });
59
60
  return { claude: detect("claude"), codex: detect("codex") };
60
61
  }
61
62
 
@@ -171,6 +172,7 @@ export async function prepareMacClis({
171
172
  gatewayUrl,
172
173
  tenantId,
173
174
  skipInstall = false,
175
+ inspectOnly = false,
174
176
  installTools = ["claude", "codex"],
175
177
  } = {}, dependencies = {}) {
176
178
  const io = {
@@ -193,6 +195,22 @@ export async function prepareMacClis({
193
195
  if (requested.size !== installTools.length || [...requested].some((tool) => !io.installers[tool])) {
194
196
  throw new Error("installTools must contain unique supported macOS CLI names");
195
197
  }
198
+ // A pure discovery probe: recovery goals and health checks re-evaluate
199
+ // this repeatedly, so it must never install, write profiles, or sync
200
+ // skills — only report what is currently discoverable.
201
+ if (inspectOnly) {
202
+ return {
203
+ binaries: before,
204
+ missingBefore,
205
+ missingAfter: [...missingBefore],
206
+ installAttempted: false,
207
+ installSucceeded: null,
208
+ installFailure: null,
209
+ installations: {},
210
+ installCommands: {},
211
+ profiles: null,
212
+ };
213
+ }
196
214
 
197
215
  const installations = {};
198
216
  if (missingBefore.length && !skipInstall) {