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
@@ -136,6 +136,44 @@
136
136
  "stillOffline": "Still offline \u2014 start the server and try again",
137
137
  "runCommand": "Run this command to start the server:",
138
138
  "copyToClipboard": "Copy to clipboard",
139
+ "start": {
140
+ "asked": "Asked your system to start SynaBun. If your browser shows a prompt, choose Open.",
141
+ "unconfirmedMac": "No word from the launcher yet. If your browser is asking for permission, choose Open: a Terminal window then opens with the server.",
142
+ "unconfirmedWindows": "No word from the launcher yet. If your browser is asking for permission, choose Open: a console window then opens with the server.",
143
+ "unconfirmedLinux": "No word from the launcher yet. If your browser is asking for permission, choose Open: the server then starts in the background.",
144
+ "stalled": "Nothing has answered. If no prompt or window appeared, run the command below once: it also repairs this button.",
145
+ "launcherRunning": "The launcher is running.",
146
+ "terminalOpened": "A Terminal window was opened for the server.",
147
+ "terminalQuiet": "A Terminal window was opened {seconds} s ago, but the server has not started in it. Look at that window.",
148
+ "processStarted": "The server's supervisor was started.",
149
+ "checking": "Checking the install.",
150
+ "dependencies": "Installing dependencies with npm. A first start, or the first after an update, takes a few minutes.",
151
+ "browser": "Downloading the automation browser. This happens once.",
152
+ "build": "Building the MCP server.",
153
+ "snapshot": "Backing up your data before the first start of this version.",
154
+ "snapshotSized": "Backing up your data before the first start of this version: {files} files, {megabytes} MB.",
155
+ "server": "The server process is running. Waiting for it to answer.",
156
+ "listening": "The server is listening. Connecting.",
157
+ "answered": "The server answered the launcher. Connecting.",
158
+ "quiet": "Nothing new from the start for {seconds} s.",
159
+ "failed": "The start failed: {reason}",
160
+ "failedHint": "Run the command below to see the full output, or try again.",
161
+ "failedLog": "Launcher log: {path}",
162
+ "gaveUp": "The server was started but has not answered. Check its window, or run the command below.",
163
+ "online": "The server is up. Loading SynaBun.",
164
+ "trailLauncher": "Launcher started",
165
+ "trailTerminal": "Terminal opened",
166
+ "trailProcess": "Supervisor started",
167
+ "trailSupervisor": "Supervisor running",
168
+ "trailServer": "Server process started",
169
+ "trailListening": "Server listening",
170
+ "elapsedLabel": "Time since Start was pressed",
171
+ "headingStarting": "Starting the server",
172
+ "headingReady": "Server is up",
173
+ "headingFailed": "The server did not start"
174
+ },
175
+ "runCommandOr": "Or run this command:",
176
+ "runCommandSetup": "Run this command once. It also sets up the Start button:",
139
177
  "serverOffline": "Server Offline",
140
178
  "serverNotRunning": "The Neural Interface server is not running",
141
179
  "connectionFailed": "Connection Failed",
@@ -525,7 +563,7 @@
525
563
  "help": "Storage, search, backups"
526
564
  },
527
565
  "ai_decisions": {
528
- "label": "AI decisions",
566
+ "label": "Jev",
529
567
  "help": "Smart judgments and cost"
530
568
  },
531
569
  "projects": {
@@ -571,7 +609,7 @@
571
609
  "purpose": "See where your memories live, choose how SynaBun finds them, and keep a copy you can restore."
572
610
  },
573
611
  "ai_decisions": {
574
- "title": "AI decisions",
612
+ "title": "Jev",
575
613
  "purpose": "Control where smart judgments help, what they may use, and how much they cost."
576
614
  },
577
615
  "projects": {
@@ -3597,6 +3635,7 @@
3597
3635
  "judgments": {
3598
3636
  "showEveryJudgmentAboutThisMemory": ": show every judgment about this memory in the log",
3599
3637
  "checkingTypesafe": "Checking TypeSafe…",
3638
+ "learnMoreAtTypesafe": "Learn more at",
3600
3639
  "jevTypesafeSystemOneReturnsTyped": "Jev (TypeSafe System One) returns typed judgments where SynaBun used to guess with a regex or a similarity constant. The key is stored in",
3601
3640
  "andNeverLeavesTheServerOnly": "and never leaves the server; only its last four characters reach this page.",
3602
3641
  "apiKey": "API key",
@@ -131,6 +131,44 @@
131
131
  "stillOffline": "Ainda offline — inicie o servidor e tente novamente",
132
132
  "runCommand": "Execute este comando para iniciar o servidor:",
133
133
  "copyToClipboard": "Copiar para a área de transferência",
134
+ "start": {
135
+ "asked": "Pedimos ao sistema para iniciar o SynaBun. Se o navegador mostrar um aviso, escolha Abrir.",
136
+ "unconfirmedMac": "Ainda sem notícias do iniciador. Se o navegador estiver pedindo permissão, escolha Abrir: uma janela do Terminal abre em seguida com o servidor.",
137
+ "unconfirmedWindows": "Ainda sem notícias do iniciador. Se o navegador estiver pedindo permissão, escolha Abrir: uma janela de console abre em seguida com o servidor.",
138
+ "unconfirmedLinux": "Ainda sem notícias do iniciador. Se o navegador estiver pedindo permissão, escolha Abrir: o servidor inicia em seguida em segundo plano.",
139
+ "stalled": "Nada respondeu. Se nenhum aviso ou janela apareceu, execute o comando abaixo uma vez: ele também conserta este botão.",
140
+ "launcherRunning": "O iniciador está em execução.",
141
+ "terminalOpened": "Uma janela do Terminal foi aberta para o servidor.",
142
+ "terminalQuiet": "Uma janela do Terminal foi aberta há {seconds} s, mas o servidor não iniciou nela. Veja essa janela.",
143
+ "processStarted": "O supervisor do servidor foi iniciado.",
144
+ "checking": "Verificando a instalação.",
145
+ "dependencies": "Instalando dependências com o npm. A primeira inicialização, ou a primeira depois de uma atualização, leva alguns minutos.",
146
+ "browser": "Baixando o navegador de automação. Isso acontece uma única vez.",
147
+ "build": "Compilando o servidor MCP.",
148
+ "snapshot": "Fazendo backup dos seus dados antes da primeira inicialização desta versão.",
149
+ "snapshotSized": "Fazendo backup dos seus dados antes da primeira inicialização desta versão: {files} arquivos, {megabytes} MB.",
150
+ "server": "O processo do servidor está em execução. Aguardando a resposta dele.",
151
+ "listening": "O servidor está escutando. Conectando.",
152
+ "answered": "O servidor respondeu ao iniciador. Conectando.",
153
+ "quiet": "Nada de novo na inicialização há {seconds} s.",
154
+ "failed": "A inicialização falhou: {reason}",
155
+ "failedHint": "Execute o comando abaixo para ver a saída completa, ou tente novamente.",
156
+ "failedLog": "Log do iniciador: {path}",
157
+ "gaveUp": "O servidor foi iniciado, mas não respondeu. Verifique a janela dele ou execute o comando abaixo.",
158
+ "online": "O servidor está no ar. Carregando o SynaBun.",
159
+ "trailLauncher": "Iniciador acionado",
160
+ "trailTerminal": "Terminal aberto",
161
+ "trailProcess": "Supervisor iniciado",
162
+ "trailSupervisor": "Supervisor em execução",
163
+ "trailServer": "Processo do servidor iniciado",
164
+ "trailListening": "Servidor escutando",
165
+ "elapsedLabel": "Tempo desde que Iniciar foi pressionado",
166
+ "headingStarting": "Iniciando o servidor",
167
+ "headingReady": "Servidor no ar",
168
+ "headingFailed": "O servidor não iniciou"
169
+ },
170
+ "runCommandOr": "Ou execute este comando:",
171
+ "runCommandSetup": "Execute este comando uma vez. Ele também configura o botão Iniciar:",
134
172
  "serverOffline": "Servidor offline",
135
173
  "serverNotRunning": "O servidor do Neural Interface não está em execução",
136
174
  "connectionFailed": "Falha na conexão",
@@ -511,7 +549,7 @@
511
549
  "help": "Armazenamento, busca e backups"
512
550
  },
513
551
  "ai_decisions": {
514
- "label": "Decisões de IA",
552
+ "label": "Jev",
515
553
  "help": "Avaliações inteligentes e custo"
516
554
  },
517
555
  "projects": {
@@ -557,7 +595,7 @@
557
595
  "purpose": "Veja onde suas memórias ficam, escolha como o SynaBun as encontra e mantenha uma cópia que você possa restaurar."
558
596
  },
559
597
  "ai_decisions": {
560
- "title": "Decisões de IA",
598
+ "title": "Jev",
561
599
  "purpose": "Controle onde as avaliações inteligentes ajudam, o que podem usar e quanto custam."
562
600
  },
563
601
  "projects": {
@@ -3583,6 +3621,7 @@
3583
3621
  "judgments": {
3584
3622
  "showEveryJudgmentAboutThisMemory": ": mostrar no registro todas as avaliações desta memória",
3585
3623
  "checkingTypesafe": "Verificando TypeSafe…",
3624
+ "learnMoreAtTypesafe": "Saiba mais em",
3586
3625
  "jevTypesafeSystemOneReturnsTyped": "Jev (TypeSafe System One) retorna avaliações tipadas onde o SynaBun antes fazia estimativas com regex ou uma constante de similaridade. A chave fica armazenada em",
3587
3626
  "andNeverLeavesTheServerOnly": "e nunca sai do servidor; apenas seus quatro últimos caracteres chegam a esta página.",
3588
3627
  "apiKey": "Chave de API",
@@ -158,13 +158,16 @@ export function codexControlResponse(pending, response = {}) {
158
158
  // Codex matches a reply by id value and type: 0 and "0" are different requests to it.
159
159
  const base = { requestId: native.rpcId ?? pending.request_id, responseToken: randomUUID(), threadId: native.threadId || null, turnId: native.turnId || null, toolCallId: native.toolCallId || null };
160
160
  if (method === 'item/commandExecution/requestApproval' || method === 'item/fileChange/requestApproval') {
161
- const available = Array.isArray(native.availableDecisions) && native.availableDecisions.length
162
- ? native.availableDecisions.map((d) => (typeof d === 'string' ? d : Object.keys(d)[0]))
161
+ const available = Array.isArray(native.availableDecisions)
162
+ ? native.availableDecisions
163
163
  : ['accept', 'acceptForSession', 'decline', 'cancel'];
164
- let decision;
165
- if (response.decision && available.includes(response.decision)) decision = response.decision;
166
- else if (allow) decision = response.always && available.includes('acceptForSession') ? 'acceptForSession' : 'accept';
167
- else decision = response.cancel && available.includes('cancel') ? 'cancel' : 'decline';
164
+ // The UI puts policy amendment objects in result.decision and only their
165
+ // label in decision. Preserve the wire value for strict server validation.
166
+ let decision = response.result?.decision ?? response.decision;
167
+ if (decision == null) {
168
+ if (allow) decision = response.always && available.includes('acceptForSession') ? 'acceptForSession' : 'accept';
169
+ else decision = response.cancel && available.includes('cancel') ? 'cancel' : 'decline';
170
+ }
168
171
  return { ...base, result: { decision } };
169
172
  }
170
173
  if (method === 'item/permissions/requestApproval') {
@@ -9,14 +9,21 @@
9
9
  // Extracted from server.js so the model catalog can share it: discovery spawns
10
10
  // the CLI by name too, and without this a daemon-started server silently
11
11
  // degrades to the static fallback model list. The version-skew guard has the
12
- // same dependency — it compares the installed CLI against the SDK-bundled one,
13
- // and a CLI it cannot resolve reads as "nothing to compare" rather than a skew.
12
+ // same dependency — it compares the installed CLI against the release the Agent
13
+ // SDK was built for, and a CLI it cannot resolve reads as "nothing to compare".
14
+ //
15
+ // SYNABUN_TOOL_DISCOVERY=path turns the additions off: the tools are then looked
16
+ // for on the PATH this process was given and nowhere else. It is what a check
17
+ // of "nothing is installed" needs on a machine where something is (the
18
+ // packaged application's own run), and what a user sets to make SynaBun see
19
+ // exactly their PATH.
14
20
 
15
21
  import os from 'node:os';
16
22
  import { join, dirname, delimiter } from 'node:path';
17
23
  import { existsSync } from 'node:fs';
18
24
 
19
25
  export function getAugmentedPath() {
26
+ if (process.env.SYNABUN_TOOL_DISCOVERY === 'path') return process.env.PATH || '';
20
27
  const home = os.homedir();
21
28
  const npmPrefix = process.env.NPM_CONFIG_PREFIX || process.env.npm_config_prefix || '';
22
29
  const extra = process.platform === 'win32'
@@ -38,7 +45,20 @@ export function getAugmentedPath() {
38
45
  join(home, '.npm-global', 'bin'),
39
46
  npmPrefix ? join(npmPrefix, 'bin') : '',
40
47
  ];
41
- const entries = [...extra.filter(d => d && existsSync(d)), ...(process.env.PATH || '').split(delimiter)];
48
+ // Where the tools' own installers put them, for a PATH that names none of it:
49
+ // Claude Code's native installer (also on Windows), its older local install,
50
+ // OpenCode's install script. After the user's PATH, so that they never
51
+ // outrank a choice the user made there.
52
+ const fallback = [
53
+ join(home, '.local', 'bin'),
54
+ join(home, '.claude', 'local'),
55
+ join(home, '.opencode', 'bin'),
56
+ ];
57
+ const entries = [
58
+ ...extra.filter(d => d && existsSync(d)),
59
+ ...(process.env.PATH || '').split(delimiter),
60
+ ...fallback.filter(d => existsSync(d)),
61
+ ];
42
62
  const seen = new Set();
43
63
  return entries.filter((entry) => {
44
64
  if (!entry) return false;
@@ -0,0 +1,83 @@
1
+ const DEFAULT_LOCALE = 'en-US';
2
+ const DEFAULT_ACCEPT_LANGUAGE = 'en-US,en;q=0.9';
3
+ // HTTP language ranges allow '*' and an optional qvalue with at most three decimals.
4
+ const LANGUAGE_RANGE = /^(\*|[a-z]{1,8}(?:-[a-z0-9]{1,8})*)(?:[ \t]*;[ \t]*q[ \t]*=[ \t]*(0(?:\.\d{0,3})?|1(?:\.0{0,3})?))?$/i;
5
+
6
+ function isAutomatic(value) {
7
+ return value == null || (typeof value === 'string' && !value.trim());
8
+ }
9
+
10
+ function canonicalLocale(value) {
11
+ if (typeof value !== 'string' || !value.trim()) return null;
12
+ try {
13
+ return Intl.getCanonicalLocales(value.trim())[0] || null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function parseAcceptLanguage(value) {
20
+ if (typeof value !== 'string' || /[\x00-\x08\x0a-\x1f\x7f]/.test(value)) return null;
21
+ const header = value.trim();
22
+ const entries = header.split(',').map(entry => entry.trim()).filter(Boolean);
23
+ if (!entries.length) return null;
24
+
25
+ const languages = new Set();
26
+ for (const entry of entries) {
27
+ const match = LANGUAGE_RANGE.exec(entry);
28
+ if (!match) return null;
29
+ const [, range, quality] = match;
30
+ if (range === '*' || (quality !== undefined && Number(quality) === 0)) continue;
31
+ // A valid HTTP language range need not be a locale supported by Intl (e.g. x-private).
32
+ const locale = canonicalLocale(range);
33
+ if (locale) languages.add(locale);
34
+ }
35
+ return { header, languages: [...languages] };
36
+ }
37
+
38
+ /** Resolve legacy settings and request headers without rewriting the saved config. */
39
+ export function resolveBrowserLanguage(savedCfg = {}, requestAcceptLanguage) {
40
+ const savedLocale = canonicalLocale(savedCfg.locale);
41
+ const savedHeader = parseAcceptLanguage(savedCfg.acceptLanguage);
42
+ const requestHeader = savedHeader ? null : parseAcceptLanguage(requestAcceptLanguage);
43
+ const resolvedHeader = savedHeader || requestHeader || parseAcceptLanguage(DEFAULT_ACCEPT_LANGUAGE);
44
+ const locale = savedLocale || resolvedHeader.languages[0] || DEFAULT_LOCALE;
45
+ const warnings = [];
46
+ if (!isAutomatic(savedCfg.locale) && !savedLocale) {
47
+ warnings.push({ field: 'locale', value: locale });
48
+ }
49
+ if (!isAutomatic(savedCfg.acceptLanguage) && !savedHeader) {
50
+ warnings.push({ field: 'acceptLanguage', value: resolvedHeader.header });
51
+ }
52
+ if (!savedHeader && !isAutomatic(requestAcceptLanguage) && !requestHeader) {
53
+ warnings.push({ field: 'request Accept-Language', value: resolvedHeader.header });
54
+ }
55
+ return {
56
+ locale,
57
+ acceptLanguage: resolvedHeader.header,
58
+ languages: resolvedHeader.languages.length ? resolvedHeader.languages : [locale],
59
+ warnings,
60
+ };
61
+ }
62
+
63
+ /** Return a normalized copy; invalid explicit language fields throw with status/field. */
64
+ export function validateBrowserLanguageConfig(config) {
65
+ const normalized = { ...config };
66
+ for (const field of ['locale', 'acceptLanguage']) {
67
+ if (!Object.hasOwn(config, field)) continue;
68
+ const value = config[field];
69
+ if (isAutomatic(value)) {
70
+ normalized[field] = null;
71
+ continue;
72
+ }
73
+ const validated = field === 'locale' ? canonicalLocale(value) : parseAcceptLanguage(value)?.header;
74
+ if (!validated) {
75
+ const help = field === 'locale'
76
+ ? 'use a single locale tag such as pt-BR, or leave it blank for automatic detection'
77
+ : 'use comma-separated language ranges (or *) with optional q weights between 0 and 1 (up to three decimal places), or leave it blank';
78
+ throw Object.assign(new Error(`Invalid ${field}: ${help}.`), { status: 400, field });
79
+ }
80
+ normalized[field] = validated;
81
+ }
82
+ return normalized;
83
+ }
@@ -25,14 +25,9 @@ import { detectProject as memoryProject } from '../../mcp-server/dist/config.js'
25
25
  // WS bound, so page-refresh reattach is the same object.
26
26
 
27
27
  import { query, startup } from '@anthropic-ai/claude-agent-sdk';
28
- import { CLAUDE_EFFORT_LEVELS, checkClaudeCliSkew, markAlignedClaudeExecutableFailed } from './claude-model-catalog.js';
29
- import {
30
- claudeRuntime,
31
- clearNativeBinaryCache,
32
- isNativeBinaryLaunchFailure,
33
- planRuntimeRecovery,
34
- resolveClaudeExecutableOverride,
35
- } from './native-binary-runtime.js';
28
+ import { CLAUDE_EFFORT_LEVELS } from './claude-model-catalog.js';
29
+ import { CLAUDE_NOT_INSTALLED, resolveClaudeSdkExecutable } from './claude-executable.js';
30
+ import { diagnoseLaunchFailure, isNativeBinaryLaunchFailure } from './native-binary-runtime.js';
36
31
  import { normalizePanelSession, startSignature, applyPanelSessionOptions, liveSettingsPatch, normalizeMcpServers, mergeTabMcpServers } from './claude-panel-session.js';
37
32
  import { SESSION_REQUESTS, runSessionRequest, slimRewind, slimMcpStatus } from './claude-panel-requests.js';
38
33
  import { applyTemporaryOptions, plansDirsOf, planFileOfToolCall, removeTemporaryPlanFiles } from './claude-temporary.js';
@@ -348,15 +343,9 @@ export class ClaudeSession {
348
343
  this.lastPrompt = null;
349
344
  this.stallRetries = 0;
350
345
 
351
- // Native-runtime recovery latches. Set here and NOWHERE else — resetting
352
- // them on query recreation would turn a permanently broken binary into an
353
- // infinite repair/retry loop.
354
- this._nativeRepairTried = false;
355
- this._fallbackBin = null;
356
- // The installed CLI the current Query runs on because it is newer than the
357
- // bundled one (see ensureQuery). Per-Query, not a latch: a launch failure is
358
- // latched process-wide in claude-model-catalog.js instead.
359
- this._alignedBin = null;
346
+ // The Claude Code executable the current Query was started with (see
347
+ // ensureQuery): what a launch failure is diagnosed against.
348
+ this._claudeExecutable = null;
360
349
 
361
350
  this.pendingPerms = new Map(); // requestId → { toolName, resolve, input, kind, wire }
362
351
  this.alwaysAllowed = new Set();
@@ -499,6 +488,31 @@ export class ClaudeSession {
499
488
  return null;
500
489
  }
501
490
  }
491
+ // Which Claude Code runs this session: the user's own installation, always.
492
+ // SynaBun carries none (lib/external-tools.js at the package root) and the
493
+ // SDK is never left to look for one. The host answers (deps.claudeExecutable,
494
+ // lib/claude-executable.js): an explicit override from cli-config.json
495
+ // ("claude-skin": { "sdkExecutable": "…" }), else the installed Claude Code.
496
+ // The model picker lists that same installation's models, so an alias means
497
+ // in the session what it meant in the picker.
498
+ //
499
+ // Nothing installed is an ordinary state: the start is refused with the
500
+ // sentence the panel shows its install help on, and nothing was started.
501
+ // The SDK itself is never called without an executable; only a host that
502
+ // brings its own query factory and no resolver (a test) starts without one.
503
+ let executable = null;
504
+ if (typeof deps.claudeExecutable === 'function') {
505
+ try { executable = deps.claudeExecutable() || null; } catch { executable = null; }
506
+ } else if (deps.sdkExecutable) {
507
+ executable = resolveClaudeSdkExecutable({ sdkExecutable: deps.sdkExecutable });
508
+ }
509
+ const ownFactory = typeof (warm ? deps.startupFactory : deps.queryFactory) === 'function';
510
+ if (!executable?.path && (typeof deps.claudeExecutable === 'function' || !ownFactory)) {
511
+ this._closeWarm();
512
+ this._startRefused = { code: 'claude_not_installed', message: executable?.reason || CLAUDE_NOT_INSTALLED };
513
+ log(`session create refused: no Claude Code to run (${executable?.reason || 'not installed'})`);
514
+ return null;
515
+ }
502
516
  if (this._warm) {
503
517
  const w = this._warm;
504
518
  this._warm = null;
@@ -732,51 +746,20 @@ export class ClaudeSession {
732
746
  this._tempPlansDirs = plansDirsOf(options.env, { cwd: workDir, settings: options.settings });
733
747
  if (!this._tempSince) this._tempSince = Date.now();
734
748
  }
735
- // Which Claude binary runs this session, first match wins:
736
- // 1. an explicit override — cli-config.json → "claude-skin": { "sdkExecutable": "…" },
737
- // either a .js entrypoint (launched via node) or a native binary path;
738
- // 2. the installed CLI we fell back to after the bundled one failed to launch;
739
- // 3. the installed CLI when it is newer than the bundled one. The model picker
740
- // lists the installed CLI's models, and an older binary resolves the same
741
- // alias to an older model (alignedClaudeExecutable, claude-model-catalog.js);
742
- // 4. the SDK's bundled, version-matched CLI.
743
- //
744
- // The SDK uses pathToClaudeCodeExecutable verbatim with zero validation, so
745
- // everything is vetted here first — notably a bare command name, which would
746
- // ENOENT under the SDK's spawn(shell:false).
747
- const sdkExec = deps.sdkExecutable;
748
- const override = resolveClaudeExecutableOverride(sdkExec);
749
- this._alignedBin = null;
750
- let runtimeLabel = 'bundled';
751
- if (override.path) {
752
- options.pathToClaudeCodeExecutable = override.path;
753
- runtimeLabel = 'override';
754
- } else if (sdkExec) {
755
- log('ignoring sdkExecutable override:', override.reason);
756
- this.send({ type: 'stderr', text: `Ignoring sdkExecutable override — ${override.reason}` });
757
- }
758
-
759
- if (!options.pathToClaudeCodeExecutable) {
760
- let aligned = null;
761
- if (!this._fallbackBin) {
762
- try { aligned = deps.alignedClaudeBin?.() || null; } catch { aligned = null; }
763
- }
764
- if (this._fallbackBin) {
765
- // A previous turn proved the bundled binary unusable; stay on the CLI we
766
- // already fell back to rather than re-failing every turn.
767
- options.pathToClaudeCodeExecutable = this._fallbackBin;
768
- runtimeLabel = 'fallback';
769
- } else if (aligned) {
770
- options.pathToClaudeCodeExecutable = aligned;
771
- this._alignedBin = aligned;
772
- runtimeLabel = aligned;
773
- } else {
774
- // Called for its side effect only: this stats the binary the SDK is about
775
- // to resolve and restores the execute bit if it is missing. We
776
- // deliberately do NOT pass the resolved path on — letting the SDK resolve
777
- // for itself keeps us correct even if its resolution logic changes.
778
- claudeRuntime();
779
- }
749
+ // The executable resolved at the top of this call. The SDK uses
750
+ // pathToClaudeCodeExecutable verbatim with zero validation, so it was vetted
751
+ // there — notably a bare command name, which would ENOENT under the SDK's
752
+ // spawn(shell:false).
753
+ let runtimeLabel = 'unset';
754
+ this._claudeExecutable = null;
755
+ if (executable?.ignored) {
756
+ log('ignoring sdkExecutable override:', executable.ignored);
757
+ this.send({ type: 'stderr', text: `Ignoring sdkExecutable override — ${executable.ignored}` });
758
+ }
759
+ if (executable?.path) {
760
+ options.pathToClaudeCodeExecutable = executable.path;
761
+ this._claudeExecutable = executable.path;
762
+ runtimeLabel = executable.source === 'override' ? 'override' : executable.path;
780
763
  }
781
764
 
782
765
  if (warm) {
@@ -1256,94 +1239,18 @@ export class ClaudeSession {
1256
1239
  }
1257
1240
 
1258
1241
  /**
1259
- * The bundled native CLI failed to launch.
1242
+ * The user's Claude Code would not start.
1260
1243
  *
1261
1244
  * The SDK's own message for this always blames a musl/glibc mismatch, which is
1262
- * only ever right on Linux — on macOS and Windows the real cause is almost
1263
- * always a missing execute bit (npm does not preserve it for binaries shipped
1264
- * without a `bin` entry, and this repo commits node_modules).
1265
- *
1266
- * Recovery is: re-stat and repair → retry once → fall back to the user's
1267
- * installed CLI with a visible notice → give up with an accurate diagnosis.
1245
+ * only ever right on Linux — elsewhere the cause is a missing execute bit, a
1246
+ * quarantined download, a build for another processor or a file that is gone.
1247
+ * There is no other runtime to move to (SynaBun carries none), so the turn
1248
+ * ends with what is wrong with that installation and how to fix it.
1268
1249
  */
1269
1250
  _recoverNativeRuntime(err) {
1270
- const prompt = this.lastPrompt;
1271
- // The newer installed CLI this session was aligned to would not launch. Latch
1272
- // it off for the whole process and go back to the bundled runtime, which has
1273
- // its own repair path below if it fails too.
1274
- if (this._alignedBin) {
1275
- const failed = this._alignedBin;
1276
- this._alignedBin = null;
1277
- markAlignedClaudeExecutableFailed(failed);
1278
- log(`aligned CLI failed to launch (${failed}) — using the bundled runtime`);
1279
- this.sendEvent({
1280
- type: 'system',
1281
- subtype: 'runtime_notice',
1282
- level: 'warn',
1283
- message: `Your installed Claude CLI (${failed}) failed to launch; using the bundled runtime instead.`,
1284
- });
1285
- this._recreateQuery(prompt);
1286
- return;
1287
- }
1288
- // The cached verdict predates the failure; re-stat so a repair performed by
1289
- // another session (or the boot sweep) is visible.
1290
- clearNativeBinaryCache();
1291
- const runtime = claudeRuntime();
1292
-
1293
- let fallbackBin = this._fallbackBin;
1294
- if (!fallbackBin) {
1295
- // getClaudeBin()'s last resort is the bare string 'claude'. The SDK spawns
1296
- // with shell:false, so a bare name is a guaranteed ENOENT — the override
1297
- // validator rejects it, leaving fallbackBin null and giving the user a
1298
- // real diagnosis instead of a second, more confusing failure.
1299
- const candidate = resolveClaudeExecutableOverride(deps.getClaudeBin?.());
1300
- if (candidate.ok && candidate.path) fallbackBin = candidate.path;
1301
- }
1302
-
1303
- const plan = planRuntimeRecovery({
1304
- err,
1305
- runtime,
1306
- fallbackBin,
1307
- alreadyTried: this._nativeRepairTried,
1308
- });
1309
- log(`native runtime recovery: action=${plan.action} state=${runtime.state} kind=${plan.kind}`);
1310
-
1311
- if (plan.action === 'retry') {
1312
- this._nativeRepairTried = true;
1313
- this.sendEvent({ type: 'system', subtype: 'runtime_notice', level: 'warn', message: plan.message });
1314
- this._recreateQuery(prompt);
1315
- return;
1316
- }
1317
-
1318
- if (plan.action === 'fallback') {
1319
- this._fallbackBin = fallbackBin;
1320
- this.sendEvent({ type: 'system', subtype: 'runtime_notice', level: 'warn', message: plan.message });
1321
- // Report-only: the fallback CLI may be a different version than the SDK
1322
- // was built against, and alias ids like "opus[1m]" resolve per-binary — so
1323
- // a picked model can silently launch as another version.
1324
- this._announceSkew(fallbackBin);
1325
- this._recreateQuery(prompt);
1326
- return;
1327
- }
1328
-
1329
- this._finishTurnWithError(plan.message);
1330
- }
1331
-
1332
- _announceSkew(bin) {
1333
- Promise.resolve()
1334
- .then(() => checkClaudeCliSkew(bin, { log: () => {} }))
1335
- .then((skew) => {
1336
- if (!skew?.skewed || this.destroyed) return;
1337
- this.sendEvent({
1338
- type: 'system',
1339
- subtype: 'runtime_notice',
1340
- level: 'warn',
1341
- message: `Version skew: your installed Claude CLI is ${skew.discovered}, but the bundled `
1342
- + `runtime is ${skew.bundled}. Model aliases and effort levels resolve per-binary, so `
1343
- + 'behaviour may differ until the bundled binary is repaired.',
1344
- });
1345
- })
1346
- .catch(() => {});
1251
+ const dx = diagnoseLaunchFailure({ err, executable: this._claudeExecutable });
1252
+ log(`Claude Code failed to launch: kind=${dx.kind} executable=${this._claudeExecutable || 'unset'}`);
1253
+ this._finishTurnWithError(dx.message);
1347
1254
  }
1348
1255
 
1349
1256
  _pushUserText(text, images, { human = false } = {}) {
@@ -2222,7 +2129,7 @@ export class ClaudeSession {
2222
2129
  // AskUserQuestion answers arrive as updatedInput {questions, answers} from the
2223
2130
  // existing frontend card. The CLI's AskUserQuestion tool reads ONLY the
2224
2131
  // top-level `answers` map, looked up by each question's exact `question` text
2225
- // (verified against the bundled CLI: missing/empty map → "The user did not
2132
+ // (verified against the CLI itself: missing/empty map → "The user did not
2226
2133
  // answer the questions."). So: keep the original questions, pass the answers
2227
2134
  // map through, and re-key any answer the panel stored under a fallback key
2228
2135
  // (text/header) back onto the question text.