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.
- package/CHANGELOG.md +40 -0
- package/README.md +2 -1
- package/THIRD-PARTY-LICENSES.md +13 -0
- package/docs/whatsapp.md +151 -16
- package/hooks/claude-code/shared.mjs +6 -3
- package/lib/child-path.js +303 -0
- package/lib/claude-hooks.js +2 -1
- package/lib/data-home-migration.js +2 -0
- package/lib/desktop-app.js +265 -0
- package/lib/external-tools.js +7 -3
- package/lib/log-redact.js +102 -0
- package/lib/packaged-runtime.js +138 -11
- package/mcp-server/dist/http.d.ts +3 -1
- package/mcp-server/dist/http.d.ts.map +1 -1
- package/mcp-server/dist/http.js +15 -4
- package/mcp-server/dist/http.js.map +1 -1
- package/mcp-server/dist/index.d.ts.map +1 -1
- package/mcp-server/dist/index.js +9 -6
- package/mcp-server/dist/index.js.map +1 -1
- package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
- package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
- package/mcp-server/dist/services/budget-judgments.js +153 -0
- package/mcp-server/dist/services/budget-judgments.js.map +1 -0
- package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
- package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
- package/mcp-server/dist/services/codex-config-heal.js +41 -4
- package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
- package/mcp-server/dist/services/identity.d.ts +11 -0
- package/mcp-server/dist/services/identity.d.ts.map +1 -1
- package/mcp-server/dist/services/identity.js +24 -0
- package/mcp-server/dist/services/identity.js.map +1 -1
- package/mcp-server/dist/services/neural-interface.d.ts +13 -0
- package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
- package/mcp-server/dist/services/neural-interface.js +34 -2
- package/mcp-server/dist/services/neural-interface.js.map +1 -1
- package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
- package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
- package/mcp-server/dist/services/typesafe-config.js +4 -1
- package/mcp-server/dist/services/typesafe-config.js.map +1 -1
- package/mcp-server/dist/tools/agents.d.ts +10 -7
- package/mcp-server/dist/tools/agents.d.ts.map +1 -1
- package/mcp-server/dist/tools/agents.js +34 -13
- package/mcp-server/dist/tools/agents.js.map +1 -1
- package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
- package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
- package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
- package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
- package/mcp-server/dist/tui.js +2 -1
- package/mcp-server/dist/tui.js.map +1 -1
- package/mcp-server/dist/version.d.ts +5 -0
- package/mcp-server/dist/version.d.ts.map +1 -0
- package/mcp-server/dist/version.js +21 -0
- package/mcp-server/dist/version.js.map +1 -0
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/run.mjs +15 -9
- package/mcp-server/src/http.ts +18 -3
- package/mcp-server/src/index.ts +9 -6
- package/mcp-server/src/services/budget-judgments.ts +183 -0
- package/mcp-server/src/services/codex-config-heal.ts +35 -4
- package/mcp-server/src/services/identity.ts +27 -0
- package/mcp-server/src/services/neural-interface.ts +34 -2
- package/mcp-server/src/services/typesafe-config.ts +4 -1
- package/mcp-server/src/tools/agents.ts +33 -14
- package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
- package/mcp-server/src/tui.ts +2 -1
- package/mcp-server/src/version.ts +22 -0
- package/neural-interface/i18n/en.json +627 -57
- package/neural-interface/i18n/pt-BR.json +626 -56
- package/neural-interface/lib/account-usage.js +163 -0
- package/neural-interface/lib/assistant-api.js +255 -52
- package/neural-interface/lib/assistant-brains/claude.js +15 -2
- package/neural-interface/lib/assistant-brains/codex.js +3 -0
- package/neural-interface/lib/assistant-brains/opencode.js +17 -4
- package/neural-interface/lib/assistant-budget-planner.js +390 -0
- package/neural-interface/lib/assistant-budget-split.js +408 -0
- package/neural-interface/lib/assistant-budget.js +94 -95
- package/neural-interface/lib/assistant-config.js +62 -14
- package/neural-interface/lib/assistant-dispatch.js +432 -125
- package/neural-interface/lib/assistant-jev.js +26 -3
- package/neural-interface/lib/assistant-persona.js +68 -11
- package/neural-interface/lib/assistant-plan-permissions.js +8 -1
- package/neural-interface/lib/assistant-route-accounts.js +14 -0
- package/neural-interface/lib/assistant-route-gate.js +19 -3
- package/neural-interface/lib/assistant-router.js +373 -42
- package/neural-interface/lib/assistant-runtime.js +307 -47
- package/neural-interface/lib/assistant-user-writer.js +4 -2
- package/neural-interface/lib/augmented-path.js +317 -53
- package/neural-interface/lib/claude-accounts.js +342 -31
- package/neural-interface/lib/claude-agent-bridge.js +90 -3
- package/neural-interface/lib/claude-executable.js +6 -1
- package/neural-interface/lib/claude-model-catalog.js +3 -3
- package/neural-interface/lib/claude-panel-auth.js +221 -0
- package/neural-interface/lib/claude-profile-api-key.js +62 -0
- package/neural-interface/lib/cli-install-api.js +137 -0
- package/neural-interface/lib/cli-install.js +797 -0
- package/neural-interface/lib/cli-path.js +130 -0
- package/neural-interface/lib/cli-resolver.js +744 -0
- package/neural-interface/lib/cli-update-planner.js +143 -7
- package/neural-interface/lib/codex-account-auth.js +178 -0
- package/neural-interface/lib/codex-accounts.js +489 -0
- package/neural-interface/lib/codex-capabilities.js +2 -1
- package/neural-interface/lib/codex-compat-rpc.js +8 -2
- package/neural-interface/lib/codex-mcp-approvals.js +5 -1
- package/neural-interface/lib/codex-panel-auth.js +62 -0
- package/neural-interface/lib/codex-session-settings.js +1 -1
- package/neural-interface/lib/codex-settings-schema.js +120 -0
- package/neural-interface/lib/desktop/config.js +3 -0
- package/neural-interface/lib/git-bash/bzip2.js +339 -0
- package/neural-interface/lib/git-bash/claude-settings.js +73 -0
- package/neural-interface/lib/git-bash/extract-worker.js +34 -0
- package/neural-interface/lib/git-bash/install.js +529 -0
- package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
- package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
- package/neural-interface/lib/git-bash/manager.js +268 -0
- package/neural-interface/lib/git-bash/pins.js +206 -0
- package/neural-interface/lib/git-bash/pins.json +34 -0
- package/neural-interface/lib/git-bash/resolver.js +194 -0
- package/neural-interface/lib/git-read.js +34 -2
- package/neural-interface/lib/install-details.js +145 -0
- package/neural-interface/lib/mcp-installer.js +14 -2
- package/neural-interface/lib/native-loop-providers.js +16 -3
- package/neural-interface/lib/native-loop-runtime.js +9 -4
- package/neural-interface/lib/os-path.js +319 -0
- package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
- package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
- package/neural-interface/lib/pty-host/core.js +2 -1
- package/neural-interface/lib/pty-host/manager.js +27 -2
- package/neural-interface/lib/remote-policy.js +20 -11
- package/neural-interface/lib/session-title-generator.js +4 -3
- package/neural-interface/lib/system-about.js +340 -0
- package/neural-interface/lib/system-activity.js +66 -0
- package/neural-interface/lib/terminal-env.js +11 -3
- package/neural-interface/lib/terminal-path.js +55 -0
- package/neural-interface/lib/virtual-socket.js +12 -1
- package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
- package/neural-interface/lib/whatsapp/bridge.js +78 -34
- package/neural-interface/lib/whatsapp/commands.js +13 -14
- package/neural-interface/lib/whatsapp/config.js +134 -20
- package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
- package/neural-interface/lib/whatsapp/host-core.js +168 -35
- package/neural-interface/lib/whatsapp/inbound.js +5 -3
- package/neural-interface/lib/whatsapp/local-file.js +524 -0
- package/neural-interface/lib/whatsapp/manager.js +92 -32
- package/neural-interface/lib/whatsapp/paths.js +11 -1
- package/neural-interface/lib/whatsapp/protocol.js +65 -5
- package/neural-interface/lib/whatsapp/redact.js +63 -0
- package/neural-interface/lib/whatsapp/service.js +343 -91
- package/neural-interface/lib/whatsapp/transcription.js +463 -0
- package/neural-interface/package-lock.json +2 -2
- package/neural-interface/package.json +1 -1
- package/neural-interface/public/index.html +2 -0
- package/neural-interface/public/index2d.html +2 -0
- package/neural-interface/public/offline.html +16 -1
- package/neural-interface/public/onboarding.html +96 -2
- package/neural-interface/public/shared/account-i18n.js +7 -0
- package/neural-interface/public/shared/account-menu.js +539 -0
- package/neural-interface/public/shared/account-usage.js +19 -0
- package/neural-interface/public/shared/account-view.js +335 -0
- package/neural-interface/public/shared/agent-runtime-options.js +28 -8
- package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
- package/neural-interface/public/shared/assistant/asst-api.js +32 -2
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
- package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
- package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
- package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
- package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
- package/neural-interface/public/shared/assistant/asst-render.js +38 -7
- package/neural-interface/public/shared/assistant/asst-route.js +72 -14
- package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
- package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
- package/neural-interface/public/shared/assistant/asst-state.js +1 -1
- package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
- package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
- package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
- package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
- package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
- package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
- package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
- package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
- package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
- package/neural-interface/public/shared/claude-account-view.js +3 -0
- package/neural-interface/public/shared/cli-install-view.js +392 -0
- package/neural-interface/public/shared/cli-status.js +282 -22
- package/neural-interface/public/shared/cp/cp-events.js +2 -1
- package/neural-interface/public/shared/cp/cp-styles.js +46 -0
- package/neural-interface/public/shared/desktop-bridge.js +39 -0
- package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
- package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
- package/neural-interface/public/shared/desktop-titlebar.js +153 -0
- package/neural-interface/public/shared/desktop-update-view.js +462 -0
- package/neural-interface/public/shared/editor/editor-panel.js +1 -1
- package/neural-interface/public/shared/explorer/fx-main.js +1 -1
- package/neural-interface/public/shared/git-bash-view.js +145 -0
- package/neural-interface/public/shared/html-shell.js +1 -0
- package/neural-interface/public/shared/i18n.js +19 -1
- package/neural-interface/public/shared/install-details-view.js +86 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
- package/neural-interface/public/shared/pwa-install-page.js +3 -1
- package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
- package/neural-interface/public/shared/settings/settings-about.js +254 -0
- package/neural-interface/public/shared/settings/settings-ia.js +15 -0
- package/neural-interface/public/shared/showcase-privacy.js +489 -0
- package/neural-interface/public/shared/storage.js +30 -3
- package/neural-interface/public/shared/styles.css +278 -31
- package/neural-interface/public/shared/synabun-mascot.js +48 -2
- package/neural-interface/public/shared/ui-claude-panel.js +212 -213
- package/neural-interface/public/shared/ui-cli-banner.js +355 -0
- package/neural-interface/public/shared/ui-git-bash.js +124 -0
- package/neural-interface/public/shared/ui-judgments.js +10 -1
- package/neural-interface/public/shared/ui-loading.js +30 -6
- package/neural-interface/public/shared/ui-menubar.js +13 -3
- package/neural-interface/public/shared/ui-navbar.js +3 -1
- package/neural-interface/public/shared/ui-provider-auth.js +104 -0
- package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
- package/neural-interface/public/shared/ui-sessions.js +9 -3
- package/neural-interface/public/shared/ui-settings.js +55 -3
- package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
- package/neural-interface/public/shared/ui-sync.js +6 -0
- package/neural-interface/public/shared/ui-terminal.js +17 -0
- package/neural-interface/public/shared/ui-tool-updates.js +5 -0
- package/neural-interface/public/shared/ui-update.js +594 -5
- package/neural-interface/public/shared/ui-whatsapp.js +21 -6
- package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
- package/neural-interface/public/shared/worker-end.js +2 -0
- package/neural-interface/public/sw.js +1 -1
- package/neural-interface/public/variant/2d/main.js +10 -1
- package/neural-interface/public/variant/3d/main.js +10 -1
- package/neural-interface/server.js +1219 -608
- package/package.json +4 -1
- 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 {
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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',
|
|
27
|
-
opencode: { metered: 'reported',
|
|
28
|
-
codex: { metered: 'estimated',
|
|
29
|
-
brain: { metered: 'reported',
|
|
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]
|
|
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
|
-
|
|
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,
|
|
481
|
-
|
|
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
|
|
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
|
|
534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
|
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
|
|
956
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
981
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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?.
|
|
1175
|
+
if (!codexAccounts?.create) return codexUnavailable(res);
|
|
1004
1176
|
try {
|
|
1005
|
-
const
|
|
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
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
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
|
|
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
|
|
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,
|
|
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);
|