synabun 2.0.5 → 2.0.7

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 (233) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +2 -1
  3. package/THIRD-PARTY-LICENSES.md +13 -0
  4. package/docs/whatsapp.md +151 -16
  5. package/hooks/claude-code/shared.mjs +6 -3
  6. package/lib/child-path.js +303 -0
  7. package/lib/claude-hooks.js +2 -1
  8. package/lib/data-home-migration.js +2 -0
  9. package/lib/desktop-app.js +265 -0
  10. package/lib/external-tools.js +7 -3
  11. package/lib/log-redact.js +102 -0
  12. package/lib/packaged-runtime.js +138 -11
  13. package/mcp-server/dist/http.d.ts +3 -1
  14. package/mcp-server/dist/http.d.ts.map +1 -1
  15. package/mcp-server/dist/http.js +15 -4
  16. package/mcp-server/dist/http.js.map +1 -1
  17. package/mcp-server/dist/index.d.ts.map +1 -1
  18. package/mcp-server/dist/index.js +9 -6
  19. package/mcp-server/dist/index.js.map +1 -1
  20. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  21. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  22. package/mcp-server/dist/services/budget-judgments.js +153 -0
  23. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  24. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  25. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  26. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  27. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  28. package/mcp-server/dist/services/identity.d.ts +11 -0
  29. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  30. package/mcp-server/dist/services/identity.js +24 -0
  31. package/mcp-server/dist/services/identity.js.map +1 -1
  32. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  33. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  34. package/mcp-server/dist/services/neural-interface.js +34 -2
  35. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  36. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  37. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  38. package/mcp-server/dist/services/typesafe-config.js +4 -1
  39. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  40. package/mcp-server/dist/tools/agents.d.ts +10 -7
  41. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  42. package/mcp-server/dist/tools/agents.js +34 -13
  43. package/mcp-server/dist/tools/agents.js.map +1 -1
  44. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  45. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  46. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  47. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  48. package/mcp-server/dist/tui.js +2 -1
  49. package/mcp-server/dist/tui.js.map +1 -1
  50. package/mcp-server/dist/version.d.ts +5 -0
  51. package/mcp-server/dist/version.d.ts.map +1 -0
  52. package/mcp-server/dist/version.js +21 -0
  53. package/mcp-server/dist/version.js.map +1 -0
  54. package/mcp-server/package-lock.json +2 -2
  55. package/mcp-server/package.json +1 -1
  56. package/mcp-server/run.mjs +15 -9
  57. package/mcp-server/src/http.ts +18 -3
  58. package/mcp-server/src/index.ts +9 -6
  59. package/mcp-server/src/services/budget-judgments.ts +183 -0
  60. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  61. package/mcp-server/src/services/identity.ts +27 -0
  62. package/mcp-server/src/services/neural-interface.ts +34 -2
  63. package/mcp-server/src/services/typesafe-config.ts +4 -1
  64. package/mcp-server/src/tools/agents.ts +33 -14
  65. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  66. package/mcp-server/src/tui.ts +2 -1
  67. package/mcp-server/src/version.ts +22 -0
  68. package/neural-interface/i18n/en.json +627 -57
  69. package/neural-interface/i18n/pt-BR.json +626 -56
  70. package/neural-interface/lib/account-usage.js +163 -0
  71. package/neural-interface/lib/assistant-api.js +255 -52
  72. package/neural-interface/lib/assistant-brains/claude.js +15 -2
  73. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  74. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  75. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  76. package/neural-interface/lib/assistant-budget-split.js +408 -0
  77. package/neural-interface/lib/assistant-budget.js +94 -95
  78. package/neural-interface/lib/assistant-config.js +62 -14
  79. package/neural-interface/lib/assistant-dispatch.js +432 -125
  80. package/neural-interface/lib/assistant-jev.js +26 -3
  81. package/neural-interface/lib/assistant-persona.js +68 -11
  82. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  83. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  84. package/neural-interface/lib/assistant-route-gate.js +19 -3
  85. package/neural-interface/lib/assistant-router.js +373 -42
  86. package/neural-interface/lib/assistant-runtime.js +307 -47
  87. package/neural-interface/lib/assistant-user-writer.js +4 -2
  88. package/neural-interface/lib/augmented-path.js +317 -53
  89. package/neural-interface/lib/claude-accounts.js +342 -31
  90. package/neural-interface/lib/claude-agent-bridge.js +90 -3
  91. package/neural-interface/lib/claude-executable.js +6 -1
  92. package/neural-interface/lib/claude-model-catalog.js +3 -3
  93. package/neural-interface/lib/claude-panel-auth.js +221 -0
  94. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  95. package/neural-interface/lib/cli-install-api.js +137 -0
  96. package/neural-interface/lib/cli-install.js +797 -0
  97. package/neural-interface/lib/cli-path.js +130 -0
  98. package/neural-interface/lib/cli-resolver.js +744 -0
  99. package/neural-interface/lib/cli-update-planner.js +143 -7
  100. package/neural-interface/lib/codex-account-auth.js +178 -0
  101. package/neural-interface/lib/codex-accounts.js +489 -0
  102. package/neural-interface/lib/codex-capabilities.js +2 -1
  103. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  104. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  105. package/neural-interface/lib/codex-panel-auth.js +62 -0
  106. package/neural-interface/lib/codex-session-settings.js +1 -1
  107. package/neural-interface/lib/codex-settings-schema.js +120 -0
  108. package/neural-interface/lib/desktop/config.js +3 -0
  109. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  110. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  111. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  112. package/neural-interface/lib/git-bash/install.js +529 -0
  113. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  114. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  115. package/neural-interface/lib/git-bash/manager.js +268 -0
  116. package/neural-interface/lib/git-bash/pins.js +206 -0
  117. package/neural-interface/lib/git-bash/pins.json +34 -0
  118. package/neural-interface/lib/git-bash/resolver.js +194 -0
  119. package/neural-interface/lib/git-read.js +34 -2
  120. package/neural-interface/lib/install-details.js +145 -0
  121. package/neural-interface/lib/mcp-installer.js +14 -2
  122. package/neural-interface/lib/native-loop-providers.js +16 -3
  123. package/neural-interface/lib/native-loop-runtime.js +9 -4
  124. package/neural-interface/lib/os-path.js +319 -0
  125. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  126. package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
  127. package/neural-interface/lib/pty-host/core.js +2 -1
  128. package/neural-interface/lib/pty-host/manager.js +27 -2
  129. package/neural-interface/lib/remote-policy.js +20 -11
  130. package/neural-interface/lib/session-title-generator.js +4 -3
  131. package/neural-interface/lib/system-about.js +340 -0
  132. package/neural-interface/lib/system-activity.js +66 -0
  133. package/neural-interface/lib/terminal-env.js +11 -3
  134. package/neural-interface/lib/terminal-path.js +55 -0
  135. package/neural-interface/lib/virtual-socket.js +12 -1
  136. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  137. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  138. package/neural-interface/lib/whatsapp/commands.js +13 -14
  139. package/neural-interface/lib/whatsapp/config.js +134 -20
  140. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  141. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  142. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  143. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  144. package/neural-interface/lib/whatsapp/manager.js +92 -32
  145. package/neural-interface/lib/whatsapp/paths.js +11 -1
  146. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  147. package/neural-interface/lib/whatsapp/redact.js +63 -0
  148. package/neural-interface/lib/whatsapp/service.js +343 -91
  149. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  150. package/neural-interface/package-lock.json +2 -2
  151. package/neural-interface/package.json +1 -1
  152. package/neural-interface/public/index.html +2 -0
  153. package/neural-interface/public/index2d.html +2 -0
  154. package/neural-interface/public/offline.html +16 -1
  155. package/neural-interface/public/onboarding.html +96 -2
  156. package/neural-interface/public/shared/account-i18n.js +7 -0
  157. package/neural-interface/public/shared/account-menu.js +539 -0
  158. package/neural-interface/public/shared/account-usage.js +19 -0
  159. package/neural-interface/public/shared/account-view.js +335 -0
  160. package/neural-interface/public/shared/agent-runtime-options.js +28 -8
  161. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  162. package/neural-interface/public/shared/assistant/asst-api.js +32 -2
  163. package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
  164. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  165. package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
  166. package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
  167. package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
  168. package/neural-interface/public/shared/assistant/asst-render.js +38 -7
  169. package/neural-interface/public/shared/assistant/asst-route.js +72 -14
  170. package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
  171. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  172. package/neural-interface/public/shared/assistant/asst-state.js +1 -1
  173. package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
  174. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  175. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  176. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  177. package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
  178. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  179. package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
  180. package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
  181. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  182. package/neural-interface/public/shared/claude-account-view.js +3 -0
  183. package/neural-interface/public/shared/cli-install-view.js +392 -0
  184. package/neural-interface/public/shared/cli-status.js +282 -22
  185. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  186. package/neural-interface/public/shared/cp/cp-styles.js +46 -0
  187. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  188. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  189. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  190. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  191. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  192. package/neural-interface/public/shared/editor/editor-panel.js +1 -1
  193. package/neural-interface/public/shared/explorer/fx-main.js +1 -1
  194. package/neural-interface/public/shared/git-bash-view.js +145 -0
  195. package/neural-interface/public/shared/html-shell.js +1 -0
  196. package/neural-interface/public/shared/i18n.js +19 -1
  197. package/neural-interface/public/shared/install-details-view.js +86 -0
  198. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
  199. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
  200. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
  201. package/neural-interface/public/shared/pwa-install-page.js +3 -1
  202. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  203. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  204. package/neural-interface/public/shared/settings/settings-ia.js +15 -0
  205. package/neural-interface/public/shared/showcase-privacy.js +489 -0
  206. package/neural-interface/public/shared/storage.js +30 -3
  207. package/neural-interface/public/shared/styles.css +278 -31
  208. package/neural-interface/public/shared/synabun-mascot.js +48 -2
  209. package/neural-interface/public/shared/ui-claude-panel.js +212 -213
  210. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  211. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  212. package/neural-interface/public/shared/ui-judgments.js +10 -1
  213. package/neural-interface/public/shared/ui-loading.js +30 -6
  214. package/neural-interface/public/shared/ui-menubar.js +13 -3
  215. package/neural-interface/public/shared/ui-navbar.js +3 -1
  216. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  217. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  218. package/neural-interface/public/shared/ui-sessions.js +9 -3
  219. package/neural-interface/public/shared/ui-settings.js +55 -3
  220. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  221. package/neural-interface/public/shared/ui-sync.js +6 -0
  222. package/neural-interface/public/shared/ui-terminal.js +17 -0
  223. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  224. package/neural-interface/public/shared/ui-update.js +594 -5
  225. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  226. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  227. package/neural-interface/public/shared/worker-end.js +2 -0
  228. package/neural-interface/public/sw.js +1 -1
  229. package/neural-interface/public/variant/2d/main.js +10 -1
  230. package/neural-interface/public/variant/3d/main.js +10 -1
  231. package/neural-interface/server.js +1219 -608
  232. package/package.json +4 -1
  233. package/setup.js +9 -8
@@ -0,0 +1,744 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun — where the user's Claude Code, Codex and OpenCode are
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // One answer for every place that looks for one of these CLIs: the version
6
+ // check behind the side panels' banner and the update badges
7
+ // (GET /api/system/tool-versions), Settings' Detect button, the one-click
8
+ // install's final check (cli-install.js), and every launcher in server.js
9
+ // (getClaudeBin, getCodexBin, the `opencode serve` launches, terminal tabs,
10
+ // update commands). Detection, the banner, launching and updates therefore
11
+ // always mean the same file.
12
+ //
13
+ // Resolution order per tool. The first candidate that exists AND answers
14
+ // `--version` wins:
15
+ //
16
+ // configured cli-config.json, Settings > Terminal: a file path (~ is
17
+ // the home folder), or a command name looked up like PATH
18
+ // path the PATH this process was given, then the OS PATH a new
19
+ // terminal would have now (os-path.js: the registry on
20
+ // Windows, the login shell on macOS and Linux)
21
+ // known-location the folders each install method uses on this system:
22
+ // the vendors' own (toolSpecificFiles) first, then every
23
+ // package manager's (augmented-path.js knownInstallDirs)
24
+ //
25
+ // Never a file inside SynaBun's own install or runtime folders
26
+ // (`isInsideSynabun`), never a dependency's `node_modules/.bin` command, never
27
+ // a Windows app alias in WindowsApps (an older Claude Desktop registers a
28
+ // `claude.exe` there that opens the desktop app). On Windows a folder is
29
+ // searched in PATHEXT order (.exe before .cmd/.bat, then .ps1; an
30
+ // extensionless file is never a Windows program), and an npm command file is
31
+ // followed to the program it runs (claude.exe, Codex's vendored codex.exe,
32
+ // OpenCode's opencode.exe) so that nothing needs a shell to start it; one that
33
+ // cannot be followed is run through cmd.exe with every word escaped.
34
+ //
35
+ // A status has three states: installed (path, version, how it was found, the
36
+ // install method), missing, and broken: found, but `--version` failed, with
37
+ // the real error. Versions are cached per file identity (real path, size,
38
+ // mtime), so an update is probed again by itself; `force` (Re-check) reads the
39
+ // OS PATH again and probes everything again.
40
+ //
41
+ // Every dependency is injectable, so Windows, Linux and macOS resolution is
42
+ // tested from any host. Nothing here throws.
43
+
44
+ import { spawn } from 'node:child_process';
45
+ import { accessSync, closeSync, constants, existsSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync } from 'node:fs';
46
+ import os from 'node:os';
47
+ import path from 'node:path';
48
+ import { forgetAugmentedPath, knownInstallDirs, withPathEnv, getAugmentedPath, pathEnvValue } from './augmented-path.js';
49
+ import { detectInstallSourceOf } from './cli-update-planner.js';
50
+ import { windowsClaudeTargets } from './claude-executable.js';
51
+ import { resolveTrustedWindowsCodexBinary, windowsCodexPackageJsonCandidates } from './codex-runtime-path.js';
52
+ import { osPath as defaultOsPath } from './os-path.js';
53
+
54
+ export const CLI_TOOLS = Object.freeze({
55
+ 'claude-code': Object.freeze({ key: 'claude-code', command: 'claude', label: 'Claude Code' }),
56
+ codex: Object.freeze({ key: 'codex', command: 'codex', label: 'Codex' }),
57
+ opencode: Object.freeze({ key: 'opencode', command: 'opencode', label: 'OpenCode' }),
58
+ gemini: Object.freeze({ key: 'gemini', command: 'gemini', label: 'Gemini CLI' }),
59
+ });
60
+
61
+ const PROBE_TIMEOUT_MS = 15_000;
62
+ const LAUNCH_MEMO_MS = 3_000;
63
+ const FAILURE_REPROBE_MS = 60_000; // a failed --version is asked again by a plain check after this
64
+ const FAILURE_KEEP_MS = 10 * 60_000; // launchers skip a known failure for this long (or until the file changes)
65
+ const MAX_PROBES = 6;
66
+ const LAUNCHABLE_EXTENSIONS = ['.com', '.exe', '.bat', '.cmd'];
67
+
68
+ const pathFor = (platform) => (platform === 'win32' ? path.win32 : path.posix);
69
+
70
+ function homeOf(env, platform) {
71
+ if (platform === 'win32') return env.USERPROFILE || env.HOME || os.homedir();
72
+ return env.HOME || os.homedir();
73
+ }
74
+
75
+ // ── Names, places, launch specs (pure) ─────────────────────────────────────
76
+
77
+ /**
78
+ * The file names a command has on Windows, in the order Windows tries them:
79
+ * PATHEXT's own order, kept to the extensions a program can be started from
80
+ * (.com .exe .bat .cmd), then a PowerShell script.
81
+ */
82
+ export function windowsCommandNames(command, pathext = '') {
83
+ const listed = String(pathext || '.COM;.EXE;.BAT;.CMD').split(';')
84
+ .map(ext => ext.trim().toLowerCase())
85
+ .filter(ext => LAUNCHABLE_EXTENSIONS.includes(ext));
86
+ const order = [...new Set(listed.length ? listed : LAUNCHABLE_EXTENSIONS)];
87
+ return [...order.map(ext => `${command}${ext}`), `${command}.ps1`];
88
+ }
89
+
90
+ /** A PATH folder that is a dependency's commands, never the user's tool. */
91
+ export function isPackageBinDir(dir) {
92
+ return /[\\/]node_modules[\\/]\.bin[\\/]?$/i.test(String(dir || ''));
93
+ }
94
+
95
+ /** A Windows app alias folder (zero-byte reparse points that start a Store/desktop app). */
96
+ export function isWindowsAppsDir(file) {
97
+ return /[\\/]microsoft[\\/]windowsapps([\\/]|$)/i.test(String(file || ''));
98
+ }
99
+
100
+ function listDir(readdir, dir) {
101
+ try { return readdir(dir).map(String).slice(0, 300); } catch { return []; }
102
+ }
103
+
104
+ /**
105
+ * WinGet installs a portable package under
106
+ * %LOCALAPPDATA%\Microsoft\WinGet\Packages\<Publisher.Id>_<source>\ and links
107
+ * it from WinGet\Links (a known folder). The package folder is looked in too,
108
+ * for a link that is missing.
109
+ */
110
+ function wingetPackageFiles({ localAppData, prefixes, exe, readdir, pathImpl }) {
111
+ const root = pathImpl.join(localAppData, 'Microsoft', 'WinGet', 'Packages');
112
+ const out = [];
113
+ for (const name of listDir(readdir, root)) {
114
+ const lower = name.toLowerCase();
115
+ if (!prefixes.some(prefix => lower.startsWith(prefix))) continue;
116
+ const dir = pathImpl.join(root, name);
117
+ out.push(pathImpl.join(dir, exe));
118
+ for (const sub of listDir(readdir, dir).slice(0, 20)) out.push(pathImpl.join(dir, sub, exe));
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * The vendors' own install locations for one tool (before every package
125
+ * manager's folders), checked against their installers and docs on
126
+ * 2026-10-17:
127
+ *
128
+ * Claude Code native installer: ~/.local/bin/claude, %USERPROFILE%\.local\bin\claude.exe
129
+ * (a known folder); the older local install ~/.claude/local/claude;
130
+ * WinGet Anthropic.ClaudeCode
131
+ * Codex standalone installer: $CODEX_INSTALL_DIR, else ~/.local/bin/codex
132
+ * (macOS, Linux) or %LOCALAPPDATA%\Programs\OpenAI\Codex\bin\codex.exe,
133
+ * each a link into $CODEX_HOME/packages/standalone/current/bin
134
+ * OpenCode install script: ~/.opencode/bin/opencode (older scripts:
135
+ * $OPENCODE_INSTALL_DIR, $XDG_BIN_DIR); on Windows the folder
136
+ * SynaBun's own install uses, %USERPROFILE%\.opencode\bin
137
+ */
138
+ export function toolSpecificFiles(toolKey, {
139
+ platform = process.platform,
140
+ env = process.env,
141
+ home = homeOf(env, platform),
142
+ readdir = readdirSync,
143
+ } = {}) {
144
+ const pathImpl = pathFor(platform);
145
+ const join = (...parts) => pathImpl.join(...parts);
146
+ const windows = platform === 'win32';
147
+ const exe = (name) => (windows ? `${name}.exe` : name);
148
+ const localAppData = env.LOCALAPPDATA || join(home, 'AppData', 'Local');
149
+ const out = [];
150
+ const add = (file, via) => { if (file) out.push({ file, via }); };
151
+ const codexHome = env.CODEX_HOME || join(home, '.codex');
152
+ switch (toolKey) {
153
+ case 'claude-code':
154
+ add(join(home, '.local', 'bin', exe('claude')), 'native');
155
+ if (!windows) add(join(home, '.claude', 'local', 'claude'), 'native');
156
+ if (windows) for (const file of wingetPackageFiles({ localAppData, prefixes: ['anthropic.claudecode'], exe: 'claude.exe', readdir, pathImpl })) add(file, 'winget');
157
+ break;
158
+ case 'codex':
159
+ if (env.CODEX_INSTALL_DIR) add(join(env.CODEX_INSTALL_DIR, exe('codex')), 'standalone');
160
+ add(windows ? join(localAppData, 'Programs', 'OpenAI', 'Codex', 'bin', 'codex.exe') : join(home, '.local', 'bin', 'codex'), 'standalone');
161
+ add(join(codexHome, 'packages', 'standalone', 'current', 'bin', exe('codex')), 'standalone');
162
+ add(join(codexHome, 'packages', 'standalone', 'current', exe('codex')), 'standalone');
163
+ if (windows) for (const file of wingetPackageFiles({ localAppData, prefixes: ['openai.codex'], exe: 'codex.exe', readdir, pathImpl })) add(file, 'winget');
164
+ break;
165
+ case 'opencode':
166
+ add(join(home, '.opencode', 'bin', exe('opencode')), 'standalone');
167
+ if (env.OPENCODE_INSTALL_DIR) add(join(env.OPENCODE_INSTALL_DIR, exe('opencode')), 'standalone');
168
+ if (env.XDG_BIN_DIR) add(join(env.XDG_BIN_DIR, exe('opencode')), 'standalone');
169
+ if (windows) for (const file of wingetPackageFiles({ localAppData, prefixes: ['sst.opencode', 'anomalyco.opencode', 'opencode.'], exe: 'opencode.exe', readdir, pathImpl })) add(file, 'winget');
170
+ break;
171
+ default:
172
+ break;
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * What a value in Settings > Terminal means: a file path, a command name, or
179
+ * a command line (which terminal tabs run as written but nothing can resolve
180
+ * to a file). The tool's own name is "not configured".
181
+ */
182
+ export function describeConfigured(value, toolKey, {
183
+ platform = process.platform,
184
+ env = process.env,
185
+ home = homeOf(env, platform),
186
+ exists = existsSync,
187
+ } = {}) {
188
+ const raw = String(value || '').trim();
189
+ const tool = CLI_TOOLS[toolKey];
190
+ if (!raw || !tool || raw === tool.command) return null;
191
+ const pathImpl = pathFor(platform);
192
+ let text = raw.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1');
193
+ if (text === '~' || text.startsWith('~/') || text.startsWith('~\\')) text = pathImpl.join(home, text.slice(1));
194
+ if (exists(text)) return { value: raw, kind: 'path', file: pathImpl.normalize(text) };
195
+ if (/\s/.test(text)) return { value: raw, kind: 'command-line' };
196
+ const hasSeparator = text.includes('/') || (platform === 'win32' && text.includes('\\'));
197
+ if (hasSeparator) return { value: raw, kind: 'path', file: pathImpl.normalize(text) };
198
+ return { value: raw, kind: 'command', name: text };
199
+ }
200
+
201
+ /** OpenCode's npm package: its command file runs node_modules\opencode-ai\bin\opencode.exe, the platform build its postinstall put there. */
202
+ export function windowsOpencodeTargets(launcher, arch = process.arch) {
203
+ const pathImpl = path.win32;
204
+ const folder = pathImpl.dirname(String(launcher || ''));
205
+ const roots = [pathImpl.join(folder, 'node_modules')];
206
+ if (pathImpl.basename(folder).toLowerCase() === '.bin') roots.push(pathImpl.dirname(folder));
207
+ const platformPackages = arch === 'x64' ? ['opencode-windows-x64', 'opencode-windows-x64-baseline'] : [`opencode-windows-${arch}`];
208
+ return roots.flatMap(root => [
209
+ pathImpl.join(root, 'opencode-ai', 'bin', 'opencode.exe'),
210
+ ...platformPackages.flatMap(name => [
211
+ pathImpl.join(root, 'opencode-ai', 'node_modules', name, 'bin', 'opencode.exe'),
212
+ pathImpl.join(root, name, 'bin', 'opencode.exe'),
213
+ ]),
214
+ ]);
215
+ }
216
+
217
+ /** A Windows program begins with "MZ" (OpenCode's npm placeholder is a shell script). Reads two bytes: a CLI can be 200 MB. */
218
+ export function isPortableExecutable(file) {
219
+ let fd = null;
220
+ try {
221
+ fd = openSync(file, 'r');
222
+ const head = Buffer.alloc(2);
223
+ return readSync(fd, head, 0, 2, 0) === 2 && head[0] === 0x4d && head[1] === 0x5a;
224
+ } catch {
225
+ return false;
226
+ } finally {
227
+ if (fd != null) { try { closeSync(fd); } catch { /* closed */ } }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * How a found file is started without a shell.
233
+ *
234
+ * direct the file itself (every POSIX file; a Windows .exe/.com; an
235
+ * npm command file followed to the .exe it runs)
236
+ * node-script a JavaScript entry run by the Node running SynaBun (an old
237
+ * npm Claude Code's cli.js)
238
+ * powershell a .ps1, through powershell.exe -File
239
+ * cmd a .cmd/.bat that could not be followed: cmd.exe, escaped
240
+ * unsupported nothing Windows can start (an extensionless shell script)
241
+ */
242
+ export function launchSpecFor(file, toolKey, {
243
+ platform = process.platform,
244
+ arch = process.arch,
245
+ env = process.env,
246
+ exists = existsSync,
247
+ isExecutableFile = isPortableExecutable,
248
+ resolvePlatformPackage,
249
+ acceptBinary = () => true,
250
+ } = {}) {
251
+ if (platform !== 'win32') return { kind: 'direct', file, launchPath: file };
252
+ const ext = path.win32.extname(file).toLowerCase();
253
+ if (ext === '.exe' || ext === '.com') return { kind: 'direct', file, launchPath: file };
254
+ if (ext === '.ps1') return { kind: 'powershell', file, launchPath: file };
255
+ if (ext !== '.cmd' && ext !== '.bat') return { kind: 'unsupported', file, launchPath: null };
256
+
257
+ let rejectedBinary = false;
258
+ const acceptTarget = (target) => {
259
+ if (acceptBinary(target)) return true;
260
+ rejectedBinary = true;
261
+ return false;
262
+ };
263
+ if (toolKey === 'claude-code') {
264
+ for (const target of windowsClaudeTargets(file)) {
265
+ if (!exists(target) || !acceptTarget(target)) continue;
266
+ if (/\.exe$/i.test(target)) return { kind: 'direct', file, launchPath: target, via: 'npm-package' };
267
+ return { kind: 'node-script', file, launchPath: target, via: 'npm-package' };
268
+ }
269
+ } else if (toolKey === 'codex') {
270
+ const found = resolveTrustedWindowsCodexBinary({
271
+ launchers: [file],
272
+ codexPackageJsonPaths: windowsCodexPackageJsonCandidates({ appData: env.APPDATA, npmConfigPrefix: env.NPM_CONFIG_PREFIX || env.npm_config_prefix }),
273
+ arch,
274
+ exists,
275
+ acceptBinary: acceptTarget,
276
+ ...(resolvePlatformPackage ? { resolvePlatformPackage } : {}),
277
+ });
278
+ if (found.path) return { kind: 'direct', file, launchPath: found.path, via: found.source };
279
+ } else if (toolKey === 'opencode') {
280
+ // The package's bin\opencode.exe is a placeholder script until its postinstall ran: only a real program counts.
281
+ const target = windowsOpencodeTargets(file, arch).find(candidate => exists(candidate) && acceptTarget(candidate) && isExecutableFile(candidate));
282
+ if (target) return { kind: 'direct', file, launchPath: target, via: 'npm-package' };
283
+ }
284
+ // Do not fall back to executing a shim whose known target is SynaBun's copy.
285
+ if (rejectedBinary) return { kind: 'unsupported', file, launchPath: null, rejectedReason: 'inside-synabun' };
286
+ return { kind: 'cmd', file, launchPath: file };
287
+ }
288
+
289
+ // cmd.exe quoting, as cross-spawn does it: every metacharacter escaped with ^,
290
+ // the arguments of a command file twice (the file hands them on with %*).
291
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
292
+
293
+ export function cmdEscapeCommand(file) {
294
+ return String(file).replace(CMD_META, '^$1');
295
+ }
296
+
297
+ export function cmdEscapeArgument(value, doubleEscape = true) {
298
+ let arg = String(value);
299
+ arg = arg.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"');
300
+ arg = arg.replace(/(?=(\\+?)?)\1$/, '$1$1');
301
+ arg = `"${arg}"`.replace(CMD_META, '^$1');
302
+ return doubleEscape ? arg.replace(CMD_META, '^$1') : arg;
303
+ }
304
+
305
+ /** The program, its arguments and the spawn options that start `spec` with `args`, never through a shell of ours. */
306
+ export function spawnArgsFor(spec, args = [], { env = process.env, execPath = process.execPath } = {}) {
307
+ const list = args.map(String);
308
+ switch (spec?.kind) {
309
+ case 'node-script':
310
+ return { command: execPath, args: [spec.launchPath, ...list], options: {} };
311
+ case 'powershell': {
312
+ const root = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows';
313
+ const shell = path.win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
314
+ return { command: shell, args: ['-NoProfile', '-NonInteractive', '-File', spec.launchPath, ...list], options: {} };
315
+ }
316
+ case 'cmd': {
317
+ const root = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows';
318
+ const comspec = env.ComSpec || env.COMSPEC || path.win32.join(root, 'System32', 'cmd.exe');
319
+ const line = [cmdEscapeCommand(spec.launchPath), ...list.map(arg => cmdEscapeArgument(arg, true))].join(' ');
320
+ return { command: comspec, args: ['/d', '/s', '/c', `"${line}"`], options: { windowsVerbatimArguments: true } };
321
+ }
322
+ default:
323
+ return { command: spec?.launchPath || spec?.file, args: list, options: {} };
324
+ }
325
+ }
326
+
327
+ // ── --version ───────────────────────────────────────────────────────────────
328
+
329
+ /** "2.1.302 (Claude Code)" → "2.1.302"; "codex-cli 0.163.0" → "0.163.0"; a prerelease suffix is kept. */
330
+ export function parseCliVersion(text) {
331
+ const match = String(text ?? '').match(/(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?/);
332
+ return match ? `${match[1]}.${match[2]}.${match[3]}${match[4] || ''}` : null;
333
+ }
334
+
335
+ function firstLines(text, count = 3) {
336
+ return String(text || '').split(/\r?\n/).map(line => line.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').trim()).filter(Boolean).slice(0, count).join(' · ').slice(0, 400);
337
+ }
338
+
339
+ /**
340
+ * Run `<spec> --version` and read the answer: { ok, version, output, error }.
341
+ * `error` is a sentence about what went wrong, with what the CLI printed. The
342
+ * CLI runs in a process group of its own (POSIX); its whole group/tree is stopped at
343
+ * the timeout: a CLI that hangs, or leaves a child holding its output open,
344
+ * never holds a check up longer than `timeoutMs`.
345
+ */
346
+ export function probeVersion(spec, {
347
+ env = process.env,
348
+ timeoutMs = PROBE_TIMEOUT_MS,
349
+ spawnImpl = spawn,
350
+ execPath = process.execPath,
351
+ cwd = os.tmpdir(),
352
+ platform = process.platform,
353
+ } = {}) {
354
+ return new Promise((resolve) => {
355
+ if (!spec || spec.kind === 'unsupported') {
356
+ resolve({ ok: false, version: null, output: '', error: 'Windows cannot start this file: it has no program extension (.exe, .cmd).' });
357
+ return;
358
+ }
359
+ const { command, args, options } = spawnArgsFor(spec, ['--version'], { env, execPath });
360
+ const childEnv = { ...withPathEnv(env, pathEnvValue(env), platform), NO_COLOR: '1', TERM: 'dumb' };
361
+ delete childEnv.FORCE_COLOR;
362
+ let child;
363
+ let out = '';
364
+ let err = '';
365
+ let settled = false;
366
+ let timedOut = false;
367
+ let stopped = false;
368
+ const finish = (result) => {
369
+ if (settled) return;
370
+ settled = true;
371
+ clearTimeout(timer);
372
+ resolve(result);
373
+ };
374
+ const stop = () => {
375
+ if (stopped || !child?.pid) return;
376
+ stopped = true;
377
+ const killChild = () => { try { child.kill('SIGKILL'); } catch { /* gone */ } };
378
+ if (platform === 'win32') {
379
+ // Keep cmd.exe alive until taskkill has enumerated the descendants.
380
+ try {
381
+ const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
382
+ killer.on('error', killChild);
383
+ killer.on('close', (code) => { if (code !== 0) killChild(); });
384
+ } catch { killChild(); }
385
+ return;
386
+ }
387
+ if (platform !== 'win32') { try { process.kill(-child.pid, 'SIGKILL'); return; } catch { /* not a group */ } }
388
+ killChild();
389
+ };
390
+ const timer = setTimeout(() => {
391
+ timedOut = true;
392
+ stop();
393
+ const said = firstLines(err) || firstLines(out);
394
+ const message = `did not answer --version within ${Math.max(1, Math.round(timeoutMs / 1000))} s`;
395
+ finish({ ok: false, version: parseCliVersion(out), output: said, error: said ? `${message}: ${said}` : message });
396
+ }, timeoutMs);
397
+ timer.unref?.();
398
+ try {
399
+ child = spawnImpl(command, args, {
400
+ ...options,
401
+ cwd,
402
+ env: childEnv,
403
+ stdio: ['ignore', 'pipe', 'pipe'],
404
+ windowsHide: true,
405
+ detached: platform !== 'win32',
406
+ });
407
+ } catch (error) {
408
+ finish({ ok: false, version: null, output: '', error: `could not be started (${error?.code || error?.message || error})` });
409
+ return;
410
+ }
411
+ const cap = (text, chunk) => (text.length > 64 * 1024 ? text : text + chunk);
412
+ child.stdout?.setEncoding?.('utf8');
413
+ child.stderr?.setEncoding?.('utf8');
414
+ child.stdout?.on('data', (chunk) => { out = cap(out, chunk); });
415
+ child.stderr?.on('data', (chunk) => { err = cap(err, chunk); });
416
+ child.on('error', (error) => {
417
+ const code = error?.code || 'error';
418
+ const message = String(error?.message || '');
419
+ finish({ ok: false, version: null, output: '', error: `could not be started (${message && message !== code ? `${code}: ${message}` : code})` });
420
+ });
421
+ child.on('exit', (code, signal) => {
422
+ // What it printed may still be on its way: read it when the streams end (or soon after: a child of
423
+ // the CLI may hold them open, and is then stopped with the group).
424
+ let fallback = null;
425
+ let read = false;
426
+ const done = () => {
427
+ if (timedOut || read) return;
428
+ read = true;
429
+ clearTimeout(fallback);
430
+ if (code === 0) {
431
+ const version = parseCliVersion(out) || parseCliVersion(err) || firstLines(out, 1).slice(0, 40) || 'unknown';
432
+ finish({ ok: true, version, output: firstLines(out || err), error: null });
433
+ return;
434
+ }
435
+ const message = signal
436
+ ? `was stopped by ${signal} as it started${signal === 'SIGKILL' ? ' (on macOS: Gatekeeper or a code signature refused it)' : ''}`
437
+ : `exited with code ${code}`;
438
+ const said = firstLines(err) || firstLines(out);
439
+ finish({ ok: false, version: parseCliVersion(out), output: said, error: said ? `${message}: ${said}` : message });
440
+ };
441
+ let pending = 2;
442
+ const one = () => { if (--pending === 0) done(); };
443
+ fallback = setTimeout(() => { stop(); done(); }, 300);
444
+ fallback.unref?.();
445
+ if (child.stdout && !child.stdout.readableEnded) child.stdout.once('end', one); else one();
446
+ if (child.stderr && !child.stderr.readableEnded) child.stderr.once('end', one); else one();
447
+ });
448
+ });
449
+ }
450
+
451
+ // ── The resolver ────────────────────────────────────────────────────────────
452
+
453
+ function defaultIsLaunchable(file, platform) {
454
+ try {
455
+ if (!statSync(file).isFile()) return false;
456
+ if (platform !== 'win32') accessSync(file, constants.X_OK);
457
+ return true;
458
+ } catch {
459
+ return false;
460
+ }
461
+ }
462
+
463
+ /** Why a configured file cannot be a candidate, or null when it can. */
464
+ function configuredFileProblem(file, platform, { stat, access }) {
465
+ let info;
466
+ try { info = stat(file); } catch { return 'not found'; }
467
+ if (typeof info?.isFile === 'function' && !info.isFile()) return 'not a file';
468
+ if (platform !== 'win32') {
469
+ try { access(file, constants.X_OK); } catch { return 'not executable by this user'; }
470
+ }
471
+ return null;
472
+ }
473
+
474
+ function fileIdentity(file, { realpath, stat }) {
475
+ try {
476
+ const real = realpath(file);
477
+ const info = stat(real);
478
+ return `${real}|${info.mtimeMs}|${info.size}`;
479
+ } catch {
480
+ return String(file);
481
+ }
482
+ }
483
+
484
+ /**
485
+ * @param {object} deps
486
+ * @param {(file: string) => boolean} [deps.isInsideSynabun] SynaBun's own install/runtime folders
487
+ * @param {(toolKey: string) => (string|null)} [deps.configured] the Settings value (cli-config.json)
488
+ * @param {object} [deps.osPath] os-path.js reader ({ cached, refresh, stale, snapshot })
489
+ */
490
+ export function createCliResolver({
491
+ platform = process.platform,
492
+ arch = process.arch,
493
+ env = process.env,
494
+ home = homeOf(env, platform),
495
+ isInsideSynabun = () => false,
496
+ configured = () => null,
497
+ osPath = defaultOsPath,
498
+ probe = probeVersion,
499
+ exists = existsSync,
500
+ isLaunchable = defaultIsLaunchable,
501
+ readdir = readdirSync,
502
+ readFile = readFileSync,
503
+ realpath = realpathSync,
504
+ stat = statSync,
505
+ access = accessSync,
506
+ resolvePlatformPackage,
507
+ isExecutableFile,
508
+ now = Date.now,
509
+ probeTimeoutMs = PROBE_TIMEOUT_MS,
510
+ maxProbes = MAX_PROBES,
511
+ log = () => {},
512
+ } = {}) {
513
+ const pathImpl = pathFor(platform);
514
+ const windows = platform === 'win32';
515
+ const versions = new Map(); // launch identity → { at, result }
516
+ const launchMemo = new Map(); // toolKey → { at, value }
517
+ const lastStatus = new Map(); // toolKey → status
518
+ const background = new Map(); // toolKey → promise
519
+ const pathOnly = () => env.SYNABUN_TOOL_DISCOVERY === 'path';
520
+
521
+ const searchDirs = (osEntries) => {
522
+ const delimiter = windows ? ';' : ':';
523
+ const inherited = String(pathEnvValue(env)).split(delimiter);
524
+ const list = pathOnly() ? inherited : [...inherited, ...osEntries];
525
+ const seen = new Set();
526
+ return list.filter((dir) => {
527
+ const text = String(dir || '').trim();
528
+ if (!text || isPackageBinDir(text)) return false;
529
+ const key = windows ? text.toLowerCase().replace(/[\\/]+$/, '') : text.replace(/\/+$/, '');
530
+ if (seen.has(key)) return false;
531
+ seen.add(key);
532
+ return true;
533
+ });
534
+ };
535
+
536
+ const namesOf = (command) => (windows ? windowsCommandNames(command, env.PATHEXT) : [command]);
537
+
538
+ /** Every candidate for a tool, in resolution order, each file once (by what it really is). */
539
+ function collect(toolKey, { osEntries = osPath.cached(), configuredValue } = {}) {
540
+ const tool = CLI_TOOLS[toolKey];
541
+ const out = [];
542
+ const rejected = [];
543
+ const seen = new Set();
544
+ const settings = describeConfigured(configuredValue === undefined ? safeConfigured(toolKey) : configuredValue, toolKey, { platform, env, home, exists });
545
+ const consider = (file, foundBy, via) => {
546
+ if (!file) return;
547
+ const normal = pathImpl.normalize(file);
548
+ if (!isLaunchable(normal, platform)) return;
549
+ let id;
550
+ try { id = realpath(normal); } catch { id = normal; }
551
+ if (windows) id = id.toLowerCase();
552
+ if (seen.has(id)) return;
553
+ seen.add(id);
554
+ if (windows && isWindowsAppsDir(normal)) { rejected.push({ path: normal, reason: 'windows-app-alias' }); return; }
555
+ if (isInsideSynabun(normal)) { rejected.push({ path: normal, reason: 'inside-synabun' }); return; }
556
+ out.push({ file: normal, foundBy, via });
557
+ };
558
+ const dirs = searchDirs(osEntries);
559
+
560
+ if (settings?.kind === 'path') consider(settings.file, 'configured', 'settings');
561
+ else if (settings?.kind === 'command') {
562
+ for (const dir of dirs) for (const name of namesOf(settings.name)) consider(pathImpl.join(dir, name), 'configured', 'settings');
563
+ if (!pathOnly()) for (const { dir } of knownInstallDirs({ platform, env, home, readdir, readFile })) for (const name of namesOf(settings.name)) consider(pathImpl.join(dir, name), 'configured', 'settings');
564
+ }
565
+ for (const dir of dirs) for (const name of namesOf(tool.command)) consider(pathImpl.join(dir, name), 'path', 'path');
566
+ if (!pathOnly()) {
567
+ for (const { file, via } of toolSpecificFiles(toolKey, { platform, env, home, readdir })) consider(file, 'known-location', via);
568
+ for (const { dir, via } of knownInstallDirs({ platform, env, home, readdir, readFile })) for (const name of namesOf(tool.command)) consider(pathImpl.join(dir, name), 'known-location', via);
569
+ }
570
+ return { candidates: out, rejected, settings };
571
+ }
572
+
573
+ function safeConfigured(toolKey) {
574
+ try { return configured(toolKey) ?? null; } catch { return null; }
575
+ }
576
+
577
+ const specOf = (candidate, toolKey) => launchSpecFor(candidate.file, toolKey, { platform, arch, env, exists, acceptBinary: (file) => !isInsideSynabun(file), ...(resolvePlatformPackage ? { resolvePlatformPackage } : {}), ...(isExecutableFile ? { isExecutableFile } : {}) });
578
+
579
+ const versionKey = (spec) => `${spec.kind}|${fileIdentity(spec.launchPath || spec.file, { realpath, stat })}|${spec.kind === 'direct' ? '' : fileIdentity(spec.file, { realpath, stat })}`;
580
+
581
+ async function probeCached(spec, { force = false } = {}) {
582
+ const key = versionKey(spec);
583
+ const hit = versions.get(key);
584
+ const age = hit ? now() - hit.at : Infinity;
585
+ if (!force && hit && (hit.result.ok || age < FAILURE_REPROBE_MS)) return hit.result;
586
+ const result = await probe(spec, { env: withPathEnv(env, getAugmentedPath({ platform, env, osPath: osPath.cached() }), platform), timeoutMs: probeTimeoutMs });
587
+ versions.set(key, { at: now(), result });
588
+ return result;
589
+ }
590
+
591
+ function realOf(file) {
592
+ try { return realpath(file); } catch { return file; }
593
+ }
594
+
595
+ function configuredStatus(settings, winner, tried) {
596
+ if (!settings) return null;
597
+ if (settings.kind === 'command-line') {
598
+ return { value: settings.value, ok: null, error: 'a command line: terminal tabs run it as written, everything else looks for the CLI elsewhere' };
599
+ }
600
+ if (winner?.foundBy === 'configured') return { value: settings.value, ok: true, error: null };
601
+ if (settings.kind === 'path') {
602
+ const problem = configuredFileProblem(settings.file, platform, { stat, access });
603
+ if (problem) return { value: settings.value, ok: false, error: problem };
604
+ if (isInsideSynabun(settings.file)) return { value: settings.value, ok: false, error: 'inside SynaBun, not an installation of the CLI' };
605
+ const own = tried.find(item => item.foundBy === 'configured');
606
+ return { value: settings.value, ok: false, error: own?.error || 'did not answer --version' };
607
+ }
608
+ const own = tried.find(item => item.foundBy === 'configured');
609
+ return { value: settings.value, ok: false, error: own?.error || 'not found on PATH or in the known install folders' };
610
+ }
611
+
612
+ function statusOf(toolKey, state, { candidate = null, spec = null, result = null, settings = null, rejected = [], tried = [] } = {}) {
613
+ const realPath = candidate ? realOf(candidate.file) : null;
614
+ const snapshot = typeof osPath.snapshot === 'function' ? osPath.snapshot() : null;
615
+ return {
616
+ tool: toolKey,
617
+ label: CLI_TOOLS[toolKey]?.label || toolKey,
618
+ state,
619
+ version: result?.version || null,
620
+ path: candidate?.file || null,
621
+ launchPath: spec?.launchPath || null,
622
+ launchKind: spec?.kind || null,
623
+ realPath,
624
+ foundBy: candidate?.foundBy || null,
625
+ via: candidate?.via || null,
626
+ installSource: candidate ? detectInstallSourceOf({ path: candidate.file, realPath }) : 'unknown',
627
+ error: state === 'broken' ? (result?.error || 'did not answer --version') : null,
628
+ configured: configuredStatus(settings, state === 'installed' ? candidate : null, tried),
629
+ candidates: tried.slice(0, 8),
630
+ rejected: rejected.slice(0, 5),
631
+ osPath: snapshot ? { source: snapshot.source, ok: snapshot.ok, error: snapshot.error } : null,
632
+ checkedAt: new Date(now()).toISOString(),
633
+ };
634
+ }
635
+
636
+ /** The full answer for one tool: every candidate in order, probed until one answers. */
637
+ async function resolve(toolKey, { force = false, configured: configuredValue, freshOsPath = false } = {}) {
638
+ if (!CLI_TOOLS[toolKey]) return statusOf(toolKey, 'missing');
639
+ if (force) {
640
+ if (!freshOsPath) { try { await osPath.refresh({ force: true }); } catch { /* the old answer stays */ } }
641
+ forgetAugmentedPath();
642
+ launchMemo.delete(toolKey);
643
+ } else if (typeof osPath.stale === 'function' && osPath.stale()) {
644
+ try { await osPath.refresh(); } catch { /* the old answer stays */ }
645
+ }
646
+ const { candidates, rejected, settings } = collect(toolKey, { osEntries: osPath.cached(), configuredValue });
647
+ const tried = [];
648
+ let failure = null;
649
+ for (const candidate of candidates) {
650
+ if (tried.length >= maxProbes) break;
651
+ const spec = specOf(candidate, toolKey);
652
+ if (spec.rejectedReason) {
653
+ rejected.push({ path: candidate.file, reason: spec.rejectedReason });
654
+ continue;
655
+ }
656
+ const result = await probeCached(spec, { force });
657
+ tried.push({ path: candidate.file, foundBy: candidate.foundBy, ok: result.ok, version: result.version || null, error: result.error || null });
658
+ if (result.ok) {
659
+ const status = statusOf(toolKey, 'installed', { candidate, spec, result, settings, rejected, tried });
660
+ if (configuredValue === undefined) { lastStatus.set(toolKey, status); launchMemo.delete(toolKey); }
661
+ return status;
662
+ }
663
+ if (!failure) failure = { candidate, spec, result };
664
+ }
665
+ const status = failure
666
+ ? statusOf(toolKey, 'broken', { ...failure, settings, rejected, tried })
667
+ : statusOf(toolKey, 'missing', { settings, rejected, tried });
668
+ if (configuredValue === undefined) { lastStatus.set(toolKey, status); launchMemo.delete(toolKey); }
669
+ return status;
670
+ }
671
+
672
+ async function resolveAll(keys = Object.keys(CLI_TOOLS), { force = false } = {}) {
673
+ if (force) {
674
+ try { await osPath.refresh({ force: true }); } catch { /* the old answer stays */ }
675
+ forgetAugmentedPath();
676
+ launchMemo.clear();
677
+ }
678
+ const entries = await Promise.all(keys.map(async key => [key, await resolve(key, { force, freshOsPath: force })]));
679
+ return Object.fromEntries(entries);
680
+ }
681
+
682
+ function inBackground(toolKey) {
683
+ if (background.has(toolKey)) return;
684
+ const run = resolve(toolKey).catch(() => null).finally(() => background.delete(toolKey));
685
+ background.set(toolKey, run);
686
+ }
687
+
688
+ /**
689
+ * What a launcher starts, at once (no process is started here): the same
690
+ * candidates in the same order, skipping any whose --version is known to
691
+ * fail. A candidate not probed yet is taken as it is, and the full check
692
+ * runs in the background so that the next launch knows. All known to fail:
693
+ * the first is answered as `broken`, so the launch fails with its own error
694
+ * rather than "not installed".
695
+ */
696
+ function launcher(toolKey) {
697
+ const osKey = osPath.cached().join('\0');
698
+ const memo = launchMemo.get(toolKey);
699
+ if (memo && memo.osKey === osKey && now() - memo.at < LAUNCH_MEMO_MS) return memo.value;
700
+ let value = { tool: toolKey, path: null, launchPath: null, spec: null, foundBy: null, version: null, state: 'missing' };
701
+ if (CLI_TOOLS[toolKey]) {
702
+ const { candidates } = collect(toolKey);
703
+ let firstFailed = null;
704
+ let unprobed = false;
705
+ for (const candidate of candidates) {
706
+ const spec = specOf(candidate, toolKey);
707
+ if (spec.kind === 'unsupported') continue;
708
+ const hit = versions.get(versionKey(spec));
709
+ if (hit && !hit.result.ok && now() - hit.at < FAILURE_KEEP_MS) {
710
+ if (!firstFailed) firstFailed = { candidate, spec, hit };
711
+ continue;
712
+ }
713
+ if (!hit) unprobed = true;
714
+ value = { tool: toolKey, path: candidate.file, launchPath: spec.launchPath, spec, foundBy: candidate.foundBy, version: hit?.result?.version || null, state: hit?.result?.ok ? 'installed' : 'unverified' };
715
+ break;
716
+ }
717
+ if (!value.path && firstFailed) {
718
+ value = { tool: toolKey, path: firstFailed.candidate.file, launchPath: firstFailed.spec.launchPath, spec: firstFailed.spec, foundBy: firstFailed.candidate.foundBy, version: firstFailed.hit.result.version || null, state: 'broken', error: firstFailed.hit.result.error };
719
+ }
720
+ if (unprobed) inBackground(toolKey);
721
+ }
722
+ launchMemo.set(toolKey, { at: now(), osKey, value });
723
+ return value;
724
+ }
725
+
726
+ /** Forget what was found: the next answer reads the OS PATH and probes again. */
727
+ function invalidate() {
728
+ versions.clear();
729
+ launchMemo.clear();
730
+ lastStatus.clear();
731
+ forgetAugmentedPath();
732
+ try { osPath.invalidate?.(); } catch { /* nothing to forget */ }
733
+ }
734
+
735
+ return {
736
+ resolve,
737
+ resolveAll,
738
+ launcher,
739
+ invalidate,
740
+ last: (toolKey) => lastStatus.get(toolKey) || null,
741
+ candidates: (toolKey, options) => collect(toolKey, options),
742
+ spawnArgs: (spec, args) => spawnArgsFor(spec, args, { env }),
743
+ };
744
+ }