synabun 2.0.0-beta → 2.0.0

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/THIRD-PARTY-LICENSES.md +5 -0
  3. package/launcher.mjs +40 -0
  4. package/lib/claude-hooks.js +61 -8
  5. package/lib/client-config-repair.js +4 -3
  6. package/lib/data-home-migration.js +4 -0
  7. package/lib/desktop-pwa.js +206 -0
  8. package/lib/external-tools.js +221 -0
  9. package/lib/first-launch-protection.js +5 -0
  10. package/lib/packaged-runtime.js +110 -0
  11. package/lib/start-launcher.js +1309 -0
  12. package/neural-interface/i18n/en.json +41 -2
  13. package/neural-interface/i18n/pt-BR.json +41 -2
  14. package/neural-interface/lib/assistant-envelope.js +9 -6
  15. package/neural-interface/lib/augmented-path.js +23 -3
  16. package/neural-interface/lib/browser-language.js +83 -0
  17. package/neural-interface/lib/claude-agent-bridge.js +54 -147
  18. package/neural-interface/lib/claude-executable.js +241 -0
  19. package/neural-interface/lib/claude-model-catalog.js +52 -91
  20. package/neural-interface/lib/native-binary-runtime.js +69 -337
  21. package/neural-interface/lib/native-loop-providers.js +30 -44
  22. package/neural-interface/lib/offline-page.js +54 -0
  23. package/neural-interface/lib/session-title-generator.js +5 -0
  24. package/neural-interface/lib/synabun-update-plan.js +19 -0
  25. package/neural-interface/package-lock.json +0 -251
  26. package/neural-interface/public/install-app.html +31 -0
  27. package/neural-interface/public/manifest.json +2 -0
  28. package/neural-interface/public/offline.html +401 -66
  29. package/neural-interface/public/onboarding.html +252 -146
  30. package/neural-interface/public/shared/assistant/asst-mascot.js +12 -10
  31. package/neural-interface/public/shared/assistant/asst-rack-styles.js +0 -3
  32. package/neural-interface/public/shared/assistant/asst-render.js +58 -49
  33. package/neural-interface/public/shared/assistant/asst-styles.js +4 -2
  34. package/neural-interface/public/shared/command-runner-model.js +30 -0
  35. package/neural-interface/public/shared/html-shell.js +9 -5
  36. package/neural-interface/public/shared/pwa-install-page.js +34 -0
  37. package/neural-interface/public/shared/pwa-install.css +12 -0
  38. package/neural-interface/public/shared/pwa-install.js +28 -0
  39. package/neural-interface/public/shared/settings/settings-rules.js +160 -0
  40. package/neural-interface/public/shared/start-bridge.js +583 -0
  41. package/neural-interface/public/shared/styleguide/sg-colors.js +5 -2
  42. package/neural-interface/public/shared/styleguide/sg-import-export.js +4 -3
  43. package/neural-interface/public/shared/styleguide/sg-kit.js +4 -1
  44. package/neural-interface/public/shared/styleguide/sg-panel.js +9 -1
  45. package/neural-interface/public/shared/styleguide/sg-typography.js +1 -0
  46. package/neural-interface/public/shared/styles.css +801 -392
  47. package/neural-interface/public/shared/term-ready.js +307 -0
  48. package/neural-interface/public/shared/ui-claude-panel.js +3 -3
  49. package/neural-interface/public/shared/ui-command-runner.js +251 -468
  50. package/neural-interface/public/shared/ui-judgments.js +1 -1
  51. package/neural-interface/public/shared/ui-loading.js +221 -10
  52. package/neural-interface/public/shared/ui-menubar.js +11 -27
  53. package/neural-interface/public/shared/ui-settings.js +36 -59
  54. package/neural-interface/public/shared/ui-terminal.js +130 -144
  55. package/neural-interface/public/sw.js +3 -1
  56. package/neural-interface/public/variant/2d/main.js +2 -1
  57. package/neural-interface/public/variant/3d/main.js +2 -1
  58. package/neural-interface/scripts/rebuild-pty.js +15 -12
  59. package/neural-interface/server.js +220 -185
  60. package/package.json +13 -1
  61. package/postinstall.js +11 -81
  62. package/setup.js +72 -4
  63. package/docs/browser-v2.md +0 -109
  64. package/docs/hook-integrity.md +0 -89
  65. package/docs/judgments.md +0 -182
  66. package/docs/style-guide.md +0 -246
  67. package/scripts/audit-claude-hook-usage.mjs +0 -12
  68. package/scripts/audit-memory-duplicates.mjs +0 -44
  69. package/scripts/measure-claude-hook-overhead.mjs +0 -77
  70. package/scripts/recover-user-config.mjs +0 -254
@@ -0,0 +1,241 @@
1
+ // ── Which Claude Code runs: the user's own installation ──
2
+ //
3
+ // Claude Code is installed by the user, separately from SynaBun (the native
4
+ // installer, Homebrew, npm). Nothing in SynaBun carries a copy: the Claude
5
+ // Agent SDK is installed without the executable it would otherwise bring (see
6
+ // lib/external-tools.js at the package root), so every SDK call has to be told
7
+ // which executable to start. This module is where that answer comes from, for
8
+ // the side panel, the Assistant's Claude brain, native loops, session titles
9
+ // and the model probe alike:
10
+ //
11
+ // resolveClaudeLauncher what a terminal or a shell would start: the
12
+ // user's configured command, else the first
13
+ // `claude` on PATH that is not inside SynaBun
14
+ // resolveClaudeSdkExecutable what the SDK can start. It spawns with
15
+ // shell:false, so an npm command file on Windows
16
+ // is followed to the executable behind it
17
+ //
18
+ // A missing installation is an ordinary state, not a failure: both answer with
19
+ // `path: null` and the caller says CLAUDE_NOT_INSTALLED.
20
+ //
21
+ // Every dependency is injectable (platform, PATH, the file system), so Windows
22
+ // resolution is tested from any host. Nothing here throws.
23
+
24
+ import { accessSync, constants, existsSync, statSync } from 'node:fs';
25
+ import path from 'node:path';
26
+ import { getAugmentedPath } from './augmented-path.js';
27
+ import { resolveClaudeExecutableOverride } from './native-binary-runtime.js';
28
+
29
+ /**
30
+ * What a session says when no Claude Code is installed. The words "Claude CLI
31
+ * not found" are what the side panel shows its install help on.
32
+ */
33
+ export const CLAUDE_NOT_INSTALLED = 'Claude CLI not found. Claude Code is installed separately from SynaBun: install it, then send your message again.';
34
+
35
+ /** A real executable first, then the command files a shell can run. */
36
+ const WINDOWS_LAUNCHER_ORDER = ['.exe', '.cmd', '.bat', '.ps1'];
37
+
38
+ const pathFor = (platform) => (platform === 'win32' ? path.win32 : path.posix);
39
+
40
+ function unique(values, pathImpl, insensitive) {
41
+ const seen = new Set();
42
+ const out = [];
43
+ for (const value of values) {
44
+ const text = String(value || '').trim();
45
+ if (!text) continue;
46
+ const normal = pathImpl.normalize(text);
47
+ const key = insensitive ? normal.toLowerCase() : normal;
48
+ if (seen.has(key)) continue;
49
+ seen.add(key);
50
+ out.push(normal);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * The launcher Windows should start, from a list in PATH order (as `where`
57
+ * prints it, or findOnPath below). PATH order decides between folders; inside
58
+ * one folder a real executable beats a command file, and npm's extensionless
59
+ * shell script (which Windows cannot start) is only ever the last resort.
60
+ */
61
+ export function selectWindowsLauncher(launchers = [], accept = () => true) {
62
+ const accepted = unique(launchers, path.win32, true).filter(accept);
63
+ const folders = new Map();
64
+ for (const launcher of accepted) {
65
+ const folder = path.win32.dirname(launcher).toLowerCase();
66
+ if (!folders.has(folder)) folders.set(folder, []);
67
+ folders.get(folder).push(launcher);
68
+ }
69
+ for (const group of folders.values()) {
70
+ for (const extension of WINDOWS_LAUNCHER_ORDER) {
71
+ const hit = group.find(launcher => launcher.toLowerCase().endsWith(extension));
72
+ if (hit) return hit;
73
+ }
74
+ }
75
+ return accepted[0] || null;
76
+ }
77
+
78
+ function defaultIsLaunchable(file, platform) {
79
+ try {
80
+ if (!statSync(file).isFile()) return false;
81
+ if (platform !== 'win32') accessSync(file, constants.X_OK);
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /** PATH without any `node_modules/.bin` folder: a dependency's command is never the user's tool. */
89
+ export function withoutPackageBins(pathValue, platform = process.platform) {
90
+ const delimiter = platform === 'win32' ? ';' : ':';
91
+ return String(pathValue || '').split(delimiter)
92
+ .filter(entry => entry && !/[\\/]node_modules[\\/]\.bin[\\/]?$/i.test(entry))
93
+ .join(delimiter);
94
+ }
95
+
96
+ /**
97
+ * Every file a command name resolves to, in PATH order, read from the file
98
+ * system (no `which`, no shell: a folder with a space in its name is just a
99
+ * folder). On Windows each folder is tried with the launcher extensions, then
100
+ * bare.
101
+ */
102
+ export function findOnPath(command, {
103
+ platform = process.platform,
104
+ pathValue = getAugmentedPath(),
105
+ isLaunchable = defaultIsLaunchable,
106
+ } = {}) {
107
+ const name = String(command || '').trim();
108
+ if (!name) return [];
109
+ const pathImpl = pathFor(platform);
110
+ const windows = platform === 'win32';
111
+ const names = windows && !pathImpl.extname(name) ? [...WINDOWS_LAUNCHER_ORDER.map(extension => name + extension), name] : [name];
112
+ const found = [];
113
+ for (const folder of String(pathValue || '').split(windows ? ';' : ':')) {
114
+ if (!folder) continue;
115
+ for (const candidate of names) {
116
+ const file = pathImpl.join(folder, candidate);
117
+ if (isLaunchable(file, platform)) found.push(file);
118
+ }
119
+ }
120
+ return unique(found, pathImpl, windows);
121
+ }
122
+
123
+ /**
124
+ * The Claude Code a terminal would start.
125
+ *
126
+ * configured cli-config.json → "claude-code": { "command": "…" } (Settings > Terminal):
127
+ * a path is taken as it is, a name is looked up on PATH
128
+ * path the first `claude` on PATH
129
+ *
130
+ * Never a file inside SynaBun (`isInsideSynabun`) and never one from a
131
+ * `node_modules/.bin` folder on PATH: a copy that came with a dependency must
132
+ * not stand in for the user's own.
133
+ *
134
+ * @returns {{path: string|null, source: 'configured'|'path'|'missing', rejected: string[]}}
135
+ */
136
+ export function resolveClaudeLauncher({
137
+ configured = '',
138
+ platform = process.platform,
139
+ pathValue = getAugmentedPath(),
140
+ isInsideSynabun = () => false,
141
+ exists = existsSync,
142
+ isLaunchable = defaultIsLaunchable,
143
+ } = {}) {
144
+ const pathImpl = pathFor(platform);
145
+ const windows = platform === 'win32';
146
+ const rejected = [];
147
+ const outside = (file) => {
148
+ if (!isInsideSynabun(file)) return true;
149
+ rejected.push(file);
150
+ return false;
151
+ };
152
+ const lookup = (command) => {
153
+ const hits = findOnPath(command, { platform, pathValue: withoutPackageBins(pathValue, platform), isLaunchable }).filter(outside);
154
+ return (windows ? selectWindowsLauncher(hits) : hits[0]) || null;
155
+ };
156
+
157
+ const wanted = String(configured || '').trim();
158
+ if (wanted && wanted !== 'claude') {
159
+ const isPath = wanted.includes('/') || (windows && wanted.includes('\\'));
160
+ if (isPath) {
161
+ if (exists(wanted) && outside(wanted)) return { path: pathImpl.normalize(wanted), source: 'configured', rejected };
162
+ } else {
163
+ const hit = lookup(wanted);
164
+ if (hit) return { path: hit, source: 'configured', rejected };
165
+ }
166
+ }
167
+
168
+ const hit = lookup('claude');
169
+ return hit ? { path: hit, source: 'path', rejected } : { path: null, source: 'missing', rejected };
170
+ }
171
+
172
+ /**
173
+ * Where the executable behind an npm command file is, most likely first. npm
174
+ * puts `claude.cmd` next to `node_modules\@anthropic-ai\claude-code`, whose
175
+ * `bin\claude.exe` is the program (older releases: `cli.js`, run by Node). A
176
+ * `.bin` folder has the package one level up instead.
177
+ */
178
+ export function windowsClaudeTargets(launcher) {
179
+ const pathImpl = path.win32;
180
+ const folder = pathImpl.dirname(String(launcher || ''));
181
+ const roots = [pathImpl.join(folder, 'node_modules')];
182
+ if (pathImpl.basename(folder).toLowerCase() === '.bin') roots.push(pathImpl.dirname(folder));
183
+ return roots.flatMap((root) => {
184
+ const packageDir = pathImpl.join(root, '@anthropic-ai', 'claude-code');
185
+ return [pathImpl.join(packageDir, 'bin', 'claude.exe'), pathImpl.join(packageDir, 'cli.js')];
186
+ });
187
+ }
188
+
189
+ /**
190
+ * The executable an Agent SDK call is given as `pathToClaudeCodeExecutable`.
191
+ *
192
+ * override cli-config.json → "claude-skin": { "sdkExecutable": "…" }, when it is usable
193
+ * installed the user's Claude Code (`launcher`, from resolveClaudeLauncher)
194
+ *
195
+ * The SDK starts this path itself, without a shell, so it has to be a program
196
+ * or a script: on Windows a `.cmd`, `.bat` or `.ps1` launcher is followed to
197
+ * what it runs. `path: null` means there is nothing to start; `reason` says
198
+ * why in a sentence a person can act on, `ignored` why an override was passed
199
+ * over.
200
+ *
201
+ * @returns {{path: string|null, source: 'override'|'installed'|'missing', kind: string|null, reason: string|null, ignored: string|null, launcher: string|null}}
202
+ */
203
+ export function resolveClaudeSdkExecutable({
204
+ launcher = null,
205
+ sdkExecutable = null,
206
+ platform = process.platform,
207
+ exists = existsSync,
208
+ isInsideSynabun = () => false,
209
+ validate = resolveClaudeExecutableOverride,
210
+ } = {}) {
211
+ const answer = (fields) => ({ path: null, source: 'missing', kind: null, reason: null, ignored: null, launcher: launcher || null, ...fields });
212
+ let ignored = null;
213
+
214
+ const explicit = typeof sdkExecutable === 'string' ? sdkExecutable.trim() : '';
215
+ if (explicit) {
216
+ const verdict = validate(explicit, { platform });
217
+ if (verdict.ok && verdict.path) return answer({ path: verdict.path, source: 'override', kind: verdict.kind });
218
+ ignored = verdict.reason || `Not usable: ${explicit}`;
219
+ }
220
+
221
+ const installed = typeof launcher === 'string' ? launcher.trim() : '';
222
+ // A bare name is what "nothing was found" looks like to older callers.
223
+ if (!installed || !(installed.includes('/') || installed.includes('\\'))) return answer({ reason: CLAUDE_NOT_INSTALLED, ignored });
224
+
225
+ const candidates = platform === 'win32' && !/\.exe$/i.test(installed) && !/\.[cm]?js$/i.test(installed)
226
+ ? windowsClaudeTargets(installed).filter(file => exists(file))
227
+ : [installed];
228
+ let refusal = null;
229
+ for (const candidate of candidates) {
230
+ if (isInsideSynabun(candidate)) { refusal = `${candidate} is inside SynaBun, not an installation of Claude Code`; continue; }
231
+ const verdict = validate(candidate, { platform });
232
+ if (verdict.ok && verdict.path) return answer({ path: verdict.path, source: 'installed', kind: verdict.kind, ignored });
233
+ refusal = verdict.reason || `Not usable: ${candidate}`;
234
+ }
235
+ return answer({
236
+ reason: refusal
237
+ ? `Claude Code was found at ${installed}, but it cannot be started from there: ${refusal}.`
238
+ : `Claude Code was found at ${installed}, but not the program that command runs. Reinstall Claude Code, or name its executable in cli-config.json ("claude-skin": { "sdkExecutable": "…" }).`,
239
+ ignored,
240
+ });
241
+ }
@@ -15,7 +15,6 @@ import { dirname, join, sep } from 'node:path';
15
15
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
16
16
  import { createRequire } from 'node:module';
17
17
  import { getAugmentedPath } from './augmented-path.js';
18
- import { resolveClaudeExecutableOverride } from './native-binary-runtime.js';
19
18
 
20
19
  // Effort ladder the Claude Code CLI accepts for --effort. Models advertise their
21
20
  // own subset via supportedEffortLevels; this is the superset used as a fallback
@@ -155,10 +154,23 @@ export function normalizeClaudeModels(models = []) {
155
154
  return out;
156
155
  }
157
156
 
157
+ // With a shell, Node joins the command and its arguments into one line and
158
+ // quotes nothing. An installation under a folder with a space in its name
159
+ // (C:\Users\Jane Doe\AppData\Roaming\npm\claude.cmd) would be cut at the
160
+ // space, so the command is quoted here whenever a shell runs it. Where the
161
+ // value may also be a command line the user configured ("wsl codex"), pass
162
+ // `isFile`: only what is a file on disk is then treated as one word.
163
+ export function shellCommand(bin, useShell, { isFile = null } = {}) {
164
+ const value = String(bin);
165
+ if (!useShell || !/\s/.test(value) || /^".*"$/.test(value)) return value;
166
+ if (isFile && !isFile(value)) return value;
167
+ return `"${value}"`;
168
+ }
169
+
158
170
  // Ask the CLI for its model list. Resolves to [] on any failure — callers fall
159
171
  // back to CLAUDE_FALLBACK_MODELS rather than surfacing an error, because a
160
172
  // missing model list should degrade the picker, not break the panel.
161
- export function queryClaudeCliModels(claudeBin, { cwd, timeoutMs = DISCOVERY_TIMEOUT_MS } = {}) {
173
+ export function queryClaudeCliModels(claudeBin, { cwd, timeoutMs = DISCOVERY_TIMEOUT_MS, spawnImpl = spawn } = {}) {
162
174
  return new Promise((resolveResult) => {
163
175
  let bin = claudeBin;
164
176
  const args = [
@@ -200,7 +212,7 @@ export function queryClaudeCliModels(claudeBin, { cwd, timeoutMs = DISCOVERY_TIM
200
212
  timer.unref?.();
201
213
 
202
214
  try {
203
- child = spawn(bin, args, {
215
+ child = spawnImpl(shellCommand(bin, useShell), args, {
204
216
  cwd: cwd || process.cwd(),
205
217
  env: { ...process.env, PATH: getAugmentedPath(), CLAUDE_CODE_ENTRYPOINT: 'sdk-cli' },
206
218
  stdio: ['pipe', 'pipe', 'ignore'],
@@ -212,6 +224,11 @@ export function queryClaudeCliModels(claudeBin, { cwd, timeoutMs = DISCOVERY_TIM
212
224
 
213
225
  child.on('error', () => finish([]));
214
226
  child.on('exit', () => finish([]));
227
+ // A CLI that is not installed is a shell that exits at once, sometimes
228
+ // before the request below is written. The write then fails on the stream
229
+ // (EPIPE), not in the try around it, and an unhandled stream error ends the
230
+ // whole server. Seen on Linux, where `sh` is fast.
231
+ child.stdin.on('error', () => finish([]));
215
232
 
216
233
  let buf = '';
217
234
  child.stdout.setEncoding('utf-8');
@@ -274,27 +291,26 @@ export function clearClaudeModelCache() {
274
291
  _cache = { at: 0, key: '', models: [] };
275
292
  _skewCache = { key: null, result: undefined };
276
293
  _skewInflight.clear();
277
- _alignFailures.clear();
278
294
  }
279
295
 
280
296
  // ── CLI version skew ──────────────────────────────────────────────────────────
281
- // Two different Claude Code binaries are in play: this module discovers models
282
- // from the CLI resolved for terminals/agents/loops, while sidepanel chats run on
283
- // the one the Agent SDK bundles. Both accept the same *alias* ids ("default",
284
- // "opus[1m]", "sonnet"), but each maps them with its own model table — so when
285
- // the versions drift the picker labels a model from one table and the session
286
- // silently runs whatever the other table calls "opus". That is invisible from
287
- // the outside: the only symptom is the model naming a different version of
288
- // itself. Compare the two, say so, and — when the installed CLI is the newer
289
- // one — run sessions on it (see alignedClaudeExecutable below).
297
+ // One Claude Code is in play: the user's own installation. This module asks it
298
+ // for its models, and the Agent SDK starts that same executable for sidepanel
299
+ // chats, the Assistant and native loops (lib/claude-executable.js), so an alias
300
+ // such as "opus[1m]" means in a session what it meant in the picker.
301
+ //
302
+ // What can still drift is the SDK itself: each release is built against one
303
+ // Claude Code version (its manifest names it) and may send options an older
304
+ // installation does not know. That is worth one line in the log, never a
305
+ // refusal: the installed Claude Code is the only one there is to run.
290
306
 
291
307
  const _require = createRequire(import.meta.url);
292
308
 
293
- // The Claude Code version the installed Agent SDK ships, from the SDK's own
294
- // manifest (`manifest.json` → "version": "2.1.220"). Not in the package's
295
- // exports map, so walk up from the resolved entry file. null when the SDK is
296
- // absent — the legacy engine spawns the discovered CLI and cannot skew.
297
- export function sdkBundledCliVersion() {
309
+ // The Claude Code version the installed Agent SDK was built against, from the
310
+ // SDK's own manifest (`manifest.json` → "version": "2.1.220"). Not in the
311
+ // package's exports map, so walk up from the resolved entry file. null when the
312
+ // SDK is absent.
313
+ export function sdkReferenceCliVersion() {
298
314
  try {
299
315
  let dir = dirname(_require.resolve('@anthropic-ai/claude-agent-sdk'));
300
316
  for (let i = 0; i < 5; i++) {
@@ -355,7 +371,7 @@ export function queryClaudeCliVersion(claudeBin, { timeoutMs = 10000 } = {}) {
355
371
  timer.unref?.();
356
372
 
357
373
  try {
358
- child = spawn(bin, args, {
374
+ child = spawn(shellCommand(bin, useShell), args, {
359
375
  env: { ...process.env, PATH: getAugmentedPath() },
360
376
  stdio: ['ignore', 'pipe', 'ignore'],
361
377
  shell: useShell,
@@ -371,33 +387,25 @@ export function queryClaudeCliVersion(claudeBin, { timeoutMs = 10000 } = {}) {
371
387
  }
372
388
 
373
389
  // Never blocks discovery. The result is cached per binary identity — the model
374
- // endpoints ask on every request, and it now also decides which binary SDK
375
- // sessions run — so a `claude --version` spawn happens once per installed CLI,
376
- // not once per request. A CLI update changes the identity and re-probes. `null`
377
- // (nothing to compare: no SDK, unreadable CLI) is cached too. Warns once per
378
- // distinct pair so repeated checks don't spam the log.
390
+ // endpoints ask on every request — so a `claude --version` spawn happens once
391
+ // per installed CLI, not once per request. A CLI update changes the identity and
392
+ // re-probes. `null` (nothing to compare: no SDK, no CLI, unreadable version) is
393
+ // cached too. Warns once per distinct pair so repeated checks don't spam the log.
379
394
  const _warnedSkew = new Set();
380
395
  let _skewCache = { key: null, result: undefined };
381
396
  const _skewInflight = new Map(); // binary identity → pending probe
382
397
 
383
398
  function warnSkewOnce(result, claudeBin, log) {
384
- const pair = `${result.discovered}→${result.bundled}`;
399
+ // A newer installation than the SDK was built against is the ordinary state
400
+ // between two SynaBun releases, and nothing to report.
401
+ if (!result.installedOlder) return;
402
+ const pair = `${result.discovered}→${result.reference}`;
385
403
  if (_warnedSkew.has(pair)) return;
386
404
  _warnedSkew.add(pair);
387
- if (result.installedNewer) {
388
- log(
389
- `[claude-models] CLI version skew: the installed CLI ${result.discovered} (${claudeBin}) is newer `
390
- + `than the ${result.bundled} bundled with @anthropic-ai/claude-agent-sdk. Sidepanel sessions and `
391
- + 'native loops will run the installed CLI, so a picked model launches as the version the picker '
392
- + 'names. Bump @anthropic-ai/claude-agent-sdk to the release that bundles it to re-align the runtime.',
393
- );
394
- return;
395
- }
396
405
  log(
397
- `[claude-models] CLI version skew: model picker reads ${result.discovered} (${claudeBin}) `
398
- + `but sidepanel sessions run ${result.bundled} (bundled with @anthropic-ai/claude-agent-sdk). `
399
- + 'Alias ids like "opus[1m]" resolve per-binary, so a picked model can launch as a '
400
- + `different version. Align them: update the installed Claude CLI to ${result.bundled} or newer.`,
406
+ `[claude-models] The installed Claude Code is ${result.discovered} (${claudeBin}); `
407
+ + `@anthropic-ai/claude-agent-sdk was built against ${result.reference}. Sessions run the `
408
+ + `installed one. If a session misbehaves, update Claude Code to ${result.reference} or newer.`,
401
409
  );
402
410
  }
403
411
 
@@ -405,7 +413,7 @@ export async function checkClaudeCliSkew(claudeBin, {
405
413
  log = console.warn,
406
414
  force = false,
407
415
  queryVersion = queryClaudeCliVersion,
408
- bundledVersion = sdkBundledCliVersion,
416
+ referenceVersion = sdkReferenceCliVersion,
409
417
  } = {}) {
410
418
  const key = binaryIdentity(claudeBin);
411
419
  let result;
@@ -415,14 +423,14 @@ export async function checkClaudeCliSkew(claudeBin, {
415
423
  let probe = _skewInflight.get(key);
416
424
  if (!probe) {
417
425
  probe = (async () => {
418
- const bundled = bundledVersion();
419
- const discovered = bundled ? await queryVersion(claudeBin) : null;
420
- const order = compareCliVersions(discovered, bundled);
426
+ const reference = referenceVersion();
427
+ const discovered = reference ? await queryVersion(claudeBin) : null;
428
+ const order = compareCliVersions(discovered, reference);
421
429
  const value = order === null ? null : {
422
430
  discovered,
423
- bundled,
431
+ reference,
424
432
  skewed: order !== 0,
425
- installedNewer: order > 0,
433
+ installedOlder: order < 0,
426
434
  };
427
435
  _skewCache = { key, result: value };
428
436
  return value;
@@ -434,50 +442,3 @@ export async function checkClaudeCliSkew(claudeBin, {
434
442
  if (result?.skewed) warnSkewOnce(result, claudeBin, log);
435
443
  return result;
436
444
  }
437
-
438
- // Synchronous view of the last probe for this binary: the result, null (probed,
439
- // nothing to compare), or undefined (not probed at this identity — typically
440
- // because the CLI was updated since).
441
- export function cachedClaudeCliSkew(claudeBin) {
442
- return _skewCache.key === binaryIdentity(claudeBin) ? _skewCache.result : undefined;
443
- }
444
-
445
- // ── Runtime alignment ─────────────────────────────────────────────────────────
446
- // The picker's model table comes from the installed CLI; SDK sessions (sidepanel)
447
- // and native loops default to the CLI the SDK bundles. When the installed one is
448
- // newer, the same alias resolves to a newer model there — "opus[1m]" is Opus 5.5 on
449
- // 2.1.280 but Opus 5 on 2.1.278 — so the picker would promise a model the session
450
- // cannot launch. In that case sessions run the installed CLI instead. Everywhere
451
- // else the bundled, version-matched binary stays the default: when the versions
452
- // match, when the bundled one is the newer of the two, and when no usable CLI is
453
- // installed.
454
- const _alignFailures = new Set(); // binary identities whose aligned launch failed
455
-
456
- // A launch failure of the aligned CLI sends every later session back to the bundled
457
- // runtime. Keyed by identity, so the next CLI update gets a fresh chance.
458
- export function markAlignedClaudeExecutableFailed(claudeBin) {
459
- const key = binaryIdentity(claudeBin);
460
- if (key) _alignFailures.add(key);
461
- }
462
-
463
- // The path SDK sessions should run instead of the bundled CLI, or null to keep the
464
- // bundled one. Never blocks: when this binary has not been compared yet, the check
465
- // starts in the background and this call answers null, so no session waits on a
466
- // version probe — the next one aligns.
467
- export function alignedClaudeExecutable(claudeBin, {
468
- skew = cachedClaudeCliSkew(claudeBin),
469
- refresh = true,
470
- resolveOverride = resolveClaudeExecutableOverride,
471
- } = {}) {
472
- if (!claudeBin) return null;
473
- if (skew === undefined) {
474
- if (refresh) checkClaudeCliSkew(claudeBin).catch(() => {});
475
- return null;
476
- }
477
- if (!skew?.installedNewer) return null;
478
- if (_alignFailures.has(binaryIdentity(claudeBin))) return null;
479
- // The SDK spawns this path verbatim with shell:false; the shared override
480
- // contract rejects bare command names and anything that is not executable.
481
- const candidate = resolveOverride(claudeBin);
482
- return candidate?.ok && candidate.path ? candidate.path : null;
483
- }