synabun 2026.7.31 β†’ 2026.7.34

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/CHANGELOG.md CHANGED
@@ -1,3 +1,51 @@
1
+ # SynaBun v.2026.07.34
2
+
3
+ A reliability release: the Claude sidepanel no longer dies with a misleading "does not match this system's libc" error, and every bundled native binary is checked and repaired at startup.
4
+
5
+ ## πŸ›  Fixed
6
+
7
+ - **"Native binary exists but failed to launch"** β€” The Claude sidepanel could fail every turn with *"Claude Code native binary at `…/claude` exists but failed to launch. This usually means the binary does not match this system's libc…"*. On macOS and Windows that explanation is never right: the real cause is almost always a missing execute bit. npm does not preserve the execute bit for files a package ships without a `bin` entry, which is exactly how the Claude Agent SDK and Codex deliver their native payloads, so a perfectly good binary can land as mode `0644` and fail to spawn. SynaBun now stats the binary the SDK is about to launch, restores the execute bit when it is missing, and retries β€” and when it cannot repair it, it says what is actually wrong: a permission problem on macOS and Linux, Gatekeeper quarantine (with the `xattr -d com.apple.quarantine` command) on macOS, a genuine libc mismatch only on Linux, and an unlaunchable `.cmd`/`.bat`/`.ps1` shim on Windows.
8
+ - **Only the current machine's binaries were repaired** β€” The startup repair looked at one directory, `prebuilds/<your platform>-<your arch>`, so a checkout carrying several platform builds left the others broken β€” including `darwin-x64`, which is what an Intel Mac needs for terminals. Startup and postinstall now sweep every vendored binary that is present (Claude Agent SDK, Codex, `node-pty`) by walking the tree instead of consulting a hardcoded list, so new platform directories are covered automatically.
9
+ - **The postinstall repair had stopped doing anything** β€” `scripts/rebuild-pty.js` was still fixing permissions on `node_modules/.bin/claude` and `@anthropic-ai/claude-code/cli.js`, neither of which has existed since that package was replaced by the Agent SDK. Its prebuild list named Linux directories that are not shipped while missing ones that are. It now runs the shared sweep, and runs it *before* the "node-pty not found, skipping" guard β€” previously an install without node-pty skipped the Claude and Codex repair entirely.
10
+ - **A `.cjs` executable override was launched the wrong way** β€” A custom Claude executable ending in `.cjs` was treated as a script to run through Node, but the SDK spawns it directly, so it needed the execute bit and did not get it.
11
+
12
+ ## ✨ New
13
+
14
+ - **Runtime notices in the Claude sidepanel** β€” A new amber notice sits between grey status text and a red fatal error: something went wrong, was worked around, and the session is continuing in a degraded state. You will see it when the bundled binary is repaired and retried, and when a session falls back to your globally installed Claude CLI. It appears in both the sidepanel and the standalone chat page.
15
+ - **Fallback to your installed Claude CLI** β€” If the bundled runtime cannot be launched or repaired, sessions and unattended automation loops now fall back to the Claude CLI you have installed rather than failing the turn. Because model aliases and effort levels resolve per-binary, a version difference between your CLI and the bundled runtime is reported alongside the fallback instead of silently changing which model runs.
16
+
17
+ ## βš™οΈ Changed
18
+
19
+ - **A custom Claude executable path is now validated before use** β€” The `claude-skin` β†’ `sdkExecutable` override was passed straight through to the SDK, which uses it verbatim with no checks of its own. It is now vetted first, and a bare command name such as `claude` is rejected with an explanation: the SDK spawns without a shell and cannot resolve a name from `PATH`, so handing one over produces a worse and more confusing failure than the one being recovered from. Overrides may be either a script entrypoint or a native binary path, and the same rule now applies to automation loops, which previously accepted anything.
20
+
21
+ ---
22
+
23
+ # SynaBun v.2026.07.33
24
+
25
+ A Windows release: Codex schedules and sidepanels now find a globally installed Codex runtime instead of failing at launch.
26
+
27
+ ## πŸ›  Fixed
28
+
29
+ - **Codex could not be found on Windows even with the global CLI installed** β€” SynaBun located the Codex runtime by looking it up on the inherited `PATH`, and the PATH it built for that lookup only added POSIX directories (`~/.local/bin`, `/usr/local/bin`, `~/.npm-global/bin`) that do not exist on Windows. Started from anywhere whose environment did not already carry the npm global bin β€” a shortcut, a service, a fresh terminal after an install β€” Codex was invisible and every Codex schedule failed. Discovery no longer depends on `PATH` at all: `@openai/codex` is now located under `npm root -g`, `%APPDATA%\npm\node_modules`, an `npm_config_prefix` override, the `node_modules` directory that contains SynaBun itself, and Node's own install directory. The Windows PATH augmentation also picks up `%APPDATA%\npm`, the configured npm prefix, and Node's directory, deduplicated, so the shell-aware launch paths improve too.
30
+ - **The native Codex executable was missed under current npm layouts** β€” The resolver looked in exactly one place, `vendor/<target>/codex/codex.exe` inside the platform package resolved through `require.resolve`. Current Codex releases ship the executable at `vendor/<target>/bin/`, and depending on the npm version the platform package sits either beside `@openai/codex` or nested inside its own `node_modules` β€” and package `exports` can block the resolve entirely. Both vendor layouts and all three package locations are now checked directly, so hoisting differences no longer read as "Codex is not installed".
31
+ - **Windows launched npm's shell shim instead of the real launcher** β€” `npm install -g` writes both an extensionless `codex` sh shim and a `codex.cmd`, and `where codex` lists the shim first, which Windows cannot spawn. The sidepanel launch path now prefers a `.cmd`, `.exe`, `.bat`, or `.ps1` launcher and keeps the first result only as a fallback for non-npm installations.
32
+
33
+ ## βš™οΈ Improved
34
+
35
+ - **A missing Codex runtime says which problem it is** β€” The failure notice now distinguishes "the global Codex package is installed but its Windows runtime is missing" from "Codex is not installed globally", and both point at `npm install -g @openai/codex@latest --include=optional`. The platform executable ships as an optional dependency, so an install that skipped optional packages leaves a Codex that looks present and cannot run. Successful resolution logs which layout it came from, and failures log the candidate paths that were checked.
36
+
37
+ ---
38
+
39
+ # SynaBun v.2026.07.32
40
+
41
+ A follow-up to 2026.07.31 that makes updating fast again on a large data home.
42
+
43
+ ## πŸ›  Fixed
44
+
45
+ - **Updating took several minutes on a large data home** β€” Before a new version starts, SynaBun snapshots your data home so the update can be rolled back. That snapshot archived *everything*, so a multi-GB data home spent minutes compressing during `npm install` β€” one report was 9,470 files and 3,832 MB. An upgrade snapshot only needs to protect state: it never rewrites your generated images, audio, or video, and those survive a rollback on disk regardless. Upgrade snapshots now skip generated media, and if what remains is still over ~1 GB they fall back to irreplaceable state only β€” your `.env`, everything under `mcp-data/`, and any `.db`, `.sqlite`, or `.json`. Compression on the blocking path drops to the fastest level, since what is left is SQLite and JSON that still shrink well. The snapshot reports what it skipped and why, and its manifest records whether it is `state-only` or `complete` so a restore can never mistake one for the other. Scheduled and manual backups are unchanged and still capture everything.
46
+
47
+ ---
48
+
1
49
  # SynaBun v.2026.07.30
2
50
 
3
51
  A point release on top of 2026.07.20, focused on correct Claude Code model selection, a new project storage cleanup tool, and a stricter default browser policy.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-memory-mcp",
3
- "version": "2026.7.30",
3
+ "version": "2026.7.34",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-memory-mcp",
9
- "version": "2026.7.30",
9
+ "version": "2026.7.34",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@huggingface/transformers": "^3.0.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-mcp",
3
- "version": "2026.7.30",
3
+ "version": "2026.7.34",
4
4
  "private": true,
5
5
  "description": "Private MCP server runtime bundled with SynaBun for persistent memory and agent tooling",
6
6
  "license": "Apache-2.0",
@@ -59,6 +59,65 @@ const INCOMPRESSIBLE_EXTENSIONS = new Set([
59
59
  '.png', '.webm', '.webp', '.woff', '.woff2', '.zip',
60
60
  ]);
61
61
 
62
+ // Generated media. An upgrade snapshot exists to roll back *state* if a new
63
+ // version misbehaves; it never rewrites these, and they survive a rollback on
64
+ // disk regardless. Archiving them put multi-GB data homes through several
65
+ // minutes of compression on the npm update path, so upgrade snapshots skip
66
+ // them. Scheduled and manual backups still capture everything.
67
+ const BULK_MEDIA_EXTENSIONS = new Set([
68
+ '.avif', '.bmp', '.gif', '.heic', '.jpeg', '.jpg', '.m4a', '.m4v', '.mkv',
69
+ '.mov', '.mp3', '.mp4', '.ogg', '.png', '.tiff', '.wav', '.webm', '.webp',
70
+ ]);
71
+
72
+ // Snapshot kinds taken while the user is blocked on a version change.
73
+ const UPGRADE_BACKUP_KINDS = new Set(['pre-update', 'pre-migration']);
74
+
75
+ // Irreplaceable state: the memory database, UI state databases, and the JSON
76
+ // that configures them. Everything a rollback actually needs lives here.
77
+ const ESSENTIAL_STATE_EXTENSIONS = new Set(['.db', '.sqlite', '.sqlite3', '.json']);
78
+
79
+ // Above this, an upgrade snapshot stops trying to be comprehensive and keeps
80
+ // only irreplaceable state. Dropping media is not enough on its own β€” a data
81
+ // home can reach multiple GB in ordinary files, and compressing that on the
82
+ // npm update path is what made upgrades look broken.
83
+ const UPGRADE_SNAPSHOT_SOFT_LIMIT_BYTES = 1024 * 1024 * 1024;
84
+
85
+ function fileBytes(path) {
86
+ try { return statSync(path).size; } catch { return 0; }
87
+ }
88
+
89
+ /**
90
+ * Pick what an upgrade snapshot archives. Generated media always goes; if the
91
+ * remainder is still oversized, fall back to irreplaceable state only. Returns
92
+ * the kept files plus what was dropped, so the caller can say so out loud.
93
+ */
94
+ function selectUpgradeSnapshotFiles(files, { softLimitBytes = UPGRADE_SNAPSHOT_SOFT_LIMIT_BYTES } = {}) {
95
+ const excluded = { reason: 'generated-media', files: 0, bytes: 0 };
96
+ const kept = [];
97
+ for (const file of files) {
98
+ if (BULK_MEDIA_EXTENSIONS.has(extname(file.archivePath).toLowerCase())) {
99
+ excluded.files += 1;
100
+ excluded.bytes += fileBytes(file.diskPath);
101
+ continue;
102
+ }
103
+ kept.push(file);
104
+ }
105
+
106
+ const keptBytes = kept.reduce((total, file) => total + fileBytes(file.diskPath), 0);
107
+ if (keptBytes <= softLimitBytes) return { files: kept, excluded };
108
+
109
+ const essential = [];
110
+ for (const file of kept) {
111
+ const isEnv = file.archivePath === 'env.bak';
112
+ const isMcpState = file.archivePath.startsWith('mcp-data/');
113
+ const isStateFile = ESSENTIAL_STATE_EXTENSIONS.has(extname(file.archivePath).toLowerCase());
114
+ if (isEnv || isMcpState || isStateFile) { essential.push(file); continue; }
115
+ excluded.files += 1;
116
+ excluded.bytes += fileBytes(file.diskPath);
117
+ }
118
+ return { files: essential, excluded: { ...excluded, reason: 'oversized-data-home' } };
119
+ }
120
+
62
121
  export class BackupStorageError extends Error {
63
122
  constructor({ availableBytes, requiredBytes, folderPath }) {
64
123
  const availableMiB = Math.floor(availableBytes / 1024 / 1024);
@@ -293,11 +352,11 @@ function normalizeAdditionalEntries(entries = []) {
293
352
  return result;
294
353
  }
295
354
 
296
- async function writeArchive({ tempPath, files, manifest }) {
355
+ async function writeArchive({ tempPath, files, manifest, level = 6 }) {
297
356
  const { createWriteStream } = await import('node:fs');
298
357
  await new Promise((resolveWrite, reject) => {
299
358
  const output = createWriteStream(tempPath, { flags: 'wx' });
300
- const archive = archiver('zip', { zlib: { level: 6 } });
359
+ const archive = archiver('zip', { zlib: { level } });
301
360
  output.on('close', resolveWrite);
302
361
  output.on('error', reject);
303
362
  archive.on('warning', error => error.code === 'ENOENT' ? null : reject(error));
@@ -341,6 +400,7 @@ export async function createVerifiedBackup({
341
400
  getAvailableBytes = backupAvailableBytes,
342
401
  onBeforeArchive = null,
343
402
  onProgress = null,
403
+ upgradeSoftLimitBytes = UPGRADE_SNAPSHOT_SOFT_LIMIT_BYTES,
344
404
  now = () => new Date(),
345
405
  } = {}) {
346
406
  // A large data home spends minutes in checksum/archive with no output. Phase
@@ -372,10 +432,19 @@ export async function createVerifiedBackup({
372
432
 
373
433
  // De-duplicate before estimating work space so explicit additional entries
374
434
  // cannot inflate the preflight or create ambiguous ZIP members.
375
- const sourceFiles = [...new Map(files.map(file => [
435
+ const deduped = [...new Map(files.map(file => [
376
436
  file.archivePath.replace(/\\/g, '/'),
377
437
  file,
378
438
  ])).values()];
439
+
440
+ // Keep the upgrade path bounded: state is archived, generated media is not.
441
+ const leanSnapshot = UPGRADE_BACKUP_KINDS.has(kind);
442
+ const selection = leanSnapshot
443
+ ? selectUpgradeSnapshotFiles(deduped, { softLimitBytes: upgradeSoftLimitBytes })
444
+ : { files: deduped, excluded: null };
445
+ const sourceFiles = selection.files;
446
+ const excluded = selection.excluded;
447
+
379
448
  const workingSpace = assertBackupWorkingSpace({
380
449
  folderPath,
381
450
  files: sourceFiles,
@@ -384,7 +453,14 @@ export async function createVerifiedBackup({
384
453
  freeSpaceReserveBytes,
385
454
  getAvailableBytes,
386
455
  });
387
- report({ phase: 'collect', files: sourceFiles.length, bytes: workingSpace.archiveBytes });
456
+ report({
457
+ phase: 'collect',
458
+ files: sourceFiles.length,
459
+ bytes: workingSpace.archiveBytes,
460
+ skippedFiles: excluded?.files ?? 0,
461
+ skippedBytes: excluded?.bytes ?? 0,
462
+ skippedReason: excluded?.reason ?? null,
463
+ });
388
464
 
389
465
  sqliteSnapshot = createConsistentSqliteSnapshot(databasePath, sqliteTemp);
390
466
  files.length = 0;
@@ -429,12 +505,18 @@ export async function createVerifiedBackup({
429
505
  } : null,
430
506
  files: finalFiles.map(file => file.archivePath),
431
507
  checksums,
508
+ // A lean snapshot is not a whole-data-home backup. Recording that here
509
+ // keeps a restore from silently presenting it as one.
510
+ scope: leanSnapshot ? 'state-only' : 'complete',
511
+ excluded,
432
512
  verified: true,
433
513
  verification: { algorithm: 'sha256', publication: 'verify-before-atomic-rename' },
434
514
  };
435
515
  if (typeof onBeforeArchive === 'function') await onBeforeArchive({ files: finalFiles, manifest });
436
516
  report({ phase: 'archive', files: finalFiles.length, bytes: workingSpace.archiveBytes });
437
- await writeArchive({ tempPath, files: finalFiles, manifest });
517
+ // Level 1 on the blocking upgrade path: the remaining payload is mostly
518
+ // SQLite and JSON, where fast deflate still shrinks well.
519
+ await writeArchive({ tempPath, files: finalFiles, manifest, level: leanSnapshot ? 1 : 6 });
438
520
  report({ phase: 'verify', files: finalFiles.length });
439
521
  const publishedManifest = verifyBackupArchive(tempPath);
440
522
  renameSync(tempPath, destination);
@@ -20,7 +20,14 @@
20
20
  // session with no WS bound, so page-refresh reattach is the same object.
21
21
 
22
22
  import { query } from '@anthropic-ai/claude-agent-sdk';
23
- import { CLAUDE_EFFORT_LEVELS } from './claude-model-catalog.js';
23
+ import { CLAUDE_EFFORT_LEVELS, checkClaudeCliSkew } from './claude-model-catalog.js';
24
+ import {
25
+ claudeRuntime,
26
+ clearNativeBinaryCache,
27
+ isNativeBinaryLaunchFailure,
28
+ planRuntimeRecovery,
29
+ resolveClaudeExecutableOverride,
30
+ } from './native-binary-runtime.js';
24
31
  import { randomUUID } from 'node:crypto';
25
32
  import { dirname } from 'node:path';
26
33
  import { existsSync, readFileSync } from 'node:fs';
@@ -154,6 +161,12 @@ class ClaudeSession {
154
161
  this.lastPrompt = null;
155
162
  this.stallRetries = 0;
156
163
 
164
+ // Native-runtime recovery latches. Set here and NOWHERE else β€” resetting
165
+ // them on query recreation would turn a permanently broken binary into an
166
+ // infinite repair/retry loop.
167
+ this._nativeRepairTried = false;
168
+ this._fallbackBin = null;
169
+
157
170
  this.pendingPerms = new Map(); // requestId β†’ { toolName, resolve, input, kind }
158
171
  this.alwaysAllowed = new Set();
159
172
 
@@ -291,13 +304,34 @@ class ClaudeSession {
291
304
  if (parentDir && parentDir !== workDir && dirname(parentDir) !== parentDir) {
292
305
  options.additionalDirectories = [parentDir];
293
306
  }
294
- // Default to the SDK's bundled CLI (version-matched). An explicit .js override
295
- // can be set via cli-config.json β†’ "claude-skin": { "sdkExecutable": "…/cli.js" }.
307
+ // Default to the SDK's bundled CLI (version-matched). An override can be set
308
+ // via cli-config.json β†’ "claude-skin": { "sdkExecutable": "…" } and may be
309
+ // either a .js entrypoint (launched via node) or a native binary path.
310
+ //
311
+ // The SDK uses pathToClaudeCodeExecutable verbatim with zero validation, so
312
+ // everything is vetted here first β€” notably a bare command name, which would
313
+ // ENOENT under the SDK's spawn(shell:false).
296
314
  const sdkExec = deps.sdkExecutable;
297
- if (sdkExec && /\.[cm]?js$/i.test(sdkExec) && existsSync(sdkExec)) {
298
- options.pathToClaudeCodeExecutable = sdkExec;
315
+ const override = resolveClaudeExecutableOverride(sdkExec);
316
+ if (override.path) {
317
+ options.pathToClaudeCodeExecutable = override.path;
299
318
  } else if (sdkExec) {
300
- log('ignoring non-.js sdkExecutable override:', sdkExec);
319
+ log('ignoring sdkExecutable override:', override.reason);
320
+ this.send({ type: 'stderr', text: `Ignoring sdkExecutable override β€” ${override.reason}` });
321
+ }
322
+
323
+ if (!options.pathToClaudeCodeExecutable) {
324
+ if (this._fallbackBin) {
325
+ // A previous turn proved the bundled binary unusable; stay on the CLI we
326
+ // already fell back to rather than re-failing every turn.
327
+ options.pathToClaudeCodeExecutable = this._fallbackBin;
328
+ } else {
329
+ // Called for its side effect only: this stats the binary the SDK is about
330
+ // to resolve and restores the execute bit if it is missing. We
331
+ // deliberately do NOT pass the resolved path on β€” letting the SDK resolve
332
+ // for itself keeps us correct even if its resolution logic changes.
333
+ claudeRuntime();
334
+ }
301
335
  }
302
336
 
303
337
  log(`session create: resume=${this.sessionId || 'none'} model=${this.model || 'default'} effort=${this.effort || 'default'} mode=${this.permissionMode} cwd=${workDir} sdk=${SDK_VERSION}`);
@@ -327,10 +361,11 @@ class ClaudeSession {
327
361
  const msg = err?.message || String(err);
328
362
  log('pump error:', msg);
329
363
  if (/No conversation found/i.test(msg)) { this._recoverLostSession(); return; }
330
- const friendly = /ENOENT/.test(msg)
331
- ? 'Claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code'
332
- : msg;
333
- this._finishTurnWithError(friendly);
364
+ // The SDK cannot tell a permission problem from a libc mismatch: it buckets
365
+ // every spawn error together and always blames musl/glibc. Diagnose it
366
+ // properly, repair what we can, and fall back to the user's CLI.
367
+ if (isNativeBinaryLaunchFailure(err)) { this._recoverNativeRuntime(err); return; }
368
+ this._finishTurnWithError(msg);
334
369
  } finally {
335
370
  if (this.q === q) this.q = null;
336
371
  }
@@ -384,6 +419,80 @@ class ClaudeSession {
384
419
  });
385
420
  }
386
421
 
422
+ /**
423
+ * The bundled native CLI failed to launch.
424
+ *
425
+ * The SDK's own message for this always blames a musl/glibc mismatch, which is
426
+ * only ever right on Linux β€” on macOS and Windows the real cause is almost
427
+ * always a missing execute bit (npm does not preserve it for binaries shipped
428
+ * without a `bin` entry, and this repo commits node_modules).
429
+ *
430
+ * Recovery is: re-stat and repair β†’ retry once β†’ fall back to the user's
431
+ * installed CLI with a visible notice β†’ give up with an accurate diagnosis.
432
+ */
433
+ _recoverNativeRuntime(err) {
434
+ const prompt = this.lastPrompt;
435
+ // The cached verdict predates the failure; re-stat so a repair performed by
436
+ // another session (or the boot sweep) is visible.
437
+ clearNativeBinaryCache();
438
+ const runtime = claudeRuntime();
439
+
440
+ let fallbackBin = this._fallbackBin;
441
+ if (!fallbackBin) {
442
+ // getClaudeBin()'s last resort is the bare string 'claude'. The SDK spawns
443
+ // with shell:false, so a bare name is a guaranteed ENOENT β€” the override
444
+ // validator rejects it, leaving fallbackBin null and giving the user a
445
+ // real diagnosis instead of a second, more confusing failure.
446
+ const candidate = resolveClaudeExecutableOverride(deps.getClaudeBin?.());
447
+ if (candidate.ok && candidate.path) fallbackBin = candidate.path;
448
+ }
449
+
450
+ const plan = planRuntimeRecovery({
451
+ err,
452
+ runtime,
453
+ fallbackBin,
454
+ alreadyTried: this._nativeRepairTried,
455
+ });
456
+ log(`native runtime recovery: action=${plan.action} state=${runtime.state} kind=${plan.kind}`);
457
+
458
+ if (plan.action === 'retry') {
459
+ this._nativeRepairTried = true;
460
+ this.sendEvent({ type: 'system', subtype: 'runtime_notice', level: 'warn', message: plan.message });
461
+ this._recreateQuery(prompt);
462
+ return;
463
+ }
464
+
465
+ if (plan.action === 'fallback') {
466
+ this._fallbackBin = fallbackBin;
467
+ this.sendEvent({ type: 'system', subtype: 'runtime_notice', level: 'warn', message: plan.message });
468
+ // Report-only: the fallback CLI may be a different version than the SDK
469
+ // was built against, and alias ids like "opus[1m]" resolve per-binary β€” so
470
+ // a picked model can silently launch as another version.
471
+ this._announceSkew(fallbackBin);
472
+ this._recreateQuery(prompt);
473
+ return;
474
+ }
475
+
476
+ this._finishTurnWithError(plan.message);
477
+ }
478
+
479
+ _announceSkew(bin) {
480
+ Promise.resolve()
481
+ .then(() => checkClaudeCliSkew(bin, { log: () => {} }))
482
+ .then((skew) => {
483
+ if (!skew?.skewed || this.destroyed) return;
484
+ this.sendEvent({
485
+ type: 'system',
486
+ subtype: 'runtime_notice',
487
+ level: 'warn',
488
+ message: `Version skew: your installed Claude CLI is ${skew.discovered}, but the bundled `
489
+ + `runtime is ${skew.bundled}. Model aliases and effort levels resolve per-binary, so `
490
+ + 'behaviour may differ until the bundled binary is repaired.',
491
+ });
492
+ })
493
+ .catch(() => {});
494
+ }
495
+
387
496
  _pushUserText(text, images) {
388
497
  const content = [];
389
498
  if (images?.length) {
@@ -15,6 +15,8 @@ const CODEX_TARGETS = {
15
15
  },
16
16
  };
17
17
 
18
+ const CODEX_VENDOR_BINARY_DIRS = ['bin', 'codex'];
19
+
18
20
  export function codexPlatformTarget(platform = process.platform, arch = process.arch) {
19
21
  return CODEX_TARGETS[`${platform}:${arch}`] || null;
20
22
  }
@@ -34,6 +36,19 @@ function uniquePaths(values, pathImpl) {
34
36
  return result;
35
37
  }
36
38
 
39
+ /**
40
+ * Pick a Windows launcher suitable for the sidepanel's shell-aware spawn path.
41
+ * npm lists its extensionless sh shim before the native Windows launcher, so
42
+ * prefer a PATHEXT-style launcher while retaining the first result as a last
43
+ * resort for non-npm installations.
44
+ */
45
+ export function selectWindowsCodexLauncher(launchers = [], acceptLauncher = () => true) {
46
+ const accepted = uniquePaths(launchers, path.win32).filter(acceptLauncher);
47
+ return accepted.find((launcher) => /\.(cmd|exe|bat|ps1)$/i.test(launcher))
48
+ || accepted[0]
49
+ || null;
50
+ }
51
+
37
52
  function packageJsonCandidatesForLauncher(launcher, pathImpl) {
38
53
  const binDir = pathImpl.dirname(launcher);
39
54
  const candidates = [
@@ -45,11 +60,55 @@ function packageJsonCandidatesForLauncher(launcher, pathImpl) {
45
60
  return candidates;
46
61
  }
47
62
 
63
+ export function windowsCodexPackageJsonCandidates({
64
+ npmRoots = [],
65
+ appData = '',
66
+ npmConfigPrefix = '',
67
+ synabunPackageRoot = '',
68
+ nodeExecutable = '',
69
+ } = {}) {
70
+ const pathImpl = path.win32;
71
+ const nodeModulesRoots = [...npmRoots];
72
+ if (appData) nodeModulesRoots.push(pathImpl.join(appData, 'npm', 'node_modules'));
73
+ if (npmConfigPrefix) {
74
+ nodeModulesRoots.push(
75
+ pathImpl.basename(npmConfigPrefix).toLowerCase() === 'node_modules'
76
+ ? npmConfigPrefix
77
+ : pathImpl.join(npmConfigPrefix, 'node_modules'),
78
+ );
79
+ }
80
+ if (
81
+ synabunPackageRoot
82
+ && pathImpl.basename(synabunPackageRoot).toLowerCase() === 'synabun'
83
+ && pathImpl.basename(pathImpl.dirname(synabunPackageRoot)).toLowerCase() === 'node_modules'
84
+ ) {
85
+ nodeModulesRoots.push(pathImpl.dirname(synabunPackageRoot));
86
+ }
87
+ if (nodeExecutable) {
88
+ nodeModulesRoots.push(pathImpl.join(pathImpl.dirname(nodeExecutable), 'node_modules'));
89
+ }
90
+ return uniquePaths(
91
+ nodeModulesRoots.map((root) => pathImpl.join(root, '@openai', 'codex', 'package.json')),
92
+ pathImpl,
93
+ );
94
+ }
95
+
48
96
  function defaultResolvePlatformPackage(codexPackageJsonPath, platformPackage) {
49
97
  const req = createRequire(codexPackageJsonPath);
50
98
  return req.resolve(`${platformPackage}/package.json`);
51
99
  }
52
100
 
101
+ function vendorBinaryCandidates(packageJsonPath, spec, pathImpl) {
102
+ const packageDir = pathImpl.dirname(packageJsonPath);
103
+ return CODEX_VENDOR_BINARY_DIRS.map((binaryDir) => pathImpl.join(
104
+ packageDir,
105
+ 'vendor',
106
+ spec.targetTriple,
107
+ binaryDir,
108
+ spec.binaryName,
109
+ ));
110
+ }
111
+
53
112
  /**
54
113
  * Resolve the spawnable native Codex executable from a trusted global npm
55
114
  * installation on Windows. `@openai/codex-sdk` uses child_process.spawn with
@@ -91,50 +150,58 @@ export function resolveTrustedWindowsCodexBinary({
91
150
  checked.push(codexPackageJsonPath);
92
151
  if (!exists(codexPackageJsonPath)) continue;
93
152
 
94
- const localVendorBinary = pathImpl.join(
95
- pathImpl.dirname(codexPackageJsonPath),
96
- 'vendor',
97
- spec.targetTriple,
98
- 'codex',
99
- spec.binaryName,
100
- );
101
- checked.push(localVendorBinary);
102
- if (exists(localVendorBinary) && acceptBinary(localVendorBinary)) {
103
- return {
104
- path: localVendorBinary,
105
- source: 'global-package-vendor',
106
- packageJsonPath: codexPackageJsonPath,
107
- ...spec,
108
- checked,
109
- };
110
- }
111
-
112
- try {
113
- const platformPackageJsonPath = resolvePlatformPackage(
114
- codexPackageJsonPath,
115
- spec.platformPackage,
116
- );
117
- const platformBinary = pathImpl.join(
118
- pathImpl.dirname(platformPackageJsonPath),
119
- 'vendor',
120
- spec.targetTriple,
121
- 'codex',
122
- spec.binaryName,
123
- );
124
- checked.push(platformPackageJsonPath, platformBinary);
125
- if (exists(platformBinary) && acceptBinary(platformBinary)) {
153
+ for (const localVendorBinary of vendorBinaryCandidates(codexPackageJsonPath, spec, pathImpl)) {
154
+ checked.push(localVendorBinary);
155
+ if (exists(localVendorBinary) && acceptBinary(localVendorBinary)) {
126
156
  return {
127
- path: platformBinary,
128
- source: 'global-platform-package',
157
+ path: localVendorBinary,
158
+ source: 'global-package-vendor',
129
159
  packageJsonPath: codexPackageJsonPath,
130
- platformPackageJsonPath,
131
160
  ...spec,
132
161
  checked,
133
162
  };
134
163
  }
164
+ }
165
+
166
+ let resolvedPlatformPackageJsonPath = null;
167
+ try {
168
+ resolvedPlatformPackageJsonPath = resolvePlatformPackage(
169
+ codexPackageJsonPath,
170
+ spec.platformPackage,
171
+ );
135
172
  } catch (error) {
136
173
  checked.push(`${codexPackageJsonPath} -> ${spec.platformPackage}: ${error.message}`);
137
174
  }
175
+
176
+ // npm usually hoists the aliased platform package beside @openai/codex,
177
+ // but some npm versions keep it inside that package's node_modules. Check
178
+ // both layouts directly so package exports/hoisting do not block discovery.
179
+ const platformSegments = spec.platformPackage.split('/');
180
+ const codexPackageDir = pathImpl.dirname(codexPackageJsonPath);
181
+ const globalNodeModules = pathImpl.dirname(pathImpl.dirname(codexPackageDir));
182
+ const platformPackageJsonPaths = uniquePaths([
183
+ resolvedPlatformPackageJsonPath,
184
+ pathImpl.join(globalNodeModules, ...platformSegments, 'package.json'),
185
+ pathImpl.join(codexPackageDir, 'node_modules', ...platformSegments, 'package.json'),
186
+ ], pathImpl);
187
+
188
+ for (const platformPackageJsonPath of platformPackageJsonPaths) {
189
+ checked.push(platformPackageJsonPath);
190
+ if (!exists(platformPackageJsonPath)) continue;
191
+ for (const platformBinary of vendorBinaryCandidates(platformPackageJsonPath, spec, pathImpl)) {
192
+ checked.push(platformBinary);
193
+ if (exists(platformBinary) && acceptBinary(platformBinary)) {
194
+ return {
195
+ path: platformBinary,
196
+ source: 'global-platform-package',
197
+ packageJsonPath: codexPackageJsonPath,
198
+ platformPackageJsonPath,
199
+ ...spec,
200
+ checked,
201
+ };
202
+ }
203
+ }
204
+ }
138
205
  }
139
206
 
140
207
  return {