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
@@ -9,6 +9,12 @@ import { createSessionListOverlay, overlaySessions, matchesSessionFilter } from
9
9
  import { findSessionTranscript, sessionProjects, confineArtifact, confineSubPath, confineToRoot, staysInside, isInsideReal, isInside, isPlainName, PathConfineError } from './lib/path-confine.js';
10
10
  import { readConfinedFile, writeConfinedFile, makeConfinedDir, removeConfinedEntry, listConfined, openConfinedStream } from './lib/confined-fs.js';
11
11
  import { createClaudeAccounts as createPanelClaudeAccounts } from './lib/claude-accounts.js';
12
+ import { createClaudePanelAuth } from './lib/claude-panel-auth.js';
13
+ import { createCodexPanelAuth } from './lib/codex-panel-auth.js';
14
+ import { createCodexAccounts, applyCodexLaunchEnv, codexAppServerArgs, codexExecEnv } from './lib/codex-accounts.js';
15
+ import { createCodexAccountAuth } from './lib/codex-account-auth.js';
16
+ import { registerProviderPanelApiKeyAuthRoutes, redactProviderSecrets, providerApiKeyBodyError, providerDiagnosticSink } from './lib/provider-panel-api-key-auth.js';
17
+ import { createProviderPanelTerminalAuth, providerLoginSpec, registerProviderPanelTerminalAuthRoutes } from './lib/provider-panel-terminal-auth.js';
12
18
  import { claudeLegacyPricing, claudeUsageCostUsd } from './lib/assistant-pricing.js';
13
19
  import { HOOK_SCRIPTS, hookDefinitionsForEvent, hookCommandString, hookInstallBlocker, hookInstallPlan, isSynaBunHookCommand, sweepSettingsHooks, ensureHookRoot } from '../lib/claude-hooks.js';
14
20
  import { browserV2Enabled, browserRequestMiddleware, assertBrowserRequestActive, browserActionTimeout, markBrowserActionStarted, runBrowserRequest, cancellableBrowserDelay, measureBrowserPhase, createEngagementScheduler, engagementTargetSelector, isEngagementWriteSelector, isXShortcutPublish, isXScriptedWrite } from './lib/browser-execution.js';
@@ -34,8 +40,9 @@ import { createPtyHostManager } from './lib/pty-host/manager.js';
34
40
  import { hostNameOf, isAllowedHost, isAllowedWebSocketOrigin, parseCookies, refuseUpgrade } from './lib/http-guards.js';
35
41
  import { createRequestGates, extraHostNames, hostsOf, savedValueOf, startedValueOf } from './lib/request-gate.js';
36
42
  import { createGitDiffRouter } from './lib/git-diff-api.js';
37
- import { gitRead, gitReadSync } from './lib/git-read.js';
43
+ import { gitRead, gitReadSync, setGitFallbackFolder, withGitFallback } from './lib/git-read.js';
38
44
  import { createPhoneAuthority } from './lib/remote-policy.js';
45
+ import { brainCredentialOf } from './lib/virtual-socket.js';
39
46
  import { buildTerminalEnv, interactiveShellArgs, cliWrapperArgs } from './lib/terminal-env.js';
40
47
  import { startJudgeBackfill, stopJudgeBackfill } from '../mcp-server/dist/services/memory-judge-backfill.js';
41
48
  import { judgeLoopGoal } from '../mcp-server/dist/services/memory-judgments.js';
@@ -106,12 +113,18 @@ import { loopLog, loopLogPath, flushLoopLogs } from './lib/loop-logger.js';
106
113
  import {
107
114
  buildInstallCommand,
108
115
  buildUpdateCommand,
109
- detectInstallSource,
110
116
  isBrewSource,
111
117
  parseBrewOutdated,
118
+ quoteCommandPath,
112
119
  resolveBrewToken,
113
120
  resolveLatest,
114
121
  } from './lib/cli-update-planner.js';
122
+ import { createCliResolver, spawnArgsFor } from './lib/cli-resolver.js';
123
+ import { osPath } from './lib/os-path.js';
124
+ import { createCliInstaller, CLI_INSTALL_GUIDES } from './lib/cli-install.js';
125
+ import { createCliInstallApi, createCliPathApi, createGitBashApi } from './lib/cli-install-api.js';
126
+ import { createCliPathManager } from './lib/cli-path.js';
127
+ import { composeTerminalPath, terminalLaunchCommand, terminalArgument } from './lib/terminal-path.js';
115
128
  import {
116
129
  applyBackupRetention,
117
130
  createVerifiedBackup,
@@ -145,11 +158,6 @@ import {
145
158
  createCodexNativeLoopAdapter,
146
159
  createOpenCodeNativeLoopAdapter,
147
160
  } from './lib/native-loop-providers.js';
148
- import {
149
- resolveTrustedWindowsCodexBinary,
150
- selectWindowsCodexLauncher,
151
- windowsCodexPackageJsonCandidates,
152
- } from './lib/codex-runtime-path.js';
153
161
  import {
154
162
  mergeCodexModelContextCapabilities,
155
163
  resolveCodexExtendedContext,
@@ -233,10 +241,14 @@ import {
233
241
  onClaudeModelsChanged,
234
242
  shellCommand,
235
243
  } from './lib/claude-model-catalog.js';
236
- import { resolveClaudeLauncher, resolveClaudeSdkExecutable } from './lib/claude-executable.js';
237
- import { getAugmentedPath } from './lib/augmented-path.js';
244
+ import { resolveClaudeSdkExecutable } from './lib/claude-executable.js';
245
+ import { collectInstallDetails } from './lib/install-details.js';
246
+ import { withPathEnv, pathEnvValue, getAugmentedPath, rememberedOsPath, rememberPackagedPath, rememberedPackagedPath, withPackagedPathLast } from './lib/augmented-path.js';
247
+ import { readCodexSettingsSchema, readCodexFeatureSupport, validateCodexSettings } from './lib/codex-settings-schema.js';
238
248
  import { semverNewer, synabunReleaseChannel } from './lib/synabun-version.js';
239
249
  import { selectSynabunUpdate, buildSynabunInstallPlan, refuseStaleUpdateClick } from './lib/synabun-update-plan.js';
250
+ import { collectActivity, countWhere, recentCliSessions } from './lib/system-activity.js';
251
+ import { createSystemAbout } from './lib/system-about.js';
240
252
  import {
241
253
  listCodexSessionsFromAccounts,
242
254
  mergeCodexSessionSummaries,
@@ -264,7 +276,10 @@ import { ensureProjectCategories } from '../hooks/claude-code/shared.mjs';
264
276
  import { getDataHome, getDataHomeDiagnostics, isGlobalInstall, pathIsInside } from '../lib/paths.js';
265
277
  import { launcherLogPath } from '../lib/first-launch-protection.js';
266
278
  import { registerStartLauncher, startLauncherState } from '../lib/start-launcher.js';
267
- import { durableEntry, mcpStdioCommand, packagedRuntime } from '../lib/packaged-runtime.js';
279
+ import { effectiveNpmPrefix } from '../lib/child-path.js';
280
+ import { redactDeep, redactSecrets } from '../lib/log-redact.js';
281
+ import { applyPackagedChildPath, durableEntry, mcpStdioCommand, npmGlobalBinFor, packagedNpmPrefix, packagedPathFolders, packagedRuntime } from '../lib/packaged-runtime.js';
282
+ import { dataHomeId } from '../lib/desktop-app.js';
268
283
  import { pruneExternalTools } from '../lib/external-tools.js';
269
284
  import { renderOfflinePage } from './lib/offline-page.js';
270
285
  import {
@@ -346,6 +361,11 @@ let _greetingError = null;
346
361
  let _assistantDispatcher = null;
347
362
  // The Assistant's model catalog (lib/assistant-catalog.js): Settings → WhatsApp checks its brain choice against it.
348
363
  let _assistantCatalog = null;
364
+ // The Claude account the Assistant's Claude Code workers use when a dispatch names none (assistant-config.json
365
+ // `workers.claudeAccount`); a function once the Assistant is wired. An account it names cannot be removed.
366
+ let _assistantClaudeWorkerAccount = null;
367
+ // The same for Codex workers (`workers.codexAccount`).
368
+ let _assistantCodexWorkerAccount = null;
349
369
  // Who may ask, in front of every route (lib/request-gate.js). A request for the owner's data must name this
350
370
  // server: everything under /api and /mcp, the invite sign-in, the rendered offline page and the uploaded icons
351
371
  // answer 403 BAD_HOST to any other Host (a page whose name was re-pointed at this machine sends its own); the
@@ -371,6 +391,7 @@ app.use(express.json({ limit: '25mb' }));
371
391
  // exceed the JSON limit. Default Express handler dumps a 10-line raw-body stack with no
372
392
  // URL — this logs one line with context and responds 413 instead.
373
393
  app.use((err, req, res, next) => {
394
+ if (/^\/api\/provider-panel-auth\/[^/]+\/[^/]+\/api-key(?:\?|$)/.test(req.originalUrl || req.url || '')) return providerApiKeyBodyError(err, req, res, next);
374
395
  if (err && (err.type === 'entity.too.large' || err.type === 'entity.parse.failed')) {
375
396
  const len = req.headers['content-length'] || '?';
376
397
  console.warn(`[body-parser] ${err.type} ${req.method} ${req.originalUrl || req.url} content-length=${len} limit=${err.limit || '?'}`);
@@ -435,6 +456,8 @@ import { createEndpointCompletionEngine } from './lib/editor-completion-endpoint
435
456
  import { ownerRouteGuard } from './lib/http-guards.js';
436
457
  import { createCloudflaredManager } from './lib/cloudflared/manager.js';
437
458
  import { createQuickTunnel } from './lib/cloudflared/tunnel.js';
459
+ import { createGitBashManager } from './lib/git-bash/manager.js';
460
+ import { settingsGitBashValue, syncClaudeSettingsGitBash } from './lib/git-bash/claude-settings.js';
438
461
 
439
462
  const PORT = process.env.NEURAL_PORT || 3344;
440
463
  // SQLite database is used directly via lib/db.js
@@ -754,6 +777,26 @@ const INSTALL_KIND = PACKAGED_RUNTIME ? 'app' : isGlobalInstall() ? 'npm' : 'git
754
777
  // What a person types to start a packaged application: its entry executable,
755
778
  // when that is a path that will still be there once this run is over.
756
779
  const START_ENTRY = durableEntry(PACKAGED_RUNTIME) || '';
780
+ // What the desktop app's window asks /api/health (lib/desktop-app.js): the version this server runs and which
781
+ // data home it uses, as a hash and never the path, so it can tell its own server from another installation's.
782
+ const SERVER_VERSION = (() => { try { return String(JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf-8')).version || ''); } catch { return ''; } })();
783
+ const DATA_HOME_ID = dataHomeId(DATA_HOME);
784
+ // The entry this install runs from, even when it does not outlive the run (START_ENTRY is then empty): how the
785
+ // desktop app tells its own server from another install's (lib/desktop-app.js classifyServer).
786
+ const INSTALL_ENTRY = PACKAGED_RUNTIME?.entry || '';
787
+ // A packaged application's own Node runtime, and the global bin of the npm that came with it (where `npm i -g`
788
+ // through it puts commands: the prefix as npm settles it from npm_config_prefix, the user's config file and the
789
+ // global one, else SynaBun's per-user folder its builtin npmrc names, never the application: lib/child-path.js,
790
+ // lib/packaged-runtime.js packagedNpmPrefix). On the PATH of everything this server starts, after everything of
791
+ // the user's: the user's own Node and npm stay first, these are the fallback on a machine without. The bin is asked
792
+ // for each PATH (cached for seconds), so a prefix set later counts without a restart. This process's own environment
793
+ // gets the same order, for every child that simply inherits it (an npm install, git, a CLI); the server's own
794
+ // Node children are started by process.execPath, never through PATH.
795
+ rememberPackagedPath({
796
+ last: packagedPathFolders(PACKAGED_RUNTIME).last,
797
+ npmBin: PACKAGED_RUNTIME ? () => packagedNpmPrefix(PACKAGED_RUNTIME)?.bin || null : null,
798
+ });
799
+ applyPackagedChildPath(process.env, PACKAGED_RUNTIME);
757
800
 
758
801
  // Serve offline.html with what it needs once we are down: the install path,
759
802
  // how it was installed, the launcher's state and the bridge code itself.
@@ -2142,6 +2185,10 @@ async function runGracefulShutdown(reason) {
2142
2185
  // The public address: its cloudflared is ended with the server that owns the address (killed when it does not
2143
2186
  // end by itself; never waited for more than a moment).
2144
2187
  try { await tunnel.shutdown({ timeoutMs: 1500 }); } catch (err) { console.warn(' tunnel stop err:', err?.message); }
2188
+ // A CLI install under way (lib/cli-install.js): its installer and what it started are stopped.
2189
+ try { cliInstaller.stopAll(); } catch (err) { console.warn(' cli install stop err:', err?.message); }
2190
+ // A Git for Windows download under way (lib/git-bash/): stopped; its work folder goes with the next attempt.
2191
+ try { gitBash.cancel(); } catch (err) { console.warn(' git bash stop err:', err?.message); }
2145
2192
 
2146
2193
  // 1. opencode sidecar — has its own teardown that handles managed PID + ollama.
2147
2194
  try { stopOpencodeServer(); } catch (err) { console.warn(' opencode stop err:', err?.message); }
@@ -2462,6 +2509,13 @@ app.post('/api/system/run-update', async (req, res) => {
2462
2509
  // Nothing is ever installed into the resources of a packaged application.
2463
2510
  if (PACKAGED_RUNTIME) {
2464
2511
  const openUrl = `${SYNABUN_GITHUB_REPO_URL}/releases`;
2512
+ // The desktop app updates itself, through its window's update function (packaging/desktop).
2513
+ if (PACKAGED_RUNTIME.shell) {
2514
+ return res.status(409).json({
2515
+ error: 'This copy of SynaBun is the desktop app, and it updates itself: use Check for updates in its menu. Nothing was installed.',
2516
+ packaged: true, desktopManaged: true, openUrl,
2517
+ });
2518
+ }
2465
2519
  return res.status(409).json({
2466
2520
  error: `This copy of SynaBun is a packaged application. It is updated by installing a newer build over it, not from inside the app. Nothing was installed. Releases: ${openUrl}`,
2467
2521
  packaged: true,
@@ -3247,36 +3301,88 @@ function _heartbeatLock(sessionId, windowId) {
3247
3301
  }
3248
3302
  }
3249
3303
 
3250
- // Cache resolved claude binary path (avoids a PATH walk on every query)
3251
- let _claudeBinPath = null;
3252
- // A tool that was not found is looked for again after a moment: installing it
3253
- // is what a user does next, and that must not take a restart of SynaBun.
3254
- const MISSING_TOOL_RECHECK_MS = 15_000;
3255
- let _claudeBinMissingAt = 0;
3304
+ // ── Where the user's CLIs are (lib/cli-resolver.js) ──
3305
+ // One resolver for every lookup and launch of Claude Code, Codex and OpenCode:
3306
+ // the version check behind the side panels' banner and the update badges,
3307
+ // Settings' Detect, the one-click install (lib/cli-install.js) and the
3308
+ // launchers below. The path in Settings > Terminal first, then PATH with the OS
3309
+ // PATH read afresh (lib/os-path.js: the registry on Windows, the login shell on
3310
+ // macOS and Linux), then the folders each install method uses; never a copy
3311
+ // inside SynaBun. A CLI installed while SynaBun runs is found by the next
3312
+ // lookup: nothing here is remembered for more than a few seconds, and Re-check
3313
+ // forgets everything.
3314
+ const cliResolver = createCliResolver({
3315
+ isInsideSynabun: (file) => isInsideSynabun(file),
3316
+ configured: (toolKey) => loadCliConfig()[toolKey]?.command ?? null,
3317
+ });
3318
+ // The first read of the OS PATH starts now, so the first lookups already have it.
3319
+ osPath.refresh().catch(() => {});
3320
+
3321
+ // ── Git Bash for Claude Code on Windows (lib/git-bash/) ──
3322
+ // Claude Code on Windows runs its Bash tool and every hook through Git Bash and
3323
+ // falls back to PowerShell without one, where SynaBun's own hooks of an
3324
+ // installed app do not even parse. Which Git Bash a Claude Code started by
3325
+ // SynaBun gets: CLAUDE_CODE_GIT_BASH_PATH (this environment, or the user's
3326
+ // settings), a Git for Windows of the PC's own, then SynaBun's copy
3327
+ // (<resources>\git in the installer; DATA_HOME/runtime/git/<version>, fetched
3328
+ // once, on an npm install). claudeChildEnv() is the last word on the
3329
+ // environment of every Claude Code process SynaBun starts. Nothing changes on
3330
+ // macOS and Linux, or where the PC has a Git Bash Claude Code finds by itself;
3331
+ // the system's PATH and registry are never changed.
3332
+ const gitBash = createGitBashManager({
3333
+ dataHome: DATA_HOME,
3334
+ pathValue: () => [pathEnvValue(process.env), ...rememberedOsPath()].join(';'),
3335
+ settingsValue: () => settingsGitBashValue({ settingsPath: getGlobalClaudeSettingsPath(), read: readClaudeSettingsForWrite }),
3336
+ log: (line) => console.log(line),
3337
+ });
3338
+ function claudeChildEnv(env = process.env) {
3339
+ // A packaged application's runtime and npm after the user's own on PATH (rememberPackagedPath above).
3340
+ const ordered = withPackagedPathLast(env);
3341
+ try { return gitBash.childEnv(ordered); } catch { return ordered; }
3342
+ }
3343
+ // SynaBun's own git features (the terminal's branch and checkout, the File Explorer's and the code editor's git,
3344
+ // the git routes) run the copy's git.exe on a PC without git: its path for spawnGit, its folder for every read.
3345
+ setGitFallbackFolder(() => { const git = gitBash.gitExecutable(); return git ? dirname(git) : null; });
3346
+ // The user's own `claude`, started in another terminal: ~/.claude/settings.json names SynaBun's copy while it is
3347
+ // the one in use and loses that value when it is not; a value of the user's own is never touched. Windows only.
3348
+ function syncGitBashSettings(resolution = gitBash.resolution()) {
3349
+ const settingsPath = getGlobalClaudeSettingsPath();
3350
+ const result = syncClaudeSettingsGitBash({ resolution, settingsPath, read: readClaudeSettingsForWrite, write: writeClaudeSettings, ownRoots: gitBash.ownRoots });
3351
+ gitBash.noteSettings(result);
3352
+ if (result.state === 'written') console.log(`[git-bash] ${settingsPath}: CLAUDE_CODE_GIT_BASH_PATH set to ${result.value} (SynaBun's Git Bash, for a claude started outside SynaBun)`);
3353
+ else if (result.state === 'removed') console.log(`[git-bash] ${settingsPath}: CLAUDE_CODE_GIT_BASH_PATH removed (SynaBun's Git Bash is not the one in use)`);
3354
+ else if (result.state === 'error') console.warn(`[git-bash] ${settingsPath} was not changed: ${result.message}`);
3355
+ return result;
3356
+ }
3357
+ gitBash.onChange((resolution) => { syncGitBashSettings(resolution); });
3358
+ const _cliMissingLogged = new Set();
3359
+ function _noteCliLookup(toolKey, found, label) {
3360
+ if (!found?.path) {
3361
+ if (!_cliMissingLogged.has(toolKey)) {
3362
+ _cliMissingLogged.add(toolKey);
3363
+ console.warn(`[cli] ${label} not found (Settings path, PATH, the OS PATH and the known install folders). The side panel offers to install it.`);
3364
+ }
3365
+ return;
3366
+ }
3367
+ if (_cliMissingLogged.delete(toolKey)) console.log(`[cli] ${label} found: ${found.path}`);
3368
+ }
3369
+
3370
+ /** The Settings > Terminal value of a tool when it is a command line ("wsl codex") that a shell runs as written. */
3371
+ function configuredCommandLine(toolKey) {
3372
+ let value = '';
3373
+ try { value = String(loadCliConfig()[toolKey]?.command || '').trim(); } catch {}
3374
+ if (!value || !/\s/.test(value) || existsSync(value.replace(/^"(.*)"$/, '$1'))) return null;
3375
+ return value;
3376
+ }
3256
3377
 
3257
- // The Claude Code a terminal would start: the command from cli-config.json
3258
- // (Settings > Terminal), else the first `claude` on the augmented PATH. Always
3259
- // the user's own installation: never a file inside SynaBun, never a command
3260
- // from a node_modules/.bin folder (lib/claude-executable.js).
3378
+ // The Claude Code a terminal would start: the resolver's answer (Settings'
3379
+ // path, PATH, the OS PATH, the known install folders), always the user's own
3380
+ // installation. The bare 'claude' when there is none, which
3381
+ // resolveClaudeSdkExecutable and every caller read as "not installed".
3261
3382
  function getClaudeBin() {
3262
- if (_claudeBinPath && _claudeBinPath !== 'claude') return _claudeBinPath;
3263
- if (_claudeBinPath && Date.now() - _claudeBinMissingAt < MISSING_TOOL_RECHECK_MS) return _claudeBinPath;
3264
- let configured = '';
3265
- try { configured = loadCliConfig()['claude-code']?.command?.trim() || ''; } catch {}
3266
- const found = resolveClaudeLauncher({ configured, isInsideSynabun });
3267
- for (const rejected of found.rejected) {
3268
- console.warn(`[claude-skin] Ignoring Claude CLI inside the SynaBun install: ${rejected}`);
3269
- }
3270
- if (!found.path) {
3271
- if (!_claudeBinMissingAt) console.warn('[claude-skin] Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code');
3272
- _claudeBinMissingAt = Date.now();
3273
- _claudeBinPath = 'claude'; // last resort — will fail at spawn time with a clear error
3274
- return _claudeBinPath;
3275
- }
3276
- if (_claudeBinMissingAt) console.log(`[claude-skin] Claude CLI found: ${found.path}`);
3277
- _claudeBinMissingAt = 0;
3278
- _claudeBinPath = found.path;
3279
- return _claudeBinPath;
3383
+ const found = cliResolver.launcher('claude-code');
3384
+ _noteCliLookup('claude-code', found, 'Claude Code');
3385
+ return found.path || 'claude';
3280
3386
  }
3281
3387
 
3282
3388
  // The executable every Agent SDK call is given (pathToClaudeCodeExecutable):
@@ -3291,131 +3397,63 @@ function getClaudeSdkExecutable() {
3291
3397
  });
3292
3398
  }
3293
3399
 
3294
- let _codexBinPath = null;
3400
+ // Codex: what the SDK and the app-server start. On Windows the native codex.exe
3401
+ // an npm command file leads to (the SDK spawns without a shell); the file found
3402
+ // otherwise. SynaBun carries no Codex (lib/external-tools.js): without one,
3403
+ // Codex is reported as not installed. 'codex' when there is none.
3295
3404
  let _codexBinSource = 'missing';
3296
- let _codexBinMissingAt = 0;
3297
- let _nativeCodexBinPath = null;
3298
3405
  // The SDK is statically imported at module load; reaching this point is the
3299
3406
  // authoritative capability check. Its package.json is intentionally not an
3300
3407
  // exported subpath, so resolving that file reports a false negative.
3301
3408
  const _codexSdkInstalled = true;
3302
3409
 
3303
- function resolveWindowsGlobalCodexRuntime() {
3304
- const globalPath = getAugmentedPath().split(delimiter)
3305
- .filter((entry) => !/[\\/]node_modules[\\/]\.bin[\\/]?$/i.test(entry))
3306
- .join(delimiter);
3307
- const env = { ...process.env, PATH: globalPath };
3308
- const whereExecutable = process.env.SystemRoot
3309
- ? join(process.env.SystemRoot, 'System32', 'where.exe')
3310
- : 'where.exe';
3311
- const lookup = spawnSync(whereExecutable, ['codex'], {
3312
- encoding: 'utf-8',
3313
- env,
3314
- windowsHide: true,
3315
- });
3316
- const launchers = lookup.status === 0
3317
- ? String(lookup.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
3318
- : [];
3319
-
3320
- const npmRootProbe = spawnSync(
3321
- process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
3322
- ['/d', '/s', '/c', 'npm root -g'],
3323
- { encoding: 'utf-8', env, windowsHide: true, timeout: 5000 },
3324
- );
3325
- const npmRoots = npmRootProbe.status === 0
3326
- ? String(npmRootProbe.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
3327
- : [];
3328
- const codexPackageJsonPaths = windowsCodexPackageJsonCandidates({
3329
- npmRoots,
3330
- appData: process.env.APPDATA,
3331
- npmConfigPrefix: process.env.NPM_CONFIG_PREFIX || process.env.npm_config_prefix,
3332
- synabunPackageRoot: PACKAGE_ROOT,
3333
- nodeExecutable: process.execPath,
3334
- });
3335
- const resolved = resolveTrustedWindowsCodexBinary({
3336
- launchers,
3337
- codexPackageJsonPaths,
3338
- arch: process.arch,
3339
- acceptBinary: (candidate) => !isInsideSynabun(candidate),
3340
- });
3341
- return { ...resolved, launchers, codexPackageJsonPaths, npmRoots };
3342
- }
3343
-
3344
3410
  function getCodexBin() {
3345
- // (Not found is looked for again after a moment, like Claude Code above.)
3346
- if (_codexBinPath && (_codexBinSource !== 'missing' || Date.now() - _codexBinMissingAt < MISSING_TOOL_RECHECK_MS)) return _codexBinPath;
3347
- // All Codex surfaces follow the user's trusted global install. SynaBun
3348
- // carries no Codex of its own (lib/external-tools.js): the SDK is always
3349
- // handed this path, and without one Codex is reported as not installed.
3350
- const globalPath = getAugmentedPath().split(delimiter)
3351
- .filter((entry) => !/[\\/]node_modules[\\/]\.bin[\\/]?$/i.test(entry))
3352
- .join(delimiter);
3353
- const envWithPath = { ...process.env, PATH: globalPath };
3354
- if (process.platform === 'win32') {
3355
- const resolution = resolveWindowsGlobalCodexRuntime();
3356
- if (resolution.path) {
3357
- _codexBinPath = resolution.path;
3358
- _nativeCodexBinPath = resolution.path;
3359
- } else {
3360
- _codexBinPath = selectWindowsCodexLauncher(
3361
- resolution.launchers,
3362
- (launcher) => !isInsideSynabun(launcher),
3363
- );
3364
- }
3365
- } else {
3366
- try { _codexBinPath = execSync('which codex', { encoding: 'utf-8', env: envWithPath }).trim(); }
3367
- catch { _codexBinPath = null; }
3368
- }
3369
- if (_codexBinPath && _codexBinPath !== 'codex' && !isInsideSynabun(_codexBinPath)) {
3370
- if (_codexBinMissingAt) console.log(`[codex-skin] Codex CLI found: ${_codexBinPath}`);
3371
- _codexBinMissingAt = 0;
3372
- _codexBinSource = 'global';
3373
- return _codexBinPath;
3374
- }
3375
- if (!_codexBinMissingAt) {
3376
- if (_codexBinPath && _codexBinPath !== 'codex') console.warn(`[codex-skin] Ignoring Codex CLI inside the SynaBun install: ${_codexBinPath}`);
3377
- console.warn('[codex-skin] Codex CLI not found. Install: npm install -g @openai/codex');
3378
- }
3379
- _codexBinMissingAt = Date.now();
3380
- _codexBinPath = 'codex';
3381
- _codexBinSource = 'missing';
3382
- return _codexBinPath;
3411
+ const found = cliResolver.launcher('codex');
3412
+ _noteCliLookup('codex', found, 'Codex');
3413
+ _codexBinSource = found.path ? 'global' : 'missing';
3414
+ return found.launchPath || found.path || 'codex';
3383
3415
  }
3384
3416
 
3417
+ // Native Codex schedules hand the SDK a program it can start without a shell:
3418
+ // on Windows a codex.exe, never a command file.
3385
3419
  function getNativeCodexBin() {
3386
- if (_nativeCodexBinPath) return _nativeCodexBinPath;
3387
-
3420
+ const found = cliResolver.launcher('codex');
3421
+ _codexBinSource = found.path ? 'global' : 'missing';
3388
3422
  if (process.platform === 'win32') {
3389
- const resolved = resolveWindowsGlobalCodexRuntime();
3390
- if (resolved.path) {
3391
- _nativeCodexBinPath = resolved.path;
3392
- _codexBinPath = resolved.path;
3393
- _codexBinSource = 'global';
3394
- console.log(`[codex-native] Resolved Windows runtime (${resolved.source}): ${resolved.path}`);
3395
- return _nativeCodexBinPath;
3396
- }
3397
- if (resolved.reason) {
3398
- console.warn(`[codex-native] ${resolved.reason}`);
3399
- }
3400
- if (resolved.checked?.length) {
3401
- console.warn(`[codex-native] Checked: ${resolved.checked.slice(0, 16).join(' | ')}`);
3402
- }
3403
- const globalPackageFound = resolved.codexPackageJsonPaths.some((candidate) => existsSync(candidate));
3404
- throw new Error(
3405
- globalPackageFound
3406
- ? 'The global Codex package was found, but its Windows runtime is missing. Reinstall it with: npm install -g @openai/codex@latest --include=optional, then restart SynaBun.'
3407
- : 'Codex is not installed globally or could not be located. Install it with: npm install -g @openai/codex@latest --include=optional, then restart SynaBun.',
3408
- );
3423
+ if (found.launchPath && /\.exe$/i.test(found.launchPath)) return found.launchPath;
3424
+ throw new Error(found.path
3425
+ ? `Codex was found at ${found.path}, but not the codex.exe it runs. Reinstall Codex (the Codex panel's Install button, or npm install -g @openai/codex@latest --include=optional), then try again.`
3426
+ : 'Codex is not installed. Install it with the Codex panel\'s Install button, or run: powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"');
3409
3427
  }
3410
-
3411
- const codexBin = getCodexBin();
3412
- if (_codexBinSource !== 'global' || !codexBin || codexBin === 'codex') {
3413
- throw new Error(
3414
- 'Codex schedules require a trusted global Codex CLI. Install it with: npm install -g @openai/codex',
3415
- );
3428
+ if (!found.path) {
3429
+ throw new Error('Codex schedules need Codex installed. Install it with the Codex panel\'s Install button, or run: curl -fsSL https://chatgpt.com/codex/install.sh | sh');
3430
+ }
3431
+ return found.launchPath || found.path;
3432
+ }
3433
+
3434
+ // How `opencode serve --port N` starts, for the shared serve, the isolated
3435
+ // serves of sessions and loops and the editor's completion serve. On macOS and
3436
+ // Linux through the user's login shell, as a terminal starts it (OpenCode gets
3437
+ // the provider keys exported in .zshrc), naming the CLI the resolver found by
3438
+ // its full path; on Windows the opencode.exe found, started directly with the
3439
+ // PATH a new terminal has (a command file that leads nowhere: through cmd.exe,
3440
+ // every word escaped). A command line in Settings is run as written.
3441
+ function opencodeServeLaunch(port, { env = process.env } = {}) {
3442
+ const found = cliResolver.launcher('opencode');
3443
+ _noteCliLookup('opencode', found, 'OpenCode');
3444
+ const line = configuredCommandLine('opencode');
3445
+ if (IS_WIN) {
3446
+ const spawnEnv = withPathEnv(env, getAugmentedPath());
3447
+ if (!line && found.spec) {
3448
+ const launch = spawnArgsFor(found.spec, ['serve', '--port', String(port)], { env: spawnEnv });
3449
+ return { command: launch.command, args: launch.args, options: { ...launch.options, env: spawnEnv, windowsHide: true }, label: `${found.launchPath} serve --port ${port}` };
3450
+ }
3451
+ const text = `${line || 'opencode'} serve --port ${port}`;
3452
+ return { command: DEFAULT_SHELL, args: ['/d', '/s', '/c', text], options: { env: spawnEnv, windowsHide: true }, label: text };
3416
3453
  }
3417
- _nativeCodexBinPath = codexBin;
3418
- return _nativeCodexBinPath;
3454
+ const cmd = line || (found.path ? shellSingleQuote(found.path) : 'opencode');
3455
+ // A packaged application's runtime and npm after the user's own (the login shell adds the user's profile on top).
3456
+ return { command: DEFAULT_SHELL, args: ['-lc', `exec ${cmd} serve --port ${port}`], options: { env: withPackagedPathLast(env) }, label: `${cmd} serve --port ${port}` };
3419
3457
  }
3420
3458
 
3421
3459
  function extractCodexConfigValues(result) {
@@ -3455,10 +3493,10 @@ function buildCodexWritableRoots(cwd = PACKAGE_ROOT) {
3455
3493
  return [...roots];
3456
3494
  }
3457
3495
 
3458
- function getCodexSidepanelApprovalPolicy() {
3496
+ function getCodexSidepanelApprovalPolicy(config = {}) {
3459
3497
  // The sidepanel is interactive; Auto Accept decides which emitted requests
3460
3498
  // can be approved without presenting a card.
3461
- return CODEX_SIDEPANEL_APPROVAL_POLICY;
3499
+ return config.approval_policy ?? CODEX_SIDEPANEL_APPROVAL_POLICY;
3462
3500
  }
3463
3501
 
3464
3502
  function buildCodexSandboxPolicy(cwd = PACKAGE_ROOT, sandboxMode) {
@@ -3654,7 +3692,7 @@ async function resolveOpenCodeMcpDirectory(entry, sessionIds) {
3654
3692
  // auth.json + sessions/ + state DB, while config.toml and prompts/ are
3655
3693
  // symlinked back to the canonical ~/.codex so model/MCP/tool-permission config
3656
3694
  // and custom prompts stay shared. The "default" account IS ~/.codex itself.
3657
- const CODEX_DEFAULT_HOME = resolve(getHomePath(), '.codex');
3695
+ const CODEX_DEFAULT_HOME = resolve(process.env.CODEX_HOME || resolve(getHomePath(), '.codex'));
3658
3696
  const CODEX_ACCOUNTS_ROOT = resolve(getHomePath(), '.codex-accounts');
3659
3697
 
3660
3698
  function readCodexRuntimeModelCatalog(codexHome = CODEX_DEFAULT_HOME) {
@@ -3670,78 +3708,62 @@ function readCodexRuntimeModelCatalog(codexHome = CODEX_DEFAULT_HOME) {
3670
3708
  }
3671
3709
  const CODEX_ACCOUNTS_PATH = resolve(DATA_HOME, 'data', 'codex-accounts.json');
3672
3710
 
3673
- function loadCodexAccounts() {
3674
- let data = { accounts: [], defaultId: 'default' };
3675
- try {
3676
- if (existsSync(CODEX_ACCOUNTS_PATH)) {
3677
- const parsed = JSON.parse(readFileSync(CODEX_ACCOUNTS_PATH, 'utf-8'));
3678
- if (parsed && Array.isArray(parsed.accounts)) data = parsed;
3679
- }
3680
- } catch (err) {
3681
- console.warn('[codex-accounts] Failed to read registry:', err.message);
3682
- }
3683
- // Auto-seed the Default account (the existing ~/.codex)
3684
- if (!data.accounts.some((a) => a && a.id === 'default')) {
3685
- data.accounts.unshift({
3686
- id: 'default',
3687
- label: 'Default',
3688
- email: '',
3689
- planType: '',
3690
- type: 'chatgpt',
3691
- home: CODEX_DEFAULT_HOME,
3692
- createdAt: new Date().toISOString(),
3711
+ // The Codex accounts registry (lib/codex-accounts.js): one instance for the
3712
+ // sidepanel, the Assistant (brain, workers, catalog), loops and every route, so
3713
+ // one account state is shown everywhere. Its ChatGPT half is Codex's own
3714
+ // `account/read` for each account (codexAccountAuth().nativeStatus); a change
3715
+ // of any account reaches every window as `codex:accounts-changed`.
3716
+ let _assistantAccountUsage = null;
3717
+ let _codexAccountsInstance = null;
3718
+ function _codexAccounts() {
3719
+ if (!_codexAccountsInstance) {
3720
+ _codexAccountsInstance = createCodexAccounts({
3721
+ dataHome: DATA_HOME, homeDir: getHomePath(), accountsRoot: CODEX_ACCOUNTS_ROOT,
3722
+ readNativeStatus: (accountId) => codexAccountAuth().nativeStatus(accountId),
3723
+ prepareDefaultConfig: () => ensureCanonicalCodexConfig({ strict: true }),
3724
+ log: (tag, message) => console.log(`[${tag}]`, message),
3725
+ });
3726
+ _codexAccountsInstance.subscribe((event) => {
3727
+ try {
3728
+ broadcastSync({ type: 'codex:accounts-changed', accountId: event.accountId || null, removed: !!event.removed, created: !!event.created });
3729
+ broadcastSync({ type: 'assistant:accounts-changed', provider: 'codex', accountId: event.accountId || null, removed: !!event.removed });
3730
+ _assistantCatalog?.invalidate?.();
3731
+ } catch {}
3693
3732
  });
3694
- data.defaultId = 'default';
3695
3733
  }
3696
- return data;
3734
+ return _codexAccountsInstance;
3697
3735
  }
3698
3736
 
3699
- function saveCodexAccounts(data) {
3700
- try {
3701
- const dir = dirname(CODEX_ACCOUNTS_PATH);
3702
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
3703
- writeFileSync(CODEX_ACCOUNTS_PATH, JSON.stringify(data, null, 2), 'utf-8');
3704
- } catch (err) {
3705
- console.warn('[codex-accounts] Failed to write registry:', err.message);
3706
- }
3737
+ // The registry rows (id, label, home, …) for readers that only need where an account lives.
3738
+ function loadCodexAccounts() {
3739
+ return { accounts: _codexAccounts().registryRows(), defaultId: 'default' };
3707
3740
  }
3708
3741
 
3742
+ // The account, or Default when none is named or it is gone (launch selectors, session catalogs).
3709
3743
  function getCodexAccount(accountId) {
3710
- const data = loadCodexAccounts();
3711
- return data.accounts.find((a) => a.id === accountId)
3712
- || data.accounts.find((a) => a.id === data.defaultId)
3713
- || data.accounts[0]
3714
- || null;
3744
+ const accounts = _codexAccounts();
3745
+ return (accountId ? accounts.profileFor(accountId) : null) || accounts.profileFor('default');
3715
3746
  }
3716
3747
 
3748
+ // The account a request names, or null: never another account in its place.
3717
3749
  function findCodexAccount(accountId) {
3718
3750
  if (!accountId) return null;
3719
- return loadCodexAccounts().accounts.find((account) => account?.id === accountId) || null;
3751
+ return _codexAccounts().profileFor(accountId);
3720
3752
  }
3721
3753
 
3722
3754
  function getCodexAccountHome(accountId) {
3723
3755
  return getCodexAccount(accountId)?.home || CODEX_DEFAULT_HOME;
3724
3756
  }
3725
3757
 
3726
- function sanitizeCodexAccountForClient(a) {
3727
- return {
3728
- id: a.id,
3729
- label: a.label || a.email || 'Account',
3730
- email: a.email || '',
3731
- planType: a.planType || '',
3732
- type: a.type || 'chatgpt',
3733
- isDefault: a.id === 'default',
3734
- };
3735
- }
3736
-
3758
+ // Every account with its state (mode, signed in, identity: an email or a masked key, never a key).
3737
3759
  function listCodexAccountsForClient() {
3738
- return loadCodexAccounts().accounts.map(sanitizeCodexAccountForClient);
3760
+ return _codexAccounts().listForClient();
3739
3761
  }
3740
3762
 
3741
- function ensureCanonicalCodexConfig({ strict = false } = {}) {
3763
+ function ensureCanonicalCodexConfig({ strict = false, codexHome = CODEX_DEFAULT_HOME } = {}) {
3742
3764
  try {
3743
- if (!existsSync(CODEX_DEFAULT_HOME)) mkdirSync(CODEX_DEFAULT_HOME, { recursive: true });
3744
- const cfg = resolve(CODEX_DEFAULT_HOME, 'config.toml');
3765
+ if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
3766
+ const cfg = resolve(codexHome, 'config.toml');
3745
3767
  if (!existsSync(cfg)) writeFileSync(cfg, '', 'utf-8');
3746
3768
  const repair = healCodexConfig(cfg);
3747
3769
  if (repair.healed) {
@@ -3751,50 +3773,83 @@ function ensureCanonicalCodexConfig({ strict = false } = {}) {
3751
3773
  if (strict) throw error;
3752
3774
  console.warn(`[codex-config] ${error.message}`);
3753
3775
  }
3776
+ // Env/tool CLI overrides require a transport, even after a fresh CLI install
3777
+ // or when setup never connected Codex. Reuse Connect Codex's canonical writer;
3778
+ // leave an already connected transport and all unrelated settings alone.
3779
+ const health = inspectCodexConfig(cfg);
3780
+ if (!health.connected || health.issues.includes('missing_transport')) {
3781
+ const inspection = upsertCodexMcpConfig(cfg, buildCodexMcpDefinition());
3782
+ if (!inspection.valid) throw new Error(`Could not register SynaBun in ${cfg}`);
3783
+ return { healed: true, reason: 'registered_missing_transport', inspection };
3784
+ }
3785
+ if (!health.valid) throw new Error(`Invalid SynaBun MCP configuration in ${cfg}: ${health.issues.join(', ')}`);
3754
3786
  return repair;
3755
3787
  } catch (err) {
3756
- console.warn(`[codex-config] Could not prepare ${CODEX_DEFAULT_HOME}/config.toml: ${err.message}`);
3788
+ console.warn(`[codex-config] Could not prepare ${codexHome}/config.toml: ${err.message}`);
3757
3789
  if (strict) throw err;
3758
3790
  return null;
3759
3791
  }
3760
3792
  }
3761
3793
 
3762
- function pathOrLinkExists(p) {
3763
- try { lstatSync(p); return true; } catch { return false; }
3794
+ // Live Codex connections (panel tabs, the Assistant's brain): their account,
3795
+ // whether a turn is running, and how they restart under a changed credential.
3796
+ const _codexLiveConnections = new Set();
3797
+
3798
+ // A Codex account's credential or mode changes: refused while one of its
3799
+ // connections is mid-turn; afterwards its idle app-servers restart, so the next
3800
+ // message runs under the new credential. Other accounts are not touched.
3801
+ async function withCodexAccountCredentialChange(accountId, change) {
3802
+ const connections = [..._codexLiveConnections].filter((connection) => connection.accountId() === accountId);
3803
+ if (connections.some((connection) => connection.busy())) {
3804
+ throw Object.assign(new Error('Finish or stop this account’s Codex work before changing its sign-in.'), { status: 409 });
3805
+ }
3806
+ const result = await change();
3807
+ for (const connection of connections) { try { connection.credentialsChanged(); } catch {} }
3808
+ return result;
3809
+ }
3810
+
3811
+ // Codex's own store changed outside a request (a terminal sign-in ended): idle connections restart.
3812
+ function codexAccountStoreChanged(accountId) {
3813
+ for (const connection of [..._codexLiveConnections]) {
3814
+ if (connection.accountId() === accountId && !connection.busy()) { try { connection.credentialsChanged(); } catch {} }
3815
+ }
3764
3816
  }
3765
3817
 
3766
- function linkSharedCodexResource(linkPath, targetPath, kind) {
3767
- // kind: 'file' | 'dir'
3768
- try {
3769
- if (pathOrLinkExists(linkPath)) return;
3770
- if (!existsSync(targetPath)) {
3771
- if (kind === 'dir') mkdirSync(targetPath, { recursive: true });
3772
- else writeFileSync(targetPath, '', 'utf-8');
3773
- }
3774
- symlinkSync(targetPath, linkPath, kind === 'dir' ? 'dir' : 'file');
3775
- } catch (err) {
3776
- console.warn('[codex-accounts] Failed to link', linkPath, '→', targetPath, err.message);
3818
+ let _codexAccountAuthInstance = null;
3819
+ function codexAccountAuth() {
3820
+ if (!_codexAccountAuthInstance) {
3821
+ _codexAccountAuthInstance = createCodexAccountAuth({
3822
+ accounts: _codexAccounts(), codexBin: () => getCodexBin(),
3823
+ prepareConfig: (codexHome) => ensureCanonicalCodexConfig({ strict: true, codexHome }),
3824
+ env: () => (withPathEnv(process.env, getAugmentedPath())),
3825
+ changeCredentials: withCodexAccountCredentialChange,
3826
+ });
3777
3827
  }
3828
+ return _codexAccountAuthInstance;
3778
3829
  }
3779
3830
 
3780
- function createCodexAccountHome(id) {
3781
- const home = resolve(CODEX_ACCOUNTS_ROOT, id);
3782
- mkdirSync(home, { recursive: true });
3783
- mkdirSync(resolve(home, 'sessions'), { recursive: true });
3784
- ensureCanonicalCodexConfig();
3785
- // Share config + prompts with the canonical ~/.codex; isolate auth + sessions.
3786
- linkSharedCodexResource(resolve(home, 'config.toml'), resolve(CODEX_DEFAULT_HOME, 'config.toml'), 'file');
3787
- linkSharedCodexResource(resolve(home, 'prompts'), resolve(CODEX_DEFAULT_HOME, 'prompts'), 'dir');
3788
- // Global instructions (and SynaBun's rules block in them) reach every account.
3789
- // Only when the file exists: the rules installer links it later otherwise,
3790
- // and an empty ~/.codex/AGENTS.md is not ours to create.
3791
- if (existsSync(resolve(CODEX_DEFAULT_HOME, 'AGENTS.md'))) {
3792
- linkSharedCodexResource(resolve(home, 'AGENTS.md'), resolve(CODEX_DEFAULT_HOME, 'AGENTS.md'), 'file');
3831
+ // Why a Codex account cannot be removed now ('' when it can): a live connection
3832
+ // (a panel tab, the Assistant's brain) other than `except`, a native loop, a
3833
+ // dispatched run, an Assistant session, or the Assistant's worker choice.
3834
+ function codexAccountInUse(accountId, { except = null } = {}) {
3835
+ if ([..._codexLiveConnections].some((connection) => connection !== except && connection.accountId() === accountId)) {
3836
+ return 'A Codex tab or the Assistant is running under this account. Switch it or close it first.';
3793
3837
  }
3794
- return home;
3838
+ try { if (_nativeLoopRuntime?.list({ activeOnly: true }).some((run) => run.provider === 'codex' && run.accountId === accountId)) return 'Stop native Codex automations using this account before removing it.'; } catch {}
3839
+ try { if (_assistantDispatcher?.accountInUse?.('codex', accountId)) return 'Dispatched runs are using this account. Stop them first.'; } catch {}
3840
+ try { if (_assistantRuntime?.accountInUse?.('codex', accountId)) return 'An Assistant session uses this account. Close it first.'; } catch {}
3841
+ try { if (_assistantCodexWorkerAccount?.() === accountId) return 'This is the Assistant\'s Codex worker account. Choose another one first.'; } catch {}
3842
+ return '';
3795
3843
  }
3796
3844
 
3797
3845
  function handleCodexSkinWebSocket(ws) {
3846
+ // In API-key mode with a key SynaBun holds, the app-server starts with an ephemeral
3847
+ // credential store and this key is its first request over stdin (lib/codex-accounts.js).
3848
+ // Spawn-only: never logged, sent or kept after that request.
3849
+ let childApiKeyLogin = null;
3850
+ let childUsesHeldKey = false;
3851
+ // Which credential the running app-server started with (launchFor().credential).
3852
+ let childCredential = null;
3798
3853
  const CODEX_GREETING_MARKER_START = '[SYNABUN_CODEX_GREETING_START]';
3799
3854
  const CODEX_GREETING_MARKER_END = '[SYNABUN_CODEX_GREETING_END]';
3800
3855
  const CODEX_PLAN_MODE_PREFIXES = [
@@ -3843,7 +3898,27 @@ CRITICAL — When you need clarification during planning:
3843
3898
  // Per-tab Codex account → drives CODEX_HOME of the spawned app-server.
3844
3899
  let activeAccountId = 'default';
3845
3900
  let activeCodexHome = CODEX_DEFAULT_HOME;
3846
- let _codexLoginWatcher = null;
3901
+ const panelAuth = createCodexPanelAuth({
3902
+ request: (...args) => request(...args), accountId: () => activeAccountId,
3903
+ onChange: state => sendToClient({ type: 'account_login_state', state }),
3904
+ onSuccess: async id => {
3905
+ const acct = await request('account/read', { refreshToken: false }, 10000);
3906
+ if (id !== activeAccountId) return;
3907
+ // What Codex now holds is the account's state everywhere (lib/codex-accounts.js).
3908
+ if (acct?.account?.type === 'chatgpt') _codexAccounts().noteSubscriptionSignIn(id, acct);
3909
+ else _codexAccounts().noteNativeStatus(id, acct || {});
3910
+ sendToClient({ type: 'account_info', accountId: id, account: acct, accountState: _codexAccounts().describeForClient(id) });
3911
+ sendToClient({ type: 'account_list', accounts: listCodexAccountsForClient(), activeAccountId });
3912
+ },
3913
+ });
3914
+ // This connection in the live set: account changes ask it whether it is busy and restart it when idle.
3915
+ const liveConnection = {
3916
+ accountId: () => activeAccountId,
3917
+ readRateLimits: () => initialized && !childUsesHeldKey ? request('account/rateLimits/read', {}, 2500) : null,
3918
+ busy: () => !!activeTurnId || pendingServerRequests.size > 0 || panelAuth.active(),
3919
+ credentialsChanged: () => retireForCredentialChange(),
3920
+ };
3921
+ _codexLiveConnections.add(liveConnection);
3847
3922
  let codexMcpRuntimeId = `codex-sp-${randomUUID()}`;
3848
3923
  let codexMcpRuntimeBinding = null;
3849
3924
  // 'assistant' when this connection is the main assistant's brain: the MCP
@@ -3852,6 +3927,12 @@ CRITICAL — When you need clarification during planning:
3852
3927
  // The assistant brain's computer-use grant (lib/desktop/grants.js): unlocks
3853
3928
  // the capability-gated `computer` group in this app-server's MCP child.
3854
3929
  let codexDesktopGrant = null;
3930
+ // A WhatsApp conversation brain's credential (whatsapp_send_file): taken from the in-process
3931
+ // brain's own socket object (lib/virtual-socket.js brainCredentialOf), never from a message a
3932
+ // client sends; it reaches the MCP child through the environment only (SYNABUN_BRAIN_TOKEN,
3933
+ // forwarded by name with env_vars), never argv. A name with TOKEN in it stays out of the
3934
+ // brain's own shell commands under Codex's default shell_environment_policy.
3935
+ let codexBrainCredential = null;
3855
3936
  // The assistant brain's route gate (lib/assistant-route-gate-codex.js): a
3856
3937
  // trusted session-flag PreToolUse hook plus the env it reads. Assistant only.
3857
3938
  let codexRouteGate = null;
@@ -3880,8 +3961,10 @@ CRITICAL — When you need clarification during planning:
3880
3961
 
3881
3962
  async function beforeCodexCompatRequest(type, params) {
3882
3963
  if (type === 'thread_settings_update') {
3883
- validateCodexConfigRequirements({ approval_policy: params.approvalPolicy, approvals_reviewer: params.approvalsReviewer }, await readCodexRequirements());
3964
+ validateCodexConfigRequirements({ approval_policy: params.approvalPolicy, approvals_reviewer: params.approvalsReviewer, sandbox_mode: ({ readOnly: 'read-only', workspaceWrite: 'workspace-write', dangerFullAccess: 'danger-full-access' })[params.sandboxPolicy?.type] }, await readCodexRequirements());
3884
3965
  } else if (type === 'experimental_features_set') {
3966
+ const metadata = await readSettingsMetadata();
3967
+ validateCodexSettings({ features: params.enablement }, metadata, metadata.features);
3885
3968
  validateCodexConfigRequirements({ features: params.enablement }, await readCodexRequirements());
3886
3969
  } else if (type === 'feedback_upload') {
3887
3970
  await readCodexRequirements();
@@ -3997,9 +4080,47 @@ CRITICAL — When you need clarification during planning:
3997
4080
  ...details,
3998
4081
  }));
3999
4082
  }
4083
+ // A tab names its account; one that is no longer set up runs as Default, and says so
4084
+ // (every answer carries activeAccountId), never under Default's home with another name.
4000
4085
  function setActiveCodexAccount(id) {
4001
- activeAccountId = id || 'default';
4002
- activeCodexHome = getCodexAccountHome(activeAccountId);
4086
+ const account = getCodexAccount(id || 'default');
4087
+ activeAccountId = account?.id || 'default';
4088
+ activeCodexHome = account?.home || CODEX_DEFAULT_HOME;
4089
+ }
4090
+
4091
+ function sendAccountState() {
4092
+ sendToClient({ type: 'account_state', accountId: activeAccountId, account: _codexAccounts().describeForClient(activeAccountId), accounts: listCodexAccountsForClient() });
4093
+ }
4094
+
4095
+ // The account's credential or mode changed (withCodexAccountCredentialChange): an idle
4096
+ // app-server is retired, and the next request starts one under the new credential; the
4097
+ // conversation resumes from its thread then. The transcript stays.
4098
+ function retireForCredentialChange() {
4099
+ const next = _codexAccounts().launchFor(activeAccountId);
4100
+ if (child && !activeTurnId && !pendingServerRequests.size && (!next.ok || next.credential !== childCredential)) {
4101
+ const old = child;
4102
+ const oldHome = activeCodexHome;
4103
+ const oldThreadIds = ownedWriterThreadIds();
4104
+ panelAuth.reset();
4105
+ initialized = false;
4106
+ bootPromise = null;
4107
+ activeThreadId = null;
4108
+ appBoundary.invalidate();
4109
+ activeThreadRuntimeFingerprint = null;
4110
+ subscribedThreadIds.clear();
4111
+ threadOwnerRoots.clear();
4112
+ activeTurnAutoAccept = false;
4113
+ for (const [, entry] of pending) {
4114
+ try { clearTimeout(entry.timer); entry.reject(new Error('Codex account sign-in changed')); } catch {}
4115
+ }
4116
+ pending.clear();
4117
+ childStdoutBuf = '';
4118
+ unregisterCodexMcpProfileRuntime();
4119
+ child = null;
4120
+ childCredential = null;
4121
+ retireCodexProcess(old, { codexHome: oldHome, threadIds: oldThreadIds, reason: 'Codex account sign-in changed' });
4122
+ }
4123
+ sendAccountState();
4003
4124
  }
4004
4125
 
4005
4126
  async function withCodexWriterOperation(operation, action) {
@@ -4022,6 +4143,7 @@ CRITICAL — When you need clarification during planning:
4022
4143
  })();
4023
4144
 
4024
4145
  function sendToClient(data) {
4146
+ data = redactProviderSecrets(data);
4025
4147
  if (data?.type === 'error') {
4026
4148
  // Some legacy catch paths forward only a prefixed RPC message.
4027
4149
  const rpcError = { ...data, code: data.code ?? (/Invalid request: unknown variant/i.test(data.message || '') ? -32600 : null) };
@@ -4146,6 +4268,7 @@ CRITICAL — When you need clarification during planning:
4146
4268
  const codexHome = activeCodexHome;
4147
4269
  const threadIds = ownedWriterThreadIds();
4148
4270
  shuttingDown = true;
4271
+ panelAuth.interrupted();
4149
4272
  unregisterCodexMcpProfileRuntime({ cleanup: true });
4150
4273
  childRetirementPromise = (async () => {
4151
4274
  if (unsubscribe) await unsubscribeAllThreadsBeforeRetirement();
@@ -4364,6 +4487,19 @@ CRITICAL — When you need clarification during planning:
4364
4487
  return result || {};
4365
4488
  }
4366
4489
 
4490
+ async function readSettingsMetadata() {
4491
+ const schema = readCodexSettingsSchema(getCodexBin());
4492
+ const features = await listCodexPages(request, 'experimentalFeature/list', { limit: 100 });
4493
+ const featureSupport = readCodexFeatureSupport(getCodexBin(), features.data);
4494
+ // The catalog's enabled field is a process-start snapshot. config/read is
4495
+ // authoritative for saved defaults after reloadUserConfig writes.
4496
+ return { ...schema, features: features.data.map(feature => ({ ...feature,
4497
+ configurable: featureSupport[feature.name] === true,
4498
+ enabled: typeof _cachedConfig.features?.[feature.name] === 'boolean'
4499
+ ? _cachedConfig.features[feature.name] : feature.enabled,
4500
+ })) };
4501
+ }
4502
+
4367
4503
  async function setCodexMcpServerApprovalMode(serverName, approvalMode) {
4368
4504
  const name = String(serverName || '').trim();
4369
4505
  if (!name) throw new Error('MCP approval request is missing its server identity');
@@ -4408,7 +4544,7 @@ CRITICAL — When you need clarification during planning:
4408
4544
  rejected: isolationStats.rejected,
4409
4545
  reasons: { ...isolationStats.reasons },
4410
4546
  },
4411
- approvalPolicy: getCodexSidepanelApprovalPolicy(),
4547
+ approvalPolicy: getCodexSidepanelApprovalPolicy(_cachedConfig),
4412
4548
  sessionOverrides: codexSessionSettings.get(activeAccountId, activeThreadId),
4413
4549
  cwd,
4414
4550
  },
@@ -4498,7 +4634,7 @@ CRITICAL — When you need clarification during planning:
4498
4634
  function getDefaultThreadParams(cwd = PACKAGE_ROOT, runtime = {}) {
4499
4635
  const params = {
4500
4636
  cwd,
4501
- approvalPolicy: getCodexSidepanelApprovalPolicy(),
4637
+ approvalPolicy: getCodexSidepanelApprovalPolicy(_cachedConfig),
4502
4638
  sandbox: getCachedConfigValue('sandbox_mode', 'workspace-write'),
4503
4639
  personality: getCachedConfigValue('personality', 'pragmatic'),
4504
4640
  };
@@ -4513,16 +4649,19 @@ CRITICAL — When you need clarification during planning:
4513
4649
  threadId,
4514
4650
  cwd,
4515
4651
  excludeTurns: true,
4516
- approvalPolicy: getCodexSidepanelApprovalPolicy(),
4652
+ approvalPolicy: getCodexSidepanelApprovalPolicy(_cachedConfig),
4517
4653
  sandbox: getCachedConfigValue('sandbox_mode', 'workspace-write'),
4518
4654
  personality: getCachedConfigValue('personality', 'pragmatic'),
4519
4655
  };
4520
4656
  const sessionSettings = codexSessionSettings.get(activeAccountId, threadId);
4657
+ const sessionSandboxMode = ({ readOnly: 'read-only', workspaceWrite: 'workspace-write', dangerFullAccess: 'danger-full-access' })[sessionSettings.sandboxPolicy?.type];
4658
+ if (sessionSandboxMode) params.sandbox = sessionSandboxMode;
4521
4659
  for (const key of ['approvalPolicy', 'approvalsReviewer', 'personality', 'serviceTier']) {
4522
4660
  if (Object.prototype.hasOwnProperty.call(sessionSettings, key)) params[key] = sessionSettings[key];
4523
4661
  }
4524
4662
  if (runtime.model) params.model = runtime.model;
4525
4663
  else if (getCachedConfigValue('model')) params.model = getCachedConfigValue('model');
4664
+ if (sessionSettings.model != null) params.model = sessionSettings.model;
4526
4665
  if (runtime.config) params.config = runtime.config;
4527
4666
  return params;
4528
4667
  }
@@ -5011,6 +5150,12 @@ CRITICAL — When you need clarification during planning:
5011
5150
 
5012
5151
  function startChild() {
5013
5152
  if (child) return child;
5153
+ // The account decides the credential (lib/codex-accounts.js launchFor()): inherited
5154
+ // OpenAI/Codex credentials out, its CODEX_HOME in, and in API-key mode the key it
5155
+ // holds, given over stdin after initialize to an app-server with an ephemeral store.
5156
+ const launch = _codexAccounts().launchFor(activeAccountId);
5157
+ if (!launch.ok) throw new Error(launch.message);
5158
+ activeCodexHome = launch.home;
5014
5159
  let codexBin = getCodexBin();
5015
5160
  const runtimeProfilePath = writeCodexRuntimeProfile(codexMcpRuntimeId, codexMcpProfileState.value);
5016
5161
  const args = [
@@ -5031,24 +5176,30 @@ CRITICAL — When you need clarification during planning:
5031
5176
  // (lib/browser-tool-policy.js), so Codex's own web search stays off, as for its workers.
5032
5177
  if (codexRole === 'assistant') args.splice(args.length - 1, 0, '-c', 'web_search="disabled"');
5033
5178
  if (codexDesktopGrant) args.splice(args.length - 1, 0, '-c', `mcp_servers.SynaBun.env.SYNABUN_DESKTOP_GRANT=${JSON.stringify(codexDesktopGrant)}`);
5179
+ // The variable's name on the command line, its value only in the app-server's environment (below).
5180
+ if (codexRole === 'assistant' && codexBrainCredential) args.splice(args.length - 1, 0, '-c', 'mcp_servers.SynaBun.env_vars=["SYNABUN_BRAIN_TOKEN"]');
5034
5181
  if (codexRole === 'assistant' && codexRouteGate?.flags?.length) args.splice(args.length - 1, 0, ...codexRouteGate.flags);
5035
5182
  if (/\.js$/i.test(codexBin)) {
5036
5183
  args.unshift(codexBin);
5037
5184
  codexBin = process.execPath;
5038
5185
  }
5186
+ // API-key mode with a key SynaBun holds: an ephemeral credential store, before the subcommand.
5187
+ args.splice(args.length - 1, 0, ...codexAppServerArgs(launch));
5039
5188
  const useShell = !codexBin.includes(sep)
5040
5189
  || (process.platform === 'win32' && /\.(cmd|bat)$/i.test(codexBin));
5041
5190
  const env = {
5042
- ...process.env,
5191
+ ...applyCodexLaunchEnv(process.env, launch),
5043
5192
  NO_COLOR: '1',
5044
- CODEX_HOME: activeCodexHome,
5045
5193
  SYNABUN_PROFILE: codexMcpProfileState.value,
5046
5194
  SYNABUN_TERMINAL_SESSION: codexMcpRuntimeId,
5047
5195
  SYNABUN_RUNTIME_PROFILE_PATH: runtimeProfilePath,
5048
5196
  SYNABUN_TOOL_CATALOG_MODE: 'deferred',
5049
5197
  ...(codexDesktopGrant ? { SYNABUN_DESKTOP_GRANT: codexDesktopGrant } : {}),
5050
5198
  ...(codexRole === 'assistant' && codexRouteGate?.env ? codexRouteGate.env : {}),
5199
+ ...(codexRole === 'assistant' && codexBrainCredential ? { SYNABUN_BRAIN_TOKEN: codexBrainCredential } : {}),
5051
5200
  };
5201
+ // Never one inherited from the launching shell: only this brain's own credential reaches its MCP child.
5202
+ if (!(codexRole === 'assistant' && codexBrainCredential)) delete env.SYNABUN_BRAIN_TOKEN;
5052
5203
  delete env.FORCE_COLOR;
5053
5204
  delete env.CLICOLOR_FORCE;
5054
5205
  const proc = spawn(shellCommand(codexBin, useShell, { isFile: existsSync }), args, {
@@ -5058,10 +5209,13 @@ CRITICAL — When you need clarification during planning:
5058
5209
  shell: useShell,
5059
5210
  });
5060
5211
  child = proc;
5212
+ childApiKeyLogin = launch.apiKey || null;
5213
+ childUsesHeldKey = !!launch.apiKey;
5214
+ childCredential = launch.credential;
5061
5215
  childRetirementPromise = null;
5062
5216
  writerLifecycleState.releaseRequested = false;
5063
5217
  registerCodexMcpProfileRuntime();
5064
- console.log(`[codex-skin] Spawned app-server pid ${proc.pid || '?'} account=${activeAccountId} profile=${codexMcpProfileState.value} runtime=${codexMcpRuntimeId} CODEX_HOME=${activeCodexHome}`);
5218
+ console.log(`[codex-skin] Spawned app-server pid ${proc.pid || '?'} account=${activeAccountId} mode=${launch.mode || 'none'} profile=${codexMcpProfileState.value} runtime=${codexMcpRuntimeId} CODEX_HOME=${activeCodexHome}`);
5065
5219
  proc.stdout.setEncoding('utf-8');
5066
5220
  proc.stdout.on('data', (chunk) => {
5067
5221
  if (child !== proc) return; // stale process from a previous account
@@ -5073,9 +5227,9 @@ CRITICAL — When you need clarification during planning:
5073
5227
  if (!line) continue;
5074
5228
  let msg;
5075
5229
  try {
5076
- msg = JSON.parse(line);
5230
+ msg = redactProviderSecrets(JSON.parse(line));
5077
5231
  } catch (err) {
5078
- console.warn('[codex-skin] Invalid JSON from app-server:', err.message, line.slice(0, 160));
5232
+ console.warn('[codex-skin] Invalid JSON from app-server');
5079
5233
  continue;
5080
5234
  }
5081
5235
 
@@ -5188,6 +5342,9 @@ CRITICAL — When you need clarification during planning:
5188
5342
  if (msg.method) {
5189
5343
  const method = msg.method;
5190
5344
  const params = msg.params || {};
5345
+ if (method === 'account/rateLimits/updated') _assistantAccountUsage?.note('codex', activeAccountId, params);
5346
+ if (method === 'account/login/completed') void panelAuth.complete(params);
5347
+ if (method === 'account/updated' && params.authMode === null && !panelAuth.active()) panelAuth.reset();
5191
5348
  const scopedThreadId = notificationThreadId(method, params);
5192
5349
  const scopedTurnId = notificationTurnId(method, params);
5193
5350
  const expectedThreadId = displayedThreadId;
@@ -5262,11 +5419,11 @@ CRITICAL — When you need clarification during planning:
5262
5419
  }
5263
5420
  });
5264
5421
  proc.stderr.setEncoding('utf-8');
5265
- proc.stderr.on('data', (chunk) => {
5266
- const text = chunk.toString().trim();
5267
- if (!text) return;
5268
- console.log('[codex-skin][stderr]', text);
5422
+ const recordNativeDiagnostic = providerDiagnosticSink(text => {
5423
+ // Key-login attempts expose only structured, filtered errors to the UI.
5424
+ if (!childUsesHeldKey && !panelAuth.active()) console.log('[codex-skin][stderr]', text);
5269
5425
  });
5426
+ proc.stderr.on('data', recordNativeDiagnostic);
5270
5427
  proc.on('error', (err) => {
5271
5428
  if (child !== proc) return;
5272
5429
  console.error('[codex-skin] app-server spawn error:', err.message);
@@ -5279,6 +5436,7 @@ CRITICAL — When you need clarification during planning:
5279
5436
  });
5280
5437
  proc.on('close', (code) => {
5281
5438
  if (child !== proc) return; // a newer process replaced this one (e.g. account switch)
5439
+ panelAuth.interrupted();
5282
5440
  unregisterCodexMcpProfileRuntime();
5283
5441
  const wasShuttingDown = shuttingDown;
5284
5442
  child = null;
@@ -5310,7 +5468,7 @@ CRITICAL — When you need clarification during planning:
5310
5468
  // Repair SynaBun's legacy duplicate-env config before Codex parses it.
5311
5469
  // Doing this at the sidepanel boundary avoids the circular failure where
5312
5470
  // Codex rejects config.toml before it can launch SynaBun's MCP process.
5313
- const repair = ensureCanonicalCodexConfig();
5471
+ const repair = ensureCanonicalCodexConfig({ strict: true, codexHome: activeCodexHome });
5314
5472
  if (repair?.healed) {
5315
5473
  sendToClient({
5316
5474
  type: 'codex_config_repaired',
@@ -5319,7 +5477,7 @@ CRITICAL — When you need clarification during planning:
5319
5477
  });
5320
5478
  }
5321
5479
  // Sync tool approvals only after the base transport is known to be valid.
5322
- syncCodexToolPermissions([..._codexPermittedTools]);
5480
+ syncCodexToolPermissions([..._codexPermittedTools], activeCodexHome);
5323
5481
  const codexBin = getCodexBin();
5324
5482
  let cliVersion = null;
5325
5483
  let sdkVersion = null;
@@ -5339,9 +5497,27 @@ CRITICAL — When you need clarification during planning:
5339
5497
  capabilities: {
5340
5498
  experimentalApi: true,
5341
5499
  },
5342
- }, 15000).then((result) => {
5343
- initialized = true;
5500
+ }, 15000).then(async (result) => {
5344
5501
  writeRpc({ jsonrpc: '2.0', method: 'initialized' });
5502
+ // API-key mode: the key SynaBun holds signs this app-server in before anything else
5503
+ // runs; its ephemeral store keeps nothing and leaves the account's ChatGPT login alone.
5504
+ if (childApiKeyLogin) {
5505
+ let apiKey = childApiKeyLogin;
5506
+ childApiKeyLogin = null;
5507
+ try {
5508
+ const login = await request('account/login/start', { type: 'apiKey', apiKey }, 15000);
5509
+ if (login?.type !== 'apiKey') throw new Error('unexpected login answer');
5510
+ } catch {
5511
+ // A half-started app-server is not reused: the next request starts a fresh one.
5512
+ const failed = child;
5513
+ child = null;
5514
+ childCredential = null;
5515
+ unregisterCodexMcpProfileRuntime();
5516
+ try { failed?.kill('SIGTERM'); } catch {}
5517
+ throw new Error('Codex could not start on this account\'s API key. Save the key again, or switch the account to its ChatGPT sign-in.');
5518
+ } finally { apiKey = null; }
5519
+ }
5520
+ initialized = true;
5345
5521
  codexCapabilities.runtime.serverInfo = result || {};
5346
5522
  codexCapabilities.runtime.cliVersion ||= parseCodexVersion(result?.userAgent);
5347
5523
  sendToClient({ type: 'capabilities', ...codexCapabilitySnapshot() });
@@ -5412,9 +5588,11 @@ CRITICAL — When you need clarification during planning:
5412
5588
  // The close handler's `child !== proc` guard prevents the dying process from
5413
5589
  // clobbering the freshly-spawned one. Callers must ensure no turn is active.
5414
5590
  async function switchCodexAccount(id) {
5591
+ if (panelAuth.active()) throw new Error('Cancel or finish sign-in before switching accounts');
5415
5592
  const oldHome = activeCodexHome;
5416
5593
  const oldThreadIds = ownedWriterThreadIds();
5417
5594
  setActiveCodexAccount(id);
5595
+ panelAuth.reset();
5418
5596
  for (const key of Object.keys(_cachedConfig)) delete _cachedConfig[key];
5419
5597
  const old = child;
5420
5598
  initialized = false;
@@ -5498,45 +5676,6 @@ CRITICAL — When you need clarification during planning:
5498
5676
  return { profile: normalized, restarted: true, threadId: resumeId };
5499
5677
  }
5500
5678
 
5501
- // After account/login/start opens the OAuth browser, watch the new home's
5502
- // auth.json. Once Codex writes credentials, read the account and persist its
5503
- // email/plan as the account label, then push the update to the client.
5504
- function watchCodexLogin(id, home) {
5505
- try { _codexLoginWatcher?.close?.(); } catch {}
5506
- _codexLoginWatcher = null;
5507
- let finished = false;
5508
- const finalize = async () => {
5509
- if (finished) return;
5510
- finished = true;
5511
- try { _codexLoginWatcher?.close?.(); } catch {}
5512
- _codexLoginWatcher = null;
5513
- try {
5514
- const acct = await safeAccountRead(false);
5515
- const info = acct?.account || acct || {};
5516
- const data = loadCodexAccounts();
5517
- const entry = data.accounts.find((a) => a.id === id);
5518
- if (entry) {
5519
- entry.email = info.email || entry.email || '';
5520
- entry.planType = info.planType || entry.planType || '';
5521
- entry.label = info.email || entry.label || 'ChatGPT account';
5522
- saveCodexAccounts(data);
5523
- }
5524
- sendToClient({ type: 'account_info', accountId: id, account: acct || {} });
5525
- sendToClient({ type: 'account_list', accounts: listCodexAccountsForClient(), activeAccountId });
5526
- } catch (err) {
5527
- console.warn('[codex-accounts] login finalize failed:', err.message);
5528
- }
5529
- };
5530
- try {
5531
- _codexLoginWatcher = fsWatch(home, (_evt, fname) => {
5532
- if (fname === 'auth.json') setTimeout(finalize, 500);
5533
- });
5534
- setTimeout(() => { try { _codexLoginWatcher?.close?.(); } catch {} _codexLoginWatcher = null; }, 5 * 60 * 1000);
5535
- } catch (err) {
5536
- console.warn('[codex-accounts] auth.json watch failed:', err.message);
5537
- }
5538
- }
5539
-
5540
5679
  async function bootstrapThread(threadId, cwd = PACKAGE_ROOT) {
5541
5680
  await ensureInitialized();
5542
5681
  if (!threadId) {
@@ -5889,7 +6028,7 @@ CRITICAL — When you need clarification during planning:
5889
6028
  const turnParams = {
5890
6029
  threadId,
5891
6030
  cwd,
5892
- approvalPolicy: getCodexSidepanelApprovalPolicy(),
6031
+ approvalPolicy: getCodexSidepanelApprovalPolicy(_cachedConfig),
5893
6032
  personality: getCachedConfigValue('personality', 'pragmatic'),
5894
6033
  // opts.sandboxMode 'read-only': the Assistant brain of a WhatsApp session (lib/assistant-brains/codex.js).
5895
6034
  sandboxPolicy: buildCodexSandboxPolicy(cwd, isPlanModeTurn || opts.sandboxMode === 'read-only' ? 'read-only' : getCachedConfigValue('sandbox_mode')),
@@ -5898,8 +6037,8 @@ CRITICAL — When you need clarification during planning:
5898
6037
  if (Object.prototype.hasOwnProperty.call(_cachedConfig, 'service_tier')) turnParams.serviceTier = _cachedConfig.service_tier;
5899
6038
  if (getCachedConfigValue('model_reasoning_summary')) turnParams.summary = getCachedConfigValue('model_reasoning_summary');
5900
6039
  const sessionSettings = codexSessionSettings.get(activeAccountId, threadId);
5901
- for (const key of ['personality', 'summary', 'serviceTier', 'approvalPolicy', 'approvalsReviewer', 'disabledPluginIds']) {
5902
- if (Object.prototype.hasOwnProperty.call(sessionSettings, key)) turnParams[key] = sessionSettings[key];
6040
+ for (const key of ['personality', 'summary', 'serviceTier', 'approvalPolicy', 'sandboxPolicy', 'approvalsReviewer', 'disabledPluginIds']) {
6041
+ if (Object.prototype.hasOwnProperty.call(sessionSettings, key) && sessionSettings[key] !== null) turnParams[key] = sessionSettings[key];
5903
6042
  }
5904
6043
  if (Array.isArray(opts.localImages) && opts.localImages.length) {
5905
6044
  turnParams.local_images = opts.localImages;
@@ -5908,10 +6047,13 @@ CRITICAL — When you need clarification during planning:
5908
6047
  else if (getCachedConfigValue('model')) turnParams.model = getCachedConfigValue('model');
5909
6048
  if (opts.effort) turnParams.effort = opts.effort;
5910
6049
  else if (getCachedConfigValue('model_reasoning_effort')) turnParams.effort = getCachedConfigValue('model_reasoning_effort');
6050
+ if (isPlanModeTurn || opts.sandboxMode === 'read-only') turnParams.sandboxPolicy = buildCodexSandboxPolicy(cwd, 'read-only');
6051
+ if (sessionSettings.model != null) turnParams.model = sessionSettings.model;
6052
+ if (sessionSettings.effort != null) turnParams.effort = sessionSettings.effort;
5911
6053
  const collaborationMode = buildCodexCollaborationMode({
5912
6054
  mode: isPlanModeTurn ? 'plan' : 'default',
5913
6055
  model: turnParams.model,
5914
- effort: opts.effort || (isPlanModeTurn
6056
+ effort: sessionSettings.effort || opts.effort || (isPlanModeTurn
5915
6057
  ? getCachedConfigValue('plan_mode_reasoning_effort', turnParams.effort)
5916
6058
  : turnParams.effort),
5917
6059
  developerInstructions: opts.developerInstructions || null,
@@ -6140,6 +6282,8 @@ CRITICAL — When you need clarification during planning:
6140
6282
  if (msg.windowId) wsWindowId = msg.windowId;
6141
6283
  if (!child && msg.role === 'assistant') codexRole = 'assistant';
6142
6284
  if (!child && msg.role === 'assistant' && typeof msg.desktopGrant === 'string' && /^sbd_[A-Za-z0-9_-]{43}$/.test(msg.desktopGrant)) codexDesktopGrant = msg.desktopGrant;
6285
+ // Only the in-process brain's socket carries one (a client's WebSocket never does); no message field is read.
6286
+ if (!child && msg.role === 'assistant' && brainCredentialOf(ws)) codexBrainCredential = brainCredentialOf(ws);
6143
6287
  if (!child && msg.role === 'assistant' && msg.routeGate && Array.isArray(msg.routeGate.flags) && msg.routeGate.flags.every((f) => typeof f === 'string')) {
6144
6288
  codexRouteGate = { flags: [...msg.routeGate.flags], env: Object.fromEntries(Object.entries(msg.routeGate.env || {}).filter(([k, v]) => /^SYNABUN_ROUTE_GATE_[A-Z_]+$/.test(k) && typeof v === 'string')) };
6145
6289
  }
@@ -6231,10 +6375,21 @@ CRITICAL — When you need clarification during planning:
6231
6375
  if (await handleCodexCompatMessage(msg, {
6232
6376
  activeThreadId, activeTurnId, cwd: PACKAGE_ROOT,
6233
6377
  role: codexRole, getActiveThreadId: () => activeThreadId,
6378
+ sandboxPolicyForMode: (mode, cwd) => {
6379
+ validateCodexSettings({ sandbox_mode: mode }, readCodexSettingsSchema(getCodexBin()));
6380
+ return buildCodexSandboxPolicy(cwd, mode);
6381
+ },
6234
6382
  limitAppCall: message => appBoundary.consume(activeThreadId, message.originCallId),
6235
6383
  resolveAppCard: (callId, threadId) => appBoundary.resolve(threadId, callId,
6236
6384
  () => request('thread/read', { threadId, includeTurns: true }, 10000)),
6237
6385
  ensureInitialized, request, send: sendToClient,
6386
+ setFeatureEnablement: async enablement => {
6387
+ await request('config/batchWrite', {
6388
+ edits: buildCodexConfigEdits({ features: enablement }), reloadUserConfig: true,
6389
+ }, 10000);
6390
+ await readConfigState();
6391
+ return { enablement };
6392
+ },
6238
6393
  registry: codexCapabilities, capabilities: codexCapabilitySnapshot,
6239
6394
  withWriterOperation: withCodexWriterOperation,
6240
6395
  beforeRequest: beforeCodexCompatRequest, afterRequest: afterCodexCompatRequest,
@@ -6397,30 +6552,54 @@ CRITICAL — When you need clarification during planning:
6397
6552
  return;
6398
6553
  }
6399
6554
  if (msg.type === 'account_read') {
6555
+ // `accountState` is the account as every surface shows it (lib/codex-accounts.js describe()):
6556
+ // its mode, whether it is signed in and its identity. `account` is the running app-server's
6557
+ // own answer, kept for older pages; an account that is signed out is not started for it.
6558
+ const accounts = _codexAccounts();
6559
+ const known = accounts.describeForClient(activeAccountId);
6560
+ if (known?.signedIn === false && !child) {
6561
+ sendToClient({ type: 'account_info', requestId: msg.requestId || null, accountId: activeAccountId, account: { account: null }, accountState: known, state: panelAuth.snapshot() });
6562
+ return;
6563
+ }
6400
6564
  try {
6401
6565
  await ensureInitialized();
6402
6566
  const result = await request('account/read', { refreshToken: !!msg.refreshToken }, 10000);
6403
- sendToClient({ type: 'account_info', requestId: msg.requestId || null, account: result || {} });
6567
+ // The running app-server answers for its own store only while it runs on it (not on a held key).
6568
+ if (!childUsesHeldKey && result && Object.hasOwn(result, 'account')) accounts.noteNativeStatus(activeAccountId, result);
6569
+ sendToClient({ type: 'account_info', requestId: msg.requestId || null, accountId: activeAccountId, account: result || {}, accountState: accounts.describeForClient(activeAccountId), state: panelAuth.snapshot() });
6404
6570
  } catch (err) {
6405
- sendToClient({ type: 'account_info', requestId: msg.requestId || null, account: {}, error: err.message });
6571
+ sendToClient({ type: 'account_info', requestId: msg.requestId || null, accountId: activeAccountId, account: {}, accountState: accounts.describeForClient(activeAccountId), error: err.message });
6406
6572
  }
6407
6573
  return;
6408
6574
  }
6409
6575
  if (msg.type === 'codex_login') {
6410
6576
  try {
6577
+ if (activeTurnId || pendingServerRequests.size) throw new Error('Finish or stop the current turn before signing in');
6411
6578
  await ensureInitialized();
6412
- const result = await request('account/login/start', { type: msg.loginType || 'chatgpt' }, 10000);
6413
- sendToClient({ type: 'account_login_started', requestId: msg.requestId || null, login: result || {} });
6579
+ const state = await panelAuth.start(msg.loginType || 'chatgpt');
6580
+ sendToClient({ type: 'account_login_started', requestId: msg.requestId || null, state });
6414
6581
  } catch (err) {
6415
6582
  sendToClient({ type: 'error', requestId: msg.requestId || null, message: `Login failed: ${err.message}` });
6416
6583
  }
6417
6584
  return;
6418
6585
  }
6586
+ if (msg.type === 'account_login_status' || msg.type === 'account_login_cancel') {
6587
+ try {
6588
+ if (msg.type === 'account_login_cancel') await panelAuth.cancel();
6589
+ sendToClient({ type: 'account_login_state', requestId: msg.requestId || null, state: panelAuth.snapshot() });
6590
+ } catch (err) { sendToClient({ type: 'error', requestId: msg.requestId || null, message: err.message }); }
6591
+ return;
6592
+ }
6419
6593
  if (msg.type === 'account_logout') {
6594
+ // An older page's Logout: the credential the account uses now is signed out, the
6595
+ // same way as the account menu's per-credential sign-out (lib/codex-account-auth.js).
6420
6596
  try {
6421
- await ensureInitialized();
6422
- const result = await request('account/logout', {}, 10000);
6423
- sendToClient({ type: 'account_logged_out', requestId: msg.requestId || null, result: result || {} });
6597
+ if (activeTurnId || pendingServerRequests.size) throw new Error('Finish or stop the current turn before signing out');
6598
+ await panelAuth.cancel();
6599
+ const row = _codexAccounts().describe(activeAccountId);
6600
+ const account = await codexAccountAuth().signOut(activeAccountId, row?.mode === 'apiKey' ? 'apiKey' : 'subscription');
6601
+ panelAuth.reset();
6602
+ sendToClient({ type: 'account_logged_out', requestId: msg.requestId || null, result: {}, accountState: account });
6424
6603
  } catch (err) {
6425
6604
  sendToClient({ type: 'error', requestId: msg.requestId || null, message: `Logout failed: ${err.message}` });
6426
6605
  }
@@ -6437,7 +6616,7 @@ CRITICAL — When you need clarification during planning:
6437
6616
  }
6438
6617
  if (msg.type === 'account_switch') {
6439
6618
  try {
6440
- const target = getCodexAccount(msg.accountId);
6619
+ const target = findCodexAccount(msg.accountId);
6441
6620
  if (!target) throw new Error('Unknown account');
6442
6621
  if (target.id === activeAccountId) {
6443
6622
  const acct = await safeAccountRead();
@@ -6456,22 +6635,15 @@ CRITICAL — When you need clarification during planning:
6456
6635
  if (msg.type === 'account_add_start') {
6457
6636
  try {
6458
6637
  if (activeTurnId) throw new Error('Finish or stop the current turn before adding an account');
6459
- const id = `acct-${Date.now().toString(36)}`;
6460
- const home = createCodexAccountHome(id);
6461
- const data = loadCodexAccounts();
6462
- data.accounts.push({
6463
- id, label: 'New account', email: '', planType: '',
6464
- type: 'chatgpt', home, createdAt: new Date().toISOString(),
6465
- });
6466
- saveCodexAccounts(data);
6638
+ if (panelAuth.active()) throw new Error('Cancel or finish sign-in before adding an account');
6639
+ const { id } = _codexAccounts().create({});
6467
6640
  await withCodexWriterOperation('account_add_switch', () => switchCodexAccount(id));
6468
- const result = await request('account/login/start', { type: 'chatgpt' }, 15000);
6469
- watchCodexLogin(id, home);
6641
+ const state = await panelAuth.start('chatgpt');
6470
6642
  sendToClient({
6471
6643
  type: 'account_add_started',
6472
6644
  requestId: msg.requestId || null,
6473
6645
  accountId: id,
6474
- login: result || {},
6646
+ state,
6475
6647
  accounts: listCodexAccountsForClient(),
6476
6648
  activeAccountId,
6477
6649
  });
@@ -6483,28 +6655,14 @@ CRITICAL — When you need clarification during planning:
6483
6655
  if (msg.type === 'account_remove') {
6484
6656
  try {
6485
6657
  if (msg.accountId === 'default') throw new Error('The Default account cannot be removed');
6486
- const data = loadCodexAccounts();
6487
- const idx = data.accounts.findIndex((a) => a.id === msg.accountId);
6488
- if (idx === -1) throw new Error('Unknown account');
6489
- const nativeAccountInUse = _nativeLoopRuntime?.list({ activeOnly: true })
6490
- .some((run) => run.provider === 'codex' && run.accountId === msg.accountId)
6491
- || _assistantRuntime?.accountInUse?.('codex', msg.accountId)
6492
- || _assistantDispatcher?.accountInUse?.('codex', msg.accountId);
6493
- if (nativeAccountInUse) {
6494
- throw new Error('Stop native Codex automations using this account before removing it');
6495
- }
6496
- const removed = data.accounts[idx];
6658
+ if (!findCodexAccount(msg.accountId)) throw new Error('Unknown account');
6659
+ const reason = codexAccountInUse(msg.accountId, { except: liveConnection });
6660
+ if (reason) throw new Error(reason);
6497
6661
  if (activeAccountId === msg.accountId) {
6498
6662
  if (activeTurnId) throw new Error('Finish or stop the current turn before removing the active account');
6499
6663
  await withCodexWriterOperation('account_remove_switch', () => switchCodexAccount('default'));
6500
6664
  }
6501
- data.accounts.splice(idx, 1);
6502
- saveCodexAccounts(data);
6503
- try {
6504
- if (removed.home && removed.home !== CODEX_DEFAULT_HOME && removed.home.startsWith(CODEX_ACCOUNTS_ROOT)) {
6505
- rmSync(removed.home, { recursive: true, force: true });
6506
- }
6507
- } catch (e) { console.warn('[codex-accounts] Failed to delete home:', e.message); }
6665
+ _codexAccounts().remove(msg.accountId, { inUse: (id) => codexAccountInUse(id, { except: liveConnection }) });
6508
6666
  sendToClient({
6509
6667
  type: 'account_removed',
6510
6668
  requestId: msg.requestId || null,
@@ -6519,12 +6677,9 @@ CRITICAL — When you need clarification during planning:
6519
6677
  }
6520
6678
  if (msg.type === 'account_rename') {
6521
6679
  try {
6522
- const data = loadCodexAccounts();
6523
- const acct = data.accounts.find((a) => a.id === msg.accountId);
6524
- if (!acct) throw new Error('Unknown account');
6525
- const label = String(msg.label || '').trim().slice(0, 60);
6526
- if (label) acct.label = label;
6527
- saveCodexAccounts(data);
6680
+ const label = String(msg.label || '').trim().slice(0, 80);
6681
+ const acct = label ? _codexAccounts().rename(msg.accountId, label) : _codexAccounts().describe(msg.accountId);
6682
+ if (!acct || acct.id !== msg.accountId) throw new Error('Unknown account');
6528
6683
  sendToClient({
6529
6684
  type: 'account_renamed',
6530
6685
  requestId: msg.requestId || null,
@@ -6551,7 +6706,8 @@ CRITICAL — When you need clarification during planning:
6551
6706
  try {
6552
6707
  await ensureInitialized();
6553
6708
  const result = await readConfigState(msg.cwd || PACKAGE_ROOT);
6554
- sendToClient({ type: 'config_data', requestId: msg.requestId || null, config: result || {}, sessionOverrides: codexSessionSettings.get(activeAccountId, msg.threadId || activeThreadId) });
6709
+ const settingsSchema = await readSettingsMetadata();
6710
+ sendToClient({ type: 'config_data', requestId: msg.requestId || null, config: result || {}, settingsSchema, sessionOverrides: codexSessionSettings.get(activeAccountId, msg.threadId || activeThreadId) });
6555
6711
  } catch (err) {
6556
6712
  sendToClient({ type: 'config_data', requestId: msg.requestId || null, config: {}, error: err.message });
6557
6713
  }
@@ -6560,12 +6716,14 @@ CRITICAL — When you need clarification during planning:
6560
6716
  if (msg.type === 'config_write') {
6561
6717
  try {
6562
6718
  await ensureInitialized();
6563
- const nextConfig = validateCodexConfigRequirements(msg.config || {}, await readCodexRequirements());
6719
+ const metadata = await readSettingsMetadata();
6720
+ const supported = validateCodexSettings(msg.config || {}, metadata, metadata.features);
6721
+ const nextConfig = validateCodexConfigRequirements(supported, await readCodexRequirements());
6564
6722
  const edits = buildCodexConfigEdits(nextConfig);
6565
6723
  await request('config/batchWrite', { edits, reloadUserConfig: true }, 10000);
6566
- await readConfigState(msg.cwd || PACKAGE_ROOT);
6724
+ const config = await readConfigState(msg.cwd || PACKAGE_ROOT);
6567
6725
  codexContextModels = [];
6568
- sendToClient({ type: 'config_saved', requestId: msg.requestId || null });
6726
+ sendToClient({ type: 'config_saved', requestId: msg.requestId || null, config });
6569
6727
  sendToClient({ type: 'catalogs_changed', catalogs: ['models', 'skills', 'apps', 'mcp', 'features', 'config'] });
6570
6728
  } catch (err) {
6571
6729
  sendToClient({ type: 'error', requestId: msg.requestId || null, message: `Config save failed: ${err.message}` });
@@ -6671,7 +6829,11 @@ CRITICAL — When you need clarification during planning:
6671
6829
  if (msg.type === 'experimental_features') {
6672
6830
  try {
6673
6831
  await ensureInitialized();
6832
+ await readConfigState(msg.cwd || PACKAGE_ROOT);
6674
6833
  const result = await request('experimentalFeature/list', codexPagination(msg), 10000);
6834
+ for (const feature of result?.data || []) {
6835
+ if (typeof _cachedConfig.features?.[feature.name] === 'boolean') feature.enabled = _cachedConfig.features[feature.name];
6836
+ }
6675
6837
  sendToClient({ type: 'experimental_features', requestId: msg.requestId || null, features: result || {} });
6676
6838
  } catch (err) {
6677
6839
  sendToClient({ type: 'experimental_features', requestId: msg.requestId || null, features: {}, error: err.message });
@@ -7207,15 +7369,20 @@ CRITICAL — When you need clarification during planning:
7207
7369
  }
7208
7370
  void retireCurrentCodexChild('WebSocket closed without a reattach identity', { unsubscribe: false });
7209
7371
  };
7372
+ ws.on('close', () => _codexLiveConnections.delete(liveConnection));
7210
7373
  ws.on('close', handleCodexSocketClose);
7211
7374
  }
7212
7375
 
7213
7376
  const _codexModelListCache = new Map();
7214
7377
  const CODEX_MODEL_LIST_CACHE_MS = 60_000;
7215
7378
 
7216
- async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME) {
7217
- let codexBin = loadCliConfig()?.codex?.command?.trim() || getCodexBin();
7218
- const args = ['app-server'];
7379
+ // `launch` (lib/codex-accounts.js launchFor()): the list is read under the account's own credential,
7380
+ // with no inherited one, as its sessions would run (in API-key mode, the key it holds over stdin).
7381
+ async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME, launch = null) {
7382
+ ensureCanonicalCodexConfig({ strict: true, codexHome: launch?.home || codexHome });
7383
+ // The Codex the resolver found (Settings' path first); a command line in Settings ("wsl codex") as written.
7384
+ let codexBin = configuredCommandLine('codex') || getCodexBin();
7385
+ const args = [...(launch?.ok ? codexAppServerArgs(launch) : []), 'app-server'];
7219
7386
  if (!/\s/.test(codexBin) && /\.js$/i.test(codexBin)) {
7220
7387
  args.unshift(codexBin);
7221
7388
  codexBin = process.execPath;
@@ -7223,9 +7390,12 @@ async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME) {
7223
7390
  const useShell = /\s/.test(codexBin)
7224
7391
  || !codexBin.includes(sep)
7225
7392
  || (process.platform === 'win32' && /\.(cmd|bat)$/i.test(codexBin));
7226
- const env = { ...process.env, CODEX_HOME: codexHome, NO_COLOR: '1' };
7393
+ const env = launch?.ok
7394
+ ? { ...applyCodexLaunchEnv(process.env, launch), NO_COLOR: '1' }
7395
+ : { ...process.env, CODEX_HOME: codexHome, NO_COLOR: '1' };
7227
7396
  delete env.FORCE_COLOR;
7228
7397
  delete env.CLICOLOR_FORCE;
7398
+ let apiKey = launch?.ok ? launch.apiKey : null;
7229
7399
 
7230
7400
  return new Promise((resolve, reject) => {
7231
7401
  // (A configured command may be a command line; a file with a space in its path is one word.)
@@ -7311,6 +7481,10 @@ async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME) {
7311
7481
  clientInfo: { name: 'synabun-codex-models', title: 'SynaBun Codex Models', version: '0.0.1' },
7312
7482
  capabilities: { experimentalApi: true },
7313
7483
  }, 15000);
7484
+ if (apiKey) {
7485
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'initialized' }) + '\n');
7486
+ try { await request('account/login/start', { type: 'apiKey', apiKey }, 15000); } finally { apiKey = null; }
7487
+ }
7314
7488
  const result = await request('model/list', { includeHidden: false }, 10000);
7315
7489
  cleanup();
7316
7490
  resolve(Array.isArray(result?.models) ? result.models : (Array.isArray(result?.data) ? result.data : []));
@@ -7334,7 +7508,7 @@ app.get('/api/codex/models', async (req, res) => {
7334
7508
  return res.json({ ok: true, source: 'cache', models: cached.models });
7335
7509
  }
7336
7510
  const models = mergeCodexModelContextCapabilities(
7337
- await queryCodexAppServerModels(codexHome),
7511
+ await queryCodexAppServerModels(codexHome, _codexAccounts().launchFor(account.id)),
7338
7512
  readCodexRuntimeModelCatalog(codexHome),
7339
7513
  );
7340
7514
  // The Assistant's catalog is built from the default account's list: when that list
@@ -7348,12 +7522,20 @@ app.get('/api/codex/models', async (req, res) => {
7348
7522
  }
7349
7523
  });
7350
7524
 
7351
- // GET /api/codex/accounts — list saved ChatGPT/Codex accounts for launch selectors
7352
- // (Automation Studio + Schedules). Mirrors the per-tab account registry used by
7353
- // the Codex sidepanel; each account maps to its own CODEX_HOME.
7354
- app.get('/api/codex/accounts', (_req, res) => {
7525
+ // GET /api/codex/accounts — every Codex account with its state (mode, signed in,
7526
+ // identity: an email or a masked key, never a key) for the Codex panel's account
7527
+ // menu and the launch selectors (Automation Studio, Schedules). Emails are
7528
+ // personal data: not for invite guests. `?refresh=true` asks Codex about every
7529
+ // account's own store first; otherwise an old answer is asked again in the
7530
+ // background and announced (`codex:accounts-changed`) when it changes.
7531
+ app.get('/api/codex/accounts', async (req, res) => {
7532
+ if (isGuestRequest(req)) return res.status(403).json({ ok: false, error: 'Admin only' });
7533
+ res.setHeader('Cache-Control', 'no-store');
7355
7534
  try {
7356
- res.json({ ok: true, accounts: listCodexAccountsForClient() });
7535
+ const accounts = _codexAccounts();
7536
+ if (req.query?.refresh === 'true') await accounts.refreshStatus(null, { force: true });
7537
+ else accounts.refreshStatus().catch(() => {});
7538
+ res.json({ ok: true, accounts: accounts.listForClient() });
7357
7539
  } catch (err) {
7358
7540
  res.json({ ok: false, error: err.message, accounts: [] });
7359
7541
  }
@@ -7779,7 +7961,7 @@ function updateOpencodeRuntimeProfileConfig(xdgRoot, profile) {
7779
7961
  return true;
7780
7962
  }
7781
7963
 
7782
- async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, keepAlive = false, mcpProfile = null } = {}) {
7964
+ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, keepAlive = false, mcpProfile = null, brainCredential = null } = {}) {
7783
7965
  if (_ocpHistoryClearInProgress) throw new Error('OpenCode history is being cleared');
7784
7966
  const requestedProfile = normalizeMcpProfileName(mcpProfile || readActiveMcpProfile());
7785
7967
  let entry = _ocpIsoServes.get(termId);
@@ -7804,11 +7986,6 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
7804
7986
  entry = { termId, port, xdgRoot, mcpProfile: requestedProfile, mcpProfileRefreshFailed: false, proc: null, client: null, ready: false, starting: true, keepAlive: !!keepAlive, unsub: null, sessions: new Set(), lastUsed: Date.now() };
7805
7987
  _ocpIsoServes.set(termId, entry);
7806
7988
  try {
7807
- const config = loadCliConfig();
7808
- const cmd = config.opencode?.command || 'opencode';
7809
- const shellArgs = IS_WIN
7810
- ? ['/d', '/s', '/c', `${cmd} serve --port ${port}`]
7811
- : ['-lc', `exec ${cmd} serve --port ${port}`];
7812
7989
  const isolatedEnv = {
7813
7990
  ...process.env,
7814
7991
  XDG_CONFIG_HOME: xdgRoot,
@@ -7820,11 +7997,16 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
7820
7997
  // Keep this above the script's own deadlines while retaining a hard cap.
7821
7998
  OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS:
7822
7999
  process.env.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || '300000',
8000
+ // A WhatsApp conversation's brain credential: the config names it ({env:SYNABUN_BRAIN_TOKEN}), only this
8001
+ // serve's environment holds it (a fresh serve per brain launch: the brain stops its serve when it ends).
8002
+ ...(brainCredential ? { SYNABUN_BRAIN_TOKEN: brainCredential } : {}),
7823
8003
  };
7824
8004
  delete isolatedEnv.CLAUDECODE;
7825
- const proc = spawn(DEFAULT_SHELL, shellArgs, {
8005
+ if (!brainCredential) delete isolatedEnv.SYNABUN_BRAIN_TOKEN;
8006
+ const launch = opencodeServeLaunch(port, { env: isolatedEnv });
8007
+ const proc = spawn(launch.command, launch.args, {
7826
8008
  stdio: ['ignore', 'pipe', 'pipe'],
7827
- env: isolatedEnv,
8009
+ ...launch.options,
7828
8010
  detached: false, shell: false,
7829
8011
  });
7830
8012
  entry.proc = proc;
@@ -8490,15 +8672,11 @@ async function ensureOpencodeServer({ allowDuringHistoryClear = false } = {}) {
8490
8672
 
8491
8673
  // 2. Spawn opencode serve
8492
8674
  try {
8493
- const config = loadCliConfig();
8494
- const cmd = config.opencode?.command || 'opencode';
8495
- console.log(`[opencode] Spawning: ${cmd} serve --port ${_ocpPort}`);
8496
- const shellArgs = IS_WIN
8497
- ? ['/d', '/s', '/c', `${cmd} serve --port ${_ocpPort}`]
8498
- : ['-lc', `exec ${cmd} serve --port ${_ocpPort}`];
8499
- _ocpProc = spawn(DEFAULT_SHELL, shellArgs, {
8675
+ const launch = opencodeServeLaunch(_ocpPort, { env: { ...process.env, FORCE_COLOR: '0', OPENCODE_DISABLE_CLAUDE_CODE: '1' } });
8676
+ console.log(`[opencode] Spawning: ${launch.label}`);
8677
+ _ocpProc = spawn(launch.command, launch.args, {
8500
8678
  stdio: ['ignore', 'pipe', 'pipe'],
8501
- env: { ...process.env, FORCE_COLOR: '0', OPENCODE_DISABLE_CLAUDE_CODE: '1' },
8679
+ ...launch.options,
8502
8680
  detached: false,
8503
8681
  shell: false,
8504
8682
  });
@@ -8786,7 +8964,7 @@ app.post('/api/ollama/configure', async (req, res) => {
8786
8964
  } catch {}
8787
8965
  if (needsInstall) {
8788
8966
  console.log('[ollama] Installing @ai-sdk/openai-compatible…');
8789
- execSync('npm install @ai-sdk/openai-compatible --save', { cwd: configDir, timeout: 60000, stdio: 'pipe' });
8967
+ execSync('npm install @ai-sdk/openai-compatible --save', { cwd: configDir, timeout: 60000, stdio: 'pipe', env: withPackagedPathLast(process.env) });
8790
8968
  console.log('[ollama] Package installed');
8791
8969
  }
8792
8970
 
@@ -8885,6 +9063,7 @@ app.post('/api/sidepanel/session-title', async (req, res) => {
8885
9063
  }
8886
9064
  const result = await generateSessionTitle(input, {
8887
9065
  claudeExecutable: getClaudeSdkExecutable,
9066
+ childEnv: claudeChildEnv,
8888
9067
  execFile: execFileAsync,
8889
9068
  getCodexBin,
8890
9069
  getCodexAccountHome,
@@ -9516,6 +9695,7 @@ const _claudeBridgeReady = (async () => {
9516
9695
  try {
9517
9696
  _claudeBridge = await import('./lib/claude-agent-bridge.js');
9518
9697
  _claudeBridge.configureClaudeBridge({
9698
+ accountRateLimit: (id, info) => _assistantAccountUsage?.note('claude-code', id, info),
9519
9699
  // The greeting a panel conversation opened with, as the one note its model gets (lib/greeting/).
9520
9700
  greetingNote: (id, opts) => _greeting?.consumeNote(id, opts) || '',
9521
9701
  // A prompt typed in the panel: when the person was last here (a temporary chat is named to nobody).
@@ -9556,9 +9736,16 @@ const _claudeBridgeReady = (async () => {
9556
9736
  // a session without one is refused with the install hint. Lazy, so the
9557
9737
  // (memoized) resolution happens on demand, not at boot.
9558
9738
  claudeExecutable: () => getClaudeSdkExecutable(),
9739
+ // On Windows, the Git Bash every session's Claude Code gets (lib/git-bash/): SynaBun's copy where the PC has
9740
+ // none, so the Bash tool exists and the hooks run in bash. Panel tabs and the Assistant's brain.
9741
+ childEnv: (env) => claudeChildEnv(env),
9559
9742
  // Sidepanel tabs can run under another Claude account (its own config
9560
9743
  // directory). The default account adds nothing to the environment.
9561
9744
  claudeAccountEnv: (accountId) => _panelClaudeAccounts().envFor(accountId),
9745
+ // How a session runs under an account: inherited credentials removed, the
9746
+ // account's directory and, in API-key mode, its key added (launchFor() in
9747
+ // lib/claude-accounts.js). Panel tabs and the Assistant's brain.
9748
+ claudeAccountLaunch: (accountId) => _panelClaudeAccounts().launchFor(accountId),
9562
9749
  // This server has the session routes (rename, tag, fork, delete, subagent
9563
9750
  // transcripts): the panel offers those actions only when told so.
9564
9751
  sessionOps: true,
@@ -9591,14 +9778,42 @@ const _claudeBridgeReady = (async () => {
9591
9778
  }
9592
9779
  })();
9593
9780
 
9594
- // The Claude accounts registry, as the sidepanel reads it (the Assistant keeps
9595
- // its own instance over the same file; both only read here).
9781
+ // The Claude accounts registry: one instance for the sidepanel, the Assistant
9782
+ // (brain, workers, catalog) and every route, so one account state is shown
9783
+ // everywhere (lib/claude-accounts.js describe()). Its subscription half is
9784
+ // Claude Code's own `auth status --json` for each account; a change of any
9785
+ // account reaches every window as `claude:accounts-changed`.
9596
9786
  let _panelClaudeAccountsInstance = null;
9597
9787
  function _panelClaudeAccounts() {
9598
- if (!_panelClaudeAccountsInstance) _panelClaudeAccountsInstance = createPanelClaudeAccounts({ dataHome: DATA_HOME, homeDir: getHomePath() });
9788
+ if (!_panelClaudeAccountsInstance) {
9789
+ _panelClaudeAccountsInstance = createPanelClaudeAccounts({
9790
+ dataHome: DATA_HOME, homeDir: getHomePath(),
9791
+ readNativeStatus: (accountId) => panelClaudeAuth().nativeStatus(accountId),
9792
+ log: (tag, message) => console.log(`[${tag}]`, message),
9793
+ });
9794
+ _panelClaudeAccountsInstance.subscribe((event) => {
9795
+ try {
9796
+ broadcastSync({ type: 'claude:accounts-changed', accountId: event.accountId || null, removed: !!event.removed, created: !!event.created });
9797
+ // The Assistant's account list and catalog listen to their own event.
9798
+ broadcastSync({ type: 'assistant:accounts-changed', provider: 'claude-code', accountId: event.accountId || null, removed: !!event.removed });
9799
+ _assistantCatalog?.invalidate?.();
9800
+ } catch {}
9801
+ });
9802
+ }
9599
9803
  return _panelClaudeAccountsInstance;
9600
9804
  }
9601
9805
 
9806
+ // Why a Claude account cannot be removed now ('' when it can): a live session
9807
+ // (a panel tab, the Assistant's brain), a dispatched run, or the Assistant's
9808
+ // choice of worker account names it.
9809
+ function claudeAccountInUse(accountId) {
9810
+ try { if (_claudeBridge?.accountInUse?.(accountId)) return 'A Claude tab or the Assistant is running under this account. End that session first.'; } catch {}
9811
+ try { if (_assistantDispatcher?.accountInUse?.('claude-code', accountId)) return 'Dispatched runs are using this account. Stop them first.'; } catch {}
9812
+ try { if (_assistantRuntime?.accountInUse?.('claude-code', accountId)) return 'An Assistant session uses this account. Close it first.'; } catch {}
9813
+ try { if (_assistantClaudeWorkerAccount?.() === accountId) return 'This is the Assistant\'s Claude worker account. Choose another one first.'; } catch {}
9814
+ return '';
9815
+ }
9816
+
9602
9817
  // The config directory of the Claude account a sidepanel request names: '' for
9603
9818
  // the default account. An account that is no longer set up is an error (404,
9604
9819
  // `account_unavailable`), never the default account's folder in its place.
@@ -9611,14 +9826,159 @@ function panelAccountHome(value) {
9611
9826
  return home;
9612
9827
  }
9613
9828
 
9614
- // GET /api/claude/accounts — the accounts a sidepanel tab can run under. Emails
9615
- // are personal data: not for invite guests.
9616
- app.get('/api/claude/accounts', (req, res) => {
9829
+ // GET /api/claude/accounts — every Claude account with its state (mode,
9830
+ // signed in, identity: an email or a masked key, never a key). Emails are
9831
+ // personal data: not for invite guests. `?refresh=true` asks Claude Code about
9832
+ // every account's subscription first; otherwise an answer older than a few
9833
+ // minutes is asked again in the background and announced when it changes.
9834
+ app.get('/api/claude/accounts', async (req, res) => {
9617
9835
  if (isGuestRequest(req)) return res.status(403).json({ ok: false, error: 'Admin only' });
9618
- try { res.json({ ok: true, accounts: _panelClaudeAccounts().listForClient() }); }
9836
+ res.setHeader('Cache-Control', 'no-store');
9837
+ try {
9838
+ const accounts = _panelClaudeAccounts();
9839
+ if (req.query.refresh === 'true') await accounts.refreshStatus(null, { force: true });
9840
+ else accounts.refreshStatus().catch(() => {});
9841
+ res.json({ ok: true, accounts: accounts.listForClient() });
9842
+ }
9619
9843
  catch (err) { res.status(500).json({ ok: false, error: err.message }); }
9620
9844
  });
9621
9845
 
9846
+ let _panelClaudeAuthInstance = null;
9847
+ const panelAuthOwnerGuard = ownerRouteGuard({ isGuestRequest, port: PORT, allowedOrigins: publicWebOrigins, refuseCrossSite: true });
9848
+ function panelClaudeAuth() {
9849
+ if (!_panelClaudeAuthInstance) {
9850
+ _panelClaudeAuthInstance = createClaudePanelAuth({ accounts: _panelClaudeAccounts(), executable: getClaudeSdkExecutable, env: () => (withPathEnv(process.env, getAugmentedPath())), changeCredentials: (id, change) => _claudeBridge?.withPanelAccountAuthChange ? _claudeBridge.withPanelAccountAuthChange(id, change) : change() });
9851
+ process.once('exit', () => _panelClaudeAuthInstance?.close());
9852
+ }
9853
+ return _panelClaudeAuthInstance;
9854
+ }
9855
+
9856
+ // Admin-only account auth; OAuth stays native and pasted keys are profile-scoped.
9857
+ app.get('/api/claude/accounts/:id/auth', panelAuthOwnerGuard, async (req, res) => {
9858
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9859
+ res.setHeader('Cache-Control', 'no-store');
9860
+ try {
9861
+ panelAccountHome(req.params.id);
9862
+ const result = req.query.refresh === 'true' ? await panelClaudeAuth().read(req.params.id) : { state: panelClaudeAuth().snapshot(req.params.id) };
9863
+ res.json(result);
9864
+ } catch (error) { res.status(error.status || 500).json({ error: error.message }); }
9865
+ });
9866
+ app.post('/api/claude/accounts/:id/auth/:action', panelAuthOwnerGuard, async (req, res) => {
9867
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9868
+ res.setHeader('Cache-Control', 'no-store');
9869
+ try {
9870
+ panelAccountHome(req.params.id);
9871
+ const auth = panelClaudeAuth();
9872
+ if (req.params.action === 'start') return res.json({ state: auth.start(req.params.id) });
9873
+ if (req.params.action === 'cancel') return res.json({ state: auth.cancel(req.params.id, req.body?.operationId) });
9874
+ if (req.params.action === 'code') return res.json({ state: auth.submitCode(req.params.id, req.body?.operationId, req.body?.code) });
9875
+ if (req.params.action === 'logout') {
9876
+ // `credential`: 'apiKey', 'subscription', or both when left out (the older /logout).
9877
+ const credential = typeof req.body?.credential === 'string' ? req.body.credential : 'all';
9878
+ await auth.signOut(req.params.id, credential);
9879
+ return res.json({ ok: true, account: _panelClaudeAccounts().describeForClient(req.params.id) });
9880
+ }
9881
+ res.status(404).json({ error: 'Unknown authentication action' });
9882
+ } catch (error) { res.status(error.status || 500).json({ error: error.message }); }
9883
+ });
9884
+
9885
+ registerProviderPanelApiKeyAuthRoutes(app, {
9886
+ guard: panelAuthOwnerGuard,
9887
+ claude: (id, key) => panelClaudeAuth().signInApiKey(id, key),
9888
+ // A Codex key is checked with OpenAI and kept by SynaBun for that account (lib/codex-account-auth.js);
9889
+ // the account's idle Codex processes restart on it. Codex's own store (its ChatGPT login) is not touched.
9890
+ codex: (id, key) => codexAccountAuth().signInApiKey(id, key),
9891
+ });
9892
+
9893
+ // Codex accounts: the same model and routes as Claude's (lib/codex-accounts.js describe(), launchFor()).
9894
+ // Everything but the list carries the owner guard; a credential or mode change restarts the
9895
+ // account's idle panel processes and is refused while one of them is mid-turn.
9896
+ app.post('/api/codex/accounts', panelAuthOwnerGuard, (req, res) => {
9897
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9898
+ res.setHeader('Cache-Control', 'no-store');
9899
+ try {
9900
+ const created = _codexAccounts().create({ label: req.body?.label });
9901
+ res.json({ account: _codexAccounts().describeForClient(created.id) });
9902
+ } catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
9903
+ });
9904
+ // Rename (`label`) and/or choose what the account's sessions use (`mode`: 'subscription' (ChatGPT) | 'apiKey').
9905
+ app.patch('/api/codex/accounts/:id', panelAuthOwnerGuard, async (req, res) => {
9906
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9907
+ res.setHeader('Cache-Control', 'no-store');
9908
+ try {
9909
+ const accounts = _codexAccounts();
9910
+ if (accounts.describe(req.params.id)?.id !== req.params.id) return res.status(404).json({ code: 'ACCOUNT_NOT_FOUND', error: 'That Codex account is no longer set up in SynaBun.' });
9911
+ const { label, mode } = req.body || {};
9912
+ if (label === undefined && mode === undefined) return res.status(400).json({ code: 'NOTHING_TO_CHANGE', error: 'Send a label or a mode.' });
9913
+ if (label !== undefined) accounts.rename(req.params.id, label);
9914
+ if (mode !== undefined) await codexAccountAuth().setMode(req.params.id, mode);
9915
+ res.json({ ok: true, account: accounts.describeForClient(req.params.id) });
9916
+ } catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
9917
+ });
9918
+ // Remove an added account and its folder. Default stays; an account something uses stays.
9919
+ app.delete('/api/codex/accounts/:id', panelAuthOwnerGuard, (req, res) => {
9920
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9921
+ res.setHeader('Cache-Control', 'no-store');
9922
+ try { res.json(_codexAccounts().remove(req.params.id, { inUse: (id) => codexAccountInUse(id) })); }
9923
+ catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
9924
+ });
9925
+ // `?refresh=true` asks Codex what the account's own store holds first.
9926
+ app.get('/api/codex/accounts/:id/auth', panelAuthOwnerGuard, async (req, res) => {
9927
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9928
+ res.setHeader('Cache-Control', 'no-store');
9929
+ try {
9930
+ if (_codexAccounts().describe(req.params.id)?.id !== req.params.id) return res.status(404).json({ code: 'ACCOUNT_NOT_FOUND', error: 'That Codex account is no longer set up in SynaBun.' });
9931
+ if (req.query.refresh === 'true') return res.json(await codexAccountAuth().read(req.params.id));
9932
+ res.json({ accountId: req.params.id, account: _codexAccounts().describeForClient(req.params.id) });
9933
+ } catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
9934
+ });
9935
+ // `credential`: 'apiKey' (the account's key), 'subscription' (Codex's own logout in its home), or both when left out.
9936
+ app.post('/api/codex/accounts/:id/auth/logout', panelAuthOwnerGuard, async (req, res) => {
9937
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9938
+ res.setHeader('Cache-Control', 'no-store');
9939
+ try {
9940
+ if (_codexAccounts().describe(req.params.id)?.id !== req.params.id) return res.status(404).json({ code: 'ACCOUNT_NOT_FOUND', error: 'That Codex account is no longer set up in SynaBun.' });
9941
+ const credential = typeof req.body?.credential === 'string' ? req.body.credential : 'all';
9942
+ const account = await codexAccountAuth().signOut(req.params.id, credential);
9943
+ res.json({ ok: true, account });
9944
+ } catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
9945
+ });
9946
+
9947
+ app.post('/api/claude/accounts', panelAuthOwnerGuard, (req, res) => {
9948
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9949
+ try {
9950
+ const created = _panelClaudeAccounts().create({ label: req.body?.label });
9951
+ const row = _panelClaudeAccounts().listForClient().find(a => a.id === created.id);
9952
+ res.json({ account: row });
9953
+ } catch (error) { res.status(error.status || 500).json({ error: error.message }); }
9954
+ });
9955
+
9956
+ // Rename an account (`label`) or choose what its sessions use (`mode`:
9957
+ // 'subscription' | 'apiKey'; the chosen credential must be there). A mode
9958
+ // change ends the account's idle sessions so the next message starts under it.
9959
+ app.patch('/api/claude/accounts/:id', panelAuthOwnerGuard, async (req, res) => {
9960
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9961
+ res.setHeader('Cache-Control', 'no-store');
9962
+ try {
9963
+ const accounts = _panelClaudeAccounts();
9964
+ if (!accounts.describe(req.params.id) || accounts.describe(req.params.id).id !== req.params.id) return res.status(404).json({ code: 'ACCOUNT_NOT_FOUND', error: 'That Claude account is no longer set up in SynaBun.' });
9965
+ const { label, mode } = req.body || {};
9966
+ if (label === undefined && mode === undefined) return res.status(400).json({ code: 'NOTHING_TO_CHANGE', error: 'Send a label or a mode.' });
9967
+ if (label !== undefined) accounts.rename(req.params.id, label);
9968
+ if (mode !== undefined) await panelClaudeAuth().setMode(req.params.id, mode);
9969
+ res.json({ ok: true, account: accounts.describeForClient(req.params.id) });
9970
+ } catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
9971
+ });
9972
+
9973
+ // Remove an added account and its config directory. Default stays; an account
9974
+ // a live session, a dispatched run or the Assistant's worker choice uses stays.
9975
+ app.delete('/api/claude/accounts/:id', panelAuthOwnerGuard, (req, res) => {
9976
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9977
+ res.setHeader('Cache-Control', 'no-store');
9978
+ try { res.json(_panelClaudeAccounts().remove(req.params.id, { inUse: claudeAccountInUse })); }
9979
+ catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
9980
+ });
9981
+
9622
9982
  function _readClaudeSkinConfig() {
9623
9983
  try {
9624
9984
  const cfg = JSON.parse(readFileSync(resolve(DATA_HOME, 'data', 'cli-config.json'), 'utf-8'));
@@ -10806,8 +11166,9 @@ app.post('/api/youtube/download', async (req, res) => {
10806
11166
 
10807
11167
  // A GUI-launched NI may not inherit the user's shell PATH, so yt-dlp can't find
10808
11168
  // node/deno/ffmpeg. Prepend the running node's dir + the common Homebrew/local bins.
11169
+ // A packaged application's own runtime still goes last (withPackagedPathLast): the user's node first when there is one.
10809
11170
  const extraPath = [dirname(process.execPath), '/opt/homebrew/bin', '/usr/local/bin', join(os.homedir(), '.local/bin')].join(delimiter);
10810
- const childEnv = { ...process.env, PATH: `${extraPath}${delimiter}${process.env.PATH || ''}` };
11171
+ const childEnv = withPackagedPathLast(withPathEnv(process.env, `${extraPath}${delimiter}${pathEnvValue(process.env)}`));
10811
11172
 
10812
11173
  const runYtDlp = (finalArgs) => new Promise((done) => {
10813
11174
  const child = spawn(yd.cmd, finalArgs, { windowsHide: true, env: childEnv });
@@ -12767,8 +13128,10 @@ app.get('/api/loop/active', (req, res) => {
12767
13128
  if (data?.active) {
12768
13129
  const elapsed = Date.now() - new Date(data.startedAt).getTime();
12769
13130
  const maxMs = ((data.maxMinutes || 30) + 5) * 60 * 1000;
12770
- // Stale loop — past time cap + grace, delete it
12771
- if (elapsed >= maxMs) {
13131
+ // Stale loop — past time cap + grace, delete it. Not a dispatched worker's (runMode
13132
+ // 'task'): its maxMinutes is work time, which the dispatcher enforces, and a warm run
13133
+ // idles for a follow-up far past it; a dead one goes with the check below.
13134
+ if (data.runMode !== 'task' && elapsed >= maxMs) {
12772
13135
  try { unlinkSync(filePath); } catch { /* ok */ }
12773
13136
  continue;
12774
13137
  }
@@ -12780,6 +13143,10 @@ app.get('/api/loop/active', (req, res) => {
12780
13143
  continue;
12781
13144
  }
12782
13145
  }
13146
+ // A dispatched worker's time left is its work time left (the dispatcher's work clock); null
13147
+ // while the dispatcher does not know the run (right after a restart): never the wall clock.
13148
+ const isTaskRun = data.runMode === 'task';
13149
+ const work = isTaskRun && data.runId ? (_assistantDispatcher?.peek?.(data.runId) || null) : null;
12783
13150
  activeLoops.push({
12784
13151
  sessionId: f.replace('.json', ''),
12785
13152
  task: data.task,
@@ -12788,7 +13155,9 @@ app.get('/api/loop/active', (req, res) => {
12788
13155
  totalIterations: data.totalIterations || 10,
12789
13156
  maxMinutes: data.maxMinutes || 30,
12790
13157
  elapsedMinutes: Math.round(elapsed / 60000),
12791
- remainingMinutes: Math.max(0, Math.round(((data.maxMinutes || 30) * 60 * 1000 - elapsed) / 60000)),
13158
+ remainingMinutes: work && Number.isFinite(work.workLeftMs)
13159
+ ? Math.round(work.workLeftMs / 60000)
13160
+ : isTaskRun ? null : Math.max(0, Math.round(((data.maxMinutes || 30) * 60 * 1000 - elapsed) / 60000)),
12792
13161
  startedAt: data.startedAt,
12793
13162
  lastIterationAt: data.lastIterationAt,
12794
13163
  browserSessionId: data.browserSessionId || null,
@@ -15454,7 +15823,7 @@ function spawnAgentProcess(agent, prompt, extraEnv = {}) {
15454
15823
 
15455
15824
  const child = spawn(shellCommand(claudeBin, AGENT_IS_WIN), args, {
15456
15825
  cwd: agent.cwd,
15457
- env: { ...cleanEnv, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb', ...extraEnv },
15826
+ env: claudeChildEnv({ ...cleanEnv, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb', ...extraEnv }),
15458
15827
  stdio: ['ignore', 'pipe', 'pipe'],
15459
15828
  shell: AGENT_IS_WIN,
15460
15829
  windowsHide: true,
@@ -16129,6 +16498,18 @@ async function checkSynabunUpdate() {
16129
16498
  // stable releases with a suffix.
16130
16499
  const installedChannel = synabunReleaseChannel(current);
16131
16500
 
16501
+ // The desktop app updates itself (packaging/desktop: its window's update function, fed by the GitHub
16502
+ // releases' desktop files). Nothing here offers it an npm or GitHub update; there is nothing to check.
16503
+ if (PACKAGED_RUNTIME?.shell) {
16504
+ _synabunUpdateCache = {
16505
+ ..._synabunUpdateCache,
16506
+ current, installedChannel, latest: null, npmUpdateAvailable: false, gitUpdateAvailable: false, updateAvailable: false,
16507
+ source: null, installPlan: null, checkedAt: new Date().toISOString(), npmError: null, gitError: null, desktopManaged: true,
16508
+ };
16509
+ console.log(` Updates: from the SynaBun app (v${current})`);
16510
+ return;
16511
+ }
16512
+
16132
16513
  const fetchJsonWithTimeout = async (url, ms = 5000) => {
16133
16514
  const controller = new AbortController();
16134
16515
  const timeout = setTimeout(() => controller.abort(), ms);
@@ -16224,6 +16605,72 @@ app.get('/api/system/version', async (req, res) => {
16224
16605
  res.json(_synabunUpdateCache);
16225
16606
  });
16226
16607
 
16608
+ // GET /api/system/about — Settings → About (lib/system-about.js): the app, the runtime, the project's links and
16609
+ // every dependency of the product's packages with the version installed, read from the files on disk on the first
16610
+ // request and kept for the life of the process. No environment, no token, no setting. Owner only (/api/system is
16611
+ // in ADMIN_ONLY_PREFIXES).
16612
+ const systemAbout = createSystemAbout({
16613
+ packageRoot: PACKAGE_ROOT,
16614
+ dataHome: DATA_HOME,
16615
+ port: PORT,
16616
+ install: PACKAGED_RUNTIME?.shell ? 'desktop' : INSTALL_KIND,
16617
+ // The WhatsApp connector is installed on demand, into the data folder (lib/whatsapp/paths.js runtimeDir).
16618
+ installDirs: { 'whatsapp-connector': [join(DATA_HOME, 'runtime', 'whatsapp')] },
16619
+ vendorDir: join(__dirname, 'public', 'vendor'),
16620
+ });
16621
+ app.get('/api/system/about', (req, res) => {
16622
+ res.set('Cache-Control', 'no-store');
16623
+ try {
16624
+ res.json(systemAbout.get());
16625
+ } catch (err) {
16626
+ res.status(500).json({ error: 'about_failed', detail: String(err?.message || err).slice(0, 200) });
16627
+ }
16628
+ });
16629
+
16630
+ // GET /api/system/activity — whether anything runs that a restart would end. The
16631
+ // desktop app installs a downloaded update only while this says idle
16632
+ // (packaging/desktop/src/updater/controller.mjs); lib/system-activity.js adds the
16633
+ // counters up, and one that throws makes the answer "not idle". Terminals are not
16634
+ // counted: nothing here can tell a command that runs from a shell that waits.
16635
+ function systemActivitySources() {
16636
+ const nativeRuns = () => _nativeLoopRuntime?.list?.({ activeOnly: true }) || [];
16637
+ return {
16638
+ assistant: () => {
16639
+ const runtime = _assistantRuntime;
16640
+ const sessions = runtime?._internals?.sessions;
16641
+ const brains = sessions ? countWhere(sessions.keys(), (id) => runtime.isBusy(id)) : 0;
16642
+ const runs = _assistantDispatcher ? _assistantDispatcher.list({ activeOnly: true, limit: 500 }).length : 0;
16643
+ const agents = countWhere(agentRegistry, (agent) => agent?.status === 'running');
16644
+ return brains + runs + agents;
16645
+ },
16646
+ 'claude-panel': () => _claudeBridge?.panelSessionsWorking?.() || 0,
16647
+ 'codex-panel': () => countWhere(_codexLiveConnections, (connection) => !!connection?.busy?.())
16648
+ + countWhere(_codexOrphanedProcs, (orphan) => orphan?.isIdle?.() !== true),
16649
+ 'opencode-panel': () => {
16650
+ const blockers = getOpenCodeHistoryBlockers();
16651
+ return blockers.activeTurns + blockers.startingTurns;
16652
+ },
16653
+ 'claude-cli': () => recentCliSessions(sessionRegistry),
16654
+ loop: () => {
16655
+ // A dispatched worker also runs in the native runtime: it is counted under assistant.
16656
+ const native = countWhere(nativeRuns(), (run) => run?.runMode !== 'task' && run?.source !== 'assistant');
16657
+ const files = countWhere(readLoopStateFiles(), ({ data }) => !!data?.active && (!data.terminalSessionId || isLoopRuntimeAlive(data)));
16658
+ const terminals = countWhere(terminalSessions, (session) => !!session?._loopTerminalSessionId);
16659
+ return Math.max(native + terminals, files);
16660
+ },
16661
+ schedule: () => _scheduleQueue.length + (_scheduleQueueRunning ? 1 : 0),
16662
+ backup: () => (_backupOperationActive ? 1 : 0) + (_autoBackupPromise ? 1 : 0),
16663
+ maintenance: () => (_reindexJob?.running ? 1 : 0) + (_indexingJob && !_indexingJob.done ? 1 : 0)
16664
+ + countWhere(Object.values(cliInstaller.jobs()), (job) => job?.state === 'running')
16665
+ + (_projectStorageClearInProgress ? 1 : 0) + (_ocpHistoryClearInProgress ? 1 : 0),
16666
+ };
16667
+ }
16668
+
16669
+ app.get('/api/system/activity', (req, res) => {
16670
+ res.set('Cache-Control', 'no-store');
16671
+ res.json(collectActivity(systemActivitySources(), { log: (line) => console.warn(`[activity] ${line}`) }));
16672
+ });
16673
+
16227
16674
  // ═══════════════════════════════════════════
16228
16675
  // EXTERNAL CLI TOOL VERSION CHECK
16229
16676
  // ═══════════════════════════════════════════
@@ -16236,6 +16683,7 @@ const TOOL_DEFS = [
16236
16683
  ];
16237
16684
 
16238
16685
  let _toolVersionCache = { checkedAt: null, tools: {} };
16686
+ let _toolVersionGeneration = 0;
16239
16687
 
16240
16688
  // Backwards-compat helper — returns a "MAJOR.MINOR.PATCH" string (no pre).
16241
16689
  // Kept because legacy callers expect a string for display purposes only.
@@ -16255,35 +16703,7 @@ function parseVersion(raw) {
16255
16703
  // parseSemver / compareSemver / semverNewer live in lib/synabun-version.js, with
16256
16704
  // the rules that order SynaBun's own date-style and semver-era releases.
16257
16705
 
16258
- function resolveBinPath(cmd, env) {
16259
- try {
16260
- const isWin = process.platform === 'win32';
16261
- const finder = isWin ? `where ${cmd}` : `command -v ${cmd}`;
16262
- const opts = { encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'], env };
16263
- if (!isWin) opts.shell = '/bin/sh';
16264
- const raw = execSync(finder, opts).trim();
16265
- const first = raw.split(/\r?\n/)[0] || null;
16266
- if (!first) return null;
16267
- // Follow symlinks so install-source detection sees the real install dir
16268
- // (e.g. /usr/local/bin/claude → /usr/local/lib/node_modules/.../bin/claude).
16269
- try { return realpathSync(first); } catch { return first; }
16270
- } catch { return null; }
16271
- }
16272
-
16273
- // Async twin of resolveBinPath — version checks run these probes for every
16274
- // tool; the sync variant blocks the event loop for the full subprocess time.
16275
- async function resolveBinPathAsync(cmd, env) {
16276
- try {
16277
- const isWin = process.platform === 'win32';
16278
- const finder = isWin ? `where ${cmd}` : `command -v ${cmd}`;
16279
- const opts = { encoding: 'utf-8', timeout: 2000, env, windowsHide: true };
16280
- if (!isWin) opts.shell = '/bin/sh';
16281
- const { stdout } = await execAsync(finder, opts);
16282
- const first = (stdout || '').trim().split(/\r?\n/)[0] || null;
16283
- if (!first) return null;
16284
- try { return realpathSync(first); } catch { return first; }
16285
- } catch { return null; }
16286
- }
16706
+ // Where each CLI is: lib/cli-resolver.js (cliResolver, near getClaudeBin).
16287
16707
 
16288
16708
  // detectInstallSource / buildUpdateCommand / NPM_PKGS now live in
16289
16709
  // ./lib/cli-update-planner.js so they can be unit tested away from this file.
@@ -16340,13 +16760,6 @@ function isInsideSynabun(binPath) {
16340
16760
  }
16341
16761
  }
16342
16762
 
16343
- // Versions of @anthropic-ai/claude-code that SynaBun has historically shipped
16344
- // as a bundled dependency. If a `claude --version` probe returns one of these,
16345
- // it's almost certainly the stale bundle (a real user-global install would be
16346
- // the current 2.1.116+). Reject hard regardless of where the binary path looks
16347
- // to come from. Add new versions here if SynaBun ever ships claude-code again.
16348
- const KNOWN_STALE_CLAUDE_VERSIONS = new Set(['2.1.71', '2.1.89', '2.1.91']);
16349
-
16350
16763
  // Remove any external tool that ended up in our own node_modules: Claude Code,
16351
16764
  // Codex, OpenCode and Gemini CLI are installed by the user, never by SynaBun,
16352
16765
  // and the agent SDKs are installed without the executables they would carry
@@ -16367,52 +16780,25 @@ function pruneBundledCliTools() {
16367
16780
  }
16368
16781
  }
16369
16782
 
16370
- async function checkToolVersions() {
16371
- // Strip node_modules/.bin entries so the version check reflects the user's
16372
- // global install rather than any copy bundled with SynaBun itself.
16373
- const augPath = getAugmentedPath()
16374
- .split(delimiter)
16375
- .filter(p => !/[\\/]node_modules[\\/]\.bin[\\/]?$/i.test(p))
16376
- .join(delimiter);
16377
- const cleanEnv = { ...process.env, PATH: augPath };
16378
- const execOpts = { encoding: 'utf-8', timeout: 5000, env: cleanEnv, windowsHide: true };
16379
-
16380
- // Probe all tools concurrently with async subprocesses — the sequential
16381
- // execSync version blocked the event loop for the sum of every probe
16382
- // (worst case: N tools × 5s timeout with nothing else able to run).
16383
- // probedBinPath is resolved by the caller (below) so the brew probe can be
16384
- // skipped entirely when no brew-managed tool is installed. If the binary
16385
- // lives inside SynaBun's own install dir (stale bundled copy), treat the
16386
- // tool as not installed — do NOT run --version against it, since that would
16387
- // report the bundled version and trigger a false-positive update badge.
16388
- const checkOneTool = async (tool, probedBinPath) => {
16389
- const isStaleBundle = isInsideSynabun(probedBinPath);
16390
-
16391
- let installed = null;
16392
- if (!isStaleBundle) {
16393
- try {
16394
- const { stdout } = await execAsync(`${tool.cmd} ${tool.versionArg}`, execOpts);
16395
- installed = parseVersion(stdout.trim());
16396
- } catch { /* not installed or errored */ }
16397
- }
16398
-
16399
- // Nuclear safety net for the recurring claude phantom-version bug:
16400
- // only reject when binary path detection failed (truly phantom). If we
16401
- // resolved a path AND it's outside SynaBun's own dirs, trust the version
16402
- // — Anthropic ships these versions in the native installer too, so a
16403
- // version-only blacklist hits legit installs at ~/.local/bin/claude.exe.
16404
- if (
16405
- tool.key === 'claude-code' &&
16406
- installed &&
16407
- KNOWN_STALE_CLAUDE_VERSIONS.has(installed) &&
16408
- !probedBinPath
16409
- ) {
16410
- console.warn(
16411
- ` [version-guard] Rejected claude v${installed} from (unknown path)` +
16412
- ` — matches known-stale SynaBun bundle and no binary path could be resolved.`
16413
- );
16414
- installed = null;
16415
- }
16783
+ async function checkToolVersions({ force = false } = {}) {
16784
+ const generation = ++_toolVersionGeneration;
16785
+ // Every CLI exactly as the panels and launchers find it (lib/cli-resolver.js):
16786
+ // the path in Settings first, then PATH with the OS PATH read afresh, then the
16787
+ // known install folders; installed (with its version), missing, or broken
16788
+ // (found, but `--version` failed: the real error). Never a copy inside
16789
+ // SynaBun, so the badge reflects the CLI the user would run in a terminal.
16790
+ // `force` is Re-check: the OS PATH is read again and every --version asked again.
16791
+ const statuses = await cliResolver.resolveAll(TOOL_DEFS.map((tool) => tool.key), { force });
16792
+ const cleanEnv = withPathEnv(process.env, getAugmentedPath());
16793
+
16794
+ // Only pay for the brew probe when a brew-managed tool is actually present.
16795
+ const brewOutdated = Object.values(statuses).some((status) => status.state === 'installed' && isBrewSource(status.installSource))
16796
+ ? await probeBrewOutdated(cleanEnv)
16797
+ : null;
16798
+
16799
+ const checkOneTool = async (tool) => {
16800
+ const status = statuses[tool.key];
16801
+ const installed = status.state === 'installed' ? status.version : null;
16416
16802
 
16417
16803
  // Upstream (npm) version. For a brew install this is NOT the yardstick —
16418
16804
  // Homebrew routinely trails npm by a few releases, so comparing against it
@@ -16433,9 +16819,8 @@ async function checkToolVersions() {
16433
16819
  }
16434
16820
  } catch { /* network error */ }
16435
16821
 
16436
- const binPath = installed ? probedBinPath : null;
16437
- const installSource = detectInstallSource(binPath);
16438
- const brewToken = isBrewSource(installSource) ? resolveBrewToken(tool.key, binPath) : null;
16822
+ const installSource = status.installSource;
16823
+ const brewToken = isBrewSource(installSource) ? resolveBrewToken(tool.key, status.realPath || status.path) : null;
16439
16824
  const latest = resolveLatest({
16440
16825
  installSource,
16441
16826
  brewToken,
@@ -16443,8 +16828,10 @@ async function checkToolVersions() {
16443
16828
  npmLatest: upstreamLatest,
16444
16829
  installed,
16445
16830
  });
16831
+ // A self-updater is started by the path found, so the update reaches this
16832
+ // very installation even where a terminal's PATH does not name its folder.
16446
16833
  const updateCommand = installed
16447
- ? buildUpdateCommand(tool.key, installSource, { brewToken })
16834
+ ? buildUpdateCommand(tool.key, installSource, { brewToken, binPath: status.path, platform: process.platform })
16448
16835
  : null;
16449
16836
 
16450
16837
  return [tool.key, {
@@ -16454,32 +16841,33 @@ async function checkToolVersions() {
16454
16841
  updateAvailable: !!(installed && latest && semverNewer(installed, latest)),
16455
16842
  installSource,
16456
16843
  brewToken,
16457
- binPath,
16844
+ binPath: status.path,
16458
16845
  updateCommand,
16459
16846
  canUpdate: !!updateCommand,
16847
+ state: status.state,
16848
+ version: status.version,
16849
+ error: status.error,
16850
+ foundBy: status.foundBy,
16851
+ realPath: status.realPath,
16852
+ launchPath: status.launchPath,
16853
+ configured: status.configured,
16854
+ checked: status.candidates,
16855
+ guideUrl: CLI_INSTALL_GUIDES[tool.key] || null,
16856
+ install: cliInstaller.describe(tool.key),
16857
+ pathStatus: cliPathManager.inspect(status),
16460
16858
  }];
16461
16859
  };
16462
16860
 
16463
- // Resolve every binary path first so we only pay for the brew probe when a
16464
- // brew-managed tool is actually present.
16465
- const probedPaths = await Promise.all(
16466
- TOOL_DEFS.map((tool) => resolveBinPathAsync(tool.cmd, cleanEnv).catch(() => null)),
16467
- );
16468
- const brewOutdated = probedPaths.some((p) => isBrewSource(detectInstallSource(p)))
16469
- ? await probeBrewOutdated(cleanEnv)
16470
- : null;
16471
-
16472
- const entries = await Promise.all(
16473
- TOOL_DEFS.map((tool, i) => checkOneTool(tool, probedPaths[i])),
16474
- );
16861
+ const entries = await Promise.all(TOOL_DEFS.map((tool) => checkOneTool(tool)));
16475
16862
  const results = Object.fromEntries(entries);
16476
16863
 
16477
- // Tell a missing tool's install banner to use the channel this machine
16478
- // actually uses. A tool that isn't installed has no path to classify, so
16479
- // infer it from the siblings that ARE installed — otherwise a Homebrew
16480
- // user is told to `npm install -g`, creating a second conflicting install
16481
- // that may not even win PATH.
16864
+ // Tell a missing tool's install banner the command that fits this machine:
16865
+ // the vendor's own installer for this system (Anthropic's native installer
16866
+ // for Claude Code always), unless the CLIs that ARE installed show a package
16867
+ // manager this machine uses (Homebrew, bun, pnpm …), which is kept to, so
16868
+ // that the manual command never creates a second, conflicting install.
16482
16869
  const siblingSources = Object.values(results)
16870
+ .filter((v) => v.state === 'installed')
16483
16871
  .map((v) => v.installSource)
16484
16872
  .filter((s) => s && s !== 'unknown' && s !== 'other');
16485
16873
  const dominantSource = siblingSources
@@ -16488,23 +16876,40 @@ async function checkToolVersions() {
16488
16876
  || 'unknown';
16489
16877
  for (const [key, info] of Object.entries(results)) {
16490
16878
  const source = info.installed ? info.installSource : dominantSource;
16491
- info.installCommand = buildInstallCommand(key, source);
16879
+ info.installCommand = buildInstallCommand(key, source, { platform: process.platform });
16492
16880
  }
16493
16881
 
16882
+ // A check started before a newer refresh must never publish its stale answer.
16883
+ if (generation !== _toolVersionGeneration) return;
16494
16884
  _toolVersionCache = { checkedAt: new Date().toISOString(), tools: results };
16495
16885
 
16496
16886
  const updates = Object.entries(results).filter(([, v]) => v.updateAvailable);
16887
+ const broken = Object.entries(results).filter(([, v]) => v.state === 'broken');
16497
16888
  if (updates.length) {
16498
16889
  console.log(` CLI Tools: ${updates.map(([k, v]) => `${k} v${v.installed}→v${v.latest}`).join(', ')}`);
16499
16890
  } else {
16500
16891
  console.log(' CLI Tools: all up to date');
16501
16892
  }
16893
+ for (const [key, info] of broken) console.warn(` CLI Tools: ${key} at ${info.binPath} does not start: ${info.error}`);
16502
16894
  }
16503
16895
 
16504
16896
  // Single-flight: concurrent requests share one refresh instead of each
16505
16897
  // spawning their own set of subprocess probes.
16506
16898
  let _toolVersionInFlight = null;
16507
- function refreshToolVersions() {
16899
+ let _toolVersionForced = null;
16900
+ function refreshToolVersions({ force = false, fresh = false } = {}) {
16901
+ // A Re-check never takes the answer of a plain check already under way: it
16902
+ // reads the OS PATH and asks every --version again.
16903
+ if (force) {
16904
+ if (fresh || !_toolVersionForced) {
16905
+ const refresh = checkToolVersions({ force: true })
16906
+ .catch(() => {})
16907
+ .finally(() => { if (_toolVersionForced === refresh) _toolVersionForced = null; });
16908
+ _toolVersionForced = refresh;
16909
+ }
16910
+ return _toolVersionForced;
16911
+ }
16912
+ if (_toolVersionForced) return _toolVersionForced;
16508
16913
  if (!_toolVersionInFlight) {
16509
16914
  _toolVersionInFlight = checkToolVersions()
16510
16915
  .catch(() => {})
@@ -16516,8 +16921,9 @@ function refreshToolVersions() {
16516
16921
  app.get('/api/system/tool-versions', async (req, res) => {
16517
16922
  const force = req.query.force === '1';
16518
16923
  if (force || !_toolVersionCache.checkedAt) {
16519
- // force=1 callers (post-update badge refresh) need fresh data — await.
16520
- await refreshToolVersions();
16924
+ // force=1 (Re-check, the post-update badge refresh, the panels' poll while
16925
+ // a CLI is missing) needs fresh data: await it.
16926
+ await refreshToolVersions({ force });
16521
16927
  } else {
16522
16928
  const age = Date.now() - new Date(_toolVersionCache.checkedAt).getTime();
16523
16929
  // Stale-while-revalidate: serve the cached value now, refresh in background.
@@ -16526,6 +16932,40 @@ app.get('/api/system/tool-versions', async (req, res) => {
16526
16932
  res.json(_toolVersionCache);
16527
16933
  });
16528
16934
 
16935
+ // ── One-click install of a missing CLI (lib/cli-install.js, lib/cli-install-api.js) ──
16936
+ // The side panels' Install button: the vendor's official installer for this
16937
+ // system (or OpenCode's release archive on Windows), never a source a request
16938
+ // names, guarded like the other local mutating endpoints, its progress streamed
16939
+ // as NDJSON. Its last step is the resolver looking again (force); the tool
16940
+ // versions are then refreshed, so every panel's next check shows the CLI.
16941
+ const cliPathManager = createCliPathManager({
16942
+ onChanged: () => { _toolVersionCache = { ..._toolVersionCache, checkedAt: null }; },
16943
+ });
16944
+ app.use('/api/cli/path', createCliPathApi({
16945
+ pathManager: cliPathManager, resolver: cliResolver, isGuestRequest, port: () => PORT,
16946
+ diagnostics: async () => {
16947
+ const fresh = await freshTerminalPath();
16948
+ const statuses = await cliResolver.resolveAll(['claude-code', 'codex', 'opencode']);
16949
+ return { platform: process.platform, pathKey: Object.keys(fresh.env).find(key => key.toUpperCase() === 'PATH'),
16950
+ entries: fresh.entries, os: fresh.os, pathext: fresh.env.PATHEXT || fresh.env.PathExt || null,
16951
+ tools: Object.fromEntries(Object.entries(statuses).map(([tool, status]) => [tool, { path: status.path, launchPath: status.launchPath, ...cliPathManager.inspect(status) }])) };
16952
+ },
16953
+ }));
16954
+ const cliInstaller = createCliInstaller({
16955
+ resolver: cliResolver,
16956
+ addToPath: status => cliPathManager.add(status),
16957
+ readOsPath: async () => (await osPath.refresh({ force: true })).entries,
16958
+ onFinished: () => {
16959
+ _toolVersionCache = { ..._toolVersionCache, checkedAt: null };
16960
+ // The install changes the disk: even a Re-check already running predates it.
16961
+ refreshToolVersions({ force: true, fresh: true });
16962
+ },
16963
+ log: (line) => console.log(line),
16964
+ });
16965
+ app.use('/api/cli/install', createCliInstallApi({ installer: cliInstaller, isGuestRequest, port: () => PORT, log: (line) => console.warn(line) }));
16966
+ // Settings beside the CLIs: the Git Bash Claude Code gets on Windows, its download and a new look (lib/git-bash/).
16967
+ app.use('/api/cli/git-bash', createGitBashApi({ manager: gitBash, isGuestRequest, port: () => PORT, onRecheck: async () => { syncGitBashSettings(); } }));
16968
+
16529
16969
  // ═══════════════════════════════════════════
16530
16970
  // FULL SYSTEM BACKUP & RESTORE
16531
16971
  // ═══════════════════════════════════════════
@@ -16713,9 +17153,9 @@ app.post('/api/system/restore', async (req, res) => {
16713
17153
  // Ensure dev deps are installed for TypeScript
16714
17154
  const mcpDir = resolve(PACKAGE_ROOT, 'mcp-server');
16715
17155
  if (!existsSync(resolve(mcpDir, 'node_modules', 'typescript'))) {
16716
- execSync('npm install', { cwd: mcpDir, stdio: 'pipe', timeout: 180_000, env: { ...process.env, NODE_ENV: 'development' } });
17156
+ execSync('npm install', { cwd: mcpDir, stdio: 'pipe', timeout: 180_000, env: withPackagedPathLast({ ...process.env, NODE_ENV: 'development' }) });
16717
17157
  }
16718
- execSync('npm run build', { cwd: mcpDir, stdio: 'pipe', timeout: 60_000 });
17158
+ execSync('npm run build', { cwd: mcpDir, stdio: 'pipe', timeout: 60_000, env: withPackagedPathLast(process.env) });
16719
17159
  results.files.push('(auto-built MCP server)');
16720
17160
  } catch (buildErr) {
16721
17161
  results.errors.push(`MCP server auto-build failed: ${buildErr.message}`);
@@ -16992,13 +17432,10 @@ const editorCompletionServe = createOpencodeCompletionEngine({
16992
17432
  // A provider's {file:./key} is relative to that configuration: it is written whole for the serve's own folder.
16993
17433
  userConfigDir: () => dirname(getOpencodeConfigPath()),
16994
17434
  allocPort: allocOpencodeIsoPort,
16995
- // As the isolated serves are started: through the login shell, so the CLI is found where a terminal finds it.
17435
+ // As the isolated serves are started (opencodeServeLaunch): the CLI the resolver found, through the login shell.
16996
17436
  spawnServe: ({ port, env, cwd }) => {
16997
- const cmd = loadCliConfig().opencode?.command || 'opencode';
16998
- const shellArgs = IS_WIN
16999
- ? ['/d', '/s', '/c', `${cmd} serve --port ${port}`]
17000
- : ['-lc', `exec ${cmd} serve --port ${port}`];
17001
- const proc = spawn(DEFAULT_SHELL, shellArgs, { stdio: ['ignore', 'ignore', 'pipe'], env, cwd, detached: false, shell: false, windowsHide: true });
17437
+ const launch = opencodeServeLaunch(port, { env });
17438
+ const proc = spawn(launch.command, launch.args, { stdio: ['ignore', 'ignore', 'pipe'], ...launch.options, cwd, detached: false, shell: false, windowsHide: true });
17002
17439
  proc.stderr?.on('data', (d) => console.log(`[editor-complete:err] ${String(d).trim().slice(0, 500)}`));
17003
17440
  return proc;
17004
17441
  },
@@ -17856,7 +18293,7 @@ app.get('/api/setup/check-deps', async (req, res) => {
17856
18293
 
17857
18294
  // npm
17858
18295
  try {
17859
- const { stdout } = await execAsync('npm --version', { timeout: 5000 });
18296
+ const { stdout } = await execAsync('npm --version', { timeout: 5000, env: withPackagedPathLast(process.env) });
17860
18297
  deps.push({ id: 'npm', name: 'npm', ok: true, version: `v${stdout.trim()}`, detail: `v${stdout.trim()}`, url: 'https://nodejs.org/' });
17861
18298
  } catch {
17862
18299
  deps.push({ id: 'npm', name: 'npm', ok: false, version: null, detail: 'Not found', url: 'https://nodejs.org/' });
@@ -17867,6 +18304,38 @@ app.get('/api/setup/check-deps', async (req, res) => {
17867
18304
  res.json({ deps });
17868
18305
  });
17869
18306
 
18307
+ // GET /api/setup/install-details — what this install has (lib/install-details.js), for the Details areas of the
18308
+ // setup wizard inside the desktop app: the Node.js and the npm and npx SynaBun runs (each asked for its version),
18309
+ // where `npm i -g` puts packages, the user's own npm, every part's dependencies and versions, the MCP build, and
18310
+ // what a packaged application came with. Read only; the owner's only (/api/setup is admin-only for guests).
18311
+ app.get('/api/setup/install-details', async (req, res) => {
18312
+ try {
18313
+ // The one prefix PATH is built from (packagedNpmPrefix; for an npm or Git install, the one the user set).
18314
+ const npm = PACKAGED_RUNTIME ? packagedNpmPrefix(PACKAGED_RUNTIME) : effectiveNpmPrefix();
18315
+ const prefix = npm?.prefix || null;
18316
+ let manifest = null;
18317
+ if (PACKAGED_RUNTIME) { try { manifest = JSON.parse(readFileSync(PACKAGED_RUNTIME.manifestPath, 'utf8')); } catch {} }
18318
+ let browserExecutable = null;
18319
+ try { browserExecutable = chromium.executablePath(); } catch {}
18320
+ let osEntries = [];
18321
+ try { osEntries = (await osPath.refresh()).entries || []; } catch {}
18322
+ res.json(await collectInstallDetails({
18323
+ packageRoot: PACKAGE_ROOT,
18324
+ packaged: PACKAGED_RUNTIME,
18325
+ manifest,
18326
+ osPath: osEntries,
18327
+ npmPrefix: prefix,
18328
+ npmPrefixSource: !prefix ? null : npm.source === 'builtin' || npm.source === 'default' ? 'synabun' : 'user',
18329
+ npmPrefixFrom: prefix && npm.source !== 'builtin' && npm.source !== 'default' ? npm.from || null : null,
18330
+ npmGlobalBin: prefix ? npmGlobalBinFor(prefix) : null,
18331
+ browserExecutable,
18332
+ }));
18333
+ } catch (err) {
18334
+ console.error('GET /api/setup/install-details error:', err.message);
18335
+ res.status(500).json({ error: err.message });
18336
+ }
18337
+ });
18338
+
17870
18339
  // GET /api/setup/onboarding — Check what's configured (onboarding wizard)
17871
18340
  app.get('/api/setup/onboarding', async (req, res) => {
17872
18341
  try {
@@ -17937,9 +18406,11 @@ app.post('/api/setup/build', async (req, res) => {
17937
18406
  res.setHeader('Connection', 'keep-alive');
17938
18407
  res.flushHeaders();
17939
18408
 
18409
+ // What npm wrote, as the wizard shows it: secrets out first (lib/log-redact.js), then the last 500 characters.
17940
18410
  function send(event, data) {
17941
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
18411
+ res.write(`event: ${event}\ndata: ${JSON.stringify(redactDeep(data))}\n\n`);
17942
18412
  }
18413
+ const tail = (text) => redactSecrets(String(text || '')).slice(-500);
17943
18414
 
17944
18415
  const dirs = [
17945
18416
  { name: 'Neural Interface', path: resolve(PACKAGE_ROOT, 'neural-interface') },
@@ -17963,14 +18434,15 @@ app.post('/api/setup/build', async (req, res) => {
17963
18434
 
17964
18435
  send('install', { dir: dir.name, status: 'start' });
17965
18436
  try {
18437
+ // The user's own npm when they have one, a packaged application's after it (withPackagedPathLast).
17966
18438
  const { stdout, stderr } = await execAsync('npm install', {
17967
18439
  cwd: dir.path,
17968
18440
  timeout: 180_000,
17969
- env: { ...process.env, NODE_ENV: 'development' },
18441
+ env: withPackagedPathLast({ ...process.env, NODE_ENV: 'development' }),
17970
18442
  });
17971
- send('install', { dir: dir.name, status: 'done', output: (stdout || '').slice(-500) });
18443
+ send('install', { dir: dir.name, status: 'done', output: tail(stdout) });
17972
18444
  } catch (err) {
17973
- send('install', { dir: dir.name, status: 'error', output: (err.stderr || err.message || '').slice(-500) });
18445
+ send('install', { dir: dir.name, status: 'error', output: tail(err.stderr || err.message) });
17974
18446
  send('complete', { ok: false, error: `npm install failed for ${dir.name}` });
17975
18447
  return res.end();
17976
18448
  }
@@ -17991,10 +18463,11 @@ app.post('/api/setup/build', async (req, res) => {
17991
18463
  const { stdout, stderr } = await execAsync('npm run build', {
17992
18464
  cwd: resolve(PACKAGE_ROOT, 'mcp-server'),
17993
18465
  timeout: 60_000,
18466
+ env: withPackagedPathLast(process.env),
17994
18467
  });
17995
- send('build', { status: 'done', output: (stdout || '').slice(-500) });
18468
+ send('build', { status: 'done', output: tail(stdout) });
17996
18469
  } catch (err) {
17997
- send('build', { status: 'error', output: (err.stderr || err.message || '').slice(-500) });
18470
+ send('build', { status: 'error', output: tail(err.stderr || err.message) });
17998
18471
  send('complete', { ok: false, error: 'MCP server build failed' });
17999
18472
  return res.end();
18000
18473
  }
@@ -18562,7 +19035,7 @@ function injectClaudeMemoryPerms() {
18562
19035
 
18563
19036
  function injectCodexMemoryPerms() {
18564
19037
  try {
18565
- const configPath = join(getHomePath(), '.codex', 'config.toml');
19038
+ const configPath = join(CODEX_DEFAULT_HOME, 'config.toml');
18566
19039
  if (!existsSync(configPath)) return { scope: 'none', paths: [] };
18567
19040
  let content = readFileSync(configPath, 'utf-8');
18568
19041
  if (!tomlHasSection(content, 'mcp_servers.SynaBun')) return { scope: 'none', paths: [] };
@@ -20729,9 +21202,9 @@ function tomlRemoveAllToolSections(content, prefix) {
20729
21202
  * - If disabled → remove that section
20730
21203
  * Preserves all non-tool config (model, projects, mcp_servers.SynaBun base, notices).
20731
21204
  */
20732
- function syncCodexToolPermissions(allowedKeys) {
21205
+ function syncCodexToolPermissions(allowedKeys, codexHome = CODEX_DEFAULT_HOME) {
20733
21206
  try {
20734
- const configPath = join(getHomePath(), '.codex', 'config.toml');
21207
+ const configPath = join(codexHome, 'config.toml');
20735
21208
  if (!existsSync(configPath)) return;
20736
21209
  healCodexConfig(configPath);
20737
21210
  const health = inspectCodexConfig(configPath);
@@ -20875,7 +21348,7 @@ app.delete('/api/setup/gemini/mcp', (req, res) => {
20875
21348
 
20876
21349
  app.get('/api/setup/codex/mcp', (req, res) => {
20877
21350
  try {
20878
- const configPath = join(getHomePath(), '.codex', 'config.toml');
21351
+ const configPath = join(CODEX_DEFAULT_HOME, 'config.toml');
20879
21352
  const toml = buildCodexMcpToml();
20880
21353
  const health = inspectCodexConfig(configPath);
20881
21354
  res.json({ ok: true, ...health, toml });
@@ -20886,7 +21359,7 @@ app.get('/api/setup/codex/mcp', (req, res) => {
20886
21359
 
20887
21360
  app.post('/api/setup/codex/mcp', (req, res) => {
20888
21361
  try {
20889
- const dir = join(getHomePath(), '.codex');
21362
+ const dir = CODEX_DEFAULT_HOME;
20890
21363
  const configPath = join(dir, 'config.toml');
20891
21364
  mkdirSync(dir, { recursive: true });
20892
21365
  ensureMcpProfileDefault('codex-browser');
@@ -20901,7 +21374,7 @@ app.post('/api/setup/codex/mcp', (req, res) => {
20901
21374
 
20902
21375
  app.delete('/api/setup/codex/mcp', (req, res) => {
20903
21376
  try {
20904
- const configPath = join(getHomePath(), '.codex', 'config.toml');
21377
+ const configPath = join(CODEX_DEFAULT_HOME, 'config.toml');
20905
21378
  if (!existsSync(configPath)) return res.json({ ok: true, rules: syncRulesWithMcp('codex', 'remove') });
20906
21379
  const health = removeCodexMcpConfig(configPath);
20907
21380
  res.json({ ok: true, ...health, message: 'SynaBun MCP removed from Codex CLI.', rules: syncRulesWithMcp('codex', 'remove') });
@@ -20936,7 +21409,7 @@ app.get('/api/setup/status', (req, res) => {
20936
21409
  // Codex
20937
21410
  let codex = { connected: false, valid: false, repairable: false, issues: [] };
20938
21411
  try {
20939
- codex = inspectCodexConfig(join(home, '.codex', 'config.toml'));
21412
+ codex = inspectCodexConfig(join(CODEX_DEFAULT_HOME, 'config.toml'));
20940
21413
  } catch {}
20941
21414
  codex.cliCommand = buildCodexMcpCliCommand();
20942
21415
 
@@ -22211,6 +22684,7 @@ app.get('/api/health', (req, res) => {
22211
22684
  const count = countMemories();
22212
22685
  res.json({
22213
22686
  ok: true, storage: 'sqlite', memories: count, projectDir: PACKAGE_ROOT, install: INSTALL_KIND, startLauncher: getStartLauncherState(), entry: START_ENTRY,
22687
+ version: SERVER_VERSION, dataHomeId: DATA_HOME_ID, installEntry: INSTALL_ENTRY,
22214
22688
  // Which server this is, and whether it is on its way out for a restart: a
22215
22689
  // page showing the restart tells this one from the one that replaces it.
22216
22690
  bootId: SERVER_BOOT_ID,
@@ -22587,14 +23061,15 @@ const SYNABUN_TOOL_GROUPS = {
22587
23061
  sync: { label: 'Sync', tools: 1, alwaysOn: true },
22588
23062
  loop: { label: 'Loop', tools: 1, alwaysOn: true },
22589
23063
  profile: { label: 'Profile', tools: 1, alwaysOn: true },
23064
+ choice: { label: 'Choice', tools: 1, alwaysOn: true },
22590
23065
  git: { label: 'Git', tools: 1 },
22591
23066
  image: { label: 'Images', tools: 1 },
22592
23067
  whiteboard: { label: 'Whiteboard', tools: 5 },
22593
23068
  card: { label: 'Cards', tools: 5 },
22594
23069
  tictactoe: { label: 'TicTacToe', tools: 1 },
22595
- browser: { label: 'Browser', tools: 18 },
22596
- browser_twitter: { label: 'Twitter/X', tools: 1 },
22597
- browser_facebook: { label: 'Facebook', tools: 3 },
23070
+ browser: { label: 'Browser', tools: 21 },
23071
+ browser_twitter: { label: 'Twitter/X', tools: 2 },
23072
+ browser_facebook: { label: 'Facebook', tools: 4 },
22598
23073
  browser_tiktok: { label: 'TikTok', tools: 4 },
22599
23074
  browser_whatsapp: { label: 'WhatsApp', tools: 2 },
22600
23075
  browser_instagram: { label: 'Instagram', tools: 5 },
@@ -23754,12 +24229,10 @@ function saveCliConfig(config) {
23754
24229
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
23755
24230
  writeFileSync(CLI_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
23756
24231
  _cliConfigCache = null;
23757
- // Clear cached binaries so getClaudeBin()/getCodexBin() re-resolve with the
23758
- // new config. Codex was previously left stale, so a corrected Codex path in
23759
- // Settings did nothing until the server was restarted.
23760
- _claudeBinPath = null;
23761
- _codexBinPath = null;
23762
- _nativeCodexBinPath = null;
24232
+ // A path saved in Settings counts at once, for the version check and every
24233
+ // launcher alike (lib/cli-resolver.js), with no restart.
24234
+ cliResolver.invalidate();
24235
+ _toolVersionCache = { ..._toolVersionCache, checkedAt: null };
23763
24236
  }
23764
24237
 
23765
24238
  // Interactive shell tabs start as login shells, like Terminal.app / iTerm2.
@@ -23781,14 +24254,16 @@ function getTerminalProfile(profileId, opts = {}) {
23781
24254
 
23782
24255
  let cmd = entry.command.trim();
23783
24256
  if (!cmd) return SHELL_PROFILE;
24257
+ const found = cliResolver.launcher(profileId);
24258
+ if (!configuredCommandLine(profileId)) cmd = terminalLaunchCommand(found, DEFAULT_SHELL) || cmd;
23784
24259
 
23785
24260
  // Append resume flag for supported CLIs (mutually exclusive with model)
23786
24261
  if (opts.resume && profileId === 'claude-code') {
23787
- cmd = `${cmd} --resume ${opts.resume}`;
24262
+ cmd = `${cmd} --resume ${terminalArgument(opts.resume, DEFAULT_SHELL)}`;
23788
24263
  } else if (opts.resume && profileId === 'codex') {
23789
- cmd = `${cmd} resume ${opts.resume}`;
24264
+ cmd = `${cmd} resume ${terminalArgument(opts.resume, DEFAULT_SHELL)}`;
23790
24265
  } else if (opts.resume && profileId === 'opencode') {
23791
- cmd = `${cmd} -s ${opts.resume}`;
24266
+ cmd = `${cmd} -s ${terminalArgument(opts.resume, DEFAULT_SHELL)}`;
23792
24267
  }
23793
24268
  // Append model flag for CLIs that support it
23794
24269
  else if (opts.model) {
@@ -23802,7 +24277,7 @@ function getTerminalProfile(profileId, opts = {}) {
23802
24277
  // suffix `[1m]` contains `[ ]`, which zsh parses as a glob character class —
23803
24278
  // unquoted it aborts with "no matches found" and the CLI never launches.
23804
24279
  // Mirror the IS_WIN-aware quoting used for --mcp-config below.
23805
- const quotedModel = IS_WIN ? `"${cleanModel}"` : shellSingleQuote(cleanModel);
24280
+ const quotedModel = terminalArgument(cleanModel, DEFAULT_SHELL);
23806
24281
  const modelMap = {
23807
24282
  'claude-code': (m) => `${cmd} --model ${m}`,
23808
24283
  'codex': (m) => `${cmd} --model ${m}`,
@@ -23841,7 +24316,7 @@ function getTerminalProfile(profileId, opts = {}) {
23841
24316
  // collisions invisibly. Trade-off: project-scope .mcp.json servers are not
23842
24317
  // loaded inside loops (user-scope servers ARE — they're merged into the file).
23843
24318
  if (opts.mcpConfigPath && profileId === 'claude-code') {
23844
- const quoted = IS_WIN ? `"${opts.mcpConfigPath}"` : `'${opts.mcpConfigPath.replace(/'/g, `'\\''`)}'`;
24319
+ const quoted = terminalArgument(opts.mcpConfigPath, DEFAULT_SHELL);
23845
24320
  cmd = `${cmd} --strict-mcp-config --mcp-config ${quoted}`;
23846
24321
  }
23847
24322
 
@@ -23854,20 +24329,25 @@ function getTerminalProfile(profileId, opts = {}) {
23854
24329
  };
23855
24330
  }
23856
24331
 
24332
+ // Read for every spawn, including a tab restored/split by the client and
24333
+ // provider sign-in terminals. The desktop app uses this same server/PTY host.
24334
+ async function freshTerminalPath(base = process.env) {
24335
+ const os = await osPath.refresh({ force: true });
24336
+ const launchers = ['claude-code', 'codex', 'opencode', 'gemini'].map(tool => cliResolver.launcher(tool));
24337
+ // A packaged application's runtime and npm's global bin (rememberPackagedPath above), after the user's own.
24338
+ const { last: synabunDirs, extra: extraDirs } = rememberedPackagedPath();
24339
+ return { ...composeTerminalPath({ env: base, os, launchers, synabunDirs, extraDirs }), os, launchers };
24340
+ }
24341
+
23857
24342
  function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
23858
24343
  if (terminalHost.ready && !terminalHost.ptyAvailable) {
23859
24344
  throw new Error('Terminal not available: node-pty failed to load. Run: cd neural-interface && npm run postinstall');
23860
24345
  }
23861
24346
  const sessionId = opts.sessionId || randomBytes(16).toString('hex');
23862
- const profileCfg = getTerminalProfile(profile, opts);
24347
+ let profileCfg = opts.launch || getTerminalProfile(profile, opts);
23863
24348
  const initialCols = cols || 120;
23864
24349
  const initialRows = rows || 30;
23865
24350
 
23866
- // Native-terminal environment: SynaBun's own TERM_PROGRAM identity,
23867
- // truecolor, a UTF-8 locale, and none of the launching terminal's or IDE's
23868
- // session variables (VS Code, tmux, iTerm2, a parent Claude Code session…).
23869
- const env = buildTerminalEnv({ profileEnv: profileCfg.env, extraEnv: opts.extraEnv });
23870
-
23871
24351
  const isLoopOwned = !!(opts.extraEnv && opts.extraEnv.SYNABUN_TERMINAL_SESSION === sessionId);
23872
24352
  if (isLoopOwned) {
23873
24353
  loopLog(sessionId, 'pty:resolve', 'profile resolved', {
@@ -23886,6 +24366,7 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
23886
24366
  profile, cwd,
23887
24367
  createdAt: Date.now(),
23888
24368
  graceTimer: null,
24369
+ onProviderAuthClosed: opts.onClosed,
23889
24370
  claudeSessionId: opts.resume || null, // known Claude session ID (from resume or detect)
23890
24371
  };
23891
24372
  terminalSessions.set(sessionId, session);
@@ -23942,6 +24423,7 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
23942
24423
  // The host already delivered the final output (to clients and the tap)
23943
24424
  // before reporting the exit.
23944
24425
  session.pty.exited = true;
24426
+ opts.onExit?.({ exitCode });
23945
24427
 
23946
24428
  // Clear grace timer if set
23947
24429
  if (session.graceTimer) { clearTimeout(session.graceTimer); session.graceTimer = null; }
@@ -24020,7 +24502,6 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
24020
24502
  file: profileCfg.shell,
24021
24503
  args: profileCfg.args,
24022
24504
  cwd: cwd || process.env.USERPROFILE || process.env.HOME || process.cwd(),
24023
- env,
24024
24505
  cols: initialCols,
24025
24506
  rows: initialRows,
24026
24507
  name: 'xterm-256color',
@@ -24036,6 +24517,12 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
24036
24517
  onResized: ({ cols: c, rows: r }) => { session.pty.cols = c; session.pty.rows = r; },
24037
24518
  onClients: ({ total }) => { session.clients.size = total; },
24038
24519
  onExit,
24520
+ }, async () => {
24521
+ const fresh = await freshTerminalPath(opts.baseEnv || process.env);
24522
+ profileCfg = opts.launch || getTerminalProfile(profile, opts);
24523
+ // On Windows, the Git Bash a claude started in this tab gets (lib/git-bash/): any tab can start one.
24524
+ const env = claudeChildEnv(buildTerminalEnv({ base: fresh.env, profileEnv: profileCfg.env, extraEnv: opts.extraEnv }));
24525
+ return { file: profileCfg.shell, args: profileCfg.args, env };
24039
24526
  });
24040
24527
 
24041
24528
  return sessionId;
@@ -24665,8 +25152,17 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24665
25152
  providerFactories: {
24666
25153
  'claude-code': (state) => {
24667
25154
  const config = _readClaudeSkinConfig();
25155
+ // The account decides the credential (lib/claude-accounts.js launchFor()): inherited credentials out,
25156
+ // its directory and, in API-key mode, its key in. Built here, never stored: the loop state file only
25157
+ // names the account. A run whose account is gone does not start under another identity.
25158
+ const launch = _panelClaudeAccounts().launchFor(state.claudeAccountId || 'default');
25159
+ if (!launch.ok) throw new Error(`${launch.message} The run did not start.`);
24668
25160
  return createClaudeNativeLoopAdapter({
24669
25161
  ...state,
25162
+ extraEnv: { ...(state.extraEnv || {}), ...launch.env },
25163
+ unsetEnv: launch.unset,
25164
+ // On Windows, the Git Bash the worker's Claude Code gets (lib/git-bash/), never stored in the loop state.
25165
+ childEnv: claudeChildEnv,
24670
25166
  model: state.contextMode === '1m' && state.model
24671
25167
  ? `${state.model}[1m]`
24672
25168
  : state.model && state.model.includes(':') ? toCliModelName(state.model) : state.model,
@@ -24686,10 +25182,18 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24686
25182
  // path, so repair the shared config before the SDK can parse it. Without
24687
25183
  // this, legacy inline + table SynaBun env definitions surface as a
24688
25184
  // configWarning and the unattended turn never becomes active.
24689
- ensureCanonicalCodexConfig({ strict: true });
25185
+ // The account decides the credential (lib/codex-accounts.js launchFor()): inherited OpenAI/Codex
25186
+ // credentials out, its CODEX_HOME in and, in API-key mode, the key SynaBun holds as CODEX_API_KEY
25187
+ // (which `codex exec` prefers over auth.json). Built here, never stored: the loop state file only
25188
+ // names the account. A run whose account is gone does not start under another identity.
25189
+ const launch = _codexAccounts().launchFor(state.codexAccountId || 'default');
25190
+ if (!launch.ok) throw new Error(`${launch.message} The run did not start.`);
25191
+ ensureCanonicalCodexConfig({ strict: true, codexHome: launch.home });
24690
25192
  return createCodexNativeLoopAdapter({
24691
25193
  ...state,
24692
- codexHome: state.codexHome || getCodexAccountHome(state.codexAccountId || 'default'),
25194
+ extraEnv: { ...(state.extraEnv || {}), ...codexExecEnv(launch) },
25195
+ unsetEnv: launch.unset,
25196
+ codexHome: launch.home,
24693
25197
  // Native schedules use the same trusted global CLI as the interactive
24694
25198
  // sidepanel; SynaBun carries no Codex for the SDK to find by itself.
24695
25199
  codexPath: getNativeCodexBin(),
@@ -24755,7 +25259,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24755
25259
  import('../mcp-server/dist/tools/remember.js'), import('../mcp-server/dist/services/categories.js'),
24756
25260
  import('./lib/assistant-catalog.js'), import('./lib/assistant-config.js'), import('./lib/assistant-router.js'),
24757
25261
  ]);
24758
- const { budgetLimits, createModelPricing } = await import('./lib/assistant-budget.js');
25262
+ const { createModelPricing } = await import('./lib/assistant-budget.js');
25263
+ const { createBudgetPlanner } = await import('./lib/assistant-budget-planner.js');
24759
25264
  const { createAssistantClarifier } = await import('./lib/assistant-clarify.js');
24760
25265
  const { createAttachmentStore } = await import('./lib/assistant-attachments.js');
24761
25266
  const assistantConfigPath = resolve(LOOP_DATA_DIR, 'assistant-config.json');
@@ -24763,7 +25268,25 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24763
25268
  // One store for assistant-config.json: routing knobs re-read on change.
24764
25269
  const assistantConfigStore = createAssistantConfigStore({ path: assistantConfigPath, log: (tag, message) => assistantLog(tag, message) });
24765
25270
  const assistantConfig = assistantConfigStore.read();
24766
- const claudeAccounts = createClaudeAccounts({ dataHome: DATA_HOME, homeDir: getHomePath(), log: (tag, message) => assistantLog(tag, message) });
25271
+ // The one Claude accounts instance (the sidepanel's): one account state for the brain picker, the catalog,
25272
+ // worker dispatch and the panel. `createClaudeAccounts` stays imported for older callers of this block.
25273
+ void createClaudeAccounts;
25274
+ const claudeAccounts = _panelClaudeAccounts();
25275
+ const { createAccountUsage } = await import('./lib/account-usage.js');
25276
+ const accountUsage = createAccountUsage({ accounts: { 'claude-code': claudeAccounts, codex: _codexAccounts() }, readCodex: async (id, opts) => {
25277
+ for (const connection of _codexLiveConnections) {
25278
+ if (opts.signal?.aborted) return null;
25279
+ if (connection.accountId() !== id) continue;
25280
+ try { const value = await connection.readRateLimits(); if (value && connection.accountId() === id) return value; } catch {}
25281
+ }
25282
+ if (opts.signal?.aborted) return null;
25283
+ return codexAccountAuth().rateLimits(id, opts);
25284
+ } });
25285
+ _assistantAccountUsage = accountUsage;
25286
+ claudeAccounts.subscribe(({ accountId }) => accountUsage.invalidate('claude-code', accountId));
25287
+ _codexAccounts().subscribe(({ accountId }) => accountUsage.invalidate('codex', accountId));
25288
+ // A Claude Code worker whose dispatch names no account runs under this one (Assistant → accounts).
25289
+ _assistantClaudeWorkerAccount = () => assistantConfigStore.workers().claudeAccount;
24767
25290
  const memoryStoredForRun = (runId) => {
24768
25291
  try { return !!assistantGetDb().prepare('SELECT 1 FROM memory_metadata WHERE source_ref = ? LIMIT 1').get(String(runId)); } catch { return false; }
24769
25292
  };
@@ -24797,8 +25320,10 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24797
25320
  import('../mcp-server/dist/services/typesafe.js'), import('../mcp-server/dist/services/typesafe-config.js'),
24798
25321
  import('./lib/assistant-jev.js'),
24799
25322
  ]);
25323
+ // The budget split's questions (surface budget-split): a dist without them leaves the split on its fixed rule.
25324
+ const budgetJudgments = await import('../mcp-server/dist/services/budget-judgments.js').catch(() => null);
24800
25325
  assistantJev = createAssistantJev({
24801
- judgePrompt, judgeWorkerOutcome,
25326
+ judgePrompt, judgeWorkerOutcome, judgeBudgetSplit: budgetJudgments?.judgeBudgetSplit || null,
24802
25327
  surfaceConfig: typesafeKnobs.surfaceConfig,
24803
25328
  enabled: typesafeClient.typesafeEnabled,
24804
25329
  retryAfterMs: typesafeClient.typesafeRetryAfterMs,
@@ -24845,7 +25370,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24845
25370
  const assistantCatalog = createAssistantCatalog({
24846
25371
  fetchJson: catalogFetch,
24847
25372
  codexPrice: (model) => assistantPricing.codexPrice(model),
24848
- listAccounts: () => ({ codex: listCodexAccountsForClient(), 'claude-code': claudeAccounts.listForClient() }),
25373
+ listAccounts: () => accountUsage.list(),
24849
25374
  readFilter: () => assistantConfigStore.routing().catalogFilter || {},
24850
25375
  readHidden: () => assistantHiddenModels.all(),
24851
25376
  // A hidden alias ("haiku") hides the model it named when it was hidden: each build settles that.
@@ -24931,6 +25456,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24931
25456
  accountEnv: () => claudeAccounts.envFor?.('default') || {},
24932
25457
  cwd: DATA_HOME,
24933
25458
  env: process.env,
25459
+ childEnv: claudeChildEnv,
24934
25460
  log: (tag, message) => assistantLog(tag, message),
24935
25461
  }),
24936
25462
  usage: assistantUsage,
@@ -24990,6 +25516,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24990
25516
  accountEnv: () => claudeAccounts.envFor?.('default') || {},
24991
25517
  cwd: DATA_HOME,
24992
25518
  env: process.env,
25519
+ childEnv: claudeChildEnv,
24993
25520
  label: 'greeting writer',
24994
25521
  logTag: 'greeting:writer-error',
24995
25522
  log: greetingLog,
@@ -25022,7 +25549,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25022
25549
  app.use('/api/assistant/hidden-models', createAssistantHiddenModelsRouter({
25023
25550
  hiddenModels: assistantHiddenModels, catalog: assistantCatalog, opencodeStore: opencodeHiddenModels, isGuestRequest, broadcastSync,
25024
25551
  }));
25025
- const buildCatalog = (opts = {}) => assistantCatalog.get(opts);
25552
+ const buildCatalog = async (opts = {}) => { await accountUsage.refresh(); return { ...(await assistantCatalog.get(opts)), accounts: accountUsage.list() }; };
25026
25553
  // Clarification: a materially ambiguous request gets 1-3 questions before it
25027
25554
  // is routed; the user's words and answers become the brief every worker of
25028
25555
  // that request receives. Knobs: assistant-config.json `clarify` (read live).
@@ -25041,10 +25568,41 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25041
25568
  },
25042
25569
  log: (tag, message) => assistantLog(tag, message),
25043
25570
  });
25571
+ // The session budget (lib/assistant-budget-planner.js): one setting for every session, divided
25572
+ // across the providers per session by Jev (surface budget-split) or a fixed rule, at the first
25573
+ // route and at checkpoints. The dispatcher, the runtime and the router read it; it reaches them lazily.
25574
+ const assistantBudget = createBudgetPlanner({
25575
+ setting: () => assistantConfigStore.budget(),
25576
+ spendOf: (sessionId) => _assistantDispatcher?.providerSpend?.(sessionId) || { providers: {}, unpricedRuns: 0, liveRuns: [] },
25577
+ brainOf: (sessionId) => _assistantRuntime?.budgetSession?.(sessionId) || null,
25578
+ isOpen: (sessionId) => _assistantRuntime?.budgetSession?.(sessionId)?.open !== false,
25579
+ catalog: () => assistantCatalog.peek?.() || null,
25580
+ refreshCatalog: () => assistantCatalog.full(),
25581
+ accounts: () => accountUsage.list(),
25582
+ routing: () => assistantConfigStore.routing(),
25583
+ routeMode: (sessionId) => { try { return assistantRouter.modeFor(sessionId); } catch { return null; } },
25584
+ workers: () => assistantConfigStore.workers(),
25585
+ pricing: assistantPricing,
25586
+ // This session's spend by provider from the usage ledger (every model row it booked).
25587
+ recentSpend: (sessionId) => {
25588
+ try {
25589
+ const out = {};
25590
+ for (const row of assistantUsage.sessionView(sessionId)?.models || []) if (row?.provider) out[row.provider] = (out[row.provider] || 0) + (Number(row.costUsd) || 0);
25591
+ return out;
25592
+ } catch { return null; }
25593
+ },
25594
+ judge: assistantJev,
25595
+ emit: (sessionId, view, meta) => { try { _assistantRuntime?.onBudgetSplit?.(sessionId, view, meta); } catch {} },
25596
+ statePath: resolve(LOOP_DATA_DIR, 'assistant-budget-splits.json'),
25597
+ log: (tag, message) => assistantLog(tag, message),
25598
+ });
25044
25599
  // DeepSeek-Harness-style routing: the brain proposes, the router enforces the
25045
25600
  // session's route mode. Sinks reach the runtime/dispatcher lazily.
25046
25601
  const assistantRouter = createAssistantRouter({
25602
+ accountUsage,
25603
+ budget: assistantBudget,
25047
25604
  catalog: assistantCatalog,
25605
+ listAccounts: () => accountUsage.list(),
25048
25606
  configStore: assistantConfigStore,
25049
25607
  getSession: (id) => _assistantRuntime?.routerSession?.(id) || null,
25050
25608
  sinks: {
@@ -25124,20 +25682,25 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25124
25682
  assistantDesktop = null;
25125
25683
  }
25126
25684
  _assistantDispatcher = createAssistantDispatcher({
25685
+ accountUsage,
25127
25686
  getRuntime: () => _nativeLoopRuntime,
25128
25687
  dataDir: LOOP_DATA_DIR,
25129
25688
  loopDir: LOOP_DIR,
25130
25689
  broadcastSync: (message) => { broadcastSync(message); try { _assistantRuntime?.observeBroadcast?.(message); } catch {} },
25131
25690
  log: (runId, tag, message, meta) => { try { loopLog(runId, tag, message, meta); } catch {} },
25132
25691
  PACKAGE_ROOT,
25133
- findCodexAccount, getCodexAccount, CODEX_DEFAULT_HOME, claudeAccounts,
25692
+ findCodexAccount, getCodexAccount, CODEX_DEFAULT_HOME, claudeAccounts, codexAccounts: _codexAccounts(),
25693
+ claudeWorkerAccount: () => assistantConfigStore.workers().claudeAccount,
25694
+ codexWorkerAccount: () => assistantConfigStore.workers().codexAccount,
25134
25695
  isValidMcpProfileValue, normalizeMcpProfileName, readActiveMcpProfile,
25135
25696
  acquireLoopBrowserAndTab, releaseSharedBrowserTab, reconcileKeepAwake,
25136
25697
  memory: assistantMemory, detectProject: assistantDetectProject,
25137
- // Limits and budget caps are read live from assistant-config.json (the Budget tab): no restart.
25698
+ // Limits are read live from assistant-config.json `limits`; the money is the session budget (the planner).
25138
25699
  limits: {},
25139
- readLimits: () => ({ ...(assistantConfigStore.read().limits || {}), ...budgetLimits(assistantConfigStore.budget()) }),
25700
+ readLimits: () => ({ ...(assistantConfigStore.read().limits || {}) }),
25701
+ budget: assistantBudget,
25140
25702
  brainSpend: (sessionId) => _assistantRuntime?.brainSpend?.(sessionId) || 0,
25703
+ brainSpendByProvider: (sessionId) => _assistantRuntime?.brainSpendByProvider?.(sessionId) || null,
25141
25704
  currentTask: (sessionId) => _assistantRuntime?.currentTask?.(sessionId) || null,
25142
25705
  pricing: assistantPricing,
25143
25706
  usage: assistantUsage,
@@ -25148,9 +25711,11 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25148
25711
  testDensity: (sessionId) => _assistantRuntime?.testDensityFor?.(sessionId) ?? null,
25149
25712
  });
25150
25713
  _assistantRuntime = createAssistantRuntime({
25714
+ accountUsage,
25151
25715
  dispatcher: _assistantDispatcher,
25152
25716
  phoneAuthority: _phoneAuthority.verify, // the verifier only
25153
- claudeAccounts,
25717
+ claudeAccounts, codexAccounts: _codexAccounts(),
25718
+ codexHomeForAccount: (accountId) => _codexAccounts().homeFor(accountId),
25154
25719
  ClaudeSession: claudeBridgeModule.ClaudeSession,
25155
25720
  sdkVersion: claudeBridgeModule.SDK_VERSION,
25156
25721
  handleCodexSkinWebSocket, ensureIsolatedServe, stopIsolatedServe, setupOpencodeSidepanelConfig,
@@ -25167,6 +25732,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25167
25732
  gateUrl: `http://127.0.0.1:${PORT}/api/assistant/route-gate/check`,
25168
25733
  codexBin: () => getCodexBin(),
25169
25734
  pricing: assistantPricing,
25735
+ budget: assistantBudget,
25170
25736
  usage: assistantUsage,
25171
25737
  clarifier: assistantClarifier,
25172
25738
  userContext: assistantUserContext,
@@ -25189,39 +25755,30 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25189
25755
  if (unverified && attempt < 3) setTimeout(() => { repairRouteEfforts(attempt + 1).catch(() => {}); }, 60_000).unref?.();
25190
25756
  };
25191
25757
  repairRouteEfforts().catch(() => {});
25192
- const codexAccounts = {
25193
- listForClient: listCodexAccountsForClient,
25194
- rename: (id, label) => {
25195
- const data = loadCodexAccounts();
25196
- const acct = data.accounts.find((a) => a.id === id);
25197
- if (!acct) throw Object.assign(new Error('Unknown Codex account'), { code: 'ACCOUNT_NOT_FOUND', status: 404 });
25198
- const next = String(label || '').trim().slice(0, 60);
25199
- if (next) acct.label = next;
25200
- saveCodexAccounts(data);
25201
- return acct;
25202
- },
25203
- remove: (id, { inUse = () => null } = {}) => {
25204
- if (id === 'default') throw Object.assign(new Error('The Default account cannot be removed'), { code: 'DEFAULT_ACCOUNT', status: 409 });
25205
- const data = loadCodexAccounts();
25206
- const idx = data.accounts.findIndex((a) => a.id === id);
25207
- if (idx === -1) throw Object.assign(new Error('Unknown Codex account'), { code: 'ACCOUNT_NOT_FOUND', status: 404 });
25208
- const reason = inUse(id);
25209
- if (reason) throw Object.assign(new Error(reason), { code: 'ACCOUNT_IN_USE', status: 409 });
25210
- const [removed] = data.accounts.splice(idx, 1);
25211
- saveCodexAccounts(data);
25212
- try {
25213
- if (removed.home && removed.home !== CODEX_DEFAULT_HOME && removed.home.startsWith(CODEX_ACCOUNTS_ROOT)) rmSync(removed.home, { recursive: true, force: true });
25214
- } catch (e) { console.warn('[codex-accounts] Failed to delete home:', e.message); }
25215
- return { removed: id };
25216
- },
25217
- };
25758
+ // The one Codex accounts instance (the sidepanel's, lib/codex-accounts.js): the brain picker, the
25759
+ // catalog, worker dispatch and the panel show one account state.
25760
+ const codexAccounts = _codexAccounts();
25761
+ // A Codex worker whose dispatch names no account runs under this one (Assistant → accounts).
25762
+ _assistantCodexWorkerAccount = () => assistantConfigStore.workers().codexAccount;
25218
25763
  const createLoginTerminal = async ({ env = {}, cwd = null, profile = 'claude-code' } = {}) => {
25219
25764
  const terminalSessionId = createTerminalSession(profile, 120, 30, cwd || getHomePath(), { extraEnv: env });
25220
25765
  broadcastSync({ type: 'terminal:session-created', sessionId: terminalSessionId, profile });
25221
25766
  return { terminalSessionId, profile };
25222
25767
  };
25223
25768
  app.use('/api/assistant', createAssistantApi({
25769
+ accountUsage,
25224
25770
  dispatcher: _assistantDispatcher, runtime: _assistantRuntime, claudeAccounts, codexAccounts, buildCatalog, createLoginTerminal,
25771
+ // Claude accounts are the sidepanel's: sign-in is Claude Code's own `auth login` in a SynaBun terminal
25772
+ // under the account's config directory, a mode change ends the account's idle sessions first, and an
25773
+ // account a panel tab, the brain, a run or the worker choice uses cannot be removed.
25774
+ claudeSignIn: (accountId) => providerPanelTerminalAuth.start('claude', accountId),
25775
+ claudeSetMode: (accountId, mode) => panelClaudeAuth().setMode(accountId, mode),
25776
+ claudeAccountInUse,
25777
+ // Codex accounts are the sidepanel's too: sign-in is `codex login` in a SynaBun terminal under the
25778
+ // account's CODEX_HOME, a mode change restarts its idle panel processes, and an account something uses stays.
25779
+ codexSignIn: (accountId) => providerPanelTerminalAuth.start('codex', accountId),
25780
+ codexSetMode: (accountId, mode) => codexAccountAuth().setMode(accountId, mode),
25781
+ codexAccountInUse: (accountId) => codexAccountInUse(accountId),
25225
25782
  broadcastSync, isGuestRequest, log: (tag, message) => assistantLog(tag, message),
25226
25783
  assistantRouter, catalog: assistantCatalog, configStore: assistantConfigStore, taskClasses: TASK_CLASS_META,
25227
25784
  pricing: assistantPricing,
@@ -25233,6 +25790,10 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25233
25790
  userContext: assistantUserContext,
25234
25791
  userContextError: () => assistantUserContextError,
25235
25792
  defaultProject: () => assistantDetectProject(assistantConfigStore.read().defaultBrain?.cwd || PACKAGE_ROOT) || 'global',
25793
+ // whatsapp_send_file (POST /api/assistant/whatsapp/send-file): the WhatsApp service, built after this
25794
+ // router (startWhatsAppLink), and the port for that route's localhost Host check.
25795
+ whatsapp: () => _whatsapp,
25796
+ port: () => Number(PORT),
25236
25797
  }));
25237
25798
  const shutdownAssistant = () => {
25238
25799
  try { assistantRouter.shutdown(); } catch {}
@@ -25241,6 +25802,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25241
25802
  try { _assistantRuntime?.shutdown?.(); } catch {}
25242
25803
  try { _assistantDispatcher?.shutdown?.(); } catch {}
25243
25804
  try { _greeting?.shutdown?.(); } catch {}
25805
+ // The sessions' budget splits, written now (the planner debounces its writes).
25806
+ try { assistantBudget.flush(); } catch {}
25244
25807
  // Usage rows whose append failed get their last try (synchronous: an 'exit' handler runs it too).
25245
25808
  try { assistantUsage.flush(); } catch {}
25246
25809
  };
@@ -25472,6 +26035,7 @@ function buildExecMcpEnvOverrides(loopState) {
25472
26035
  function prepareExecWrapperFile(terminalSessionId, loopState, taskFile, sentinel) {
25473
26036
  const config = loadCliConfig();
25474
26037
  const profile = loopState.profile;
26038
+ if (profile === 'codex') ensureCanonicalCodexConfig({ strict: true });
25475
26039
  const mcpEnvOverrides = buildExecMcpEnvOverrides(loopState);
25476
26040
 
25477
26041
  // Surface the exact pins being injected per exec iteration so a stripped/missing
@@ -26212,6 +26776,38 @@ function handleSessionMonitorWebSocket(ws) {
26212
26776
  ws.on('error', () => sessionMonitorClients.delete(ws));
26213
26777
  }
26214
26778
 
26779
+ const providerPanelTerminalAuth = createProviderPanelTerminalAuth({
26780
+ resolveSpec: (provider, accountId) => providerLoginSpec({
26781
+ provider, accountId, claudeAccounts: _panelClaudeAccounts(), codexAccount: findCodexAccount,
26782
+ claudeExecutable: getClaudeSdkExecutable, codexExecutable: getCodexBin,
26783
+ env: withPathEnv(process.env, getAugmentedPath()),
26784
+ }),
26785
+ createSession: createTerminalSession,
26786
+ whenSpawned: id => terminalHost.whenSpawned(id),
26787
+ killSession: id => { try { terminalSessions.get(id)?.pty.kill(); } catch {} },
26788
+ // A sign-in that ended: ask the CLI what the account now holds. A subscription it confirms is what the
26789
+ // account uses from now on (every window hears it through claude:accounts-changed / codex:accounts-changed).
26790
+ // A Codex app-server reads its login only when it starts: the account's idle ones restart.
26791
+ onSettled: async (provider, accountId, status) => {
26792
+ if (provider === 'codex') {
26793
+ try {
26794
+ const accounts = _codexAccounts();
26795
+ await accounts.refreshStatus([accountId], { force: true });
26796
+ if (status === 'exited' && accounts.describe(accountId)?.subscription?.signedIn === true) accounts.noteSubscriptionSignIn(accountId);
26797
+ codexAccountStoreChanged(accountId);
26798
+ } catch {}
26799
+ return;
26800
+ }
26801
+ if (provider !== 'claude') return;
26802
+ try {
26803
+ const accounts = _panelClaudeAccounts();
26804
+ await accounts.refreshStatus([accountId], { force: true });
26805
+ if (status === 'exited' && accounts.describe(accountId)?.subscription?.signedIn === true) accounts.noteSubscriptionSignIn(accountId);
26806
+ } catch {}
26807
+ },
26808
+ });
26809
+ registerProviderPanelTerminalAuthRoutes(app, { guard: panelAuthOwnerGuard, auth: providerPanelTerminalAuth });
26810
+
26215
26811
  // ── Terminal REST endpoints ──
26216
26812
 
26217
26813
  app.get('/api/terminal/sessions', (req, res) => {
@@ -26272,6 +26868,7 @@ app.delete('/api/terminal/sessions/:id', (req, res) => {
26272
26868
  const session = terminalSessions.get(req.params.id);
26273
26869
  if (!session) return res.status(404).json({ error: 'Session not found' });
26274
26870
  const wasClaudeCode = session.profile === 'claude-code';
26871
+ session.onProviderAuthClosed?.();
26275
26872
  session.pty.kill();
26276
26873
  // Clean up temp files from image paste
26277
26874
  if (session.tempFiles) {
@@ -26284,7 +26881,7 @@ app.delete('/api/terminal/sessions/:id', (req, res) => {
26284
26881
  });
26285
26882
 
26286
26883
  app.get('/api/terminal/profiles', (req, res) => {
26287
- const profileIds = ['claude-code', 'codex', 'gemini', 'shell'];
26884
+ const profileIds = ['claude-code', 'codex', 'gemini', 'opencode', 'shell'];
26288
26885
  const profiles = profileIds.map(id => {
26289
26886
  const p = getTerminalProfile(id);
26290
26887
  return { id, shell: p.shell, args: p.args };
@@ -26343,8 +26940,10 @@ let _gitResolved = false;
26343
26940
 
26344
26941
  // Resolve git's absolute path at first use. On Windows, `where git` lists all matches;
26345
26942
  // prefer .exe so spawnSync works without cmd.exe quoting issues. On Unix, use `command -v`.
26943
+ // A Windows PC without git: the git.exe of SynaBun's copy of Git for Windows (lib/git-bash/), asked each time,
26944
+ // since a copy that is still being fetched at the first call arrives later.
26346
26945
  function resolveGitPath() {
26347
- if (_gitResolved) return _gitExePath;
26946
+ if (_gitResolved) return _gitExePath || gitBash.gitExecutable();
26348
26947
  _gitResolved = true;
26349
26948
  try {
26350
26949
  const cmd = IS_WINDOWS ? 'where git' : 'command -v git';
@@ -26361,7 +26960,7 @@ function resolveGitPath() {
26361
26960
  console.warn('[git] resolution failed:', err?.message || String(err));
26362
26961
  _gitExePath = null;
26363
26962
  }
26364
- return _gitExePath;
26963
+ return _gitExePath || gitBash.gitExecutable();
26365
26964
  }
26366
26965
 
26367
26966
  // Cross-platform git invocation. Uses resolved absolute path when possible (spawnable
@@ -26820,16 +27419,16 @@ app.post('/api/git/commit', editorOwnerGuard, (req, res) => {
26820
27419
  for (const f of files) {
26821
27420
  if (typeof f !== 'string' || f.includes('..')) return res.status(400).json({ error: 'Invalid file path' });
26822
27421
  }
26823
- const result = spawnSync('git', ['add', '--', ...files], { cwd: dir, stdio: 'pipe', timeout: 10000 });
27422
+ const result = spawnSync('git', ['add', '--', ...files], { cwd: dir, env: withGitFallback(process.env), stdio: 'pipe', timeout: 10000 });
26824
27423
  if (result.status !== 0) return res.status(500).json({ error: result.stderr?.toString?.()?.trim() || 'Stage failed' });
26825
27424
  } else {
26826
27425
  // Stage all changes
26827
- const result = spawnSync('git', ['add', '-A'], { cwd: dir, stdio: 'pipe', timeout: 10000 });
27426
+ const result = spawnSync('git', ['add', '-A'], { cwd: dir, env: withGitFallback(process.env), stdio: 'pipe', timeout: 10000 });
26828
27427
  if (result.status !== 0) return res.status(500).json({ error: result.stderr?.toString?.()?.trim() || 'Stage failed' });
26829
27428
  }
26830
27429
 
26831
27430
  // Commit
26832
- const result = spawnSync('git', ['commit', '-m', message.trim()], { cwd: dir, stdio: 'pipe', timeout: 15000 });
27431
+ const result = spawnSync('git', ['commit', '-m', message.trim()], { cwd: dir, env: withGitFallback(process.env), stdio: 'pipe', timeout: 15000 });
26833
27432
  if (result.status !== 0) {
26834
27433
  const stderr = result.stderr?.toString?.()?.trim() || '';
26835
27434
  const stdout = result.stdout?.toString?.()?.trim() || '';
@@ -26985,14 +27584,10 @@ app.post('/api/git/generate-message', editorOwnerGuard, async (req, res) => {
26985
27584
  return res.json({ ok: true, message: '', source: 'heuristic', summary: 'No changes to commit' });
26986
27585
  }
26987
27586
 
26988
- // Try Claude CLI
26989
- let claudeAvailable = false;
26990
- try {
26991
- execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'pipe', timeout: 3000 });
26992
- claudeAvailable = true;
26993
- } catch {}
27587
+ // Try Claude CLI: the one the panels use (lib/cli-resolver.js), started without a shell.
27588
+ const claudeLaunch = cliResolver.launcher('claude-code');
26994
27589
 
26995
- if (claudeAvailable) {
27590
+ if (claudeLaunch.spec && claudeLaunch.state !== 'broken') {
26996
27591
  try {
26997
27592
  const prompt = [
26998
27593
  'Generate a single-line git commit message for these changes.',
@@ -27007,14 +27602,16 @@ app.post('/api/git/generate-message', editorOwnerGuard, async (req, res) => {
27007
27602
  untrackedList ? '\n## New untracked files\n' + untrackedList.split('\n').slice(0, 20).join('\n') : '',
27008
27603
  ].join('\n');
27009
27604
 
27010
- const { spawnSync: spawnS } = require('child_process');
27011
- const result = spawnS('claude', ['-p', '--model', 'sonnet'], {
27605
+ const launch = spawnArgsFor(claudeLaunch.spec, ['-p', '--model', 'sonnet']);
27606
+ const result = spawnSync(launch.command, launch.args, {
27607
+ ...launch.options,
27012
27608
  input: prompt,
27013
27609
  cwd: dir,
27014
27610
  timeout: 30000,
27015
27611
  maxBuffer: 64 * 1024,
27016
27612
  stdio: ['pipe', 'pipe', 'pipe'],
27017
- shell: true,
27613
+ env: withPathEnv(process.env, getAugmentedPath()),
27614
+ windowsHide: true,
27018
27615
  });
27019
27616
 
27020
27617
  const output = (result.stdout || '').toString().trim();
@@ -27104,27 +27701,25 @@ app.put('/api/cli/config', (req, res) => {
27104
27701
  }
27105
27702
  });
27106
27703
 
27107
- app.post('/api/cli/detect/:profileId', (req, res) => {
27704
+ // Settings > Terminal > Detect: what is installed, whatever Settings says now
27705
+ // (the button fills the field with it), found the way every launcher finds it
27706
+ // (lib/cli-resolver.js), with the OS PATH read afresh.
27707
+ app.post('/api/cli/detect/:profileId', async (req, res) => {
27108
27708
  try {
27109
27709
  const { profileId } = req.params;
27110
- const defaults = { 'claude-code': 'claude', 'codex': 'codex', 'gemini': 'gemini', 'opencode': 'opencode' };
27111
- const cmdName = defaults[profileId];
27112
- if (!cmdName) return res.status(400).json({ ok: false, error: 'Unknown profile' });
27113
-
27114
- const detectCmd = IS_WIN ? `where ${cmdName}` : `which ${cmdName}`;
27115
- try {
27116
- const result = execSync(detectCmd, {
27117
- encoding: 'utf-8',
27118
- timeout: 5000,
27119
- windowsHide: true,
27120
- stdio: ['pipe', 'pipe', 'pipe'],
27121
- env: { ...process.env, PATH: getAugmentedPath() },
27122
- }).trim();
27123
- const foundPath = result.split(/\r?\n/)[0].trim();
27124
- res.json({ ok: true, found: true, path: foundPath });
27125
- } catch {
27126
- res.json({ ok: true, found: false, path: null });
27127
- }
27710
+ if (!['claude-code', 'codex', 'gemini', 'opencode'].includes(profileId)) return res.status(400).json({ ok: false, error: 'Unknown profile' });
27711
+ const status = await cliResolver.resolve(profileId, { force: true, configured: null });
27712
+ res.json({
27713
+ ok: true,
27714
+ found: status.state !== 'missing',
27715
+ path: status.path,
27716
+ version: status.version,
27717
+ state: status.state,
27718
+ foundBy: status.foundBy,
27719
+ pathStatus: cliPathManager.inspect(status),
27720
+ installSource: status.installSource,
27721
+ error: status.error,
27722
+ });
27128
27723
  } catch (err) {
27129
27724
  res.status(500).json({ ok: false, error: err.message });
27130
27725
  }
@@ -27297,9 +27892,13 @@ function spawnSidecarProcess(linkId, sessionId, message) {
27297
27892
  const command = (cliConfig[profile]?.command || '').trim();
27298
27893
  if (!command) throw new Error(`No CLI command configured for ${profile}`);
27299
27894
 
27300
- let spawnCmd = command;
27895
+ // Named by its plain name in Settings: the CLI the panels use (lib/cli-resolver.js), a program started
27896
+ // without a shell; otherwise the Settings value as written.
27897
+ const found = CLI_DEFAULTS[profile] && command === CLI_DEFAULTS[profile].command ? cliResolver.launcher(profile) : null;
27898
+ const direct = found?.spec?.kind === 'direct' ? found.launchPath : null;
27899
+ let spawnCmd = direct || command;
27301
27900
  let spawnArgs = [...sidecarConfig.args];
27302
- let useShell = IS_WIN;
27901
+ let useShell = IS_WIN && !direct;
27303
27902
  let tmpFile = null;
27304
27903
 
27305
27904
  if (sidecarConfig.stdin) {
@@ -27313,7 +27912,7 @@ function spawnSidecarProcess(linkId, sessionId, message) {
27313
27912
  const flag = spawnArgs[0]; // e.g. '-p', '-q'
27314
27913
  spawnCmd = 'powershell.exe';
27315
27914
  spawnArgs = ['-NoProfile', '-NonInteractive', '-Command',
27316
- `$msg = Get-Content -Raw '${tmpFile}'; & '${command}' ${flag} $msg`];
27915
+ `$msg = Get-Content -Raw '${tmpFile}'; & '${String(direct || command).replace(/'/g, "''")}' ${flag} $msg`];
27317
27916
  useShell = false;
27318
27917
  } else {
27319
27918
  // Unix: pass directly as the flag's value arg (no shell quoting issues)
@@ -27324,7 +27923,7 @@ function spawnSidecarProcess(linkId, sessionId, message) {
27324
27923
 
27325
27924
  const child = spawn(spawnCmd, spawnArgs, {
27326
27925
  cwd: session.cwd || process.cwd(),
27327
- env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
27926
+ env: { ...withPathEnv(process.env, getAugmentedPath()), FORCE_COLOR: '0', NO_COLOR: '1' },
27328
27927
  stdio: ['pipe', 'pipe', 'pipe'],
27329
27928
  shell: useShell,
27330
27929
  timeout: LINK_SIDECAR_TIMEOUT_MS,
@@ -32868,6 +33467,17 @@ const httpServer = app.listen(PORT, async () => {
32868
33467
  // first session runs on it (checkClaudeCliSkew in lib/claude-model-catalog.js).
32869
33468
  setTimeout(() => { checkClaudeCliSkew(getClaudeBin()).catch(() => {}); }, 1000);
32870
33469
 
33470
+ // Windows: the Git Bash every Claude Code gets (lib/git-bash/). Looked for now (the settings of the user's own
33471
+ // claude follow it); a PC with none, and an app that carries none, gets SynaBun's copy, fetched in the background.
33472
+ setTimeout(() => {
33473
+ try {
33474
+ gitBash.refresh();
33475
+ if (gitBash.status().state === 'missing') gitBash.ensure({ reason: 'first run' });
33476
+ // What an earlier run left in the data home (a stopped attempt's work folder, an older version), off the event loop.
33477
+ gitBash.housekeep().catch(() => {});
33478
+ } catch (err) { console.warn('[git-bash] start check failed:', err?.message || err); }
33479
+ }, 2500);
33480
+
32871
33481
  // Warm the session_cache + session_fts tables so the hybrid search has data
32872
33482
  // on first keystroke. Each pass is synchronous and holds the main thread, so
32873
33483
  // they run one per tick (requests are answered in between), and each only
@@ -33815,6 +34425,7 @@ process.on('exit', () => {
33815
34425
  try { terminalHost.killNow(); } catch {}
33816
34426
  try { _whatsapp?.killNow(); } catch {}
33817
34427
  try { tunnel.killNow(); } catch {}
34428
+ try { gitBash.cancel(); } catch {}
33818
34429
  try { stopOpencodeServer(); } catch {}
33819
34430
  try { editorCompletionServe.stop('exit'); } catch {}
33820
34431
  try { _claudeBridge?.shutdownAllBridges?.(); } catch {}