@phnx-labs/agents-cli 1.20.43 → 1.20.44

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.
@@ -180,7 +180,9 @@ export declare function setGlobalDefault(agent: AgentId, version: string | undef
180
180
  /**
181
181
  * Install a specific version of an agent.
182
182
  */
183
- export declare function installVersion(agent: AgentId, version: string, onProgress?: (message: string) => void): Promise<{
183
+ export declare function installVersion(agent: AgentId, version: string, onProgress?: (message: string) => void, opts?: {
184
+ clean?: boolean;
185
+ }): Promise<{
184
186
  success: boolean;
185
187
  installedVersion: string;
186
188
  error?: string;
@@ -291,6 +293,57 @@ export { compareVersions };
291
293
  * Get actual version from an installed 'latest' directory.
292
294
  */
293
295
  export declare function getInstalledVersion(agent: AgentId, version: string): Promise<string | null>;
296
+ /**
297
+ * True when a probe's combined output/error looks like the runnable binary (or a
298
+ * native sub-binary it execs) is MISSING — the "gutted install" signature. This
299
+ * is the exact class of failure behind the ENOENT crash: an npm package whose JS
300
+ * wrapper landed at node_modules/.bin/<cli> while its native platform binary
301
+ * (shipped via an optional per-arch dependency, e.g. @openai/codex-<platform>)
302
+ * did not — so the wrapper spawns and immediately dies with `spawn … ENOENT`.
303
+ *
304
+ * Deliberately narrow: only the missing-file signature counts. An agent that
305
+ * merely dislikes `--version` (nonzero exit, ordinary error text) or ignores it
306
+ * (times out) must NOT match, so a healthy install is never falsely condemned.
307
+ */
308
+ export declare function isMissingBinarySignature(output: string): boolean;
309
+ /**
310
+ * Verify a freshly-installed agent can actually LAUNCH — not merely that its JS
311
+ * wrapper exists. getBinaryPath()/isVersionInstalled() only check the wrapper, so
312
+ * a gutted install (wrapper present, native binary missing) reads as healthy,
313
+ * gets pinned as the default, and gets picked to run — then dies with ENOENT the
314
+ * instant it spawns (which, wrapped in tmux, showed up as a silent `[detached]`).
315
+ *
316
+ * We probe `<binary> --version` under the version's isolated HOME so config
317
+ * resolution matches a real launch. Because the ENOENT originates in the child
318
+ * (the wrapper spawns fine, then fails to exec the absent native binary), we
319
+ * inspect the child's OUTPUT, not just whether our spawn succeeded. Only the
320
+ * missing-binary signature (see isMissingBinarySignature) fails the check; a
321
+ * plain nonzero exit or a timeout is treated as healthy so we never false-fail.
322
+ */
323
+ export declare function verifyInstalledBinaryLaunches(agent: AgentId, version: string): Promise<{
324
+ ok: boolean;
325
+ detail?: string;
326
+ }>;
327
+ /**
328
+ * Launch-path self-heal. Given the concrete version `agents run` is about to
329
+ * spawn, make sure it will actually run — and if not, repair it instead of
330
+ * letting the agent die with a raw `ENOENT` deep inside its own wrapper.
331
+ *
332
+ * Steps, cheapest first:
333
+ * 1. Probe the version (verifyInstalledBinaryLaunches). Healthy → return it.
334
+ * 2. Broken → **clean** reinstall in place (wipes the partial node_modules so
335
+ * npm actually re-fetches the platform binary). Re-probe; good → return it.
336
+ * 3. Still broken → fall back to another INSTALLED version that launches,
337
+ * re-pinning it as the default so the shim path heals too. Return it.
338
+ * 4. Nothing runnable installed → install `latest`, pin it, return it.
339
+ * 5. Give up → return null (caller surfaces a clear error).
340
+ *
341
+ * Gated to npm-package agents: their native binary ships as an optional per-arch
342
+ * dependency whose tarball can extract partially (interrupted/raced install) —
343
+ * the exact failure this repairs. Agents with a global/native binary (grok,
344
+ * droid) have no such tarball and are returned unchanged.
345
+ */
346
+ export declare function ensureAgentRunnable(agent: AgentId, version: string, log?: (message: string) => void): Promise<string | null>;
294
347
  /** Outcome of syncing resources to a version home, keyed by resource type. */
295
348
  export interface SyncResult {
296
349
  commands: boolean;
@@ -1054,7 +1054,7 @@ export function setGlobalDefault(agent, version) {
1054
1054
  /**
1055
1055
  * Install a specific version of an agent.
1056
1056
  */
1057
- export async function installVersion(agent, version, onProgress) {
1057
+ export async function installVersion(agent, version, onProgress, opts) {
1058
1058
  const agentConfig = AGENTS[agent];
1059
1059
  // Validate before deriving filesystem paths or npm package specs. The CLI
1060
1060
  // parser already enforces this for user input; this guard protects direct
@@ -1143,6 +1143,14 @@ export async function installVersion(agent, version, onProgress) {
1143
1143
  }
1144
1144
  ensureAgentsDir();
1145
1145
  const versionDir = getVersionDir(agent, version);
1146
+ // A `clean` (repair) reinstall wipes a possibly partially-extracted
1147
+ // node_modules first. npm treats a present-but-gutted platform package (its
1148
+ // package.json landed, its vendored native binary did not) as already
1149
+ // installed and would skip re-fetching it — so without this the corrupt
1150
+ // vendor/ survives the reinstall and the ENOENT persists. home/ is preserved.
1151
+ if (opts?.clean && fs.existsSync(versionDir)) {
1152
+ removeInstallArtifacts(versionDir);
1153
+ }
1146
1154
  // Create version directory and isolated home
1147
1155
  fs.mkdirSync(versionDir, { recursive: true });
1148
1156
  fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
@@ -1218,6 +1226,24 @@ export async function installVersion(agent, version, onProgress) {
1218
1226
  /* non-fatal; the install itself succeeded */
1219
1227
  }
1220
1228
  }
1229
+ // Integrity gate: confirm the install actually launches, not just that the
1230
+ // JS wrapper landed. A gutted install (wrapper present, native platform
1231
+ // binary missing) otherwise gets silently pinned as the default and crashes
1232
+ // with ENOENT on run. Fail loudly here so `agents add` never records a
1233
+ // broken version as healthy — the caller then won't set it as default.
1234
+ const health = await verifyInstalledBinaryLaunches(agent, installedVersion);
1235
+ if (!health.ok) {
1236
+ if (fs.existsSync(versionDir))
1237
+ removeInstallArtifacts(versionDir);
1238
+ const detail = health.detail ? ` (${health.detail})` : '';
1239
+ emit('version.install', { agent, version: installedVersion, error: `binary failed to launch${detail}` });
1240
+ return {
1241
+ success: false,
1242
+ installedVersion,
1243
+ error: `${agentConfig.name}@${installedVersion} installed but its binary failed to launch${detail}. `
1244
+ + `The install is incomplete — the platform binary is missing. Re-run: agents add ${agent}@${installedVersion}`,
1245
+ };
1246
+ }
1221
1247
  emit('version.install', { agent, version: installedVersion });
1222
1248
  return { success: true, installedVersion };
1223
1249
  }
@@ -1566,6 +1592,117 @@ export async function getInstalledVersion(agent, version) {
1566
1592
  return version;
1567
1593
  }
1568
1594
  }
1595
+ /**
1596
+ * True when a probe's combined output/error looks like the runnable binary (or a
1597
+ * native sub-binary it execs) is MISSING — the "gutted install" signature. This
1598
+ * is the exact class of failure behind the ENOENT crash: an npm package whose JS
1599
+ * wrapper landed at node_modules/.bin/<cli> while its native platform binary
1600
+ * (shipped via an optional per-arch dependency, e.g. @openai/codex-<platform>)
1601
+ * did not — so the wrapper spawns and immediately dies with `spawn … ENOENT`.
1602
+ *
1603
+ * Deliberately narrow: only the missing-file signature counts. An agent that
1604
+ * merely dislikes `--version` (nonzero exit, ordinary error text) or ignores it
1605
+ * (times out) must NOT match, so a healthy install is never falsely condemned.
1606
+ */
1607
+ export function isMissingBinarySignature(output) {
1608
+ return /\bENOENT\b|no such file|cannot find|command not found|is not recognized/i.test(output);
1609
+ }
1610
+ /**
1611
+ * Verify a freshly-installed agent can actually LAUNCH — not merely that its JS
1612
+ * wrapper exists. getBinaryPath()/isVersionInstalled() only check the wrapper, so
1613
+ * a gutted install (wrapper present, native binary missing) reads as healthy,
1614
+ * gets pinned as the default, and gets picked to run — then dies with ENOENT the
1615
+ * instant it spawns (which, wrapped in tmux, showed up as a silent `[detached]`).
1616
+ *
1617
+ * We probe `<binary> --version` under the version's isolated HOME so config
1618
+ * resolution matches a real launch. Because the ENOENT originates in the child
1619
+ * (the wrapper spawns fine, then fails to exec the absent native binary), we
1620
+ * inspect the child's OUTPUT, not just whether our spawn succeeded. Only the
1621
+ * missing-binary signature (see isMissingBinarySignature) fails the check; a
1622
+ * plain nonzero exit or a timeout is treated as healthy so we never false-fail.
1623
+ */
1624
+ export async function verifyInstalledBinaryLaunches(agent, version) {
1625
+ // Windows: `getBinaryPath` returns the extensionless `.bin/<cli>` (a shell
1626
+ // wrapper), NOT the `.cmd`/`.exe` that actually launches there — `execFile`ing
1627
+ // it would ENOENT on a perfectly healthy install, and the integrity gate would
1628
+ // then WIPE it. The gutted-native-binary failure this guards against is a POSIX
1629
+ // concern in practice; treat win32 as healthy rather than risk destroying a
1630
+ // good install. (isVersionInstalled already validates presence on Windows.)
1631
+ if (process.platform === 'win32')
1632
+ return { ok: true };
1633
+ const binary = getBinaryPath(agent, version);
1634
+ if (!fs.existsSync(binary)) {
1635
+ return { ok: false, detail: `binary not found at ${binary}` };
1636
+ }
1637
+ try {
1638
+ await execFileAsync(binary, ['--version'], {
1639
+ timeout: 15000,
1640
+ env: { ...process.env, HOME: getVersionHomePath(agent, version) },
1641
+ });
1642
+ return { ok: true };
1643
+ }
1644
+ catch (err) {
1645
+ const blob = `${err?.code ?? ''} ${err?.stdout ?? ''} ${err?.stderr ?? ''} ${err?.message ?? ''}`;
1646
+ if (err?.code === 'ENOENT' || isMissingBinarySignature(blob)) {
1647
+ const detail = String(err?.stderr || err?.message || '')
1648
+ .split('\n').map((s) => s.trim()).filter(Boolean)[0];
1649
+ return { ok: false, detail: detail || 'native binary missing (ENOENT)' };
1650
+ }
1651
+ // Launched but exited nonzero without a missing-file signature, or timed out
1652
+ // waiting for input: the binary is present and runnable. Healthy.
1653
+ return { ok: true };
1654
+ }
1655
+ }
1656
+ /**
1657
+ * Launch-path self-heal. Given the concrete version `agents run` is about to
1658
+ * spawn, make sure it will actually run — and if not, repair it instead of
1659
+ * letting the agent die with a raw `ENOENT` deep inside its own wrapper.
1660
+ *
1661
+ * Steps, cheapest first:
1662
+ * 1. Probe the version (verifyInstalledBinaryLaunches). Healthy → return it.
1663
+ * 2. Broken → **clean** reinstall in place (wipes the partial node_modules so
1664
+ * npm actually re-fetches the platform binary). Re-probe; good → return it.
1665
+ * 3. Still broken → fall back to another INSTALLED version that launches,
1666
+ * re-pinning it as the default so the shim path heals too. Return it.
1667
+ * 4. Nothing runnable installed → install `latest`, pin it, return it.
1668
+ * 5. Give up → return null (caller surfaces a clear error).
1669
+ *
1670
+ * Gated to npm-package agents: their native binary ships as an optional per-arch
1671
+ * dependency whose tarball can extract partially (interrupted/raced install) —
1672
+ * the exact failure this repairs. Agents with a global/native binary (grok,
1673
+ * droid) have no such tarball and are returned unchanged.
1674
+ */
1675
+ export async function ensureAgentRunnable(agent, version, log) {
1676
+ const cfg = AGENTS[agent];
1677
+ if (!cfg?.npmPackage)
1678
+ return version;
1679
+ if ((await verifyInstalledBinaryLaunches(agent, version)).ok)
1680
+ return version;
1681
+ log?.(`${cfg.name}@${version} is broken (platform binary missing) — repairing…`);
1682
+ const repair = await installVersion(agent, version, undefined, { clean: true });
1683
+ if (repair.success && (await verifyInstalledBinaryLaunches(agent, version)).ok) {
1684
+ log?.(`repaired ${cfg.name}@${version}.`);
1685
+ return version;
1686
+ }
1687
+ // In-place repair failed → adopt another installed version that launches.
1688
+ const others = listInstalledVersions(agent).filter(v => v !== version).sort(compareVersions).reverse();
1689
+ for (const cand of others) {
1690
+ if ((await verifyInstalledBinaryLaunches(agent, cand)).ok) {
1691
+ setGlobalDefault(agent, cand);
1692
+ log?.(`${cfg.name}@${version} could not be repaired — using ${cfg.name}@${cand} instead (now the default).`);
1693
+ return cand;
1694
+ }
1695
+ }
1696
+ // Nothing runnable installed → last resort: install latest and pin it.
1697
+ log?.(`no runnable ${cfg.name} version installed — installing ${cfg.name}@latest…`);
1698
+ const latest = await installVersion(agent, 'latest', undefined, { clean: true });
1699
+ if (latest.success) {
1700
+ setGlobalDefault(agent, latest.installedVersion);
1701
+ log?.(`installed ${cfg.name}@${latest.installedVersion} and set it as the default.`);
1702
+ return latest.installedVersion;
1703
+ }
1704
+ return null;
1705
+ }
1569
1706
  async function getCliVersionFromPath(agent) {
1570
1707
  const agentConfig = AGENTS[agent];
1571
1708
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.43",
3
+ "version": "1.20.44",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",