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
@@ -1,19 +1,22 @@
1
1
  // ═══════════════════════════════════════════
2
- // SynaBun — Assistant budgets (caps · list prices · Codex usage)
2
+ // SynaBun — Assistant budget (the one setting · list prices · Codex usage)
3
3
  // ═══════════════════════════════════════════
4
4
  //
5
- // The money rails of the central assistant, in one place:
5
+ // One setting for every Assistant session: { unlimited, amountUsd, version }.
6
+ // It lives in assistant-config.json under `budget` and is read live. Every new
7
+ // session starts with it; a change applies to the open sessions at once.
6
8
  //
7
- // defaultRunUsd per-run cap a dispatch gets when the brain names none
8
- // maxRunUsd largest per-run cap a brain may ask for (budget_usd)
9
- // sessionWarnUsd one warning to the brain and the user at this session spend
10
- // sessionHardUsd session spend (brain + every run) that stops everything
11
- // brainUsd the central brain's own spend in one session
9
+ // unlimited false the session may spend `amountUsd` (brain + every run, at
10
+ // list price). The warning is fixed at 80 %; at 100 % the
11
+ // session stops. assistant-budget-split.js divides what is
12
+ // left across the providers (Jev or a fixed heuristic).
13
+ // unlimited true No limit: no caps, no shares, no Jev call. Spend is still
14
+ // metered.
12
15
  //
13
- // They live where the dispatcher and the runtime always read them, so a hand
14
- // edit keeps working: `limits.defaultBudgetUsd`, `limits.maxBudgetUsd`,
15
- // `limits.sessionWarnUsd` (older files: 80 % of `limits.sessionSoftBudgetUsd`),
16
- // `limits.sessionHardBudgetUsd`, `brains.claude.maxBudgetUsd`.
16
+ // Older files kept five caps in `limits` (default/largest per run, session
17
+ // warning, session hard cap) and `brains.claude.maxBudgetUsd`. They are read
18
+ // once (migrateBudget): the session hard cap becomes the amount, the rest is
19
+ // dropped.
17
20
  //
18
21
  // Prices: Claude and OpenCode report what a turn cost. Codex reports tokens
19
22
  // only, so its runs are priced from the list-price table (assistant-pricing.js,
@@ -28,17 +31,15 @@ import { homedir } from 'node:os';
28
31
  import { join } from 'node:path';
29
32
  import { PRICING_SOURCES, PRICING_VERIFIED, listPrice, pricingRows } from './assistant-pricing.js';
30
33
 
31
- export const BUDGET_FIELDS = Object.freeze(['defaultRunUsd', 'maxRunUsd', 'sessionWarnUsd', 'sessionHardUsd', 'brainUsd']);
32
- export const BUDGET_DEFAULTS = Object.freeze({ defaultRunUsd: 5, maxRunUsd: 25, sessionWarnUsd: 8, sessionHardUsd: 25, brainUsd: 10 });
33
34
  export const BUDGET_MIN_USD = 0.1;
34
35
  export const BUDGET_MAX_USD = 1000;
35
- export const BUDGET_LABELS = Object.freeze({
36
- defaultRunUsd: 'Default per-run cap',
37
- maxRunUsd: 'Largest per-run cap',
38
- sessionWarnUsd: 'Session warning',
39
- sessionHardUsd: 'Session hard cap',
40
- brainUsd: 'Brain cap',
41
- });
36
+ export const BUDGET_DEFAULT_USD = 25;
37
+ /** The one warning comes at this share of the budget. */
38
+ export const BUDGET_WARN_RATIO = 0.8;
39
+ export const BUDGET_FIELDS = Object.freeze(['unlimited', 'amountUsd']);
40
+ export const BUDGET_DEFAULTS = Object.freeze({ unlimited: false, amountUsd: BUDGET_DEFAULT_USD });
41
+ /** The keys of the old five-cap budget (`limits.*`, `brains.claude.maxBudgetUsd`), dropped by the migration. */
42
+ export const LEGACY_LIMIT_KEYS = Object.freeze(['defaultBudgetUsd', 'maxBudgetUsd', 'sessionWarnUsd', 'sessionSoftBudgetUsd', 'sessionHardBudgetUsd', 'budgetVersion', 'budgetUpdatedAt']);
42
43
 
43
44
  function num(value) {
44
45
  if (value === null || value === undefined || value === '' || typeof value === 'boolean') return null;
@@ -47,6 +48,8 @@ function num(value) {
47
48
  }
48
49
  function obj(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
49
50
  export function roundUsd(value) { return Math.round(Number(value) * 100) / 100; }
51
+ /** Whole cents, never more than `value` (a budget never rounds up). */
52
+ export function floorCents(value) { return Math.max(0, Math.floor(Number(value) * 100 + 1e-6)); }
50
53
 
51
54
  function budgetError(field, message) {
52
55
  const error = new Error(message);
@@ -55,109 +58,105 @@ function budgetError(field, message) {
55
58
  error.status = 400;
56
59
  return error;
57
60
  }
61
+ const inRange = (n) => n !== null && n >= BUDGET_MIN_USD && n <= BUDGET_MAX_USD;
58
62
 
59
- /** The raw values a config file holds (null = not set). */
60
- export function storedBudget(raw = {}) {
63
+ /** Whether a config file still carries the old five-cap budget and no `budget` of its own. */
64
+ export function needsBudgetMigration(raw = {}) {
65
+ if (raw?.budget && typeof raw.budget === 'object' && !Array.isArray(raw.budget)) return false;
61
66
  const limits = obj(raw?.limits);
62
- const soft = num(limits.sessionSoftBudgetUsd);
63
- const warn = num(limits.sessionWarnUsd);
64
- return {
65
- defaultRunUsd: num(limits.defaultBudgetUsd),
66
- maxRunUsd: num(limits.maxBudgetUsd),
67
- sessionWarnUsd: warn ?? (soft !== null ? roundUsd(soft * 0.8) : null),
68
- sessionHardUsd: num(limits.sessionHardBudgetUsd),
69
- brainUsd: num(obj(obj(raw?.brains).claude).maxBudgetUsd),
70
- };
67
+ return LEGACY_LIMIT_KEYS.some((key) => Object.hasOwn(limits, key)) || Object.hasOwn(obj(obj(raw?.brains).claude), 'maxBudgetUsd');
71
68
  }
72
69
 
73
70
  /**
74
- * The invariant that fails first, or null: default ≤ largest ≤ hard,
75
- * warning < hard, brain ≤ hard.
71
+ * The budget a config file holds: its `budget` when it has one, else what an
72
+ * older file says (the saved session hard cap, else the default).
73
+ * → { unlimited, amountUsd, version, updatedAt, source: 'saved' | 'migrated' | 'default' }
76
74
  */
77
- export function budgetInvariantError(b) {
78
- if (b.maxRunUsd > b.sessionHardUsd) return { field: 'maxRunUsd', message: `${BUDGET_LABELS.maxRunUsd} ($${b.maxRunUsd}) cannot exceed the ${BUDGET_LABELS.sessionHardUsd.toLowerCase()} ($${b.sessionHardUsd}).` };
79
- if (b.defaultRunUsd > b.maxRunUsd) return { field: 'defaultRunUsd', message: `${BUDGET_LABELS.defaultRunUsd} ($${b.defaultRunUsd}) cannot exceed the ${BUDGET_LABELS.maxRunUsd.toLowerCase()} ($${b.maxRunUsd}).` };
80
- if (b.sessionWarnUsd >= b.sessionHardUsd) return { field: 'sessionWarnUsd', message: `${BUDGET_LABELS.sessionWarnUsd} ($${b.sessionWarnUsd}) must be below the ${BUDGET_LABELS.sessionHardUsd.toLowerCase()} ($${b.sessionHardUsd}).` };
81
- if (b.brainUsd > b.sessionHardUsd) return { field: 'brainUsd', message: `${BUDGET_LABELS.brainUsd} ($${b.brainUsd}) cannot exceed the ${BUDGET_LABELS.sessionHardUsd.toLowerCase()} ($${b.sessionHardUsd}).` };
82
- return null;
75
+ export function effectiveBudget(raw = {}) {
76
+ const stored = raw?.budget && typeof raw.budget === 'object' && !Array.isArray(raw.budget) ? raw.budget : null;
77
+ if (stored) {
78
+ const amount = num(stored.amountUsd);
79
+ return {
80
+ unlimited: stored.unlimited === true,
81
+ amountUsd: inRange(amount) ? roundUsd(amount) : BUDGET_DEFAULT_USD,
82
+ version: Math.max(0, Math.floor(num(stored.version) || 0)),
83
+ updatedAt: typeof stored.updatedAt === 'string' ? stored.updatedAt : null,
84
+ source: 'saved',
85
+ };
86
+ }
87
+ const limits = obj(raw?.limits);
88
+ const hard = num(limits.sessionHardBudgetUsd);
89
+ const migrated = needsBudgetMigration(raw);
90
+ return {
91
+ unlimited: false,
92
+ // The saved session hard cap is the amount (a hand edit outside the bounds is brought inside them).
93
+ amountUsd: hard !== null && hard > 0 ? roundUsd(Math.min(BUDGET_MAX_USD, Math.max(BUDGET_MIN_USD, hard))) : BUDGET_DEFAULT_USD,
94
+ version: Math.max(0, Math.floor(num(limits.budgetVersion) || 0)),
95
+ updatedAt: typeof limits.budgetUpdatedAt === 'string' ? limits.budgetUpdatedAt : null,
96
+ source: migrated ? 'migrated' : 'default',
97
+ };
83
98
  }
84
99
 
85
- /**
86
- * What applies now: each saved value in range, else its default; a hand edit
87
- * that breaks an invariant is repaired (and listed in `repairs`), never trusted.
88
- */
89
- export function effectiveBudget(raw = {}) {
90
- const stored = storedBudget(raw);
91
- const out = {};
92
- const sources = {};
93
- for (const field of BUDGET_FIELDS) {
94
- const value = stored[field];
95
- const ok = value !== null && value >= BUDGET_MIN_USD && value <= BUDGET_MAX_USD;
96
- out[field] = ok ? roundUsd(value) : BUDGET_DEFAULTS[field];
97
- sources[field] = !ok ? 'default' : (field === 'sessionWarnUsd' && num(obj(raw?.limits).sessionWarnUsd) === null ? 'legacy' : 'saved');
100
+ /** A config object without the old five-cap keys (every other key kept). */
101
+ export function withoutLegacyBudget(raw = {}) {
102
+ const base = { ...obj(raw) };
103
+ if (base.limits && typeof base.limits === 'object') {
104
+ const limits = { ...base.limits };
105
+ for (const key of LEGACY_LIMIT_KEYS) delete limits[key];
106
+ base.limits = limits;
98
107
  }
99
- const repairs = [];
100
- for (let guard = 0; guard < BUDGET_FIELDS.length; guard += 1) {
101
- const broken = budgetInvariantError(out);
102
- if (!broken) break;
103
- repairs.push(broken.field);
104
- if (broken.field === 'maxRunUsd') out.maxRunUsd = out.sessionHardUsd;
105
- else if (broken.field === 'defaultRunUsd') out.defaultRunUsd = out.maxRunUsd;
106
- else if (broken.field === 'sessionWarnUsd') out.sessionWarnUsd = roundUsd(out.sessionHardUsd * 0.8);
107
- else if (broken.field === 'brainUsd') out.brainUsd = out.sessionHardUsd;
108
+ const brains = obj(base.brains);
109
+ if (brains.claude && typeof brains.claude === 'object' && Object.hasOwn(brains.claude, 'maxBudgetUsd')) {
110
+ const { maxBudgetUsd: _cap, ...claude } = brains.claude;
111
+ base.brains = { ...brains, claude };
108
112
  }
109
- return { ...out, sources, repairs };
113
+ return base;
110
114
  }
111
115
 
112
116
  /**
113
- * Validate a patch against the current values and return the full next set.
114
- * `null` resets a field to its default. Throws BUDGET_INVALID (status 400,
115
- * `field`) on a bad value or a broken invariant.
117
+ * Validate a patch ({ unlimited?, amountUsd? }) against the current budget and
118
+ * return the full next one. Throws BUDGET_INVALID (status 400, `field`) on an
119
+ * unknown field, a bad value or an empty patch. Amounts are kept in cents.
116
120
  */
117
121
  export function validateBudgetPatch(patch = {}, current = BUDGET_DEFAULTS) {
118
122
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw budgetError('budget', 'budget must be an object');
119
- const next = {};
120
- for (const field of BUDGET_FIELDS) next[field] = num(current?.[field]) ?? BUDGET_DEFAULTS[field];
123
+ const next = { unlimited: current?.unlimited === true, amountUsd: inRange(num(current?.amountUsd)) ? roundUsd(current.amountUsd) : BUDGET_DEFAULT_USD };
124
+ let changed = 0;
121
125
  for (const [field, value] of Object.entries(patch)) {
122
- if (!BUDGET_FIELDS.includes(field)) throw budgetError(field, `Unknown budget field "${field}"`);
123
- if (value === null) { next[field] = BUDGET_DEFAULTS[field]; continue; }
126
+ if (!BUDGET_FIELDS.includes(field)) throw budgetError(field, `Unknown budget field "${field}". Send unlimited and/or amountUsd.`);
127
+ changed += 1;
128
+ if (field === 'unlimited') {
129
+ if (typeof value !== 'boolean') throw budgetError('unlimited', 'unlimited must be true or false');
130
+ next.unlimited = value;
131
+ continue;
132
+ }
124
133
  const n = typeof value === 'number' || (typeof value === 'string' && value.trim() !== '') ? Number(value) : NaN;
125
- if (!Number.isFinite(n)) throw budgetError(field, `${BUDGET_LABELS[field]} must be an amount in US dollars`);
126
- if (n < BUDGET_MIN_USD || n > BUDGET_MAX_USD) throw budgetError(field, `${BUDGET_LABELS[field]} must be between $${BUDGET_MIN_USD.toFixed(2)} and $${BUDGET_MAX_USD}`);
127
- next[field] = roundUsd(n);
134
+ if (!Number.isFinite(n)) throw budgetError('amountUsd', 'The budget must be an amount in US dollars');
135
+ if (n < BUDGET_MIN_USD || n > BUDGET_MAX_USD) throw budgetError('amountUsd', `The budget must be between $${BUDGET_MIN_USD.toFixed(2)} and $${BUDGET_MAX_USD}`);
136
+ next.amountUsd = roundUsd(n);
128
137
  }
129
- const broken = budgetInvariantError(next);
130
- if (broken) throw budgetError(broken.field, broken.message);
138
+ if (!changed) throw budgetError('budget', 'Nothing to change: send unlimited and/or amountUsd');
131
139
  return next;
132
140
  }
133
141
 
134
- /** A config object with `budget` written into the keys its readers use (every other key kept). */
142
+ /** A config object with `budget` written (the old caps dropped, every other key kept). */
135
143
  export function writeBudgetInto(raw = {}, budget, { version = 0, at = new Date().toISOString() } = {}) {
136
- const base = obj(raw);
137
- const brains = obj(base.brains);
138
144
  return {
139
- ...base,
140
- limits: {
141
- ...obj(base.limits),
142
- defaultBudgetUsd: budget.defaultRunUsd,
143
- maxBudgetUsd: budget.maxRunUsd,
144
- sessionWarnUsd: budget.sessionWarnUsd,
145
- sessionHardBudgetUsd: budget.sessionHardUsd,
146
- budgetVersion: version,
147
- budgetUpdatedAt: at,
148
- },
149
- brains: { ...brains, claude: { ...obj(brains.claude), maxBudgetUsd: budget.brainUsd } },
145
+ ...withoutLegacyBudget(raw),
146
+ budget: { unlimited: budget.unlimited === true, amountUsd: roundUsd(budget.amountUsd), version, updatedAt: at },
150
147
  };
151
148
  }
152
149
 
153
- /** The dispatcher's limit keys for a budget. */
154
- export function budgetLimits(budget) {
150
+ /** What the Assistant's budget limits at `spentUsd` (null amounts under No limit). */
151
+ export function budgetLimitsAt(budget, spentUsd = 0) {
152
+ if (!budget || budget.unlimited) return { unlimited: true, amountUsd: null, warnAtUsd: null, leftUsd: null, warned: false, exceeded: false };
153
+ const amount = Number(budget.amountUsd) || 0;
154
+ const spent = Math.max(0, Number(spentUsd) || 0);
155
+ const warnAt = roundUsd(amount * BUDGET_WARN_RATIO);
155
156
  return {
156
- defaultBudgetUsd: budget.defaultRunUsd,
157
- maxBudgetUsd: budget.maxRunUsd,
158
- sessionWarnUsd: budget.sessionWarnUsd,
159
- sessionHardBudgetUsd: budget.sessionHardUsd,
160
- brainBudgetUsd: budget.brainUsd,
157
+ unlimited: false, amountUsd: amount, warnAtUsd: warnAt,
158
+ leftUsd: Math.max(0, Number((amount - spent).toFixed(8))),
159
+ warned: spent >= warnAt, exceeded: spent >= amount,
161
160
  };
162
161
  }
163
162
 
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // The assistant's user-editable knobs live in one JSON file: `defaultBrain`,
6
6
  // `persona`, `limits`, `memory` (read by the other assistant modules),
7
- // `routing`, the budget caps (owned here; see assistant-budget.js) and
7
+ // `routing`, `budget` (owned here; see assistant-budget.js) and
8
8
  // `userContext` (the three layers of assistant-user-context.js). Knobs
9
9
  // are data: the file is re-read when its mtime changes, so a PUT from the
10
10
  // routes or budget editor, or a hand edit, applies without a restart. Writes
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { dirname } from 'node:path';
15
- import { effectiveBudget, validateBudgetPatch, writeBudgetInto } from './assistant-budget.js';
15
+ import { effectiveBudget, needsBudgetMigration, validateBudgetPatch, writeBudgetInto } from './assistant-budget.js';
16
16
  import { normalizeUserContext, USER_CONTEXT_DEFAULTS } from './assistant-user-context-budgets.js';
17
17
 
18
18
  export const ROUTE_MODES = ['always-ask', 'ask-unsure', 'never'];
@@ -55,6 +55,10 @@ function cleanTarget(field, target) {
55
55
  const provider = String(target.provider || '').trim();
56
56
  if (!ROUTE_PROVIDERS.includes(provider)) throw routingError(`${field}.provider`, `routing.${field}.provider must be one of ${ROUTE_PROVIDERS.join(', ')}`);
57
57
  const out = { kind, provider, model: target.model ? String(target.model).slice(0, 200) : null, effort: target.effort ? String(target.effort).slice(0, 40) : null };
58
+ if (target.accountId != null && target.accountId !== '') {
59
+ if (typeof target.accountId !== 'string' || !/^[a-z0-9-]{1,64}$/.test(target.accountId)) throw routingError(`${field}.accountId`, 'Invalid route account id');
60
+ if (provider !== 'opencode') out.accountId = target.accountId;
61
+ }
58
62
  if (target.label) out.label = String(target.label).slice(0, 120);
59
63
  if (target.at) out.at = String(target.at).slice(0, 40);
60
64
  if (target.by) out.by = String(target.by).slice(0, 20);
@@ -210,30 +214,46 @@ export function createAssistantConfigStore({ path, log = () => {}, now = Date.no
210
214
  return { routing: effectiveRouting(nextRouting), version: nextRouting.version };
211
215
  }
212
216
 
213
- /** Effective budget caps (assistant-budget.js effectiveBudget) + the file's budget version. */
217
+ /**
218
+ * The budget every session runs under (assistant-budget.js effectiveBudget):
219
+ * { unlimited, amountUsd, version, updatedAt, source }. A file that still
220
+ * holds the old five caps is migrated the first time it is read here: the
221
+ * session hard cap becomes the amount (else the default), the old keys go,
222
+ * and the version moves on so a client holding the old shape gets a 409.
223
+ * A file that cannot be written keeps working on the migrated values.
224
+ */
214
225
  function budget() {
215
226
  const raw = read();
216
- return { ...effectiveBudget(raw), version: Number(raw.limits?.budgetVersion) || 0 };
227
+ if (!needsBudgetMigration(raw)) return effectiveBudget(raw);
228
+ const migrated = effectiveBudget(raw);
229
+ try {
230
+ const version = migrated.version + 1;
231
+ write(writeBudgetInto(raw, migrated, { version, at: new Date(now()).toISOString() }));
232
+ log('config:budget-migrated', `amountUsd ${migrated.amountUsd} (from the session hard cap) · version ${version}`);
233
+ return { ...effectiveBudget(read()), source: 'migrated' };
234
+ } catch (error) {
235
+ log('config:budget-migration-failed', error?.message || String(error));
236
+ return migrated;
237
+ }
217
238
  }
218
- function budgetVersion() { return Number(read().limits?.budgetVersion) || 0; }
239
+ function budgetVersion() { return budget().version; }
219
240
 
220
241
  /**
221
- * Validate and save budget caps (a partial patch; `null` resets a field).
242
+ * Validate and save the budget (a partial patch: unlimited, amountUsd).
222
243
  * `expectedVersion` (when given) must match. Readers pick it up on their next
223
- * read: the dispatcher per dispatch / turn, the runtime per brain turn.
244
+ * read: the dispatcher per dispatch / turn / metered step, the runtime per brain turn.
224
245
  */
225
246
  function patchBudget(patch = {}, { expectedVersion = null } = {}) {
226
- const current = read();
227
- const currentVersion = Number(current.limits?.budgetVersion) || 0;
228
- if (expectedVersion !== null && expectedVersion !== undefined && Number(expectedVersion) !== currentVersion) {
247
+ const current = budget();
248
+ if (expectedVersion !== null && expectedVersion !== undefined && Number(expectedVersion) !== current.version) {
229
249
  const error = new Error('Budget settings changed elsewhere; reload and try again.');
230
250
  error.code = 'VERSION_CONFLICT';
231
251
  error.status = 409;
232
- error.version = currentVersion;
252
+ error.version = current.version;
233
253
  throw error;
234
254
  }
235
- const next = validateBudgetPatch(patch, effectiveBudget(current));
236
- write(writeBudgetInto(current, next, { version: currentVersion + 1, at: new Date(now()).toISOString() }));
255
+ const next = validateBudgetPatch(patch, current);
256
+ write(writeBudgetInto(read(), next, { version: current.version + 1, at: new Date(now()).toISOString() }));
237
257
  return budget();
238
258
  }
239
259
 
@@ -253,9 +273,37 @@ export function createAssistantConfigStore({ path, log = () => {}, now = Date.no
253
273
  return userContext();
254
274
  }
255
275
 
276
+ /**
277
+ * Defaults for dispatched workers, read live. `claudeAccount` / `codexAccount`:
278
+ * the account a Claude Code / Codex worker runs under when its dispatch names
279
+ * none (null = Default).
280
+ */
281
+ const WORKER_ACCOUNT_KEYS = { claudeAccount: 'Claude', codexAccount: 'Codex' };
282
+ const accountIdOf = (id) => (typeof id === 'string' && /^[a-z0-9-]{1,64}$/.test(id) && id !== 'default' ? id : null);
283
+ function workers() {
284
+ const raw = read().workers;
285
+ const stored = raw && typeof raw === 'object' ? raw : {};
286
+ return { claudeAccount: accountIdOf(stored.claudeAccount), codexAccount: accountIdOf(stored.codexAccount) };
287
+ }
288
+ /** Save worker defaults (a partial patch; null or 'default' for an account is Default). Unknown keys stay. */
289
+ function patchWorkers(patch = {}) {
290
+ const current = read();
291
+ const stored = current.workers && typeof current.workers === 'object' && !Array.isArray(current.workers) ? current.workers : {};
292
+ const next = { ...stored };
293
+ for (const [key, provider] of Object.entries(WORKER_ACCOUNT_KEYS)) {
294
+ if (!Object.prototype.hasOwnProperty.call(patch || {}, key)) continue;
295
+ const id = patch[key];
296
+ if (id === null || id === '' || id === 'default') delete next[key];
297
+ else if (typeof id === 'string' && /^[a-z0-9-]{1,64}$/.test(id)) next[key] = id;
298
+ else throw Object.assign(new Error(`That is not a ${provider} account id.`), { code: 'INVALID_ACCOUNT', status: 400 });
299
+ }
300
+ write({ ...current, workers: { ...next, updatedAt: new Date(now()).toISOString() } });
301
+ return workers();
302
+ }
303
+
256
304
  function onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
257
305
 
258
- return { read, routing, version, patchRouting, setPreference, budget, budgetVersion, patchBudget, userContext, patchUserContext, onChange, path };
306
+ return { read, routing, version, patchRouting, setPreference, budget, budgetVersion, patchBudget, userContext, patchUserContext, workers, patchWorkers, onChange, path };
259
307
  }
260
308
 
261
309
  export function fileExists(path) { try { return existsSync(path); } catch { return false; } }