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
@@ -12,21 +12,26 @@ import { DispatchError } from './assistant-dispatch.js';
12
12
  import { openServable } from './assistant-media.js';
13
13
  import { designFit, targetMakes } from './assistant-router.js';
14
14
  import { normalizeEffort } from './effort-levels.js';
15
- import { BUDGET_DEFAULTS, BUDGET_FIELDS, BUDGET_MAX_USD, BUDGET_MIN_USD } from './assistant-budget.js';
15
+ import { BUDGET_DEFAULT_USD, BUDGET_MAX_USD, BUDGET_MIN_USD, BUDGET_WARN_RATIO } from './assistant-budget.js';
16
16
  import { tooLargeMessage } from './assistant-attachments.js';
17
17
  import { getSessionPolicy, requiresHumanApproval } from './remote-policy.js';
18
18
  import { clampPlanDispatch, PLAN_DISPATCH_NOTE, runCanChangeCode } from './assistant-plan-permissions.js';
19
+ import { noStore, requireJson, requireLocal, requireLocalHost, sendGuardError } from './http-guards.js';
20
+
21
+ /** The header whatsapp_send_file's caller proves it is the WhatsApp conversation's own brain with (assistant-runtime.js BRAIN_CREDENTIAL_RE). */
22
+ export const BRAIN_CREDENTIAL_HEADER = 'x-synabun-brain-token';
23
+ const SEND_FILE_FIELDS = Object.freeze(['path', 'caption', 'as']);
19
24
 
20
25
  /**
21
- * How far each provider's spend can be held to a cap (shown in the Budget tab).
22
- * A cap is checked where the provider lets it be checked; between two checks a
23
- * run can go past it by that much.
26
+ * How far each provider's spend can be held to the budget (the Budget modal shows it). Spend is
27
+ * checked where the provider lets it be checked; between two checks a run can go past its share by
28
+ * that much.
24
29
  */
25
30
  export const BUDGET_ENFORCEMENT = Object.freeze({
26
- 'claude-code': { metered: 'reported', perRun: 'The Claude CLI stops the run (--max-budget-usd); it checks between model calls, so one call can pass the cap.', session: 'Checked after every turn; at the hard cap the run is stopped mid-turn.' },
27
- opencode: { metered: 'reported', perRun: 'OpenCode reports each step\'s cost; the run stops at the first step past its cap.', session: 'Checked at every step.' },
28
- codex: { metered: 'estimated', perRun: 'Codex reports tokens only when a turn ends: priced at the models.dev list price (an API-price estimate; ChatGPT-plan accounts are billed by plan quota) and checked after each turn, so one turn can pass the cap. A failed turn is priced from its rollout.', session: 'Checked after every turn.' },
29
- brain: { metered: 'reported', perRun: 'Checked before every brain turn; a Claude brain\'s CLI also stops at what is left of the brain cap. A Codex brain\'s tokens are priced at list price (estimated); a brain model with no price is refused.', session: 'The brain\'s spend counts toward the session hard cap.' },
31
+ 'claude-code': { metered: 'reported', share: 'Checked between model calls (each call at list price while the turn runs, the reported cost at its end), so one call can pass the share. The Claude CLI also stops a run at its ceiling, or at what the session had left when it started.', session: 'At the budget every run stops.' },
32
+ opencode: { metered: 'reported', share: 'OpenCode reports each step\'s cost; the share is checked at every step, so one step can pass it.', session: 'At the budget every run stops.' },
33
+ codex: { metered: 'estimated', share: 'Codex reports tokens: priced at list price (an API-price estimate; ChatGPT-plan accounts are billed by plan quota) as its rollout records them, so one record or turn can pass the share. A failed turn is priced from its rollout.', session: 'At the budget every run stops.' },
34
+ brain: { metered: 'reported', share: 'Your Assistant\'s own turns draw from its provider\'s share, which keeps a reserve for them; checked before every brain turn. A Claude brain\'s CLI also stops at what that share had left when it started. A brain model with no price is refused.', session: 'At the budget the brain takes no turn until the budget is raised.' },
30
35
  });
31
36
 
32
37
  const OWNER_ONLY = { error: 'The assistant is available to the owner only', code: 'GUEST_FORBIDDEN' };
@@ -89,9 +94,17 @@ export function slimRunView(run) {
89
94
  * @param {object} deps.dispatcher createAssistantDispatcher()
90
95
  * @param {object} [deps.runtime] createAssistantRuntime() (sessions; optional until wired)
91
96
  * @param {object} [deps.claudeAccounts] createClaudeAccounts()
92
- * @param {object} [deps.codexAccounts] { listForClient(), rename(id,label), remove(id, {inUse}), addStart() }
97
+ * @param {object} [deps.codexAccounts] createCodexAccounts() (lib/codex-accounts.js)
93
98
  * @param {Function} [deps.buildCatalog] async () => catalog object (models/accounts/projects/profiles)
94
99
  * @param {Function} [deps.createLoginTerminal] async ({ env, cwd, profile, accountId }) => { terminalSessionId, profile }
100
+ * @param {Function} [deps.claudeSignIn] async (accountId) => { sessionId, status, error } — Claude Code's own
101
+ * `auth login` in a SynaBun terminal under the account's config directory (lib/provider-panel-terminal-auth.js)
102
+ * @param {Function} [deps.claudeSetMode] async (accountId, mode) => row — what the account's sessions use
103
+ * @param {Function} [deps.claudeAccountInUse] (accountId) => '' | why it cannot be removed now
104
+ * @param {Function} [deps.codexSignIn] async (accountId) => { sessionId, status, error } — `codex login` in a SynaBun
105
+ * terminal under the account's CODEX_HOME (lib/provider-panel-terminal-auth.js)
106
+ * @param {Function} [deps.codexSetMode] async (accountId, mode) => row — what the account's sessions use
107
+ * @param {Function} [deps.codexAccountInUse] (accountId) => '' | why it cannot be removed now
95
108
  * @param {object} [deps.attachments] createAttachmentStore() (POST /attachments)
96
109
  */
97
110
  export function createAssistantApi({
@@ -100,8 +113,15 @@ export function createAssistantApi({
100
113
  usage = null,
101
114
  claudeAccounts = null,
102
115
  codexAccounts = null,
116
+ accountUsage = null,
103
117
  buildCatalog = null,
104
118
  createLoginTerminal = null,
119
+ claudeSignIn = null,
120
+ claudeSetMode = null,
121
+ claudeAccountInUse = null,
122
+ codexSignIn = null,
123
+ codexSetMode = null,
124
+ codexAccountInUse = null,
105
125
  broadcastSync = () => {},
106
126
  isGuestRequest = () => false,
107
127
  log = () => {},
@@ -126,6 +146,10 @@ export function createAssistantApi({
126
146
  // Why the engine did not start on this server (a string or null), for the settings page.
127
147
  userContextError = () => null,
128
148
  defaultProject = () => 'global',
149
+ // whatsapp_send_file: the WhatsApp service ({ sendFile }) or a function returning it (it is built
150
+ // after this router), and the port the server listens on (the Host check of that route).
151
+ whatsapp = null,
152
+ port = 3344,
129
153
  } = {}) {
130
154
  if (!dispatcher) throw new Error('createAssistantApi requires a dispatcher');
131
155
  const router = Router();
@@ -275,6 +299,7 @@ export function createAssistantApi({
275
299
  // ── catalog ────────────────────────────────────────────────────────────────
276
300
  router.get('/catalog', async (req, res) => {
277
301
  try {
302
+ await accountUsage?.refresh();
278
303
  // refresh=1 rebuilds from the providers' endpoints; deep=1 also makes them ask the CLI / app-server again.
279
304
  const deep = req.query.deep === '1';
280
305
  const force = req.query.refresh === '1' || deep;
@@ -288,13 +313,21 @@ export function createAssistantApi({
288
313
  let preferences = {};
289
314
  try { preferences = configStore?.routing?.().preferences || {}; } catch {}
290
315
  const brief = await catalog.get({ force, deep, view: 'brain', provider, q, preferences });
291
- return res.json({ ok: true, generatedAt: new Date(now()).toISOString(), ...brief, limits: dispatcher.limits, runs: dispatcher.list({ activeOnly: true }), totals: dispatcher.totals() });
316
+ // Accounts are read live (the catalog build can be seconds old): every Claude and Codex account with
317
+ // its mode, whether it is signed in and its identity, and the account a worker gets when a dispatch
318
+ // names none.
319
+ const accounts = {
320
+ ...(brief.accounts || {}),
321
+ ...(claudeAccounts ? { 'claude-code': accountUsage?.list()?.['claude-code'] || claudeAccounts.listForClient() } : {}),
322
+ ...(codexAccounts ? { codex: accountUsage?.list()?.codex || codexAccounts.listForClient() } : {}),
323
+ };
324
+ return res.json({ ok: true, generatedAt: new Date(now()).toISOString(), ...brief, accounts, claudeWorkerAccount: claudeWorkerAccountId(), codexWorkerAccount: codexWorkerAccountId(), limits: dispatcher.limits, runs: dispatcher.list({ activeOnly: true }), totals: dispatcher.totals() });
292
325
  }
293
326
  // The catalog says how long one of its builds is good for (one without a verified list is redone
294
327
  // within seconds), so it is asked every time: this answer is reused only while it hands back the
295
328
  // build the answer was made from (a rebuild or invalidate() gives another one).
296
329
  const base = typeof buildCatalog === 'function' ? await buildCatalog({ force, deep }) : NO_CATALOG;
297
- if (!force && catalogCache && catalogCache.base === base && now() - catalogCache.at < 30_000) return res.json(catalogCache.value);
330
+ if (!force && catalogCache && catalogCache.base === base && now() - catalogCache.at < 30_000) return res.json(accountUsage ? { ...catalogCache.value, accounts: { ...catalogCache.value.accounts, ...accountUsage.list() } } : catalogCache.value);
298
331
  // Hidden rows stay server-side (view=manage lists them); the lists themselves ship.
299
332
  const { hiddenRows: _hiddenRows, ...visible } = base || {};
300
333
  const value = {
@@ -303,9 +336,11 @@ export function createAssistantApi({
303
336
  ...visible,
304
337
  accounts: {
305
338
  ...(base.accounts || {}),
306
- 'claude-code': claudeAccounts ? claudeAccounts.listForClient() : (base.accounts?.['claude-code'] || []),
307
- codex: codexAccounts ? codexAccounts.listForClient() : (base.accounts?.codex || []),
339
+ 'claude-code': accountUsage?.list()?.['claude-code'] || (claudeAccounts ? claudeAccounts.listForClient() : (base.accounts?.['claude-code'] || [])),
340
+ codex: accountUsage?.list()?.codex || (codexAccounts ? codexAccounts.listForClient() : (base.accounts?.codex || [])),
308
341
  },
342
+ claudeWorkerAccount: claudeWorkerAccountId(),
343
+ codexWorkerAccount: codexWorkerAccountId(),
309
344
  limits: dispatcher.limits,
310
345
  runs: dispatcher.list({ activeOnly: true }),
311
346
  totals: dispatcher.totals(),
@@ -469,16 +504,20 @@ export function createAssistantApi({
469
504
  } catch (error) { sendError(res, error, 400); }
470
505
  });
471
506
  // ── budget ─────────────────────────────────────────────────────────────────
507
+ /** A session's budget view (dispatcher → budget planner): the setting, spend, and the live split. */
508
+ function sessionBudgetView(sessionId) {
509
+ if (!sessionId) return null;
510
+ try { return dispatcher.sessionBudget?.(String(sessionId)) || null; } catch { return null; }
511
+ }
472
512
  function budgetPayload(sessionId = null) {
473
513
  const current = configStore.budget();
474
- const budget = Object.fromEntries(BUDGET_FIELDS.map((field) => [field, current[field]]));
475
- let session = null;
476
- if (sessionId) { try { session = dispatcher.sessionBudget?.(String(sessionId)) || null; } catch { session = null; } }
477
514
  let pricingStatus = null;
478
515
  try { pricingStatus = pricing?.status?.() || null; } catch { pricingStatus = null; }
479
516
  return {
480
- ok: true, version: current.version, budget, defaults: { ...BUDGET_DEFAULTS }, sources: current.sources, repairs: current.repairs,
481
- bounds: { minUsd: BUDGET_MIN_USD, maxUsd: BUDGET_MAX_USD }, session, pricing: pricingStatus, enforcement: BUDGET_ENFORCEMENT,
517
+ ok: true, version: current.version,
518
+ budget: { unlimited: current.unlimited === true, amountUsd: current.amountUsd, version: current.version, updatedAt: current.updatedAt || null, source: current.source || 'saved' },
519
+ defaults: { unlimited: false, amountUsd: BUDGET_DEFAULT_USD }, bounds: { minUsd: BUDGET_MIN_USD, maxUsd: BUDGET_MAX_USD }, warnRatio: BUDGET_WARN_RATIO,
520
+ session: sessionBudgetView(sessionId), pricing: pricingStatus, enforcement: BUDGET_ENFORCEMENT,
482
521
  };
483
522
  }
484
523
  // ── User context (Settings → Automation behavior): the three layers and what a new conversation is told ──
@@ -525,13 +564,16 @@ export function createAssistantApi({
525
564
  if (callerContext(req).kind !== 'ui') return res.status(403).json({ ok: false, code: 'UI_ONLY', error: 'Budget settings are changed from the assistant panel only.' });
526
565
  try {
527
566
  const body = req.body || {};
528
- const patch = body.budget && typeof body.budget === 'object' ? body.budget : {};
567
+ const patch = body.budget && typeof body.budget === 'object' && !Array.isArray(body.budget) ? body.budget : null;
568
+ if (!patch) return res.status(400).json({ ok: false, code: 'BUDGET_INVALID', error: 'Send { budget: { unlimited?, amountUsd? }, version }', message: 'Send { budget: { unlimited?, amountUsd? }, version }', field: 'budget', version: null });
529
569
  const expectedVersion = body.version ?? null;
530
570
  const saved = configStore.patchBudget(patch, { expectedVersion });
531
- // Applies now: the dispatcher re-reads its limits, the runtime re-checks every session.
571
+ // Applies now: the planner divides every open session's budget again, the runtime re-checks
572
+ // every session (held mailboxes, the brain's next turn) and every panel hears it.
532
573
  try { dispatcher.refreshLimits?.(); } catch {}
533
- try { runtime?.onBudgetChanged?.(); } catch {}
534
- broadcastSync({ type: 'assistant:budget-changed', version: saved.version });
574
+ if (runtime?.onBudgetChanged) { try { runtime.onBudgetChanged(); } catch {} }
575
+ else { try { dispatcher.budget?.onBudgetChanged?.(); } catch {} }
576
+ broadcastSync({ type: 'assistant:budget-changed', version: saved.version, budget: { unlimited: saved.unlimited, amountUsd: saved.amountUsd, version: saved.version, updatedAt: saved.updatedAt || null } });
535
577
  res.json(budgetPayload(body.sessionId || null));
536
578
  } catch (error) {
537
579
  if (error?.code === 'BUDGET_INVALID' || error?.code === 'VERSION_CONFLICT') {
@@ -551,6 +593,45 @@ export function createAssistantApi({
551
593
  try { res.json(runtime.gateCheck({ session: body.session, token: body.token, tool: body.tool, input, inputComplete: body.inputComplete === true, providerSessionId: body.providerSessionId || null, agent: body.agent || null, hello: body.hello === true, host: typeof body.host === 'string' ? body.host : null })); }
552
594
  catch (error) { sendError(res, error); }
553
595
  });
596
+ // ── whatsapp_send_file (docs/whatsapp.md → Sending files) ──────────────────
597
+ // A local file to the owner, for the WhatsApp conversation whose brain is calling. Who calls is the
598
+ // credential that brain alone holds (X-Synabun-Brain-Token: assistant-runtime.js mintBrainCredential),
599
+ // resolved to its session in constant time; never X-Synabun-Role or X-Synabun-Terminal, which any
600
+ // local process can send. A loopback socket and a localhost Host only (the checks of /api/whatsapp);
601
+ // guests were refused above. Only { path, caption?, as? }. Exactly one send, never retried.
602
+ const portOf = () => (typeof port === 'function' ? port() : port);
603
+ let hostGuard = null;
604
+ const localHostOnly = (req, res, next) => {
605
+ const current = portOf();
606
+ if (!hostGuard || hostGuard.port !== current) {
607
+ try { hostGuard = { port: current, check: requireLocalHost(current) }; } catch { return sendGuardError(res, 500, 'BAD_PORT', 'The file route has no valid port.'); }
608
+ }
609
+ return hostGuard.check(req, res, next);
610
+ };
611
+ router.post('/whatsapp/send-file', noStore, requireLocal, localHostOnly, requireJson, async (req, res) => {
612
+ let sessionId = null;
613
+ try { sessionId = runtime?.sessionForBrainCredential?.(req.get(BRAIN_CREDENTIAL_HEADER)) || null; } catch { sessionId = null; }
614
+ if (!sessionId) return sendGuardError(res, 403, 'BRAIN_CREDENTIAL_REQUIRED', 'Only the WhatsApp conversation\'s own Assistant sends files to the phone: nothing was sent.');
615
+ const body = req.body;
616
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return sendGuardError(res, 400, 'BAD_REQUEST', 'Send { path, caption?, as? }.');
617
+ const extra = Object.keys(body).find((key) => !SEND_FILE_FIELDS.includes(key));
618
+ if (extra !== undefined) return sendGuardError(res, 400, 'BAD_REQUEST', `Unknown field "${extra.slice(0, 40)}": a file send takes path, caption and as only.`, extra.slice(0, 40));
619
+ let service = null;
620
+ try { service = typeof whatsapp === 'function' ? whatsapp() : whatsapp; } catch { service = null; }
621
+ if (typeof service?.sendFile !== 'function') return sendGuardError(res, 503, 'WHATSAPP_UNAVAILABLE', 'WhatsApp is not available on this computer: nothing was sent.');
622
+ try {
623
+ res.json(await service.sendFile(sessionId, body));
624
+ } catch (error) {
625
+ const status = Number(error?.status) || 500;
626
+ const out = { ok: false, code: error?.code || 'INTERNAL', error: error?.message || String(error) };
627
+ if (error?.field) out.field = error.field;
628
+ if (error?.uncertain === true) out.uncertain = true;
629
+ if (Number.isFinite(error?.retryAfterMs)) out.retryAfterMs = Math.max(0, Math.round(error.retryAfterMs));
630
+ if (status >= 500) log('assistant:whatsapp-send-file', `${sessionId}: ${out.code}${out.uncertain ? ' (delivery unknown)' : ''}`);
631
+ res.status(status).json(out);
632
+ }
633
+ });
634
+
554
635
  // agent_route: the brain proposes; the router approves, asks, or says pending.
555
636
  router.post('/route', orchestrationGuard, async (req, res) => {
556
637
  if (!requireRouter(res)) return;
@@ -772,13 +853,16 @@ export function createAssistantApi({
772
853
  res.json(brain ? brainWaitView(chat ? chatWaitView(result) : result) : result);
773
854
  } catch (error) { sendError(res, error); }
774
855
  });
775
- router.post('/runs/:runId/send', orchestrationGuard, (req, res) => {
856
+ router.post('/runs/:runId/send', orchestrationGuard, async (req, res) => {
776
857
  try {
777
858
  const body = req.body || {};
778
859
  const assistantSessionId = req.assistantCaller.assistantSessionId || dispatcher.get(req.params.runId)?.assistantSessionId || null;
779
860
  if (planRefusal(req, res, assistantSessionId, 'send a worker a message')) return;
780
861
  if (planState(req, assistantSessionId) === 'plan' && planSendRefusal(res, dispatcher.get(req.params.runId))) return;
781
862
  const origin = body.origin === 'user' ? 'user' : (req.assistantCaller.kind === 'ui' ? (body.origin || 'assistant') : 'assistant');
863
+ // A run whose provider share is used up: the session's unspent budget is divided again first, then
864
+ // sendTurn decides (it refuses with BUDGET_SHARE_EXHAUSTED when no money could move).
865
+ try { await dispatcher.coverFollowUp?.(req.params.runId); } catch { /* sendTurn decides on the shares as they are */ }
782
866
  res.json(dispatcher.sendTurn(req.params.runId, body.text ?? body.message, { origin, queue: body.queue !== false }));
783
867
  } catch (error) { sendError(res, error); }
784
868
  });
@@ -855,6 +939,14 @@ export function createAssistantApi({
855
939
  res.status(201).json({ ok: true, session });
856
940
  } catch (error) { sendError(res, error); }
857
941
  });
942
+ // A session's budget and its live split (the spend chip, the Budget modal): the same view the
943
+ // `assistant:budget` packet carries.
944
+ router.get('/sessions/:id/budget', (req, res) => {
945
+ if (!requireRuntime(res)) return;
946
+ const id = req.params.id;
947
+ if (!runtime.getSession(id, { transcript: false })) return res.status(404).json({ ok: false, code: 'SESSION_NOT_FOUND', error: `Unknown assistant session ${id}` });
948
+ try { res.json({ ok: true, budget: sessionBudgetView(id) }); } catch (error) { sendError(res, error); }
949
+ });
858
950
  router.get('/sessions/:id/usage', (req, res) => {
859
951
  if (!requireRuntime(res)) return;
860
952
  const id = req.params.id;
@@ -870,9 +962,10 @@ export function createAssistantApi({
870
962
  const session = usage?.sessionView(id) ?? null;
871
963
  // Whether work is in progress is the runtime's to say (a turn that has no tokens yet).
872
964
  const head = session ? runtime.usageView?.(id, 'current')?.session || null : null;
873
- return res.json({ ok: true, session: session && head ? { ...session, live: head.live, fidelity: head.fidelity } : session, ...(run ? { run: usage?.runView?.(id, run) ?? null } : {}) });
965
+ return res.json({ ok: true, session: session && head ? { ...session, live: head.live, fidelity: head.fidelity } : session, ...(run ? { run: usage?.runView?.(id, run) ?? null } : {}), budget: sessionBudgetView(id) });
874
966
  }
875
- res.json({ ok: true, usage: runtime.usageView?.(id, task) ?? usage?.taskView(id, task) ?? null });
967
+ // The session budget rides along (agent_usage: the mode and each provider's share / spent / left).
968
+ res.json({ ok: true, usage: runtime.usageView?.(id, task) ?? usage?.taskView(id, task) ?? null, budget: sessionBudgetView(id) });
876
969
  } catch (error) { sendError(res, error); }
877
970
  });
878
971
  router.get('/sessions/:id', (req, res) => {
@@ -940,25 +1033,51 @@ export function createAssistantApi({
940
1033
  return (accountId) => {
941
1034
  if (dispatcher.accountInUse(provider, accountId)) return `Stop the dispatched ${provider} runs using account ${accountId} first`;
942
1035
  if (runtime?.accountInUse?.(provider, accountId)) return `Close the assistant sessions using account ${accountId} first`;
1036
+ if (provider === 'claude-code' && typeof claudeAccountInUse === 'function') return claudeAccountInUse(accountId) || null;
1037
+ if (provider === 'codex' && typeof codexAccountInUse === 'function') return codexAccountInUse(accountId) || null;
943
1038
  return null;
944
1039
  };
945
1040
  }
946
- router.get('/claude/accounts', (req, res) => {
947
- if (!claudeAccounts) return res.status(503).json({ ok: false, code: 'ACCOUNTS_UNAVAILABLE', error: 'Claude accounts are not available' });
948
- res.json({ ok: true, accounts: claudeAccounts.listForClient() });
1041
+ // The Claude account a Claude Code worker runs under when its dispatch names none (null = Default).
1042
+ function claudeWorkerAccountId() {
1043
+ try { return configStore?.workers?.().claudeAccount || null; } catch { return null; }
1044
+ }
1045
+ // The same for Codex workers.
1046
+ function codexWorkerAccountId() {
1047
+ try { return configStore?.workers?.().codexAccount || null; } catch { return null; }
1048
+ }
1049
+ const claudeUnavailable = (res) => res.status(503).json({ ok: false, code: 'ACCOUNTS_UNAVAILABLE', error: 'Claude accounts are not available' });
1050
+ // Every Claude account as every surface shows it (lib/claude-accounts.js describe()): mode, signed in,
1051
+ // identity (an email or a masked key, never a key). `?refresh=1` asks Claude Code about each first.
1052
+ router.get('/claude/accounts', async (req, res) => {
1053
+ if (!claudeAccounts) return claudeUnavailable(res);
1054
+ try {
1055
+ if (req.query.refresh === '1' || req.query.refresh === 'true') await claudeAccounts.refreshStatus?.(null, { force: true });
1056
+ else Promise.resolve(claudeAccounts.refreshStatus?.()).catch(() => {});
1057
+ await accountUsage?.refresh('claude-code');
1058
+ res.json({ ok: true, accounts: accountUsage?.list()?.['claude-code'] || claudeAccounts.listForClient(), workerAccountId: claudeWorkerAccountId() });
1059
+ } catch (error) { sendError(res, error); }
949
1060
  });
1061
+ // Create an account. `login: true` also opens its Claude sign-in in a SynaBun terminal; otherwise the
1062
+ // caller offers the two ways to sign it in (Claude, or an API key pasted into it).
950
1063
  router.post('/claude/accounts', async (req, res) => {
951
- if (!claudeAccounts) return res.status(503).json({ ok: false, code: 'ACCOUNTS_UNAVAILABLE', error: 'Claude accounts are not available' });
1064
+ if (!claudeAccounts) return claudeUnavailable(res);
952
1065
  try {
953
1066
  const account = claudeAccounts.create({ label: req.body?.label });
954
1067
  let login = null;
955
- if (req.body?.login !== false && typeof createLoginTerminal === 'function') {
956
- login = await startClaudeLogin(account.id);
957
- }
1068
+ if (req.body?.login === true) login = await startClaudeLogin(account.id);
1069
+ catalogCache = null;
958
1070
  res.status(201).json({ ok: true, account: claudeAccounts.listForClient().find((row) => row.id === account.id) || account, login });
959
1071
  } catch (error) { sendError(res, error); }
960
1072
  });
961
1073
  async function startClaudeLogin(accountId) {
1074
+ // Claude Code's own `auth login` under the account's config directory, in a SynaBun terminal (the
1075
+ // panel's sign-in). The account list says when it is signed in (claude:accounts-changed).
1076
+ if (typeof claudeSignIn === 'function') {
1077
+ const terminal = await claudeSignIn(accountId);
1078
+ if (terminal?.status === 'error') throw Object.assign(new Error(terminal.error || 'Could not open the sign-in terminal.'), { status: 500, code: 'LOGIN_FAILED' });
1079
+ return { mode: 'terminal', terminalSessionId: terminal?.sessionId || null, status: terminal?.status || 'pending', profile: 'shell' };
1080
+ }
962
1081
  const command = claudeAccounts.loginCommand(accountId);
963
1082
  const terminal = await createLoginTerminal({ env: command.env, cwd: command.cwd, profile: 'claude-code', accountId, command: command.command });
964
1083
  claudeAccounts.watchLogin(accountId, {
@@ -970,24 +1089,34 @@ export function createAssistantApi({
970
1089
  return { mode: 'terminal', terminalSessionId: terminal?.terminalSessionId || terminal?.id || null, profile: terminal?.profile || 'claude-code' };
971
1090
  }
972
1091
  router.post('/claude/accounts/:id/login', async (req, res) => {
973
- if (!claudeAccounts) return res.status(503).json({ ok: false, code: 'ACCOUNTS_UNAVAILABLE', error: 'Claude accounts are not available' });
974
- if (typeof createLoginTerminal !== 'function') return res.status(501).json({ ok: false, code: 'LOGIN_UNAVAILABLE', error: 'Login terminals are not wired' });
1092
+ if (!claudeAccounts) return claudeUnavailable(res);
1093
+ if (typeof claudeSignIn !== 'function' && typeof createLoginTerminal !== 'function') return res.status(501).json({ ok: false, code: 'LOGIN_UNAVAILABLE', error: 'Login terminals are not wired' });
975
1094
  try {
976
- if (!claudeAccounts.get(req.params.id)) return res.status(404).json({ ok: false, code: 'ACCOUNT_NOT_FOUND', error: `Unknown Claude account ${req.params.id}` });
1095
+ const row = claudeAccounts.get(req.params.id);
1096
+ if (!row || row.id !== req.params.id) return res.status(404).json({ ok: false, code: 'ACCOUNT_NOT_FOUND', error: `Unknown Claude account ${req.params.id}` });
977
1097
  res.json({ ok: true, login: await startClaudeLogin(req.params.id) });
978
1098
  } catch (error) { sendError(res, error); }
979
1099
  });
980
- router.patch('/claude/accounts/:id', (req, res) => {
981
- if (!claudeAccounts) return res.status(503).json({ ok: false, code: 'ACCOUNTS_UNAVAILABLE', error: 'Claude accounts are not available' });
1100
+ // Rename (`label`) and/or choose what the account's sessions use (`mode`: 'subscription' | 'apiKey').
1101
+ router.patch('/claude/accounts/:id', async (req, res) => {
1102
+ if (!claudeAccounts) return claudeUnavailable(res);
982
1103
  try {
983
- const account = claudeAccounts.rename(req.params.id, req.body?.label);
1104
+ const row = claudeAccounts.get(req.params.id);
1105
+ if (!row || row.id !== req.params.id) return res.status(404).json({ ok: false, code: 'ACCOUNT_NOT_FOUND', error: `Unknown Claude account ${req.params.id}` });
1106
+ const { label, mode } = req.body || {};
1107
+ if (label === undefined && mode === undefined) return res.status(400).json({ ok: false, code: 'NOTHING_TO_CHANGE', error: 'Send a label or a mode.' });
1108
+ if (label !== undefined) claudeAccounts.rename(req.params.id, label);
1109
+ if (mode !== undefined) {
1110
+ if (typeof claudeSetMode === 'function') await claudeSetMode(req.params.id, mode);
1111
+ else claudeAccounts.setMode(req.params.id, mode);
1112
+ }
984
1113
  catalogCache = null;
985
1114
  broadcastSync({ type: 'assistant:accounts-changed', provider: 'claude-code', accountId: req.params.id });
986
- res.json({ ok: true, account });
1115
+ res.json({ ok: true, account: claudeAccounts.describeForClient ? claudeAccounts.describeForClient(req.params.id) : claudeAccounts.get(req.params.id) });
987
1116
  } catch (error) { sendError(res, error); }
988
1117
  });
989
1118
  router.delete('/claude/accounts/:id', (req, res) => {
990
- if (!claudeAccounts) return res.status(503).json({ ok: false, code: 'ACCOUNTS_UNAVAILABLE', error: 'Claude accounts are not available' });
1119
+ if (!claudeAccounts) return claudeUnavailable(res);
991
1120
  try {
992
1121
  const result = claudeAccounts.remove(req.params.id, { inUse: accountInUse('claude-code') });
993
1122
  catalogCache = null;
@@ -995,30 +1124,89 @@ export function createAssistantApi({
995
1124
  res.json({ ok: true, ...result });
996
1125
  } catch (error) { sendError(res, error); }
997
1126
  });
998
- router.get('/codex/accounts', (req, res) => {
999
- if (!codexAccounts) return res.status(503).json({ ok: false, code: 'ACCOUNTS_UNAVAILABLE', error: 'Codex accounts are not available' });
1000
- res.json({ ok: true, accounts: codexAccounts.listForClient() });
1127
+ // The account a Claude Code worker uses when its dispatch names none. Signed-out accounts are refused.
1128
+ router.put('/claude/worker-account', (req, res) => {
1129
+ if (!claudeAccounts || !configStore?.patchWorkers) return claudeUnavailable(res);
1130
+ try {
1131
+ const wanted = req.body?.accountId === null || req.body?.accountId === undefined || req.body?.accountId === '' ? 'default' : String(req.body.accountId);
1132
+ const row = claudeAccounts.get(wanted);
1133
+ if (!row || row.id !== wanted) return res.status(404).json({ ok: false, code: 'ACCOUNT_NOT_FOUND', error: `Unknown Claude account ${wanted}` });
1134
+ const unusable = claudeAccounts.unusableReason?.(wanted) || '';
1135
+ if (unusable) return res.status(409).json({ ok: false, code: 'ACCOUNT_SIGNED_OUT', error: unusable });
1136
+ const workers = configStore.patchWorkers({ claudeAccount: wanted });
1137
+ catalogCache = null;
1138
+ broadcastSync({ type: 'assistant:accounts-changed', provider: 'claude-code', accountId: wanted, worker: true });
1139
+ res.json({ ok: true, workerAccountId: workers.claudeAccount });
1140
+ } catch (error) { sendError(res, error); }
1141
+ });
1142
+ const codexUnavailable = (res) => res.status(503).json({ ok: false, code: 'ACCOUNTS_UNAVAILABLE', error: 'Codex accounts are not available' });
1143
+ // Every Codex account as every surface shows it (lib/codex-accounts.js describe()): mode, signed in,
1144
+ // identity (an email or a masked key, never a key). `?refresh=1` asks Codex about each own store first.
1145
+ router.get('/codex/accounts', async (req, res) => {
1146
+ if (!codexAccounts) return codexUnavailable(res);
1147
+ try {
1148
+ if (req.query.refresh === '1' || req.query.refresh === 'true') await codexAccounts.refreshStatus?.(null, { force: true });
1149
+ else Promise.resolve(codexAccounts.refreshStatus?.()).catch(() => {});
1150
+ await accountUsage?.refresh('codex');
1151
+ res.json({ ok: true, accounts: accountUsage?.list()?.codex || codexAccounts.listForClient(), workerAccountId: codexWorkerAccountId() });
1152
+ } catch (error) { sendError(res, error); }
1153
+ });
1154
+ // `codex login` under the account's CODEX_HOME, in a SynaBun terminal (the panel's sign-in). The account
1155
+ // list says when it is signed in (codex:accounts-changed).
1156
+ async function startCodexLogin(accountId) {
1157
+ if (typeof codexSignIn !== 'function') throw Object.assign(new Error('Login terminals are not wired'), { status: 501, code: 'LOGIN_UNAVAILABLE' });
1158
+ const terminal = await codexSignIn(accountId);
1159
+ if (terminal?.status === 'error') throw Object.assign(new Error(terminal.error || 'Could not open the sign-in terminal.'), { status: 500, code: 'LOGIN_FAILED' });
1160
+ return { mode: 'terminal', terminalSessionId: terminal?.sessionId || null, status: terminal?.status || 'pending', profile: 'shell' };
1161
+ }
1162
+ // Create an account. `login: true` also opens its ChatGPT sign-in in a SynaBun terminal; otherwise the
1163
+ // caller offers the two ways to sign it in (ChatGPT, or an API key pasted into it).
1164
+ router.post('/codex/accounts', async (req, res) => {
1165
+ if (!codexAccounts?.create) return codexUnavailable(res);
1166
+ try {
1167
+ const account = codexAccounts.create({ label: req.body?.label });
1168
+ const login = req.body?.login === true ? await startCodexLogin(account.id) : null;
1169
+ catalogCache = null;
1170
+ res.status(201).json({ ok: true, account: codexAccounts.describeForClient?.(account.id) || account, login });
1171
+ } catch (error) { sendError(res, error); }
1001
1172
  });
1173
+ // Older pages: create the account and open its ChatGPT sign-in at once.
1002
1174
  router.post('/codex/accounts/add-start', async (req, res) => {
1003
- if (!codexAccounts?.addStart) return res.status(501).json({ ok: false, code: 'NOT_IMPLEMENTED', error: 'Add a Codex account from the Codex sidepanel account menu' });
1175
+ if (!codexAccounts?.create) return codexUnavailable(res);
1004
1176
  try {
1005
- const result = await codexAccounts.addStart(req.body || {});
1177
+ const account = codexAccounts.create({ label: req.body?.label });
1178
+ const login = await startCodexLogin(account.id).catch((error) => ({ error: error.message }));
1006
1179
  catalogCache = null;
1007
- broadcastSync({ type: 'assistant:accounts-changed', provider: 'codex', accountId: result?.accountId || null });
1008
- res.status(201).json({ ok: true, ...result });
1180
+ res.status(201).json({ ok: true, accountId: account.id, account: codexAccounts.describeForClient?.(account.id) || account, login });
1181
+ } catch (error) { sendError(res, error); }
1182
+ });
1183
+ router.post('/codex/accounts/:id/login', async (req, res) => {
1184
+ if (!codexAccounts) return codexUnavailable(res);
1185
+ try {
1186
+ const row = codexAccounts.describe?.(req.params.id);
1187
+ if (!row || row.id !== req.params.id) return res.status(404).json({ ok: false, code: 'ACCOUNT_NOT_FOUND', error: `Unknown Codex account ${req.params.id}` });
1188
+ res.json({ ok: true, login: await startCodexLogin(req.params.id) });
1009
1189
  } catch (error) { sendError(res, error); }
1010
1190
  });
1011
- router.patch('/codex/accounts/:id', (req, res) => {
1012
- if (!codexAccounts?.rename) return res.status(503).json({ ok: false, code: 'ACCOUNTS_UNAVAILABLE', error: 'Codex accounts are not available' });
1191
+ // Rename (`label`) and/or choose what the account's sessions use (`mode`: 'subscription' (ChatGPT) | 'apiKey').
1192
+ router.patch('/codex/accounts/:id', async (req, res) => {
1193
+ if (!codexAccounts) return codexUnavailable(res);
1013
1194
  try {
1014
- const account = codexAccounts.rename(req.params.id, req.body?.label);
1195
+ const row = codexAccounts.describe?.(req.params.id);
1196
+ if (!row || row.id !== req.params.id) return res.status(404).json({ ok: false, code: 'ACCOUNT_NOT_FOUND', error: `Unknown Codex account ${req.params.id}` });
1197
+ const { label, mode } = req.body || {};
1198
+ if (label !== undefined) codexAccounts.rename(req.params.id, label);
1199
+ if (mode !== undefined) {
1200
+ if (typeof codexSetMode === 'function') await codexSetMode(req.params.id, mode);
1201
+ else codexAccounts.setMode(req.params.id, mode);
1202
+ }
1015
1203
  catalogCache = null;
1016
1204
  broadcastSync({ type: 'assistant:accounts-changed', provider: 'codex', accountId: req.params.id });
1017
- res.json({ ok: true, account });
1205
+ res.json({ ok: true, account: codexAccounts.describeForClient(req.params.id) });
1018
1206
  } catch (error) { sendError(res, error); }
1019
1207
  });
1020
1208
  router.delete('/codex/accounts/:id', (req, res) => {
1021
- if (!codexAccounts?.remove) return res.status(503).json({ ok: false, code: 'ACCOUNTS_UNAVAILABLE', error: 'Codex accounts are not available' });
1209
+ if (!codexAccounts?.remove) return codexUnavailable(res);
1022
1210
  try {
1023
1211
  const result = codexAccounts.remove(req.params.id, { inUse: accountInUse('codex') });
1024
1212
  catalogCache = null;
@@ -1026,6 +1214,21 @@ export function createAssistantApi({
1026
1214
  res.json({ ok: true, ...result });
1027
1215
  } catch (error) { sendError(res, error); }
1028
1216
  });
1217
+ // The account a Codex worker uses when its dispatch names none. Signed-out accounts are refused.
1218
+ router.put('/codex/worker-account', (req, res) => {
1219
+ if (!codexAccounts || !configStore?.patchWorkers) return codexUnavailable(res);
1220
+ try {
1221
+ const wanted = req.body?.accountId === null || req.body?.accountId === undefined || req.body?.accountId === '' ? 'default' : String(req.body.accountId);
1222
+ const row = codexAccounts.describe?.(wanted);
1223
+ if (!row || row.id !== wanted) return res.status(404).json({ ok: false, code: 'ACCOUNT_NOT_FOUND', error: `Unknown Codex account ${wanted}` });
1224
+ const unusable = codexAccounts.unusableReason?.(wanted) || '';
1225
+ if (unusable) return res.status(409).json({ ok: false, code: 'ACCOUNT_SIGNED_OUT', error: unusable });
1226
+ const workers = configStore.patchWorkers({ codexAccount: wanted });
1227
+ catalogCache = null;
1228
+ broadcastSync({ type: 'assistant:accounts-changed', provider: 'codex', accountId: wanted, worker: true });
1229
+ res.json({ ok: true, workerAccountId: workers.codexAccount });
1230
+ } catch (error) { sendError(res, error); }
1231
+ });
1029
1232
 
1030
1233
  // ── sidepanel tab registry (UI heartbeat) ──────────────────────────────────
1031
1234
  function listTabs() {
@@ -27,12 +27,14 @@ export function claudeSdkMode(approvalMode, planMode) {
27
27
  }
28
28
 
29
29
  // Never gate these behind a permission card: routing must not wait on an
30
- // approval prompt, and computer use is fully autonomous (its guards live in
31
- // the desktop service, not in the host's permission layer).
30
+ // approval prompt, computer use is fully autonomous (its guards live in
31
+ // the desktop service, not in the host's permission layer), and a file the
32
+ // user asked for on WhatsApp is the turn's reply (the WhatsApp host checks it).
32
33
  export const CLAUDE_BRAIN_ALLOWED_TOOLS = Object.freeze([
33
34
  'mcp__SynaBun__agent_route', 'mcp__SynaBun__agent_catalog', 'mcp__SynaBun__agent_status', 'mcp__SynaBun__agent_read',
34
35
  'mcp__SynaBun__agent_list', 'mcp__SynaBun__agent_wait', 'mcp__SynaBun__agent_usage',
35
36
  'mcp__SynaBun__computer', 'mcp__SynaBun__computer_apps', 'mcp__SynaBun__computer_ax', 'mcp__SynaBun__computer_status',
37
+ 'mcp__SynaBun__whatsapp_send_file',
36
38
  ]);
37
39
 
38
40
  /**
@@ -85,6 +87,10 @@ export function createClaudeBrain({ session, sink, deps = {}, persona = '', hook
85
87
  const inner = new deps.ClaudeSession(socket, {
86
88
  ownerKey: session.id,
87
89
  windowId,
90
+ // The Claude account the brain runs under: the bridge gives its process that
91
+ // account's credential (lib/claude-accounts.js launchFor(): its subscription
92
+ // login or its API key, nothing inherited), and refuses an account that is gone.
93
+ claudeAccountId: brain.accountId || 'default',
88
94
  sessionId: providerSessionId,
89
95
  model: brain.model || null,
90
96
  effort: brain.effort || null,
@@ -117,6 +123,11 @@ export function createClaudeBrain({ session, sink, deps = {}, persona = '', hook
117
123
  'X-Synabun-Role': 'assistant', 'X-Synabun-Terminal': session.id, 'X-Synabun-Memory-Session': session.id,
118
124
  ...(deps.desktopGrant ? { 'X-Synabun-Desktop-Grant': deps.desktopGrant } : {}),
119
125
  ...mcpHeaders,
126
+ // A WhatsApp conversation's brain credential (whatsapp_send_file). The header names the variable and
127
+ // the CLI expands it from its own environment (below): the MCP config is on its command line, which every
128
+ // account of the computer can read, an environment only this user's processes. The in-process MCP server
129
+ // keeps the value for the request it arrived with, and sends it on the file route alone. Last, so nothing replaces it.
130
+ ...(deps.brainCredential ? { 'X-Synabun-Brain-Token': '${SYNABUN_BRAIN_TOKEN}' } : {}),
120
131
  },
121
132
  // A remote (WhatsApp) session never pre-approves computer use: its PreToolUse hook refuses, asks or allows each call.
122
133
  allowedTools,
@@ -132,6 +143,8 @@ export function createClaudeBrain({ session, sink, deps = {}, persona = '', hook
132
143
  ...(typeof deps.claudeAccountEnv === 'function' ? deps.claudeAccountEnv(brain.accountId) : {}),
133
144
  SYNABUN_ASSISTANT_SESSION: String(session.id),
134
145
  ...(deps.userContextThreads === true ? { SYNABUN_ASSISTANT_THREADS: '1' } : {}),
146
+ // The value of the X-Synabun-Brain-Token header above (a WhatsApp conversation's brain only).
147
+ ...(deps.brainCredential ? { SYNABUN_BRAIN_TOKEN: deps.brainCredential } : {}),
135
148
  },
136
149
  maxBudgetUsd: Number(deps.maxBudgetUsd) > 0 ? Number(deps.maxBudgetUsd) : undefined,
137
150
  });
@@ -119,6 +119,9 @@ export function createCodexBrain({ session, sink, deps = {}, persona = '' } = {}
119
119
  });
120
120
  const socket = createVirtualSocket({
121
121
  name: `codex-brain-${session.id}`,
122
+ // A WhatsApp conversation's brain credential rides on the socket object, never in a message: the skin
123
+ // handler gives it to this app-server's SynaBun MCP child through its environment (whatsapp_send_file).
124
+ credential: deps.brainCredential || null,
122
125
  onSend: (packet) => {
123
126
  if (disposed || !packet || typeof packet !== 'object') return;
124
127
  if (packet.type === 'ready') readyResolve?.();
@@ -7,7 +7,7 @@
7
7
  // (`agent.assistant.prompt`) in the per-serve config so the model sees it as
8
8
  // its system prompt; SSE events are translated to the unified envelope.
9
9
 
10
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
11
11
  import { dirname, resolve } from 'node:path';
12
12
  import { fileURLToPath, pathToFileURL } from 'node:url';
13
13
  import { normalizeOpenCodeModel } from '../native-loop-providers.js';
@@ -46,11 +46,14 @@ export const OPENCODE_ROUTE_GATE_PLUGIN = pathToFileURL(resolve(dirname(fileURLT
46
46
  */
47
47
  export const OPENCODE_PLAN_AGENT = 'synabun-plan';
48
48
 
49
+ /** The brain's SynaBun MCP tool-call timeout (ms): as long as the Codex brain's tool_timeout_sec (server.js). */
50
+ export const OPENCODE_BRAIN_MCP_TIMEOUT_MS = 180_000;
51
+
49
52
  /**
50
53
  * `routeGate` ({ url, session, token }) installs the route-gate plugin in this
51
54
  * isolated serve's config only (assistant brains; never the shared sidepanel setup).
52
55
  */
53
- export function patchOpenCodeAssistantConfig(xdgRoot, { persona = '', role = 'assistant', model = null, desktopGrant = null, routeGate = null } = {}) {
56
+ export function patchOpenCodeAssistantConfig(xdgRoot, { persona = '', role = 'assistant', model = null, desktopGrant = null, routeGate = null, brainCredential = null } = {}) {
54
57
  const configPath = resolve(xdgRoot, 'opencode', 'config.json');
55
58
  if (!existsSync(configPath)) return false;
56
59
  const config = JSON.parse(readFileSync(configPath, 'utf8'));
@@ -60,7 +63,15 @@ export function patchOpenCodeAssistantConfig(xdgRoot, { persona = '', role = 'as
60
63
  // Computer use: an unforgeable per-brain grant unlocks the `computer` group.
61
64
  if (desktopGrant) env.SYNABUN_DESKTOP_GRANT = desktopGrant;
62
65
  else delete env.SYNABUN_DESKTOP_GRANT;
66
+ // A WhatsApp conversation's brain credential (whatsapp_send_file), for this serve's SynaBun MCP child only.
67
+ // This file names the variable; the value is in the serve's own environment (ensureIsolatedServe), so
68
+ // neither this file (it is in every backup of data/) nor OPENCODE_CONFIG_CONTENT ever holds it.
69
+ if (brainCredential) env.SYNABUN_BRAIN_TOKEN = '{env:SYNABUN_BRAIN_TOKEN}';
70
+ else delete env.SYNABUN_BRAIN_TOKEN;
63
71
  config.mcp.SynaBun[envKey] = env;
72
+ // OpenCode gives an MCP tool call 60 s unless the server says otherwise; agent_route may wait on the
73
+ // user's route card and whatsapp_send_file on an upload (130 s): the Codex brain's 180 s here too.
74
+ config.mcp.SynaBun.timeout = OPENCODE_BRAIN_MCP_TIMEOUT_MS;
64
75
  }
65
76
  config.agent = { ...(config.agent || {}) };
66
77
  config.agent.assistant = {
@@ -89,6 +100,8 @@ export function patchOpenCodeAssistantConfig(xdgRoot, { persona = '', role = 'as
89
100
  if (routeGate?.url && routeGate?.session && routeGate?.token) plugins.push([OPENCODE_ROUTE_GATE_PLUGIN, { url: routeGate.url, session: routeGate.session, token: routeGate.token, ...(routeGate.remote === true ? { remote: true } : {}) }]);
90
101
  if (plugins.length) config.plugin = plugins; else delete config.plugin;
91
102
  writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
103
+ // It carries this brain's grants (the desktop grant): readable by this user only.
104
+ try { chmodSync(configPath, 0o600); } catch { /* a filesystem without modes */ }
92
105
  return true;
93
106
  }
94
107
 
@@ -203,8 +216,8 @@ export function createOpenCodeBrain({ session, sink, deps = {}, persona = '' } =
203
216
  if (startPromise) return startPromise;
204
217
  startPromise = (async () => {
205
218
  const xdgRoot = deps.setupOpencodeSidepanelConfig(termId, brain.mcpProfile || null);
206
- try { personaInstalled = patchOpenCodeAssistantConfig(xdgRoot, { persona, model: null, desktopGrant: deps.desktopGrant || null, routeGate: deps.routeGate || null }); } catch { personaInstalled = false; }
207
- entry = await deps.ensureIsolatedServe(termId, { xdgRoot, keepAlive: true, mcpProfile: brain.mcpProfile || null });
219
+ try { personaInstalled = patchOpenCodeAssistantConfig(xdgRoot, { persona, model: null, desktopGrant: deps.desktopGrant || null, routeGate: deps.routeGate || null, brainCredential: deps.brainCredential || null }); } catch { personaInstalled = false; }
220
+ entry = await deps.ensureIsolatedServe(termId, { xdgRoot, keepAlive: true, mcpProfile: brain.mcpProfile || null, brainCredential: deps.brainCredential || null });
208
221
  if (!entry?.client) throw new Error('OpenCode isolated serve failed to start');
209
222
  client = entry.client;
210
223
  await client.waitUntilConnected?.(20_000);