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
@@ -111,7 +111,7 @@ export function buildJudgmentsTab() {
111
111
  </div>
112
112
 
113
113
  ${section({ id: 'jv-sec-connection', title: kit.tx('settings.redesign.judgments.connection'), badgeId: 'jv-key-source-badge', body: `
114
- <div class="settings-hint" style="margin-bottom:10px">${kit.te('settings.redesign.judgments.jevTypesafeSystemOneReturnsTyped')} <code>~/.synabun/.env</code> ${kit.te('settings.redesign.judgments.andNeverLeavesTheServerOnly')}</div>
114
+ <div class="settings-hint" style="margin-bottom:10px">${kit.te('settings.redesign.judgments.jevTypesafeSystemOneReturnsTyped')} <code>~/.synabun/.env</code> ${kit.te('settings.redesign.judgments.andNeverLeavesTheServerOnly')} ${kit.te('settings.redesign.judgments.learnMoreAtTypesafe')} <a href="https://typesafe.ai" target="_blank" rel="noopener noreferrer">typesafe.ai</a>.</div>
115
115
  <div class="settings-field">
116
116
  <label for="jv-api-key">${kit.te('settings.redesign.judgments.apiKey')}</label>
117
117
  <div class="settings-key-row" style="display:flex;gap:6px">
@@ -6,12 +6,40 @@
6
6
  import { fetchHealth, startHealth } from './api.js';
7
7
  import { emit } from './state.js';
8
8
  import { t } from './i18n.js';
9
+ import {
10
+ START_STORAGE, createStartBridge, fetchStartBeacon, isMacPlatform, isWindowsPlatform,
11
+ manualStartCommands, openStartLink, startBeaconTarget, startButtonUsable, startStatusView,
12
+ } from './start-bridge.js';
13
+ // Imported with the app, not when it is needed: once the server is down
14
+ // nothing more can be fetched, and that is exactly when the overlay wants it.
15
+ import { createMascot } from './synabun-mascot.js';
9
16
 
10
17
  const $ = (id) => document.getElementById(id);
11
18
 
12
19
  // ── Internal refs (resolved once on init) ──
13
20
  let _statusDot = null;
14
21
  let _initCallback = null;
22
+ // The server-offline state: watches for the server and owns the Start button.
23
+ let _offlineBridge = null;
24
+ // The title the overlay was given for "offline" (a start replaces it while it runs).
25
+ let _offlineTitle = '';
26
+ // The mascot rig in the overlay, mounted the first time the server is offline.
27
+ let _mascot = null;
28
+
29
+ // What the mascot does for each thing the page knows about a start
30
+ // (startStatusView's `mascot`), as poses of the shared rig.
31
+ const MASCOT_POSE = {
32
+ asleep: 'sleep',
33
+ waking: 'wait',
34
+ working: 'think',
35
+ ready: 'success',
36
+ failed: 'error',
37
+ stalled: 'offline',
38
+ };
39
+
40
+ function stored(key) {
41
+ try { return localStorage.getItem(key); } catch { return null; }
42
+ }
15
43
 
16
44
  // ═══════════════════════════════════════════
17
45
  // PUBLIC API
@@ -38,18 +66,31 @@ export function showLoadingError(title, sub, canStart, serverOffline) {
38
66
  const $sub = $('loading-sub');
39
67
  if ($text) $text.textContent = title;
40
68
  if ($sub) $sub.textContent = sub;
69
+ if (serverOffline) _offlineTitle = title;
41
70
 
71
+ // Offline, the button asks the OS to start the server (synabun://start); it
72
+ // is left out only when that launcher is known not to be registered.
73
+ const offlineStart = !!serverOffline && startButtonUsable(stored(START_STORAGE.launcher));
42
74
  const $action = $('loading-action');
43
- if (canStart) {
75
+ const $btn = $('loading-action-btn');
76
+ if (canStart || offlineStart) {
44
77
  if ($action) $action.style.display = 'block';
78
+ if ($btn) $btn.disabled = false;
45
79
  const $label = $('loading-action-label');
46
- if ($label) $label.textContent = t('common.start');
80
+ if ($label) $label.textContent = serverOffline ? t('loading.startServer') : t('common.start');
47
81
  } else {
48
82
  if ($action) $action.style.display = 'none';
49
83
  }
50
84
 
51
- const $status = $('loading-action-status');
52
- if ($status) $status.textContent = '';
85
+ clearStartStatus();
86
+
87
+ if (serverOffline) {
88
+ const $hint = $('loading-server-cmd-hint');
89
+ if ($hint) $hint.textContent = offlineStart ? t('loading.runCommandOr') : t('loading.runCommandSetup');
90
+ watchForServer();
91
+ } else {
92
+ stopWatchingForServer();
93
+ }
53
94
  }
54
95
 
55
96
  /**
@@ -78,6 +119,7 @@ function resetLoadingToConnecting() {
78
119
  if (!$overlay) return;
79
120
 
80
121
  $overlay.classList.remove('error', 'server-offline');
122
+ stopWatchingForServer();
81
123
 
82
124
  const $text = $('loading-text');
83
125
  const $sub = $('loading-sub');
@@ -88,6 +130,7 @@ function resetLoadingToConnecting() {
88
130
  if ($sub) $sub.textContent = t('loading.initializingNeural');
89
131
  if ($action) $action.style.display = 'none';
90
132
  if ($actionStatus) $actionStatus.textContent = '';
133
+ clearStartStatus();
91
134
  if (_statusDot) _statusDot.classList.remove('error');
92
135
  }
93
136
 
@@ -95,6 +138,24 @@ function resetLoadingToConnecting() {
95
138
  // HEALTH CHECK (called during init)
96
139
  // ═══════════════════════════════════════════
97
140
 
141
+ /**
142
+ * Keep what a health answer says about this install for the moment the server
143
+ * is down and cannot be asked: where it lives, how it was installed, and
144
+ * whether the Start Server launcher is registered.
145
+ */
146
+ export function rememberHealth(health) {
147
+ if (!health) return;
148
+ try {
149
+ if (health.projectDir) {
150
+ localStorage.setItem('synabun-project-dir', health.projectDir);
151
+ }
152
+ if (health.install) localStorage.setItem(START_STORAGE.install, health.install);
153
+ if (health.startLauncher) localStorage.setItem(START_STORAGE.launcher, health.startLauncher);
154
+ if (health.entry) localStorage.setItem(START_STORAGE.entry, health.entry);
155
+ } catch {}
156
+ if (health.projectDir) updateCmdText(health.projectDir);
157
+ }
158
+
98
159
  /**
99
160
  * Run the health check pre-flight. If the database is unhealthy,
100
161
  * show the appropriate loading error. Returns true if healthy (or
@@ -104,9 +165,7 @@ function resetLoadingToConnecting() {
104
165
  export async function checkHealth() {
105
166
  try {
106
167
  const health = await fetchHealth();
107
- if (health.projectDir) {
108
- localStorage.setItem('synabun-project-dir', health.projectDir);
109
- }
168
+ rememberHealth(health);
110
169
  if (!health.ok) {
111
170
  const messages = {
112
171
  db_missing: [t('loading.health.databaseUnreachable.title'), health.detail || t('loading.health.databaseUnreachable.sub')],
@@ -132,7 +191,158 @@ export async function checkHealth() {
132
191
  */
133
192
  function updateCmdText(projectDir) {
134
193
  const cmdEl = $('loading-cmd-text');
135
- if (cmdEl) cmdEl.textContent = `cd "${projectDir}" ; npm start`;
194
+ if (!cmdEl) return;
195
+ const commands = manualStartCommands({
196
+ projectDir,
197
+ install: stored(START_STORAGE.install),
198
+ entry: stored(START_STORAGE.entry),
199
+ windows: isWindowsPlatform(navigator),
200
+ });
201
+ // One box: the command for this install (the checkout's when it is not known).
202
+ cmdEl.textContent = commands[commands.length - 1].command;
203
+ }
204
+
205
+ // ═══════════════════════════════════════════
206
+ // INTERNAL — Server offline: Start Server + reconnect
207
+ // ═══════════════════════════════════════════
208
+
209
+ function stopWatchingForServer() {
210
+ if (_offlineBridge) _offlineBridge.stop();
211
+ _offlineBridge = null;
212
+ poseMascot('asleep');
213
+ }
214
+
215
+ /** The lines a start writes under the button, emptied. */
216
+ function clearStartStatus() {
217
+ for (const id of ['loading-action-status', 'loading-action-elapsed', 'loading-action-hint', 'loading-action-log']) {
218
+ const el = $(id);
219
+ if (el) el.textContent = '';
220
+ }
221
+ const $trail = $('loading-action-trail');
222
+ if ($trail) $trail.replaceChildren();
223
+ }
224
+
225
+ /**
226
+ * The overlay's mascot as the shared rig, so a start can be acted out. The
227
+ * markup's own still drawing stays as the fallback when the rig cannot mount.
228
+ */
229
+ function mountMascot() {
230
+ if (_mascot) return;
231
+ const host = $('loading-mascot');
232
+ if (!host) return;
233
+ try {
234
+ const still = host.querySelector('svg');
235
+ _mascot = createMascot(host, { width: 160, height: 80, pose: 'sleep', active: false });
236
+ if (still && still !== _mascot.el) still.style.display = 'none';
237
+ } catch {
238
+ _mascot = null;
239
+ }
240
+ }
241
+
242
+ /** 'asleep' is the still sleeping face (the overlay's own bob and z carry it); every other state plays its pose. */
243
+ function poseMascot(state) {
244
+ const host = $('loading-mascot');
245
+ if (host) host.setAttribute('data-state', state);
246
+ if (!_mascot) return;
247
+ try {
248
+ _mascot.setPose(MASCOT_POSE[state] || 'sleep');
249
+ _mascot.setActive(state !== 'asleep');
250
+ } catch {}
251
+ }
252
+
253
+ /**
254
+ * Draw one snapshot of the start: the sentence (a live region, written only
255
+ * when it changes), the page's own clock, and the steps the launcher reported
256
+ * with the time each one happened. Nothing here is estimated.
257
+ */
258
+ function renderStart(snap) {
259
+ const platform = isWindowsPlatform(navigator) ? 'windows' : isMacPlatform(navigator) ? 'mac' : 'linux';
260
+ const view = startStatusView(snap, { platform });
261
+ const say = (line) => (line ? t(`loading.start.${line.key}`, line.params) : '');
262
+
263
+ poseMascot(view.mascot);
264
+
265
+ const $text = $('loading-text');
266
+ if ($text) {
267
+ const title = view.mascot === 'waking' || view.mascot === 'working' ? t('loading.start.headingStarting')
268
+ : view.mascot === 'ready' ? t('loading.start.headingReady')
269
+ : view.mascot === 'failed' ? t('loading.start.headingFailed')
270
+ : _offlineTitle || t('loading.serverOffline');
271
+ if ($text.textContent !== title) $text.textContent = title;
272
+ }
273
+
274
+ const btn = $('loading-action-btn');
275
+ const $label = $('loading-action-label');
276
+ if (btn) btn.disabled = view.busy || view.state === 'online';
277
+ if ($label) {
278
+ $label.textContent = view.button === 'starting' ? t('loading.starting')
279
+ : view.button === 'retry' ? t('common.retry') : t('loading.startServer');
280
+ }
281
+
282
+ const $status = $('loading-action-status');
283
+ const line = say(view.headline);
284
+ if ($status && $status.textContent !== line) $status.textContent = line;
285
+
286
+ const $elapsed = $('loading-action-elapsed');
287
+ if ($elapsed) {
288
+ $elapsed.textContent = view.elapsed;
289
+ $elapsed.setAttribute('aria-label', view.elapsed ? `${t('loading.start.elapsedLabel')}: ${view.elapsed}` : '');
290
+ }
291
+
292
+ const $hint = $('loading-action-hint');
293
+ if ($hint) $hint.textContent = say(view.hint);
294
+
295
+ const $trail = $('loading-action-trail');
296
+ if ($trail) {
297
+ $trail.replaceChildren(...view.trail.map((row) => {
298
+ const item = document.createElement('li');
299
+ const what = document.createElement('span');
300
+ what.textContent = t(`loading.start.${row.key}`);
301
+ const when = document.createElement('span');
302
+ when.className = 'at';
303
+ when.textContent = row.time;
304
+ item.append(what, when);
305
+ return item;
306
+ }));
307
+ }
308
+
309
+ const $log = $('loading-action-log');
310
+ if ($log) $log.textContent = view.error?.log ? t('loading.start.failedLog', { path: view.error.log }) : '';
311
+ }
312
+
313
+ /**
314
+ * While the overlay says the server is offline: keep asking for it, and let
315
+ * the Start button hand the OS a synabun://start link. Reconnects by itself
316
+ * the moment the server answers, however it was started. While a start is
317
+ * under way it shows what the launcher itself reports (its beacon), never a
318
+ * guess: see start-bridge.js.
319
+ */
320
+ function watchForServer() {
321
+ if (_offlineBridge) return;
322
+ mountMascot();
323
+ const beaconAt = startBeaconTarget(window.location);
324
+ const bridge = createStartBridge({
325
+ probe: async () => {
326
+ const health = await fetchHealth();
327
+ return !!health && health.ok !== false;
328
+ },
329
+ launch: () => openStartLink({ document, location: window.location, userAgent: navigator.userAgent }),
330
+ beacon: beaconAt ? () => fetchStartBeacon(beaconAt) : null,
331
+ port: beaconAt ? beaconAt.port : 0,
332
+ onUpdate(snap) {
333
+ if (_offlineBridge !== bridge) return;
334
+ renderStart(snap);
335
+ },
336
+ onState(state) {
337
+ if (_offlineBridge !== bridge) return;
338
+ if (state !== 'online') return;
339
+ resetLoadingToConnecting();
340
+ emit('loading:retried');
341
+ if (_initCallback) _initCallback();
342
+ },
343
+ });
344
+ _offlineBridge = bridge;
345
+ bridge.watch();
136
346
  }
137
347
 
138
348
  // ═══════════════════════════════════════════
@@ -144,6 +354,9 @@ function updateCmdText(projectDir) {
144
354
  * Calls /api/health/start and re-triggers init on success.
145
355
  */
146
356
  async function handleStartAction() {
357
+ // Server offline: nothing to call, the OS starts it.
358
+ if (_offlineBridge) { _offlineBridge.start(); return; }
359
+
147
360
  const btn = $('loading-action-btn');
148
361
  const $status = $('loading-action-status');
149
362
  const $label = $('loading-action-label');
@@ -165,8 +378,6 @@ async function handleStartAction() {
165
378
  if ($label) $label.textContent = t('common.retry');
166
379
  }
167
380
  } catch (err) {
168
- // Server is unreachable — try protocol handler as fallback
169
- window.location.href = 'synabun://start';
170
381
  if ($status) $status.textContent = t('loading.somethingWrong');
171
382
  if (btn) btn.disabled = false;
172
383
  if ($label) $label.textContent = t('common.retry');
@@ -9,6 +9,7 @@ import { storage } from './storage.js';
9
9
  import { KEYS } from './constants.js';
10
10
  import { getMenuItems } from './registry.js';
11
11
  import { openHelp } from './ui-help.js';
12
+ import { initPwaInstall, isStandalone, requestPwaInstall } from './pwa-install.js';
12
13
  import { registerAction } from './ui-keybinds.js';
13
14
  import { isGuest, hasPermission } from './ui-sync.js';
14
15
  import { sendToPanel } from './ui-claude-panel.js';
@@ -83,7 +84,7 @@ export function initMenubar() {
83
84
  const dropdown = document.querySelector('.menubar-item.open .menubar-dropdown');
84
85
  if (!dropdown) return;
85
86
 
86
- const allItems = [...dropdown.querySelectorAll('.menu-item:not(.disabled)')];
87
+ const allItems = [...dropdown.querySelectorAll('.menu-item:not(.disabled):not([hidden])')];
87
88
  if (allItems.length === 0) return;
88
89
 
89
90
  const focused = dropdown.querySelector('.menu-item.focused');
@@ -413,16 +414,9 @@ function wireTerminalMenu() {
413
414
  'menu-command-runner': () => emit('command-runner:open'),
414
415
  'menu-terminal-link': () => emit('link:toggle'),
415
416
  'menu-terminal-toggle': () => emit('terminal:toggle'),
416
- 'menu-install-app': async () => {
417
- if (window._pwaInstallPrompt) {
418
- window._pwaInstallPrompt.prompt();
419
- const { outcome } = await window._pwaInstallPrompt.userChoice;
420
- if (outcome === 'accepted') {
421
- window._pwaInstallPrompt = null;
422
- const el = $('menu-install-app');
423
- if (el) el.style.display = 'none';
424
- }
425
- }
417
+ 'menu-install-app': async () => {
418
+ const result = await requestPwaInstall();
419
+ if (result === 'instructions') location.assign('/install-app.html');
426
420
  },
427
421
  'menu-restart-server': async () => {
428
422
  try {
@@ -478,23 +472,13 @@ function wireTerminalMenu() {
478
472
  registerAction('toggle-terminal', () => emit('terminal:toggle'));
479
473
  registerAction('toggle-terminal-alt', () => emit('terminal:toggle'));
480
474
 
481
- // PWA install prompt — show "Install as App" when the browser supports it
482
- window.addEventListener('beforeinstallprompt', (e) => {
483
- e.preventDefault();
484
- window._pwaInstallPrompt = e;
485
- const el = $('menu-install-app');
486
- if (el) el.style.display = '';
487
- });
488
- window.addEventListener('appinstalled', () => {
489
- window._pwaInstallPrompt = null;
475
+ initPwaInstall({ changed: value => {
490
476
  const el = $('menu-install-app');
491
- if (el) el.style.display = 'none';
492
- });
493
- // Hide if already running as installed PWA
494
- if (window.matchMedia('(display-mode: standalone)').matches || navigator.standalone) {
495
- const el = $('menu-install-app');
496
- if (el) el.style.display = 'none';
497
- }
477
+ if (el) el.style.display = value === 'installed' ? 'none' : '';
478
+ } });
479
+ const installItem = $('menu-install-app');
480
+ if (installItem && !isStandalone()) installItem.style.display = '';
481
+
498
482
  }
499
483
 
500
484
  // ── Variant menu items injection ──
@@ -3,6 +3,7 @@ import { buildJudgmentsTab, wireJudgmentsTab } from './ui-judgments.js';
3
3
  import { buildWhatsAppTab, wireWhatsAppTab } from './ui-whatsapp.js';
4
4
  import { SETTINGS_GROUPS, SETTINGS_PAGES, DEFAULT_SETTINGS_PAGE, pageKeys, getSettingsPage, resolveSettingsTarget, homeOfControl } from './settings/settings-ia.js';
5
5
  import * as kit from './settings/settings-kit.js';
6
+ import { RULES_HOST_LABELS, describeRulesHost, describeRulesInstall, describeRulesInstallAll } from './settings/settings-rules.js';
6
7
  import { initI18n, isReady as i18nReady, SUPPORTED_LOCALES, LOCALE_NAMES, SYSTEM_LOCALE, getLocale, getLocaleChoice, setLocaleChoice, getSystemLocale, englishTranslator, loadEnglishTranslator } from './i18n.js';
7
8
  import { wireSettingsShell, wireSettingsSearch, collectSearchRows, revealSettingsTarget, followSettingsTarget, onSettingsTakeover, applyStoredSections, storeSectionState } from './settings/settings-shell.js';
8
9
  import { buildSettingsSearchIndex } from './settings/settings-search.js';
@@ -44,7 +45,8 @@ const TAB_ICONS = {
44
45
  collections: '<svg viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4.03 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/></svg>',
45
46
  projects: '<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
46
47
  memory: '<svg viewBox="0 0 24 24"><path d="M12 2a7 7 0 0 0-7 7c0 2.38 1.19 4.47 3 5.74V17a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-2.26c1.81-1.27 3-3.36 3-5.74a7 7 0 0 0-7-7z"/><line x1="9" y1="21" x2="15" y2="21"/><line x1="10" y1="24" x2="14" y2="24"/></svg>',
47
- judgments: '<svg viewBox="0 0 24 24"><line x1="12" y1="3" x2="12" y2="21"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="4" y1="7" x2="20" y2="7"/><path d="M4 7l-2.5 7h5z"/><path d="M20 7l-2.5 7h5z"/></svg>',
48
+ // TypeSafe's official mark (traced from their 400px favicon, filled: the nav CSS strokes every other icon)
49
+ judgments: '<svg viewBox="0 0 24 24"><path style="fill:currentColor;stroke:none" fill-rule="evenodd" d="M11.88 0 3.74 5.35 3.71 14.28 7.47 16.85 7.55 21.12 12.04 24 20.26 18.62 20.29 9.53 16.53 6.96 16.45 2.86 12.2 0.05ZM12.93 2.36 12.8 2.45 12.8 4.55 14.94 5.89 14.95 3.74ZM10.99 2.44 5.24 6.23 5.24 12.35 5.36 12.47 7.47 11.06 7.54 7.02 11.2 4.55 11.2 2.53ZM11.96 5.98 9.75 7.44 11.96 8.93 14.25 7.44ZM9.14 8.96 9.02 9.05 9.02 11.06 11.16 12.45 11.2 10.34ZM14.86 8.96 12.83 10.34 12.76 14.38 9.05 16.85 9.02 19.03 9.14 19.17 14.98 15.33 14.98 9.05ZM16.63 8.96 16.51 9.05 16.51 15.24 16.71 15.53 18.64 16.76 18.76 16.61 18.76 10.42ZM8.18 12.5 5.99 13.96 8.26 15.45 10.5 13.96ZM15.58 16.84 9.85 20.55 9.79 20.74 12.04 22.18 17.91 18.37 17.99 18.2 17.35 17.75 15.98 16.87Z"/></svg>',
48
50
  interface: '<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="9" y1="9" x2="21" y2="9"/></svg>',
49
51
  graphics: '<svg viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
50
52
  icons: '<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
@@ -5199,13 +5201,9 @@ export async function openSettingsModal(options = {}) {
5199
5201
  // every action. `null` means the route is missing or failed, which is what
5200
5202
  // a server that was not restarted after the update answers: the tab keeps
5201
5203
  // View and Copy and says what to do, and offers no control that cannot work.
5204
+ // What a state is called, the action it calls for and the sentence next to it come from
5205
+ // settings/settings-rules.js, which the onboarding wizard reads too.
5202
5206
  const RESTART_HINT = kit.tx('settings.redesign.setup.restartSynabunToFinishThisUpdate');
5203
- const RULES_HOST_LABELS = { claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode', gemini: 'Gemini', cursor: 'Cursor', coexistence: 'coexistence' };
5204
- const RULES_BADGES = {
5205
- installed: [kit.tx('settings.redesign.setup.installed'), true], newer: [kit.tx('settings.redesign.setup.newer'), true], outdated: [kit.tx('settings.redesign.setup.updateAvailable'), false], modified: [kit.tx('settings.redesign.setup.edited2'), false],
5206
- conflict: [kit.tx('settings.redesign.setup.conflict'), false], error: [kit.tx('settings.redesign.setup.error'), false], shadowed: [kit.tx('settings.redesign.setup.shadowed'), false],
5207
- 'not-installed': [kit.tx('settings.redesign.setup.notInstalled'), false], manual: [kit.tx('settings.redesign.setup.copyOnly'), false],
5208
- };
5209
5207
  let rulesStatus = setupStatus.rules?.ok ? setupStatus.rules : null;
5210
5208
  // What the last "install for all" wrote and where, shown under that button until the next rules action.
5211
5209
  let installNote = '';
@@ -5277,40 +5275,18 @@ export async function openSettingsModal(options = {}) {
5277
5275
  const row = el('row');
5278
5276
 
5279
5277
  rulesRenderers.push(() => {
5280
- const info = rulesStatus?.hosts?.[host];
5281
- if (!info) {
5282
- badge.className = 'setup-status-badge inactive';
5283
- badge.textContent = kit.tx('settings.redesign.setup.unavailable');
5284
- pathEl.textContent = RESTART_HINT;
5285
- row.classList.remove('enabled');
5286
- setShown(message, false); setShown(primary, false); setShown(removeBtn, false);
5287
- return;
5288
- }
5289
- const [text, good] = RULES_BADGES[info.state] || [info.state || kit.tx('settings.redesign.setup.unknown'), false];
5290
- badge.className = `setup-status-badge ${good ? 'active' : 'inactive'}`;
5291
- badge.textContent = text;
5292
- row.classList.toggle('enabled', good);
5293
- pathEl.textContent = info.installedVersion ? `${info.path} (v${info.installedVersion})` : (info.path || '');
5294
- pathEl.title = info.path || '';
5295
- // The action the state calls for; conflict, error and shadowed only explain themselves.
5296
- const action = info.detected === false ? '' : ({ 'not-installed': kit.tx('settings.redesign.setup.install'), outdated: kit.tx('settings.redesign.setup.update'), modified: kit.tx('settings.redesign.setup.replace') }[info.state] || '');
5297
- // A file without SynaBun's markers is the user's own: the server refuses to replace it, so no button.
5298
- const canAct = !!action && info.replaceable !== false;
5299
- primary.textContent = canAct ? action : '';
5300
- setShown(primary, canAct);
5301
- setShown(removeBtn, ['installed', 'newer', 'outdated', 'modified', 'shadowed'].includes(info.state));
5302
- // An edited copy is the user's: nothing replaces or removes it without the confirm behind each button.
5303
- const editedNote = kit.tx('settings.redesign.setup.thisCopyWasEditedSoSynabun');
5304
- const stateNote = info.detected === false ? kit.tx('settings.redesign.setup.wasNotFoundOnThisMachine', { label: label })
5305
- : info.state === 'modified' ? (info.replaceable === false ? (info.detail || '') : editedNote)
5306
- : info.state === 'outdated' ? (rulesStatus.version ? kit.tx('settings.redesign.setup.versionOfTheRulesIsAvailable', { version: rulesStatus.version }) : kit.tx('settings.redesign.setup.aNewerVersionOfTheRules'))
5307
- : ['conflict', 'error', 'shadowed', 'newer'].includes(info.state) ? (info.error || info.detail || '')
5308
- : '';
5309
- // A removal that could not finish left rules behind: say which, until a remove or an install completes.
5310
- const partial = info.partialRemoval?.error ? kit.tx('settings.redesign.setup.theLastRemovalDidNotFinish', { error: info.partialRemoval.error }) : '';
5311
- const note = partial && stateNote && !partial.includes(stateNote) ? `${partial} ${stateNote}` : (partial || stateNote);
5312
- message.textContent = note;
5313
- setShown(message, !!note);
5278
+ // No status (the route is missing): "Unavailable", the restart hint as the path, and no action.
5279
+ const view = describeRulesHost(rulesStatus?.hosts?.[host], { label, version: rulesStatus?.version, tx: kit.tx });
5280
+ badge.className = `setup-status-badge ${view.good ? 'active' : 'inactive'}`;
5281
+ badge.textContent = view.badge;
5282
+ row.classList.toggle('enabled', view.good);
5283
+ pathEl.textContent = view.pathText;
5284
+ pathEl.title = view.path;
5285
+ primary.textContent = view.actionLabel;
5286
+ setShown(primary, !!view.action);
5287
+ setShown(removeBtn, view.removable);
5288
+ message.textContent = view.note;
5289
+ setShown(message, !!view.note);
5314
5290
  });
5315
5291
 
5316
5292
  primary.addEventListener('click', () => {
@@ -5318,9 +5294,7 @@ export async function openSettingsModal(options = {}) {
5318
5294
  const force = state === 'modified';
5319
5295
  if (force && !confirm(kit.tx('settings.redesign.setup.replaceYourEditedRulesWithSynabun', { label: label }))) return;
5320
5296
  cachedText = '';
5321
- runRulesAction(primary, () => installRules(host, { force }), (result) => (result.changed
5322
- ? kit.tx(state === 'not-installed' ? 'settings.redesign.setup.rulesInstalled2' : 'settings.redesign.setup.rulesUpdated2', { label })
5323
- : kit.tx('settings.redesign.setup.rulesAreAlreadyCurrent', { label: label })));
5297
+ runRulesAction(primary, () => installRules(host, { force }), (result) => describeRulesInstall(result, state, { label, tx: kit.tx }));
5324
5298
  });
5325
5299
  removeBtn.addEventListener('click', () => {
5326
5300
  const info = rulesStatus?.hosts?.[host];
@@ -5399,15 +5373,10 @@ export async function openSettingsModal(options = {}) {
5399
5373
  });
5400
5374
  installAllBtn.addEventListener('click', () => {
5401
5375
  runRulesAction(installAllBtn, () => installAllRules(), (result) => {
5402
- const rows = Object.entries(result.results || {});
5403
- if (!rows.length) return kit.tx('settings.redesign.setup.noConnectedToolNeedsRules');
5404
- // The file each tool reads. The rules are global, so a project's own CLAUDE.md and AGENTS.md stay as they are.
5405
- const where = rows.filter(([, row]) => row.ok !== false && row.path).map(([host, row]) => `${hostOf[host] || host}: ${row.path}`).join(' · ');
5406
- if (where) installNote = kit.tx('settings.redesign.setup.rulesInstalledAt', { where: where });
5407
- const failed = rows.filter(([, row]) => row.ok === false);
5408
- if (failed.length) return kit.tx('settings.redesign.setup.installedWithFailure', { count: rows.length - failed.length, host: hostOf[failed[0][0]] || failed[0][0], error: failed[0][1].error || failed[0][1].state });
5409
- const map = rows.map(([host]) => hostOf[host] || host).join(', ');
5410
- return kit.tx(rows.some(([, row]) => row.changed) ? 'settings.redesign.setup.rulesInstalledFor' : 'settings.redesign.setup.rulesAlreadyCurrentFor', { map: map });
5376
+ // The sentence for the toast, and under the button the file each tool reads.
5377
+ const report = describeRulesInstallAll(result, { tx: kit.tx });
5378
+ installNote = report.note;
5379
+ return report.message;
5411
5380
  });
5412
5381
  });
5413
5382
  }
@@ -5418,6 +5387,15 @@ export async function openSettingsModal(options = {}) {
5418
5387
  wireCopyBtn('setup-claude-cli-copy', () => setupStatus.claude?.cliCommand || ccIntegrations.mcp?.cliCommand || '', kit.tx('settings.redesign.setup.copyCliCommand'));
5419
5388
  wireRulesControls('claude', 'claude');
5420
5389
 
5390
+ // What an MCP client runs: `node <script>` for an npm or Git install, the
5391
+ // application's own executable for a packaged one. The server says which.
5392
+ const mcpLaunch = () => {
5393
+ const paths = setupStatus.paths || {};
5394
+ return paths.mcpCommand && paths.mcpCommand !== 'node' && Array.isArray(paths.mcpArgs)
5395
+ ? { command: paths.mcpCommand, args: paths.mcpArgs }
5396
+ : { command: 'node', args: [paths.mcpIndexPath || '<path-to>/mcp-server/run.mjs'] };
5397
+ };
5398
+
5421
5399
  // ── Gemini ──
5422
5400
  wireSetupMcpToggle('gemini', '/api/setup/gemini/mcp', '~/.gemini/settings.json');
5423
5401
  // Config preview
@@ -5430,7 +5408,7 @@ export async function openSettingsModal(options = {}) {
5430
5408
  cachedConfig = JSON.stringify(data.config, null, 2);
5431
5409
  preview.textContent = cachedConfig;
5432
5410
  } else if (data.ok) {
5433
- cachedConfig = JSON.stringify({ mcpServers: { SynaBun: { command: 'node', args: [setupStatus.paths?.mcpIndexPath || '<path-to>/mcp-server/run.mjs'], env: { DOTENV_PATH: setupStatus.paths?.envPath || '<path-to>/synabun/.env' } } } }, null, 2);
5411
+ cachedConfig = JSON.stringify({ mcpServers: { SynaBun: { ...mcpLaunch(), env: { DOTENV_PATH: setupStatus.paths?.envPath || '<path-to>/synabun/.env' } } } }, null, 2);
5434
5412
  preview.textContent = cachedConfig;
5435
5413
  } else { preview.textContent = kit.tx('settings.redesign.setup.couldNotLoadConfig'); }
5436
5414
  }).catch(() => { preview.textContent = kit.tx('settings.redesign.setup.failedToLoad'); });
@@ -5452,10 +5430,11 @@ export async function openSettingsModal(options = {}) {
5452
5430
  cachedConfig = data.toml;
5453
5431
  preview.textContent = cachedConfig;
5454
5432
  } else if (data.ok) {
5455
- const mp = setupStatus.paths?.mcpIndexPath || '<path-to>/mcp-server/run.mjs';
5433
+ const launch = mcpLaunch();
5456
5434
  const ep = setupStatus.paths?.envPath || '<path-to>/synabun/.env';
5457
5435
  const dataHome = setupStatus.paths?.dataHome || '<path-to>/synabun';
5458
- cachedConfig = `[mcp_servers.SynaBun]\ncommand = "node"\nargs = ["${mp}"]\nenv = { DOTENV_PATH = "${ep}", SYNABUN_DATA_HOME = "${dataHome}", MEMORY_DATA_DIR = "${dataHome}/mcp-data", SYNABUN_PROFILE = "full", SYNABUN_BROWSER_FAST = "1", SYNABUN_BROWSER_COMPACT = "1", SYNABUN_TOOL_CATALOG_MODE = "deferred" }`;
5436
+ // A JSON string is a TOML basic string: the same quotes and escapes.
5437
+ cachedConfig = `[mcp_servers.SynaBun]\ncommand = ${JSON.stringify(launch.command)}\nargs = [${launch.args.map(arg => JSON.stringify(arg)).join(', ')}]\nenv = { DOTENV_PATH = "${ep}", SYNABUN_DATA_HOME = "${dataHome}", MEMORY_DATA_DIR = "${dataHome}/mcp-data", SYNABUN_PROFILE = "full", SYNABUN_BROWSER_FAST = "1", SYNABUN_BROWSER_COMPACT = "1", SYNABUN_TOOL_CATALOG_MODE = "deferred" }`;
5459
5438
  preview.textContent = cachedConfig;
5460
5439
  } else { preview.textContent = kit.tx('settings.redesign.setup.couldNotLoadConfig'); }
5461
5440
  }).catch(() => { preview.textContent = kit.tx('settings.redesign.setup.failedToLoad'); });
@@ -5471,7 +5450,6 @@ export async function openSettingsModal(options = {}) {
5471
5450
  const preview = overlay.querySelector('#setup-opencode-config-preview');
5472
5451
  let cachedConfig = '';
5473
5452
  if (preview) {
5474
- const mp = setupStatus.paths?.mcpIndexPath || '<path-to>/mcp-server/run.mjs';
5475
5453
  const ep = setupStatus.paths?.envPath || '<path-to>/synabun/.env';
5476
5454
  fetch('/api/opencode/mcp').then(r => r.json()).then(data => {
5477
5455
  if (data.ok && data.data && Object.keys(data.data).length) {
@@ -5482,8 +5460,7 @@ export async function openSettingsModal(options = {}) {
5482
5460
  mcp: {
5483
5461
  SynaBun: {
5484
5462
  type: 'stdio',
5485
- command: 'node',
5486
- args: [mp],
5463
+ ...mcpLaunch(),
5487
5464
  env: { DOTENV_PATH: ep },
5488
5465
  },
5489
5466
  },