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,221 @@
1
+ /**
2
+ * SynaBun — external tools stay external
3
+ *
4
+ * Claude Code, Codex, OpenCode and Gemini CLI are programs of their own. The
5
+ * user installs them; SynaBun finds what is installed (PATH, Homebrew, npm, the
6
+ * native installers' folders) and talks to it, through a thin JavaScript SDK
7
+ * where there is one. It never installs, ships or runs a copy of its own.
8
+ *
9
+ * npm has no way to say "this SDK, without the program it carries": the Claude
10
+ * Agent SDK brings the Claude Code executable as a per-platform optional
11
+ * dependency, and @openai/codex-sdk depends on the whole Codex CLI. So the rule
12
+ * is kept in three places, all reading the one list below:
13
+ *
14
+ * the lockfiles hold none of these packages and no edge to one
15
+ * (scripts/strip-external-tools.mjs), so npm never
16
+ * downloads them
17
+ * an install removes any that came anyway (the neural-interface
18
+ * postinstall, the server at start)
19
+ * a packaged build removes them from what it stages and refuses a bundle
20
+ * that holds one (packaging/lib/stage.mjs, verify.mjs)
21
+ *
22
+ * The SDKs stay: every call hands them the user's executable
23
+ * (pathToClaudeCodeExecutable, codexPathOverride), so the payload they would
24
+ * otherwise look for is never needed.
25
+ *
26
+ * Only node:fs and node:path here: the install scripts import this on any
27
+ * Node, before a single dependency exists.
28
+ */
29
+
30
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+
33
+ const PLATFORMS = '(?:darwin|linux|win32|windows|android)';
34
+
35
+ /**
36
+ * Each tool: the command the user runs, the packages that ARE the tool (never
37
+ * a dependency of SynaBun), and how its per-platform payload packages are
38
+ * named. A name is what a package is installed as, under node_modules.
39
+ */
40
+ export const EXTERNAL_TOOLS = Object.freeze([
41
+ Object.freeze({
42
+ id: 'claude-code',
43
+ label: 'Claude Code',
44
+ command: 'claude',
45
+ packages: Object.freeze(['@anthropic-ai/claude-code']),
46
+ // The CLI's own platform packages, and the same executable as the Agent SDK carries it.
47
+ patterns: Object.freeze([
48
+ new RegExp(`^@anthropic-ai/claude-code-${PLATFORMS}-`),
49
+ new RegExp(`^@anthropic-ai/claude-agent-sdk-${PLATFORMS}-`),
50
+ ]),
51
+ }),
52
+ Object.freeze({
53
+ id: 'codex',
54
+ label: 'Codex',
55
+ command: 'codex',
56
+ packages: Object.freeze(['@openai/codex']),
57
+ // (@openai/codex-sdk is the JavaScript client and is not matched.)
58
+ patterns: Object.freeze([new RegExp(`^@openai/codex-${PLATFORMS}-`)]),
59
+ }),
60
+ Object.freeze({
61
+ id: 'opencode',
62
+ label: 'OpenCode',
63
+ command: 'opencode',
64
+ packages: Object.freeze(['opencode-ai']),
65
+ // (@opencode-ai/sdk is the JavaScript client and is not matched.)
66
+ patterns: Object.freeze([new RegExp(`^opencode-${PLATFORMS}-`)]),
67
+ }),
68
+ Object.freeze({
69
+ id: 'gemini',
70
+ label: 'Gemini CLI',
71
+ command: 'gemini',
72
+ packages: Object.freeze(['@google/gemini-cli', '@google/gemini-cli-core']),
73
+ patterns: Object.freeze([]),
74
+ }),
75
+ ]);
76
+
77
+ /** The file names a tool's command can have in a `.bin` folder or on disk. */
78
+ const COMMAND_SUFFIXES = ['', '.cmd', '.ps1', '.bat', '.exe'];
79
+ const COMMAND_FILES = new Map(EXTERNAL_TOOLS.flatMap(tool => COMMAND_SUFFIXES.map(suffix => [`${tool.command}${suffix}`, tool])));
80
+
81
+ /** The tool a package name belongs to, or null: `@openai/codex` yes, `@openai/codex-sdk` no. */
82
+ export function externalToolOf(packageName) {
83
+ const name = String(packageName || '');
84
+ if (!name) return null;
85
+ return EXTERNAL_TOOLS.find(tool => tool.packages.includes(name) || tool.patterns.some(pattern => pattern.test(name))) || null;
86
+ }
87
+
88
+ /** The package a lockfile path installs: what follows its last `node_modules/`. */
89
+ export function packageNameOfLockPath(path) {
90
+ const value = String(path || '').replace(/\\/g, '/');
91
+ const at = value.lastIndexOf('node_modules/');
92
+ return at < 0 ? '' : value.slice(at + 'node_modules/'.length);
93
+ }
94
+
95
+ const EDGE_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta', 'devDependencies'];
96
+
97
+ /**
98
+ * What a lockfile (package-lock.json, or the hidden node_modules/.package-lock.json)
99
+ * still holds of external tools: `packages` are the entries that would be
100
+ * installed, `edges` the dependencies other entries declare on one.
101
+ */
102
+ export function externalToolsInLock(lock) {
103
+ const packages = [];
104
+ const edges = [];
105
+ for (const [path, entry] of Object.entries(lock?.packages || {})) {
106
+ const tool = externalToolOf(packageNameOfLockPath(path));
107
+ if (tool) { packages.push({ path, tool: tool.id, version: entry?.version || null }); continue; }
108
+ for (const field of EDGE_FIELDS) {
109
+ for (const name of Object.keys(entry?.[field] || {})) {
110
+ const wanted = externalToolOf(name);
111
+ if (wanted) edges.push({ from: path || '(root)', field, name, tool: wanted.id });
112
+ }
113
+ }
114
+ }
115
+ return { packages, edges };
116
+ }
117
+
118
+ /**
119
+ * Take every external tool out of a lockfile object, in place: its entries, and
120
+ * the edges that would make npm fetch them again. Returns what was removed.
121
+ * A lockfile left this way installs the SDKs and nothing they would carry.
122
+ */
123
+ export function stripExternalToolsFromLock(lock) {
124
+ const found = externalToolsInLock(lock);
125
+ for (const item of found.packages) delete lock.packages[item.path];
126
+ for (const edge of found.edges) {
127
+ const entry = lock.packages[edge.from === '(root)' ? '' : edge.from];
128
+ if (!entry?.[edge.field]) continue;
129
+ delete entry[edge.field][edge.name];
130
+ if (!Object.keys(entry[edge.field]).length) delete entry[edge.field];
131
+ }
132
+ return found;
133
+ }
134
+
135
+ const list = (folder) => { try { return readdirSync(folder, { withFileTypes: true }); } catch { return []; } };
136
+
137
+ /**
138
+ * Every external-tool package and command installed under one node_modules
139
+ * folder, nested trees included: [{ kind: 'package' | 'command', name, tool, path }].
140
+ */
141
+ export function externalToolsInstalledIn(nodeModules, { depth = 6 } = {}) {
142
+ const found = [];
143
+ const visitModules = (folder, left) => {
144
+ for (const entry of list(folder)) {
145
+ if (entry.name === '.bin') {
146
+ for (const file of list(join(folder, '.bin'))) {
147
+ const tool = COMMAND_FILES.get(file.name);
148
+ if (tool) found.push({ kind: 'command', name: file.name, tool: tool.id, path: join(folder, '.bin', file.name) });
149
+ }
150
+ continue;
151
+ }
152
+ if (entry.name.startsWith('.') || !(entry.isDirectory() || entry.isSymbolicLink())) continue;
153
+ const names = entry.name.startsWith('@')
154
+ ? list(join(folder, entry.name)).filter(scoped => scoped.isDirectory() || scoped.isSymbolicLink()).map(scoped => `${entry.name}/${scoped.name}`)
155
+ : [entry.name];
156
+ for (const name of names) {
157
+ const path = join(folder, ...name.split('/'));
158
+ const tool = externalToolOf(name);
159
+ if (tool) { found.push({ kind: 'package', name, tool: tool.id, path }); continue; }
160
+ if (left > 0 && existsSync(join(path, 'node_modules'))) visitModules(join(path, 'node_modules'), left - 1);
161
+ }
162
+ }
163
+ };
164
+ visitModules(nodeModules, depth);
165
+ return found;
166
+ }
167
+
168
+ /**
169
+ * Remove what externalToolsInstalledIn() finds, and the same entries from npm's
170
+ * hidden lockfile, so that npm's own record matches the folder. Never throws:
171
+ * `failed` lists what could not be removed (a read-only install, a file in use).
172
+ * Does nothing, and writes nothing, when there is nothing to remove.
173
+ */
174
+ export function pruneExternalTools(nodeModules, { log = null } = {}) {
175
+ const result = { removed: [], failed: [] };
176
+ for (const item of externalToolsInstalledIn(nodeModules)) {
177
+ try {
178
+ rmSync(item.path, { recursive: true, force: true });
179
+ result.removed.push(item);
180
+ if (log) log(`removed ${item.kind} ${item.name} (${item.tool} is installed separately): ${item.path}`);
181
+ } catch (error) {
182
+ result.failed.push({ ...item, error: error?.message || String(error) });
183
+ if (log) log(`could not remove ${item.path}: ${error?.message || error}`);
184
+ }
185
+ }
186
+ if (!result.removed.length) return result;
187
+ const hidden = join(nodeModules, '.package-lock.json');
188
+ try {
189
+ const lock = JSON.parse(readFileSync(hidden, 'utf8'));
190
+ const stripped = stripExternalToolsFromLock(lock);
191
+ if (stripped.packages.length || stripped.edges.length) writeFileSync(hidden, JSON.stringify(lock, null, 2) + '\n');
192
+ } catch { /* no hidden lockfile, or not ours to repair */ }
193
+ return result;
194
+ }
195
+
196
+ /**
197
+ * Why a file of a packaged application is an external tool's, or null. By path
198
+ * alone, forward or back slashes: a package folder, or a command in a `.bin`
199
+ * folder. `native` says the file is a native executable; then the tool's own
200
+ * file name anywhere is one too (`claude`, `codex.exe` ...).
201
+ */
202
+ export function externalToolPayloadReason(relativePath, { native = false } = {}) {
203
+ const path = String(relativePath || '').replace(/\\/g, '/');
204
+ const parts = path.split('/');
205
+ for (let i = 0; i < parts.length - 1; i++) {
206
+ if (parts[i] !== 'node_modules') continue;
207
+ if (parts[i + 1] === '.bin') {
208
+ const tool = i + 2 === parts.length - 1 ? COMMAND_FILES.get(parts[i + 2]) : null;
209
+ if (tool) return `the ${tool.label} command (${parts[i + 2]})`;
210
+ continue;
211
+ }
212
+ const name = parts[i + 1].startsWith('@') ? `${parts[i + 1]}/${parts[i + 2] || ''}` : parts[i + 1];
213
+ const tool = externalToolOf(name);
214
+ if (tool) return `the package ${name} (${tool.label})`;
215
+ }
216
+ if (native) {
217
+ const tool = COMMAND_FILES.get(parts[parts.length - 1]);
218
+ if (tool) return `the ${tool.label} executable`;
219
+ }
220
+ return null;
221
+ }
@@ -59,6 +59,7 @@ async function handoffSnapshotProblem({ handoff, version, backupService }) {
59
59
  * @param {string} input.version the version about to launch
60
60
  * @param {() => Promise<{createVerifiedBackup: Function, verifyBackupArchive: Function, applyBackupRetention: Function}>} [input.loadBackupService]
61
61
  * @param {{info?: Function, ok?: Function, warn?: Function}} [input.log]
62
+ * @param {(detail: {step?: string, files?: number, bytes?: number, skippedFiles?: number}) => void} [input.onProgress] called when a snapshot starts and at each of its steps
62
63
  * @returns {Promise<
63
64
  * {status: 'not-due', reason: 'current-version' | 'no-state'} |
64
65
  * {status: 'protected', snapshotPath: string, created: boolean, recorded: boolean} |
@@ -70,10 +71,12 @@ export async function protectFirstLaunchAfterUpdate({
70
71
  version,
71
72
  loadBackupService = () => import('../neural-interface/lib/backup-service.js'),
72
73
  log = {},
74
+ onProgress = noop,
73
75
  } = {}) {
74
76
  const info = log.info || noop;
75
77
  const ok = log.ok || noop;
76
78
  const warn = log.warn || noop;
79
+ const progress = (detail) => { try { onProgress(detail); } catch { /* reporting never stops a snapshot */ } };
77
80
 
78
81
  const manifestPath = resolve(dataHome, DATA_HOME_MANIFEST);
79
82
  const handoffPath = resolve(dataHome, 'data', 'update-handoff.json');
@@ -105,6 +108,7 @@ export async function protectFirstLaunchAfterUpdate({
105
108
  let service;
106
109
  try {
107
110
  info(`Protecting user state before first launch of v${version}...`);
111
+ progress({});
108
112
  service = await backupService();
109
113
  const snapshot = await service.createVerifiedBackup({
110
114
  dataHome,
@@ -115,6 +119,7 @@ export async function protectFirstLaunchAfterUpdate({
115
119
  // Without this the checksum and archive phases run silently for minutes
116
120
  // on a large data home, which users read as a hang and kill.
117
121
  onProgress: ({ phase, files, bytes, skippedFiles, skippedBytes }) => {
122
+ progress({ step: phase, files, bytes, skippedFiles });
118
123
  const size = bytes ? ` (${(bytes / 1024 / 1024).toFixed(0)} MB)` : '';
119
124
  if (phase === 'collect') {
120
125
  info(` ${files} files to protect${size}`);
@@ -0,0 +1,110 @@
1
+ /**
2
+ * SynaBun — packaged application runtime
3
+ *
4
+ * A packaged build (packaging/) ships this package next to its own Node
5
+ * runtime, behind one native entry executable:
6
+ *
7
+ * <resources>/synabun-package.json written by the builder: what was built
8
+ * <resources>/runtime/ the Node that runs everything
9
+ * <resources>/app/ this package (PACKAGE_ROOT)
10
+ *
11
+ * This module answers one question for the rest of the code: is this that kind
12
+ * of install, and if so, what should the outside world run? An npm or Git
13
+ * install has no manifest next to its package root; every function here then
14
+ * says so and nothing changes for it.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from 'node:fs';
18
+ import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
19
+ import { PACKAGE_ROOT } from './paths.js';
20
+
21
+ export const PACKAGE_MANIFEST = 'synabun-package.json';
22
+
23
+ /**
24
+ * Set by the native entry: where it lives for the outside world. For an
25
+ * AppImage that is the image file, never the mount it is running from.
26
+ */
27
+ export const PACKAGED_ENTRY_ENV = 'SYNABUN_PACKAGED_ENTRY';
28
+
29
+ /**
30
+ * What the builder recorded about this install, or null when the package is
31
+ * not inside a packaged application. Never throws.
32
+ */
33
+ export function readPackagedRuntime({ packageRoot = PACKAGE_ROOT, env = process.env } = {}) {
34
+ const root = resolve(packageRoot);
35
+ const resources = dirname(root);
36
+ const manifestPath = join(resources, PACKAGE_MANIFEST);
37
+ let manifest;
38
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch { return null; }
39
+ // A manifest that does not describe this directory is somebody else's.
40
+ if (!manifest || manifest.synabunPackage !== 1 || basename(root) !== (manifest.app || 'app')) return null;
41
+
42
+ const recorded = typeof manifest.entry === 'string' && manifest.entry ? resolve(resources, manifest.entry) : null;
43
+ const entry = [env[PACKAGED_ENTRY_ENV], recorded]
44
+ .find(candidate => typeof candidate === 'string' && isAbsolute(candidate) && existsSync(candidate)) || null;
45
+
46
+ return Object.freeze({
47
+ resources,
48
+ packageRoot: root,
49
+ manifestPath,
50
+ version: String(manifest.version || ''),
51
+ target: String(manifest.target?.id || ''),
52
+ platform: String(manifest.target?.platform || ''),
53
+ arch: String(manifest.target?.arch || ''),
54
+ artifact: manifest.artifact ? String(manifest.artifact) : null,
55
+ entry,
56
+ runtimeBin: resolve(resources, String(manifest.runtime?.bin || 'runtime/bin')),
57
+ });
58
+ }
59
+
60
+ let cached;
61
+
62
+ /** readPackagedRuntime() for this process, read once. */
63
+ export function packagedRuntime() {
64
+ if (cached === undefined) cached = readPackagedRuntime();
65
+ return cached;
66
+ }
67
+
68
+ /**
69
+ * Whether a path only lasts as long as this run: macOS starts a quarantined
70
+ * copy from a random AppTranslocation folder, and an AppImage runs from a
71
+ * mount that is gone when it exits. Nothing that outlives the process (a hook
72
+ * command, a line a person is told to type) may point at such a path.
73
+ */
74
+ export function isTransientPath(path, env = process.env) {
75
+ const value = String(path || '').replace(/\\/g, '/');
76
+ if (!value) return false;
77
+ if (/\/AppTranslocation\//.test(value) || /\/\.mount_[^/]+\//.test(value)) return true;
78
+ const mount = String(env.APPDIR || '').replace(/\\/g, '/').replace(/\/+$/, '');
79
+ return Boolean(mount) && value.startsWith(`${mount}/`);
80
+ }
81
+
82
+ /** The entry executable when it outlives this run, otherwise null. */
83
+ export function durableEntry(runtime = packagedRuntime(), env = process.env) {
84
+ const entry = runtime?.entry || null;
85
+ return entry && !isTransientPath(entry, env) ? entry : null;
86
+ }
87
+
88
+ /**
89
+ * The command an MCP client runs for SynaBun's stdio server. A packaged
90
+ * application answers through its entry executable (`<entry> mcp`): the client
91
+ * has no Node of its own to find, and the entry's path outlives an update that
92
+ * moves the files behind it.
93
+ */
94
+ export function mcpStdioCommand(scriptPath, { packageRoot = PACKAGE_ROOT, env = process.env, runtime } = {}) {
95
+ const found = runtime === undefined ? readPackagedRuntime({ packageRoot, env }) : runtime;
96
+ if (found?.entry) return { command: found.entry.replace(/\\/g, '/'), args: ['mcp'] };
97
+ return { command: 'node', args: [scriptPath] };
98
+ }
99
+
100
+ /**
101
+ * `env` with the packaged runtime first on PATH, so that `node`, `npm` and
102
+ * `npx` started by name are the ones that came with the application.
103
+ */
104
+ export function withPackagedPath(env = process.env, runtime = packagedRuntime()) {
105
+ if (!runtime) return env;
106
+ const key = Object.keys(env).find(name => name.toUpperCase() === 'PATH') || 'PATH';
107
+ const parts = String(env[key] || '').split(delimiter).filter(Boolean);
108
+ if (parts[0] === runtime.runtimeBin) return env;
109
+ return { ...env, [key]: [runtime.runtimeBin, ...parts.filter(part => part !== runtime.runtimeBin)].join(delimiter) };
110
+ }