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
@@ -10,7 +10,7 @@ import { findSessionTranscript, sessionProjects, confineArtifact, confineSubPath
10
10
  import { readConfinedFile, writeConfinedFile, makeConfinedDir, removeConfinedEntry, listConfined, openConfinedStream } from './lib/confined-fs.js';
11
11
  import { createClaudeAccounts as createPanelClaudeAccounts } from './lib/claude-accounts.js';
12
12
  import { claudeLegacyPricing, claudeUsageCostUsd } from './lib/assistant-pricing.js';
13
- import { HOOK_SCRIPTS, hookDefinitionsForEvent, hookCommandString, isSynaBunHookCommand, sweepSettingsHooks, ensureHookRoot } from '../lib/claude-hooks.js';
13
+ import { HOOK_SCRIPTS, hookDefinitionsForEvent, hookCommandString, hookInstallBlocker, hookInstallPlan, isSynaBunHookCommand, sweepSettingsHooks, ensureHookRoot } from '../lib/claude-hooks.js';
14
14
  import { browserV2Enabled, browserRequestMiddleware, assertBrowserRequestActive, browserActionTimeout, markBrowserActionStarted, runBrowserRequest, cancellableBrowserDelay, measureBrowserPhase, createEngagementScheduler, engagementTargetSelector, isEngagementWriteSelector, isXShortcutPublish, isXScriptedWrite } from './lib/browser-execution.js';
15
15
  import { describeLocatorMatches, healLocatorByText, validNthMatch, captureTargetIdentity, matchesTargetIdentity } from './lib/browser-targets.js';
16
16
  import { getInteractiveHints, collectSemanticContext, buildAssist, describeTargetFacts, verifyHealTarget, createAssistContextStore } from './lib/browser-semantic-context.js';
@@ -22,6 +22,7 @@ import { createMemoryMapService, createMemoryMapApi } from './lib/memory-map-api
22
22
  import { createAiSnapshotStore, settleBrowserPage } from './lib/browser-snapshots.js';
23
23
  import { runBrowserExtraction, readBrowserContentHtml } from './lib/browser-extraction.js';
24
24
  import { attachConsoleBuffer, readConsoleBuffer, parseConsoleSince } from './lib/browser-console.js';
25
+ import { resolveBrowserLanguage, validateBrowserLanguageConfig } from './lib/browser-language.js';
25
26
  import { parseScreenshotOptions, captureScreenshot, screenshotPathRefusal, defaultScreenshotPath, writeScreenshotFile } from './lib/browser-screenshot.js';
26
27
  import { memoryRevision } from '../mcp-server/dist/services/memory-maintenance.js';
27
28
  import { startGatedMemoryMaintenance } from './lib/memory-timers.js';
@@ -155,10 +156,7 @@ import {
155
156
  resolveAutomationOverrides,
156
157
  validAutomationContextMode,
157
158
  } from './public/shared/automation-context.js';
158
- import {
159
- claudeRuntime,
160
- ensureVendoredExecutables,
161
- } from './lib/native-binary-runtime.js';
159
+ import { ensureVendoredExecutables } from './lib/native-binary-runtime.js';
162
160
  import {
163
161
  buildExecInvocation,
164
162
  execWrapperExtension,
@@ -225,10 +223,11 @@ import { generateSessionTitle } from './lib/session-title-generator.js';
225
223
  import {
226
224
  CLAUDE_EFFORT_LEVELS,
227
225
  CLAUDE_FALLBACK_MODELS,
228
- alignedClaudeExecutable,
229
226
  checkClaudeCliSkew,
230
227
  discoverClaudeModels,
228
+ shellCommand,
231
229
  } from './lib/claude-model-catalog.js';
230
+ import { resolveClaudeLauncher, resolveClaudeSdkExecutable } from './lib/claude-executable.js';
232
231
  import { getAugmentedPath } from './lib/augmented-path.js';
233
232
  import { semverNewer, synabunReleaseChannel } from './lib/synabun-version.js';
234
233
  import { selectSynabunUpdate, buildSynabunInstallPlan, refuseStaleUpdateClick } from './lib/synabun-update-plan.js';
@@ -256,8 +255,12 @@ import {
256
255
  convertPctInPlace as wbConvertPctInPlace,
257
256
  } from '../mcp-server/dist/services/whiteboard-geometry.js';
258
257
  import { ensureProjectCategories } from '../hooks/claude-code/shared.mjs';
259
- import { getDataHome, getDataHomeDiagnostics, pathIsInside } from '../lib/paths.js';
258
+ import { getDataHome, getDataHomeDiagnostics, isGlobalInstall, pathIsInside } from '../lib/paths.js';
260
259
  import { launcherLogPath } from '../lib/first-launch-protection.js';
260
+ import { registerStartLauncher, startLauncherState } from '../lib/start-launcher.js';
261
+ import { durableEntry, mcpStdioCommand, packagedRuntime } from '../lib/packaged-runtime.js';
262
+ import { pruneExternalTools } from '../lib/external-tools.js';
263
+ import { renderOfflinePage } from './lib/offline-page.js';
261
264
  import {
262
265
  getDb, closeDb, getDbPath, getEmbedding, getEmbeddingBatch, getEmbeddingDims, warmupEmbeddings,
263
266
  encodeVector, decodeVector, cosineSimilarity,
@@ -295,11 +298,11 @@ const execFileAsync = promisify(execFile);
295
298
  const __filename = fileURLToPath(import.meta.url);
296
299
  const __dirname = dirname(__filename);
297
300
 
298
- // Restore execute permissions on every vendored native binary (Claude Agent SDK,
299
- // Codex, node-pty). npm and cpSync drop the execute bit, and this repo commits
300
- // node_modules — historically with core.filemode=false, so binaries checked out
301
- // 0644 and failed to spawn. The Claude sidepanel surfaced that as the SDK's
302
- // misleading "does not match this system's libc" error.
301
+ // Restore execute permissions on SynaBun's own native helpers (node-pty's
302
+ // spawn-helper). npm and cpSync drop the execute bit, and a checkout with
303
+ // core.filemode=false materializes them 0644, so terminals failed to spawn.
304
+ // The agent tools are not here: Claude Code and Codex are the user's own
305
+ // installations (lib/claude-executable.js, getCodexBin).
303
306
  //
304
307
  // This sweeps ALL platform directories, not just this host's: a vendored
305
308
  // checkout can carry several, and repairing only the current one left e.g.
@@ -746,13 +749,34 @@ app.get('/', (req, res, next) => {
746
749
  res.redirect('/onboarding.html');
747
750
  });
748
751
 
749
- // Serve offline.html dynamically with the real project path injected
752
+ // The Start Server button on the offline page hands the OS a synabun:// link.
753
+ // Whether the handler for it is registered is read once (after the boot-time
754
+ // repair below) and told to the pages, which remember it for when we are down.
755
+ let _startLauncherState = null;
756
+ function getStartLauncherState() {
757
+ if (_startLauncherState === null) {
758
+ try { _startLauncherState = startLauncherState({ packageRoot: PACKAGE_ROOT }); } catch { _startLauncherState = 'missing'; }
759
+ }
760
+ return _startLauncherState;
761
+ }
762
+ // Set when this server runs inside a packaged application (packaging/): the
763
+ // code is then replaced by installing a newer build, never by npm or git.
764
+ const PACKAGED_RUNTIME = packagedRuntime();
765
+ const INSTALL_KIND = PACKAGED_RUNTIME ? 'app' : isGlobalInstall() ? 'npm' : 'git';
766
+ // What a person types to start a packaged application: its entry executable,
767
+ // when that is a path that will still be there once this run is over.
768
+ const START_ENTRY = durableEntry(PACKAGED_RUNTIME) || '';
769
+
770
+ // Serve offline.html with what it needs once we are down: the install path,
771
+ // how it was installed, the launcher's state and the bridge code itself.
750
772
  app.get('/offline.html', (req, res) => {
751
773
  const html = readFileSync(join(__dirname, 'public', 'offline.html'), 'utf-8');
752
- res.type('html').send(html.replace(
753
- `const projectDir = localStorage.getItem('synabun-project-dir');`,
754
- `const projectDir = localStorage.getItem('synabun-project-dir') || ${JSON.stringify(PACKAGE_ROOT)};`
755
- ));
774
+ let bridgeSource = '';
775
+ try { bridgeSource = readFileSync(join(__dirname, 'public', 'shared', 'start-bridge.js'), 'utf-8'); } catch {}
776
+ res.type('html').send(renderOfflinePage(html, {
777
+ bridgeSource,
778
+ facts: { projectDir: PACKAGE_ROOT, install: INSTALL_KIND, launcher: getStartLauncherState(), entry: START_ENTRY },
779
+ }));
756
780
  });
757
781
 
758
782
  // The standalone Claude chat page was retired (the sidepanel replaced it): its
@@ -2428,6 +2452,15 @@ function cancelUpdateHandoff(handoffPath, reason) {
2428
2452
  app.post('/api/system/run-update', async (req, res) => {
2429
2453
  let preparedHandoffPath = null;
2430
2454
  try {
2455
+ // Nothing is ever installed into the resources of a packaged application.
2456
+ if (PACKAGED_RUNTIME) {
2457
+ const openUrl = `${SYNABUN_GITHUB_REPO_URL}/releases`;
2458
+ return res.status(409).json({
2459
+ error: `This copy of SynaBun is a packaged application. It is updated by installing a newer build over it, not from inside the app. Nothing was installed. Releases: ${openUrl}`,
2460
+ packaged: true,
2461
+ openUrl,
2462
+ });
2463
+ }
2431
2464
  // Auto-restart is always on — running the click-to-update flow implies the
2432
2465
  // user wants the new version live without a manual relaunch step.
2433
2466
  const autoRestart = true;
@@ -3207,88 +3240,53 @@ function _heartbeatLock(sessionId, windowId) {
3207
3240
  }
3208
3241
  }
3209
3242
 
3210
- // Cache resolved claude binary path (avoids running `where` every query)
3243
+ // Cache resolved claude binary path (avoids a PATH walk on every query)
3211
3244
  let _claudeBinPath = null;
3212
-
3245
+ // A tool that was not found is looked for again after a moment: installing it
3246
+ // is what a user does next, and that must not take a restart of SynaBun.
3247
+ const MISSING_TOOL_RECHECK_MS = 15_000;
3248
+ let _claudeBinMissingAt = 0;
3249
+
3250
+ // The Claude Code a terminal would start: the command from cli-config.json
3251
+ // (Settings > Terminal), else the first `claude` on the augmented PATH. Always
3252
+ // the user's own installation: never a file inside SynaBun, never a command
3253
+ // from a node_modules/.bin folder (lib/claude-executable.js).
3213
3254
  function getClaudeBin() {
3214
- if (_claudeBinPath) return _claudeBinPath;
3215
- // 0. User-configured path from cli-config.json (set via Settings > Terminal)
3216
- try {
3217
- const cfg = loadCliConfig();
3218
- const userCmd = cfg['claude-code']?.command?.trim();
3219
- if (userCmd && userCmd !== 'claude') {
3220
- // Absolute or relative path — verify it exists
3221
- if (existsSync(userCmd)) {
3222
- _claudeBinPath = userCmd;
3223
- return _claudeBinPath;
3224
- }
3225
- // Might be a bare command in PATH — try which/where
3226
- try {
3227
- const envWithPath = { ...process.env, PATH: getAugmentedPath() };
3228
- let lookup;
3229
- if (process.platform === 'win32') {
3230
- const lines = execSync(`where "${userCmd}"`, { encoding: 'utf-8', env: envWithPath })
3231
- .split(/\r?\n/).map(l => l.trim()).filter(Boolean);
3232
- lookup = lines.find(l => /\.(cmd|exe|bat|ps1)$/i.test(l)) || lines[0];
3233
- } else {
3234
- lookup = execSync(`which "${userCmd}"`, { encoding: 'utf-8', env: envWithPath }).trim();
3235
- }
3236
- if (lookup) { _claudeBinPath = lookup; return _claudeBinPath; }
3237
- } catch {}
3238
- }
3239
- } catch {}
3240
- // 1. Try bundled @anthropic-ai/claude-code in node_modules/.bin
3241
- const bundledBase = resolve(__dirname, 'node_modules', '.bin', 'claude');
3242
- // On Windows npm creates .cmd shims — check for those too
3243
- const bundledCandidates = process.platform === 'win32'
3244
- ? [bundledBase + '.cmd', bundledBase + '.ps1', bundledBase]
3245
- : [bundledBase];
3246
- for (const bundled of bundledCandidates) {
3247
- if (!existsSync(bundled)) continue;
3248
- const st = statSync(bundled);
3249
- // npm sometimes creates a plain text stub instead of a symlink — resolve through to cli.js
3250
- if (st.isFile() && st.size < 256) {
3251
- const target = readFileSync(bundled, 'utf-8').trim();
3252
- if (target && !target.startsWith('#')) {
3253
- const resolved = resolve(__dirname, 'node_modules', '.bin', target);
3254
- if (existsSync(resolved)) {
3255
- if (process.platform !== 'win32') {
3256
- try { const rs = statSync(resolved); if (!(rs.mode & 0o111)) chmodSync(resolved, rs.mode | 0o755); } catch {}
3257
- }
3258
- _claudeBinPath = resolved;
3259
- return _claudeBinPath;
3260
- }
3261
- }
3262
- }
3263
- // Normal symlink or executable — ensure +x
3264
- if (process.platform !== 'win32' && !(st.mode & 0o111)) {
3265
- try { chmodSync(bundled, st.mode | 0o755); } catch {}
3266
- }
3267
- _claudeBinPath = bundled;
3268
- return _claudeBinPath;
3269
- }
3270
- // 2. Fall back to global install via which/where (with augmented PATH)
3271
- const envWithPath = { ...process.env, PATH: getAugmentedPath() };
3272
- if (process.platform === 'win32') {
3273
- try {
3274
- const lines = execSync('where claude', { encoding: 'utf-8', env: envWithPath })
3275
- .split(/\r?\n/).map(l => l.trim()).filter(Boolean);
3276
- // Prefer .cmd/.exe/.bat/.ps1 over the bare extensionless shim (which is a sh script Windows can't spawn)
3277
- _claudeBinPath = lines.find(l => /\.(cmd|exe|bat|ps1)$/i.test(l)) || lines[0] || null;
3278
- } catch { _claudeBinPath = null; }
3279
- } else {
3280
- try { _claudeBinPath = execSync('which claude', { encoding: 'utf-8', env: envWithPath }).trim(); }
3281
- catch { _claudeBinPath = null; }
3282
- }
3283
- if (!_claudeBinPath) {
3284
- console.warn('[claude-skin] Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code');
3255
+ if (_claudeBinPath && _claudeBinPath !== 'claude') return _claudeBinPath;
3256
+ if (_claudeBinPath && Date.now() - _claudeBinMissingAt < MISSING_TOOL_RECHECK_MS) return _claudeBinPath;
3257
+ let configured = '';
3258
+ try { configured = loadCliConfig()['claude-code']?.command?.trim() || ''; } catch {}
3259
+ const found = resolveClaudeLauncher({ configured, isInsideSynabun });
3260
+ for (const rejected of found.rejected) {
3261
+ console.warn(`[claude-skin] Ignoring Claude CLI inside the SynaBun install: ${rejected}`);
3262
+ }
3263
+ if (!found.path) {
3264
+ if (!_claudeBinMissingAt) console.warn('[claude-skin] Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code');
3265
+ _claudeBinMissingAt = Date.now();
3285
3266
  _claudeBinPath = 'claude'; // last resort — will fail at spawn time with a clear error
3267
+ return _claudeBinPath;
3286
3268
  }
3269
+ if (_claudeBinMissingAt) console.log(`[claude-skin] Claude CLI found: ${found.path}`);
3270
+ _claudeBinMissingAt = 0;
3271
+ _claudeBinPath = found.path;
3287
3272
  return _claudeBinPath;
3288
3273
  }
3289
3274
 
3275
+ // The executable every Agent SDK call is given (pathToClaudeCodeExecutable):
3276
+ // the override from cli-config.json ("claude-skin": { "sdkExecutable": "…" }),
3277
+ // else the installation above, followed through an npm command file on
3278
+ // Windows. `path: null` when no Claude Code is installed; SynaBun carries none.
3279
+ function getClaudeSdkExecutable() {
3280
+ return resolveClaudeSdkExecutable({
3281
+ launcher: getClaudeBin(),
3282
+ sdkExecutable: _readClaudeSkinConfig().sdkExecutable || null,
3283
+ isInsideSynabun,
3284
+ });
3285
+ }
3286
+
3290
3287
  let _codexBinPath = null;
3291
3288
  let _codexBinSource = 'missing';
3289
+ let _codexBinMissingAt = 0;
3292
3290
  let _nativeCodexBinPath = null;
3293
3291
  // The SDK is statically imported at module load; reaching this point is the
3294
3292
  // authoritative capability check. Its package.json is intentionally not an
@@ -3337,10 +3335,11 @@ function resolveWindowsGlobalCodexRuntime() {
3337
3335
  }
3338
3336
 
3339
3337
  function getCodexBin() {
3340
- if (_codexBinPath) return _codexBinPath;
3341
- // All Codex surfaces follow the user's trusted global install. Never fall
3342
- // back to the SDK's bundled platform binary: macOS may quarantine or block
3343
- // that copied payload, and unattended launches must not try to repair it.
3338
+ // (Not found is looked for again after a moment, like Claude Code above.)
3339
+ if (_codexBinPath && (_codexBinSource !== 'missing' || Date.now() - _codexBinMissingAt < MISSING_TOOL_RECHECK_MS)) return _codexBinPath;
3340
+ // All Codex surfaces follow the user's trusted global install. SynaBun
3341
+ // carries no Codex of its own (lib/external-tools.js): the SDK is always
3342
+ // handed this path, and without one Codex is reported as not installed.
3344
3343
  const globalPath = getAugmentedPath().split(delimiter)
3345
3344
  .filter((entry) => !/[\\/]node_modules[\\/]\.bin[\\/]?$/i.test(entry))
3346
3345
  .join(delimiter);
@@ -3360,14 +3359,17 @@ function getCodexBin() {
3360
3359
  try { _codexBinPath = execSync('which codex', { encoding: 'utf-8', env: envWithPath }).trim(); }
3361
3360
  catch { _codexBinPath = null; }
3362
3361
  }
3363
- if (_codexBinPath && !isInsideSynabun(_codexBinPath)) {
3362
+ if (_codexBinPath && _codexBinPath !== 'codex' && !isInsideSynabun(_codexBinPath)) {
3363
+ if (_codexBinMissingAt) console.log(`[codex-skin] Codex CLI found: ${_codexBinPath}`);
3364
+ _codexBinMissingAt = 0;
3364
3365
  _codexBinSource = 'global';
3365
3366
  return _codexBinPath;
3366
3367
  }
3367
- if (_codexBinPath) {
3368
- console.warn(`[codex-skin] Ignoring Codex CLI inside the SynaBun install: ${_codexBinPath}`);
3368
+ if (!_codexBinMissingAt) {
3369
+ if (_codexBinPath && _codexBinPath !== 'codex') console.warn(`[codex-skin] Ignoring Codex CLI inside the SynaBun install: ${_codexBinPath}`);
3370
+ console.warn('[codex-skin] Codex CLI not found. Install: npm install -g @openai/codex');
3369
3371
  }
3370
- console.warn('[codex-skin] Codex CLI not found. Install: npm install -g @openai/codex');
3372
+ _codexBinMissingAt = Date.now();
3371
3373
  _codexBinPath = 'codex';
3372
3374
  _codexBinSource = 'missing';
3373
3375
  return _codexBinPath;
@@ -5044,7 +5046,7 @@ CRITICAL — When you need clarification during planning:
5044
5046
  };
5045
5047
  delete env.FORCE_COLOR;
5046
5048
  delete env.CLICOLOR_FORCE;
5047
- const proc = spawn(codexBin, args, {
5049
+ const proc = spawn(shellCommand(codexBin, useShell, { isFile: existsSync }), args, {
5048
5050
  cwd: PACKAGE_ROOT,
5049
5051
  env,
5050
5052
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -7226,7 +7228,8 @@ async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME) {
7226
7228
  delete env.CLICOLOR_FORCE;
7227
7229
 
7228
7230
  return new Promise((resolve, reject) => {
7229
- const child = spawn(codexBin, args, {
7231
+ // (A configured command may be a command line; a file with a space in its path is one word.)
7232
+ const child = spawn(shellCommand(codexBin, useShell, { isFile: existsSync }), args, {
7230
7233
  cwd: PACKAGE_ROOT,
7231
7234
  env,
7232
7235
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -7265,6 +7268,10 @@ async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME) {
7265
7268
  child.stderr.setEncoding('utf-8');
7266
7269
  child.stderr.on('data', chunk => { stderrBuf += chunk; });
7267
7270
  child.on('error', fail);
7271
+ // Codex not installed: the shell asked to run it is gone before the first
7272
+ // request is written, and that write fails on the stream (EPIPE). Unhandled,
7273
+ // it ends the whole server.
7274
+ child.stdin.on('error', fail);
7268
7275
  child.on('exit', (code) => {
7269
7276
  if (pending.size) {
7270
7277
  const err = new Error(`Codex app-server exited while listing models (code ${code ?? 'unknown'})`);
@@ -8861,6 +8868,7 @@ app.post('/api/sidepanel/session-title', async (req, res) => {
8861
8868
  input.cwd = cwd;
8862
8869
  }
8863
8870
  const result = await generateSessionTitle(input, {
8871
+ claudeExecutable: getClaudeSdkExecutable,
8864
8872
  execFile: execFileAsync,
8865
8873
  getCodexBin,
8866
8874
  getCodexAccountHome,
@@ -9394,10 +9402,10 @@ app.post('/api/opencode/mcp', async (req, res) => {
9394
9402
  let { name, config: mcpConfig } = req.body || {};
9395
9403
  if (!name) {
9396
9404
  name = 'SynaBun';
9397
- const { mcpIndexPath, envPath } = getMcpPaths();
9405
+ const { envPath, mcpCommand, mcpArgs } = getMcpPaths();
9398
9406
  mcpConfig = {
9399
- command: 'node',
9400
- args: [mcpIndexPath],
9407
+ command: mcpCommand,
9408
+ args: mcpArgs,
9401
9409
  env: { DOTENV_PATH: envPath, SYNABUN_DATA_HOME: DATA_HOME, MEMORY_DATA_DIR: resolve(DATA_HOME, 'mcp-data') },
9402
9410
  };
9403
9411
  }
@@ -9522,17 +9530,12 @@ const _claudeBridgeReady = (async () => {
9522
9530
  if (typeof cfg.partials === 'boolean') return cfg.partials;
9523
9531
  return process.platform !== 'win32';
9524
9532
  },
9525
- // Optional explicit executable override; bundled SDK CLI is the default.
9526
- // Accepts a .js entrypoint (launched via node) or a native binary path —
9527
- // see resolveClaudeExecutableOverride() in lib/native-binary-runtime.js.
9528
- get sdkExecutable() { return _readClaudeSkinConfig().sdkExecutable || null; },
9529
- // Last-resort runtime for the bridge: if the SDK's bundled native binary
9530
- // cannot be launched or repaired, fall back to the user's installed CLI.
9531
- // Lazy so the (memoized) resolution happens on demand, not at boot.
9532
- getClaudeBin: () => getClaudeBin(),
9533
- // The installed CLI when it is newer than the bundled one, else null — the
9534
- // model picker lists the installed CLI's models, so sessions must run it.
9535
- alignedClaudeBin: () => alignedClaudeExecutable(getClaudeBin()),
9533
+ // The Claude Code every session runs: the user's own installation, or the
9534
+ // explicit override from cli-config.json ("claude-skin": { "sdkExecutable":
9535
+ // "…" }, a .js entrypoint or a native binary path). SynaBun carries none, so
9536
+ // a session without one is refused with the install hint. Lazy, so the
9537
+ // (memoized) resolution happens on demand, not at boot.
9538
+ claudeExecutable: () => getClaudeSdkExecutable(),
9536
9539
  // Sidepanel tabs can run under another Claude account (its own config
9537
9540
  // directory). The default account adds nothing to the environment.
9538
9541
  claudeAccountEnv: (accountId) => _panelClaudeAccounts().envFor(accountId),
@@ -9617,10 +9620,10 @@ function handleClaudeSkinWebSocket(ws) {
9617
9620
  async function getClaudeModelsForClient(force = false) {
9618
9621
  try {
9619
9622
  const bin = getClaudeBin();
9620
- // Sidepanel sessions and native loops default to the SDK's bundled CLI, not
9621
- // this one. The comparison is cached per binary (a spawn only when the CLI
9622
- // changes); it warns about a skew and, when this CLI is the newer one, makes
9623
- // sessions run it instead — see alignedClaudeExecutable().
9623
+ // Sidepanel sessions and native loops run this same installation. The Agent
9624
+ // SDK that starts it was built against one Claude Code release; an older
9625
+ // installation is worth one line in the log. Cached per binary (a spawn only
9626
+ // when the CLI changes).
9624
9627
  checkClaudeCliSkew(bin).catch(() => {});
9625
9628
  const result = await discoverClaudeModels(bin, { force, cwd: PACKAGE_ROOT });
9626
9629
  if (result?.models?.length) return result;
@@ -15460,7 +15463,7 @@ function spawnAgentProcess(agent, prompt, extraEnv = {}) {
15460
15463
 
15461
15464
  console.log(`[agent] ${agent.id} spawn: ${claudeBin} ${args.map(a => a.length > 80 ? a.slice(0, 80) + '…' : a).join(' ')}`);
15462
15465
 
15463
- const child = spawn(claudeBin, args, {
15466
+ const child = spawn(shellCommand(claudeBin, AGENT_IS_WIN), args, {
15464
15467
  cwd: agent.cwd,
15465
15468
  env: { ...cleanEnv, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb', ...extraEnv },
15466
15469
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -16094,6 +16097,7 @@ function isSynabunGitHubRemote(remoteText) {
16094
16097
  }
16095
16098
 
16096
16099
  function detectSynabunInstallSource() {
16100
+ if (PACKAGED_RUNTIME) return { kind: 'packaged-app' };
16097
16101
  const normalizedRoot = PACKAGE_ROOT.replace(/\\/g, '/');
16098
16102
  if (/\/node_modules\/synabun$/i.test(normalizedRoot)) {
16099
16103
  return { kind: 'npm-global' };
@@ -16345,6 +16349,10 @@ const SYNABUN_ROOTS = (() => {
16345
16349
  const roots = new Set();
16346
16350
  try { roots.add(realpathSync(__dirname).replace(/\\/g, '/').toLowerCase()); } catch {}
16347
16351
  try { roots.add(realpathSync(resolve(__dirname, '..')).replace(/\\/g, '/').toLowerCase()); } catch {}
16352
+ // A packaged application: everything it ships, its own Node and npm included.
16353
+ if (PACKAGED_RUNTIME?.resources) {
16354
+ try { roots.add(realpathSync(PACKAGED_RUNTIME.resources).replace(/\\/g, '/').toLowerCase()); } catch {}
16355
+ }
16348
16356
  return [...roots];
16349
16357
  })();
16350
16358
 
@@ -16366,35 +16374,23 @@ function isInsideSynabun(binPath) {
16366
16374
  // to come from. Add new versions here if SynaBun ever ships claude-code again.
16367
16375
  const KNOWN_STALE_CLAUDE_VERSIONS = new Set(['2.1.71', '2.1.89', '2.1.91']);
16368
16376
 
16369
- // Aggressively delete any stale bundled CLI tool packages left over in our
16370
- // own node_modules. Idempotent — runs on every startup. Belt-and-suspenders
16371
- // against `npm install` not auto-pruning packages removed from package.json.
16372
- // Logs every found package (deleted or not) so Windows users can confirm the
16373
- // prune is actually firing on their box.
16377
+ // Remove any external tool that ended up in our own node_modules: Claude Code,
16378
+ // Codex, OpenCode and Gemini CLI are installed by the user, never by SynaBun,
16379
+ // and the agent SDKs are installed without the executables they would carry
16380
+ // (the list and the reasons: lib/external-tools.js at the package root).
16381
+ // Idempotent, runs on every startup, and touches nothing when there is nothing
16382
+ // to remove — a packaged application ships without them and is never written to.
16383
+ // Catches what `npm install` leaves behind: packages an older SynaBun depended
16384
+ // on, and payloads a regenerated lockfile brought back. Logs each removal so a
16385
+ // user can see the prune fire on their machine.
16374
16386
  function pruneBundledCliTools() {
16375
- // @openai/codex is a live transitive dependency of @openai/codex-sdk, so it
16376
- // must remain installed even though SynaBun never executes its bundled binary.
16377
- const stalePkgs = ['@anthropic-ai/claude-code', 'opencode-ai', '@google/gemini-cli'];
16378
16387
  const moduleRoots = [
16379
16388
  join(__dirname, 'node_modules'),
16380
16389
  join(__dirname, '..', 'node_modules'),
16381
16390
  ];
16382
16391
  for (const root of moduleRoots) {
16383
- for (const pkg of stalePkgs) {
16384
- const target = join(root, pkg);
16385
- try {
16386
- if (existsSync(target)) {
16387
- rmSync(target, { recursive: true, force: true });
16388
- console.log(` Pruned stale bundled CLI: ${target}`);
16389
- }
16390
- } catch { /* permission or in-use — ignore */ }
16391
- // Also remove .bin shims that point at the deleted package
16392
- const binBase = pkg.split('/').pop().replace(/-cli$|-ai$/, '');
16393
- for (const ext of ['', '.cmd', '.ps1', '.exe']) {
16394
- const shim = join(root, '.bin', binBase + ext);
16395
- try { if (existsSync(shim)) rmSync(shim, { force: true }); } catch {}
16396
- }
16397
- }
16392
+ const result = pruneExternalTools(root);
16393
+ for (const item of result.removed) console.log(` Removed ${item.name} from SynaBun's own node_modules (${item.tool} is installed separately): ${item.path}`);
16398
16394
  }
16399
16395
  }
16400
16396
 
@@ -17928,6 +17924,7 @@ app.get('/api/setup/onboarding', async (req, res) => {
17928
17924
  const setupComplete = vars.SETUP_COMPLETE === 'true';
17929
17925
  const dbExists = existsSync(getDbPath());
17930
17926
  const mcpBuilt = existsSync(resolve(PACKAGE_ROOT, 'mcp-server', 'dist', 'index.js'));
17927
+ const { mcpCommand, mcpArgs } = getMcpPaths();
17931
17928
 
17932
17929
  res.json({
17933
17930
  setupComplete,
@@ -17938,6 +17935,8 @@ app.get('/api/setup/onboarding', async (req, res) => {
17938
17935
  projectDir: PACKAGE_ROOT,
17939
17936
  dataHome: DATA_HOME,
17940
17937
  packageRoot: PACKAGE_ROOT,
17938
+ mcpCommand,
17939
+ mcpArgs,
17941
17940
  dataHomeInsidePackage: DATA_HOME_DIAGNOSTICS.dataHomeInsidePackage,
17942
17941
  recommendedDataHome: DATA_HOME_DIAGNOSTICS.recommendedDataHome,
17943
17942
  dataHomeExplicitOverride: DATA_HOME_DIAGNOSTICS.explicitOverride,
@@ -18072,8 +18071,7 @@ app.post('/api/setup/write-mcp-json', (req, res) => {
18072
18071
  const envPath = resolve(DATA_HOME, '.env').replace(/\\/g, '/');
18073
18072
 
18074
18073
  const mcpEntry = {
18075
- command: 'node',
18076
- args: [mcpIndexPath],
18074
+ ...mcpStdioCommand(mcpIndexPath),
18077
18075
  env: { DOTENV_PATH: envPath, SYNABUN_DATA_HOME: DATA_HOME, MEMORY_DATA_DIR: resolve(DATA_HOME, 'mcp-data') },
18078
18076
  };
18079
18077
 
@@ -20152,7 +20150,7 @@ app.get('/api/claude-code/integrations', (req, res) => {
20152
20150
 
20153
20151
  // Auto-sync: ensure all registered projects have globally-enabled hooks
20154
20152
  const globallyEnabled = Object.entries(globalHooks).filter(([, on]) => on).map(([ev]) => ev);
20155
- for (const p of projects) {
20153
+ for (const p of hookInstallBlocker() ? [] : projects) {
20156
20154
  const projFile = getClaudeSettingsPath(p.path);
20157
20155
  let projSettings;
20158
20156
  try {
@@ -20198,7 +20196,8 @@ app.get('/api/claude-code/integrations', (req, res) => {
20198
20196
 
20199
20197
  const mcpIndexPath = resolve(PACKAGE_ROOT, 'mcp-server', 'run.mjs').replace(/\\/g, '/');
20200
20198
  const envPath = resolve(DATA_HOME, '.env').replace(/\\/g, '/');
20201
- const cliCommand = `claude mcp add SynaBun node "${mcpIndexPath}" -s user -e "DOTENV_PATH=${envPath}"`;
20199
+ const { command: mcpCommand, args: mcpArgs } = mcpStdioCommand(mcpIndexPath);
20200
+ const cliCommand = claudeMcpAddCommand({ mcpIndexPath, envPath, mcpCommand, mcpArgs });
20202
20201
 
20203
20202
  res.json({
20204
20203
  ok: true,
@@ -20220,7 +20219,11 @@ app.post('/api/claude-code/integrations', (req, res) => {
20220
20219
  const { target, projectPath, label, hook } = req.body;
20221
20220
  // target: 'global' | 'project'
20222
20221
 
20222
+ // Null unless this run has no path of its own to register (lib/claude-hooks.js).
20223
+ const blocker = hookInstallBlocker();
20224
+
20223
20225
  if (target === 'global') {
20226
+ if (blocker) return res.status(409).json({ error: blocker });
20224
20227
  const filePath = getGlobalClaudeSettingsPath();
20225
20228
  let settings = readClaudeSettingsForWrite(filePath);
20226
20229
  settings = addHookToSettings(settings, hook || undefined);
@@ -20246,15 +20249,23 @@ app.post('/api/claude-code/integrations', (req, res) => {
20246
20249
  const normalized = resolve(projectPath);
20247
20250
  if (!existsSync(normalized)) return res.status(400).json({ error: `Directory not found: ${normalized}` });
20248
20251
 
20249
- const filePath = getClaudeSettingsPath(normalized);
20250
- let settings = readClaudeSettingsForWrite(filePath);
20251
- settings = addHookToSettings(settings, hook || undefined, normalized);
20252
- writeClaudeSettings(filePath, settings);
20252
+ // Adding a project is more than its hooks: a run that cannot write them
20253
+ // still registers it, and refuses only a request that asks for nothing else.
20254
+ const projects = loadHookProjects();
20255
+ const registered = projects.some(p => resolve(p.path) === normalized);
20256
+ const plan = hookInstallPlan({ target, registered, hook });
20257
+ if (plan === 'refuse') return res.status(409).json({ error: blocker });
20258
+
20259
+ if (plan === 'write') {
20260
+ const filePath = getClaudeSettingsPath(normalized);
20261
+ let settings = readClaudeSettingsForWrite(filePath);
20262
+ settings = addHookToSettings(settings, hook || undefined, normalized);
20263
+ writeClaudeSettings(filePath, settings);
20264
+ }
20253
20265
 
20254
20266
  // Save to registered projects list
20255
20267
  const projectLabel = label || basename(normalized);
20256
- const projects = loadHookProjects();
20257
- if (!projects.some(p => resolve(p.path) === normalized)) {
20268
+ if (!registered) {
20258
20269
  projects.push({ path: normalized, label: projectLabel });
20259
20270
  saveHookProjects(projects);
20260
20271
  }
@@ -20293,6 +20304,9 @@ app.post('/api/claude-code/integrations', (req, res) => {
20293
20304
  }
20294
20305
  } catch { /* non-critical */ }
20295
20306
 
20307
+ if (plan !== 'write') {
20308
+ return res.json({ ok: true, message: `${basename(normalized)} was added without hooks. ${blocker}`, hooksSkipped: true, dubiousOwnership, trustPath });
20309
+ }
20296
20310
  return res.json({ ok: true, message: `Hook enabled for ${basename(normalized)}.`, dubiousOwnership, trustPath });
20297
20311
  }
20298
20312
 
@@ -21319,7 +21333,18 @@ function syncCodexToolPermissions(allowedKeys) {
21319
21333
  function getMcpPaths() {
21320
21334
  const mcpIndexPath = resolve(PACKAGE_ROOT, 'mcp-server', 'dist', 'preload.js').replace(/\\/g, '/');
21321
21335
  const envPath = resolve(DATA_HOME, '.env').replace(/\\/g, '/');
21322
- return { mcpIndexPath, envPath };
21336
+ // What a client outside this process runs: `node <preload.js>`, or the
21337
+ // entry executable of a packaged application (`<entry> mcp`).
21338
+ const { command: mcpCommand, args: mcpArgs } = mcpStdioCommand(mcpIndexPath);
21339
+ return { mcpIndexPath, envPath, mcpCommand, mcpArgs };
21340
+ }
21341
+
21342
+ /** The `claude mcp add` line for the registration getMcpPaths() describes. */
21343
+ function claudeMcpAddCommand({ mcpIndexPath, envPath, mcpCommand, mcpArgs }) {
21344
+ // Inside double quotes a POSIX shell still reads \ " $ and `; a Windows path has none of them to escape.
21345
+ const quoted = (value) => (/^[A-Za-z]:[\\/]/.test(value) ? `"${value}"` : `"${String(value).replace(/[\\"$`]/g, '\\$&')}"`);
21346
+ const launch = mcpCommand === 'node' ? `node "${mcpIndexPath}"` : `${quoted(mcpCommand)} ${mcpArgs.join(' ')}`;
21347
+ return `claude mcp add SynaBun ${launch} -s user -e "DOTENV_PATH=${envPath}"`;
21323
21348
  }
21324
21349
 
21325
21350
  function getHomePath() {
@@ -21340,8 +21365,8 @@ function buildCodexMcpEnv() {
21340
21365
  }
21341
21366
 
21342
21367
  function buildCodexMcpDefinition() {
21343
- const { mcpIndexPath } = getMcpPaths();
21344
- return { command: 'node', args: [mcpIndexPath], env: buildCodexMcpEnv() };
21368
+ const { mcpCommand, mcpArgs } = getMcpPaths();
21369
+ return { command: mcpCommand, args: mcpArgs, env: buildCodexMcpEnv() };
21345
21370
  }
21346
21371
 
21347
21372
  function buildCodexMcpToml() {
@@ -21374,13 +21399,13 @@ app.post('/api/setup/gemini/mcp', (req, res) => {
21374
21399
  try {
21375
21400
  const dir = join(getHomePath(), '.gemini');
21376
21401
  const settingsPath = join(dir, 'settings.json');
21377
- const { mcpIndexPath, envPath } = getMcpPaths();
21402
+ const { envPath, mcpCommand, mcpArgs } = getMcpPaths();
21378
21403
  mkdirSync(dir, { recursive: true });
21379
21404
  let data = {};
21380
21405
  try { data = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch {}
21381
21406
  data = mergeGeminiMcpConfig(data, {
21382
- command: 'node',
21383
- args: [mcpIndexPath],
21407
+ command: mcpCommand,
21408
+ args: mcpArgs,
21384
21409
  env: { DOTENV_PATH: envPath, SYNABUN_DATA_HOME: DATA_HOME, MEMORY_DATA_DIR: resolve(DATA_HOME, 'mcp-data') },
21385
21410
  });
21386
21411
  writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
@@ -21450,7 +21475,8 @@ app.delete('/api/setup/codex/mcp', (req, res) => {
21450
21475
  app.get('/api/setup/status', (req, res) => {
21451
21476
  try {
21452
21477
  const home = getHomePath();
21453
- const { mcpIndexPath, envPath } = getMcpPaths();
21478
+ const mcpPaths = getMcpPaths();
21479
+ const { mcpIndexPath, envPath, mcpCommand, mcpArgs } = mcpPaths;
21454
21480
 
21455
21481
  // Claude
21456
21482
  let claude = { connected: false };
@@ -21458,7 +21484,7 @@ app.get('/api/setup/status', (req, res) => {
21458
21484
  const cj = JSON.parse(readFileSync(join(home, '.claude.json'), 'utf-8'));
21459
21485
  claude.connected = !!(cj.mcpServers && cj.mcpServers.SynaBun);
21460
21486
  } catch {}
21461
- claude.cliCommand = `claude mcp add SynaBun node "${mcpIndexPath}" -s user -e "DOTENV_PATH=${envPath}"`;
21487
+ claude.cliCommand = claudeMcpAddCommand(mcpPaths);
21462
21488
 
21463
21489
  // Gemini
21464
21490
  let gemini = { connected: false };
@@ -21488,7 +21514,7 @@ app.get('/api/setup/status', (req, res) => {
21488
21514
  let rules = null;
21489
21515
  try { rules = rulesetInstaller.status(); } catch (err) { rules = { ok: false, error: err.message }; }
21490
21516
 
21491
- res.json({ ok: true, claude, gemini, codex, opencode, rules, paths: { mcpIndexPath, envPath } });
21517
+ res.json({ ok: true, claude, gemini, codex, opencode, rules, paths: { mcpIndexPath, envPath, mcpCommand, mcpArgs } });
21492
21518
  } catch (err) {
21493
21519
  res.status(500).json({ error: err.message });
21494
21520
  }
@@ -22743,7 +22769,7 @@ app.get('/api/health', (req, res) => {
22743
22769
  // getDb() auto-creates the directory, file, and schema if missing
22744
22770
  getDb();
22745
22771
  const count = countMemories();
22746
- res.json({ ok: true, storage: 'sqlite', memories: count, projectDir: PACKAGE_ROOT });
22772
+ res.json({ ok: true, storage: 'sqlite', memories: count, projectDir: PACKAGE_ROOT, install: INSTALL_KIND, startLauncher: getStartLauncherState(), entry: START_ENTRY });
22747
22773
  } catch (err) {
22748
22774
  res.json({ ok: false, reason: 'db_error', detail: err.message });
22749
22775
  }
@@ -25248,14 +25274,11 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25248
25274
  ? `${state.model}[1m]`
25249
25275
  : state.model && state.model.includes(':') ? toCliModelName(state.model) : state.model,
25250
25276
  mcpUrl: `http://localhost:${PORT}/mcp`,
25251
- sdkExecutable: config.sdkExecutable || undefined,
25252
- // Fallback if the SDK's bundled native binary is unusable — unattended
25253
- // loops cannot surface a permission error to anyone, so they need the
25254
- // same escape hatch the sidepanel has.
25255
- claudeBin: getClaudeBin(),
25256
- // Run the installed CLI instead when it is newer than the bundled one, so
25257
- // the loop's model resolves the way the picker it was chosen from named it.
25258
- alignedClaudeBin: alignedClaudeExecutable(getClaudeBin()),
25277
+ // The user's Claude Code (or the cli-config override): the same
25278
+ // executable the model picker read its list from, so the loop's model
25279
+ // resolves the way it was named there. Without one the run fails at
25280
+ // once with the install hint, before anything is started.
25281
+ claudeExecutable: getClaudeSdkExecutable(),
25259
25282
  includePartialMessages: typeof config.partials === 'boolean'
25260
25283
  ? config.partials
25261
25284
  : process.platform !== 'win32',
@@ -25271,7 +25294,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25271
25294
  ...state,
25272
25295
  codexHome: state.codexHome || getCodexAccountHome(state.codexAccountId || 'default'),
25273
25296
  // Native schedules use the same trusted global CLI as the interactive
25274
- // sidepanel. The SDK-owned platform payload is never repaired or run.
25297
+ // sidepanel; SynaBun carries no Codex for the SDK to find by itself.
25275
25298
  codexPath: getNativeCodexBin(),
25276
25299
  });
25277
25300
  },
@@ -29916,9 +29939,11 @@ async function createBrowserSession(options = {}) {
29916
29939
  // detection on sites like Twitter. Let the automated Chrome use its own default UA.
29917
29940
  const realHeaders = options._realHeaders || {};
29918
29941
  const userAgent = savedCfg.userAgent || undefined; // only override if explicitly configured
29919
- const cfgAcceptLang = savedCfg.acceptLanguage || null;
29920
- const acceptLanguage = cfgAcceptLang || realHeaders['accept-language'] || 'en-US,en;q=0.9';
29921
- const locale = savedCfg.locale || acceptLanguage.split(',')[0].split(';')[0] || 'en-US';
29942
+ const { locale, acceptLanguage, languages, warnings: languageWarnings } =
29943
+ resolveBrowserLanguage(savedCfg, realHeaders['accept-language']);
29944
+ for (const warning of languageWarnings) {
29945
+ console.warn(`[browser] Invalid ${warning.field}; using ${JSON.stringify(warning.value)}`);
29946
+ }
29922
29947
 
29923
29948
  // Build context options from saved config
29924
29949
  const contextOpts = {
@@ -30311,7 +30336,7 @@ async function createBrowserSession(options = {}) {
30311
30336
  ],
30312
30337
  });
30313
30338
  Object.defineProperty(navigator, 'languages', {
30314
- get: () => ${JSON.stringify(acceptLanguage.split(',').map(l => l.split(';')[0].trim()))},
30339
+ get: () => ${JSON.stringify(languages)},
30315
30340
  });
30316
30341
  const origQuery = window.Permissions.prototype.query;
30317
30342
  window.Permissions.prototype.query = function(params) {
@@ -32824,7 +32849,7 @@ app.get('/api/browser/config', (req, res) => {
32824
32849
  app.put('/api/browser/config', (req, res) => {
32825
32850
  try {
32826
32851
  // Full replacement — the frontend sends the complete config object
32827
- const body = req.body || {};
32852
+ const body = validateBrowserLanguageConfig(req.body || {});
32828
32853
  if (body.connectMode !== undefined) body.connectMode = ['attach', 'morelogin'].includes(body.connectMode) ? body.connectMode : 'mirror';
32829
32854
  if (body.attachSeed !== undefined) body.attachSeed = body.attachSeed === 'fresh' ? 'fresh' : 'real';
32830
32855
  if (body.moreloginEnvId !== undefined && body.moreloginEnvId !== null && body.moreloginEnvId !== '') body.moreloginEnvId = String(body.moreloginEnvId);
@@ -32836,7 +32861,8 @@ app.put('/api/browser/config', (req, res) => {
32836
32861
  .catch(() => {});
32837
32862
  res.json({ ok: true });
32838
32863
  } catch (err) {
32839
- res.status(500).json({ error: err.message });
32864
+ const invalidLanguage = err.status === 400 && ['locale', 'acceptLanguage'].includes(err.field);
32865
+ res.status(invalidLanguage ? 400 : 500).json({ error: err.message, ...(invalidLanguage && { field: err.field }) });
32840
32866
  }
32841
32867
  });
32842
32868
 
@@ -32966,6 +32992,16 @@ const httpServer = app.listen(PORT, async () => {
32966
32992
  setKvConfig('embedding_dims', String(EMBEDDING_DIMS));
32967
32993
  } catch {}
32968
32994
 
32995
+ // Keep the Start Server launcher (synabun://) pointing at this install even
32996
+ // when the server was started directly rather than through setup.js. Writes
32997
+ // only what differs; skipped for test runs and throwaway data homes.
32998
+ try {
32999
+ const launcher = registerStartLauncher({ packageRoot: PACKAGE_ROOT });
33000
+ _startLauncherState = launcher.state === 'skipped' ? startLauncherState({ packageRoot: PACKAGE_ROOT }) : launcher.state;
33001
+ if (launcher.changed) console.log('[server] Start Server launcher registered (synabun://)');
33002
+ else if (!launcher.ok) console.warn(`[server] Start Server launcher not registered: ${launcher.errors[0]}`);
33003
+ } catch {}
33004
+
32969
33005
  // Auto-heal legacy synabun-symlink plugin state and reinstall via CC CLI
32970
33006
  try {
32971
33007
  const repair = pluginInstaller.repairSynabunPlugins();
@@ -33013,10 +33049,9 @@ const httpServer = app.listen(PORT, async () => {
33013
33049
  // opencode-<sid> node_modules that never got their per-loop cleanup.
33014
33050
  setTimeout(() => { try { cleanupStaleLoopConfigs(); } catch {} }, 1500);
33015
33051
 
33016
- // Compare the installed Claude CLI with the one the Agent SDK bundles now, so
33017
- // the first sidepanel session or native loop after boot already knows whether
33018
- // to run the installed CLI (it does when that one is newer — see
33019
- // alignedClaudeExecutable in lib/claude-model-catalog.js).
33052
+ // Compare the installed Claude CLI with the release the Agent SDK was built
33053
+ // against, once, so an older installation is named in the log before the
33054
+ // first session runs on it (checkClaudeCliSkew in lib/claude-model-catalog.js).
33020
33055
  setTimeout(() => { checkClaudeCliSkew(getClaudeBin()).catch(() => {}); }, 1000);
33021
33056
 
33022
33057
  // Warm the session_cache + session_fts tables so the hybrid search has data
@@ -33130,10 +33165,10 @@ const httpServer = app.listen(PORT, async () => {
33130
33165
  // Check for SynaBun updates (npm + github releases)
33131
33166
  try { await checkSynabunUpdate(); } catch {}
33132
33167
 
33133
- // Aggressively prune any stale bundled CLI tools left in our node_modules
33134
- // before the version check runs — defends against `npm install` not auto-
33135
- // pruning packages that were removed from package.json (recurring source of
33136
- // phantom-version badges, e.g. v2.1.89 from a stale @anthropic-ai/claude-code).
33168
+ // Prune any external CLI tool left in our node_modules before the version
33169
+ // check runs — defends against `npm install` not auto-pruning packages that
33170
+ // were removed from package.json (recurring source of phantom-version
33171
+ // badges, e.g. v2.1.89 from a stale @anthropic-ai/claude-code).
33137
33172
  try { pruneBundledCliTools(); } catch {}
33138
33173
 
33139
33174
  // Check for CLI tool updates — fire-and-forget so boot isn't serialized