synabun 2.0.0 → 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 +97 -0
- package/NOTICE +5 -0
- package/README.md +2 -1
- package/THIRD-PARTY-LICENSES.md +59 -0
- package/benchmarks/fixtures/browser/README.md +1 -1
- package/benchmarks/fixtures/desktop/README.md +1 -1
- package/docs/whatsapp.md +153 -18
- package/hooks/claude-code/pre-compact.mjs +11 -0
- package/hooks/claude-code/prompt-submit.mjs +60 -184
- package/hooks/claude-code/session-start.mjs +73 -16
- package/hooks/claude-code/shared.mjs +26 -3
- package/hooks/claude-code/stop.mjs +5 -5
- 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/lib/start-launcher.js +88 -1
- 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 -3
- 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 +2126 -209
- package/neural-interface/i18n/pt-BR.json +2123 -207
- package/neural-interface/lib/account-usage.js +163 -0
- package/neural-interface/lib/assistant-api.js +316 -57
- package/neural-interface/lib/assistant-brains/claude.js +19 -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-catalog.js +179 -27
- package/neural-interface/lib/assistant-config.js +91 -14
- package/neural-interface/lib/assistant-dispatch.js +494 -138
- package/neural-interface/lib/assistant-hidden-models.js +393 -66
- package/neural-interface/lib/assistant-jev.js +26 -3
- package/neural-interface/lib/assistant-memory.js +24 -10
- package/neural-interface/lib/assistant-persona.js +73 -12
- package/neural-interface/lib/assistant-plan-permissions.js +8 -1
- package/neural-interface/lib/assistant-pricing.js +21 -6
- 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 +488 -59
- package/neural-interface/lib/assistant-task-prompt.js +13 -3
- package/neural-interface/lib/assistant-test-density.js +142 -0
- package/neural-interface/lib/assistant-user-context-budgets.js +123 -0
- package/neural-interface/lib/assistant-user-context.js +908 -0
- package/neural-interface/lib/assistant-user-knowledge.js +328 -0
- package/neural-interface/lib/assistant-user-learn.js +404 -0
- package/neural-interface/lib/assistant-user-threads.js +226 -0
- package/neural-interface/lib/assistant-user-writer.js +183 -0
- package/neural-interface/lib/augmented-path.js +317 -53
- package/neural-interface/lib/browser-background-tab.js +134 -0
- package/neural-interface/lib/browser-screenshot.js +678 -61
- package/neural-interface/lib/claude-accounts.js +342 -31
- package/neural-interface/lib/claude-agent-bridge.js +123 -9
- package/neural-interface/lib/claude-executable.js +6 -1
- package/neural-interface/lib/claude-model-catalog.js +33 -5
- 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/claude-temporary.js +46 -7
- 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/cloudflared/install.js +533 -0
- package/neural-interface/lib/cloudflared/manager.js +545 -0
- package/neural-interface/lib/cloudflared/pins.js +115 -0
- package/neural-interface/lib/cloudflared/pins.json +63 -0
- package/neural-interface/lib/cloudflared/tunnel.js +265 -0
- 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/editor-completion-endpoint.js +264 -0
- package/neural-interface/lib/editor-completion-opencode.js +436 -0
- package/neural-interface/lib/editor-completion.js +714 -0
- package/neural-interface/lib/editor-config.js +149 -0
- package/neural-interface/lib/editor-files-api.js +268 -0
- package/neural-interface/lib/editor-git-api.js +134 -0
- package/neural-interface/lib/file-explorer-api.js +1039 -0
- package/neural-interface/lib/file-explorer-system.js +355 -0
- package/neural-interface/lib/file-explorer-trash.ps1 +85 -0
- package/neural-interface/lib/file-explorer-watch.js +239 -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-diff-api.js +103 -0
- package/neural-interface/lib/git-read.js +234 -0
- package/neural-interface/lib/greeting/api.js +89 -0
- package/neural-interface/lib/greeting/budgets.js +90 -0
- package/neural-interface/lib/greeting/engine.js +461 -0
- package/neural-interface/lib/greeting/facts.js +320 -0
- package/neural-interface/lib/greeting/sources.js +252 -0
- package/neural-interface/lib/greeting/writer.js +139 -0
- package/neural-interface/lib/http-guards.js +189 -13
- package/neural-interface/lib/install-details.js +145 -0
- package/neural-interface/lib/machine-names.js +88 -0
- package/neural-interface/lib/mcp-installer.js +14 -2
- package/neural-interface/lib/memory-api.js +31 -7
- package/neural-interface/lib/native-loop-providers.js +26 -8
- package/neural-interface/lib/native-loop-runtime.js +9 -4
- package/neural-interface/lib/os-path.js +319 -0
- package/neural-interface/lib/plugin-installer.js +4 -2
- package/neural-interface/lib/project-storage.js +5 -7
- 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/request-gate.js +589 -0
- 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/ui-blob-api.js +108 -0
- package/neural-interface/lib/ui-blob-store.js +68 -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 +701 -2
- package/neural-interface/package.json +13 -2
- package/neural-interface/public/index.html +2 -0
- package/neural-interface/public/index2d.html +2 -0
- package/neural-interface/public/install-app.html +9 -6
- package/neural-interface/public/offline.html +19 -3
- package/neural-interface/public/onboarding.html +107 -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 +50 -14
- 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-automation.js +286 -0
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +430 -81
- package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
- package/neural-interface/public/shared/assistant/asst-composer.js +11 -1
- package/neural-interface/public/shared/assistant/asst-mascot.js +140 -61
- package/neural-interface/public/shared/assistant/asst-menu.js +27 -11
- package/neural-interface/public/shared/assistant/asst-panel.js +198 -42
- package/neural-interface/public/shared/assistant/asst-render.js +197 -23
- package/neural-interface/public/shared/assistant/asst-route-models.js +108 -0
- package/neural-interface/public/shared/assistant/asst-route.js +95 -343
- package/neural-interface/public/shared/assistant/asst-routes-editor.js +534 -0
- package/neural-interface/public/shared/assistant/asst-sanitize.js +8 -1
- package/neural-interface/public/shared/assistant/asst-sidepanel.js +31 -9
- package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
- package/neural-interface/public/shared/assistant/asst-state.js +186 -2
- package/neural-interface/public/shared/assistant/asst-styles.js +247 -11
- package/neural-interface/public/shared/blob-store.js +111 -22
- 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 +80 -118
- package/neural-interface/public/shared/cdx/cdx-render.js +25 -89
- package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
- package/neural-interface/public/shared/cdx/cdx-styles.js +164 -107
- package/neural-interface/public/shared/cdx/cdx-tabs.js +348 -668
- 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/constants.js +6 -1
- package/neural-interface/public/shared/cp/cp-events.js +2 -1
- package/neural-interface/public/shared/cp/cp-rehydrate.js +2 -1
- package/neural-interface/public/shared/cp/cp-styles.js +61 -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-ai-logic.js +409 -0
- package/neural-interface/public/shared/editor/editor-ai.js +619 -0
- package/neural-interface/public/shared/editor/editor-commands.js +212 -0
- package/neural-interface/public/shared/editor/editor-compare.js +103 -0
- package/neural-interface/public/shared/editor/editor-completion-list.js +172 -0
- package/neural-interface/public/shared/editor/editor-doc.js +257 -0
- package/neural-interface/public/shared/editor/editor-git-logic.js +131 -0
- package/neural-interface/public/shared/editor/editor-git.js +95 -0
- package/neural-interface/public/shared/editor/editor-languages.js +156 -0
- package/neural-interface/public/shared/editor/editor-lint.js +229 -0
- package/neural-interface/public/shared/editor/editor-loader.js +51 -0
- package/neural-interface/public/shared/editor/editor-markdown.js +90 -0
- package/neural-interface/public/shared/editor/editor-menu.js +233 -0
- package/neural-interface/public/shared/editor/editor-minimap.js +371 -0
- package/neural-interface/public/shared/editor/editor-palette-logic.js +192 -0
- package/neural-interface/public/shared/editor/editor-palette.js +125 -0
- package/neural-interface/public/shared/editor/editor-panel-logic.js +288 -0
- package/neural-interface/public/shared/editor/editor-panel.js +2099 -0
- package/neural-interface/public/shared/editor/editor-prefs.js +224 -0
- package/neural-interface/public/shared/editor/editor-safety.js +500 -0
- package/neural-interface/public/shared/editor/editor-search.js +352 -0
- package/neural-interface/public/shared/editor/editor-statusbar.js +144 -0
- package/neural-interface/public/shared/editor/editor-tabs-logic.js +331 -0
- package/neural-interface/public/shared/editor/editor-tabs.js +233 -0
- package/neural-interface/public/shared/editor/editor-theme.js +244 -0
- package/neural-interface/public/shared/editor/editor-view.js +332 -0
- package/neural-interface/public/shared/explorer/fx-api.js +216 -0
- package/neural-interface/public/shared/explorer/fx-links.js +204 -0
- package/neural-interface/public/shared/explorer/fx-logic.js +537 -0
- package/neural-interface/public/shared/explorer/fx-main.js +865 -0
- package/neural-interface/public/shared/explorer/fx-names.js +87 -0
- package/neural-interface/public/shared/explorer/fx-ops.js +370 -0
- package/neural-interface/public/shared/explorer/fx-popups.js +490 -0
- package/neural-interface/public/shared/explorer/fx-tree.js +790 -0
- package/neural-interface/public/shared/file-links.js +339 -0
- package/neural-interface/public/shared/git-bash-view.js +145 -0
- package/neural-interface/public/shared/greeting/greeting-card.js +178 -0
- package/neural-interface/public/shared/greeting/greeting-view.js +148 -0
- package/neural-interface/public/shared/html-shell.js +161 -165
- 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-childpanel.js +4 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-greeting.js +47 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +131 -17
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +7 -1
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +22 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +158 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +3 -2
- package/neural-interface/public/shared/pwa-install-page.js +32 -2
- package/neural-interface/public/shared/pwa-install.css +3 -3
- package/neural-interface/public/shared/pwa-install.js +100 -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-code-editor-pane.js +412 -0
- package/neural-interface/public/shared/settings/settings-code-editor.js +328 -0
- package/neural-interface/public/shared/settings/settings-greeting.js +133 -0
- package/neural-interface/public/shared/settings/settings-ia.js +33 -4
- package/neural-interface/public/shared/settings/settings-user-context.js +154 -0
- package/neural-interface/public/shared/showcase-privacy.js +489 -0
- package/neural-interface/public/shared/start-bridge.js +238 -19
- package/neural-interface/public/shared/storage.js +30 -3
- package/neural-interface/public/shared/styles.css +1710 -770
- package/neural-interface/public/shared/synabun-mascot.js +68 -58
- package/neural-interface/public/shared/tunnel-view.js +167 -0
- package/neural-interface/public/shared/ui-claude-panel.js +369 -229
- package/neural-interface/public/shared/ui-cli-banner.js +355 -0
- package/neural-interface/public/shared/ui-detail.js +15 -0
- package/neural-interface/public/shared/ui-explorer.js +22 -0
- package/neural-interface/public/shared/ui-file-explorer.js +481 -2496
- package/neural-interface/public/shared/ui-file-links.js +103 -0
- package/neural-interface/public/shared/ui-git-bash.js +124 -0
- package/neural-interface/public/shared/ui-help.js +26 -0
- package/neural-interface/public/shared/ui-invite.js +126 -116
- package/neural-interface/public/shared/ui-judgments.js +10 -1
- package/neural-interface/public/shared/ui-keybinds.js +4 -0
- package/neural-interface/public/shared/ui-loading.js +187 -19
- package/neural-interface/public/shared/ui-menubar.js +25 -33
- package/neural-interface/public/shared/ui-native-loop-router-state.js +11 -0
- package/neural-interface/public/shared/ui-native-loop-router.js +15 -2
- package/neural-interface/public/shared/ui-navbar.js +48 -22
- 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 +509 -490
- package/neural-interface/public/shared/ui-sidepanel-runtime.js +2 -1
- package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
- package/neural-interface/public/shared/ui-sync.js +22 -0
- package/neural-interface/public/shared/ui-terminal.js +40 -5
- package/neural-interface/public/shared/ui-tool-updates.js +5 -0
- package/neural-interface/public/shared/ui-tunnel.js +130 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +6 -2
- 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/ui-whiteboard.js +12 -0
- package/neural-interface/public/shared/ui-worker-end.js +245 -0
- package/neural-interface/public/shared/ui-workspaces.js +41 -27
- package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
- package/neural-interface/public/shared/worker-end.js +253 -0
- package/neural-interface/public/sw.js +2 -2
- package/neural-interface/public/variant/2d/main.js +10 -1
- package/neural-interface/public/variant/3d/main.js +10 -1
- package/neural-interface/public/vendor/codemirror/chunks/apl-CSDKT72Z.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/asciiarmor-JYYPU5BM.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/asn1-NRWCZDA6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/asterisk-5OU6D4ZQ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/brainfuck-ID4N4SYC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-4RZWGCS2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-ACTJM3JR.js +11 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-S6BMAXLK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-TO2RDOHQ.js +3 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-US2GMZZQ.js +13 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-WYYO5ZAJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-XALZTDJC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-YRNUTRDV.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-Z3QOKH62.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/clike-OWQBSOGL.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/clojure-KV75UQWC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/cmake-GTEP5Y3C.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/cobol-WCZ7QEGJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/coffeescript-TGLBOT6B.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/commonlisp-WVVI432V.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/crystal-S6R6HLKQ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/css-PDA2RINP.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/cypher-HL5QPL4C.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/d-HHJH2HAJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/diff-MN3A35BY.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-25SQLBVS.js +3 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-27ZGGJJS.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-4E52OJIF.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-6FT73XDC.js +2 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-BE6YGRMW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-CJSGKW2O.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-CRPH2WLI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-CTG7JEQN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-I3FEVELX.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-IM5BBSWZ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-KERUKTIF.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-LXTMWZAM.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-MABASSET.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-MJVBUZLL.js +3 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-MNB3WZVU.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-QYWXOE3U.js +7 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-TEBOFWLZ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-UNXFROM3.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-XL2SLQO6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-XO42Y3AP.js +6 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-Y2WWDWJK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-YFEZKWMU.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-ZY7SWQO6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dockerfile-3Y4R4NCH.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dtd-5343GJ4P.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dylan-FI2CXSW2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ebnf-GNQ77YWA.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ecl-MRPUAE2L.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/eiffel-DZROES27.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/elm-4KICRFKQ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/erlang-3IHYC263.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/factor-C6D3E5UK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/fcl-VYTWUJMI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/forth-ZBO2EH5M.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/fortran-5NDCRWIK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/gas-SKRMOXFP.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/gherkin-L45YXZLW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/groovy-GLPTLN44.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/haskell-E7NF7P4Y.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/haxe-K2NVDOB6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/http-Q6DRQIJB.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/idl-MZYNJVLN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/javascript-6YRYK2HR.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/julia-HXEL2T5G.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/livescript-MV7CI2JZ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/lua-E36NHOOD.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mathematica-3XOZZCMC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mbox-ODYGFKS7.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mirc-WB43AWF5.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mllike-ZT6ET7ZH.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/modelica-LLMZPFGA.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mscgen-2OPNNYFH.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mumps-6P3HWJH6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/nginx-TCGKQTRW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/nsis-ATIGIXTJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ntriples-5PLBI5OV.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/octave-QF2S3NL6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/oz-JFIVFFH2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/pascal-JG6SW76V.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/perl-4B3IWVVO.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/pig-ABEBH4MI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/powershell-H32XE7TL.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/properties-NN644MMA.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/protobuf-YKWQK2J3.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/pug-RIYXEKFW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/puppet-JZQ5WPEV.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/python-EBCGGHAC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/q-5UMH2YQY.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/r-7XMGG453.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/rpm-IEV7TSLR.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ruby-T4SPFPNM.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sas-GEDX67ET.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/scheme-FTLOWKPC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/shell-HUFRKFSJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sieve-P5QUDVYO.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/smalltalk-2SORJNQI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/solr-M22TGAR5.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sparql-4M3KL752.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/spreadsheet-3K23LA4N.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sql-VHRFMUBI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/stex-QZDAQUY2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/stylus-7ZDKAKBD.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/swift-WCLCLGMR.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/tcl-JLMU6WOK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/textile-4CHDKZ4T.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/tiddlywiki-DKMCZZQ4.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/tiki-F4JIG36Q.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/toml-BHMHTIM2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/troff-YQ2KV5NT.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ttcn-ZHRCVFJY.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ttcn-cfg-KJOVTY4X.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/turtle-535P5N6I.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/vb-76UMQHOT.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/vbscript-CGNDTK6Z.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/velocity-VSJDO5T5.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/verilog-4T6ETXIP.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/vhdl-T7FNAXUN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/webidl-P6MJSJ6J.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/xquery-UOVILRGN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/yacas-CV2APROL.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/z80-3KFNVM4M.js +1 -0
- package/neural-interface/public/vendor/codemirror/codemirror-6.43.14-75f05e67.min.js +2 -0
- package/neural-interface/public/vendor/codemirror/manifest.json +60 -0
- package/neural-interface/scripts/build-vendor.mjs +78 -2
- package/neural-interface/scripts/vendor-codemirror-entry.js +121 -0
- package/neural-interface/server.js +2085 -1629
- package/neural-interface/templates/rulesets/README.md +1 -1
- package/package.json +5 -8
- package/setup.js +53 -15
- package/benchmarks/browser-live-results.json +0 -79
- package/benchmarks/browser-v2-results.json +0 -258
- package/neural-interface/lib/codex-capabilities.d.ts +0 -41
- package/neural-interface/public/category-logos/claude.svg +0 -1
- package/neural-interface/public/category-logos/gemini-color.svg +0 -1
- package/neural-interface/public/category-logos/openai.svg +0 -1
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude.svg +0 -1
- package/neural-interface/public/gallery.svg +0 -6
- package/neural-interface/public/leonardoai.svg +0 -1
- package/neural-interface/public/mcp.svg +0 -4
- package/neural-interface/public/opencode-logo-dark-square.svg +0 -1
- package/neural-interface/public/pin-svgrepo-com.svg +0 -25
- package/neural-interface/public/sessionicon.svg +0 -8
- package/neural-interface/public/toolbar-album.svg +0 -8
- package/neural-interface/public/toolbar-question.svg +0 -6
- package/neural-interface/public/toolbar-sessions.svg +0 -6
- package/neural-interface/scripts/codex-protocol-fixture.mjs +0 -83
- package/skills/synabun/synabunicon.png +0 -0
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// attach) through the per-launch buildPrompt/wrapAdapter overrides, so the
|
|
10
10
|
// existing router shows it as an automation tab in the matching sidepanel.
|
|
11
11
|
//
|
|
12
|
-
// This module owns: validation + rails (concurrency,
|
|
12
|
+
// This module owns: validation + rails (concurrency, the session budget and
|
|
13
|
+
// its provider shares through the budget planner, quarantine),
|
|
13
14
|
// the task adapter wrapper (turn loop), result capture, the permission broker
|
|
14
15
|
// for 'ask' dispatches, waits, transcripts, the dispatch registry, the
|
|
15
16
|
// auto-remember hook, and the runs' usage meters (assistant-usage.js: exact
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
// injected.
|
|
19
20
|
|
|
20
21
|
import { randomBytes } from 'node:crypto';
|
|
22
|
+
import { chooseUsageAccount } from './account-usage.js';
|
|
21
23
|
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
22
24
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
23
25
|
import {
|
|
@@ -29,9 +31,12 @@ import { TASK_CLASS_META, classPlaybook, collectedMedia, describeTarget, designF
|
|
|
29
31
|
import { MAX_MEDIA_BYTES, MAX_RUN_MEDIA_BYTES, copyRunMedia, mediaType, mediaUrl, servableRunMedia } from './assistant-media.js';
|
|
30
32
|
import { loadPlaybook } from './assistant-playbooks.js';
|
|
31
33
|
import { loadStyleGuideForRun } from './style-guide/store.js';
|
|
34
|
+
import { claimCheckWaived, normalizeTestDensity, testingApplies } from './assistant-test-density.js';
|
|
32
35
|
import { normalizeEffort } from './effort-levels.js';
|
|
33
36
|
import { createTurnEvidence, recordProviderEvent, evidenceSnapshot, claimEvidenceLine } from './assistant-evidence.js';
|
|
34
|
-
import { codexUsageCostUsd } from './assistant-budget.js';
|
|
37
|
+
import { BUDGET_MAX_USD, codexUsageCostUsd, floorCents } from './assistant-budget.js';
|
|
38
|
+
import { MIN_SPEND_USD, PROVIDER_LABELS, isFreePrice } from './assistant-budget-split.js';
|
|
39
|
+
import { createBudgetPlanner } from './assistant-budget-planner.js';
|
|
35
40
|
import { CLAUDE_ESTIMATE_SOURCE, CODEX_USAGE_KEYS, TOKEN_KEYS, addTokens, codexUsageTokens, createClaudeMeter, createCodexMeter, createOpenCodeMeter, totalTokens, withTotal, zeroTokens } from './assistant-usage.js';
|
|
36
41
|
import { claudeTokensCostUsd } from './assistant-pricing.js';
|
|
37
42
|
import { RemotePolicyError, clampDispatchSpec, getSessionPolicy, readRegisteredProjects } from './remote-policy.js';
|
|
@@ -57,6 +62,12 @@ export function runStatusFromState(state) {
|
|
|
57
62
|
return ACTIVE_STATES.has(value) ? 'running' : null;
|
|
58
63
|
}
|
|
59
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The least work time a run gets: a max_minutes below it is raised to it, never refused. A run's
|
|
67
|
+
* max_minutes counts work only (the work clock below), so a short cap would only cut a turn off.
|
|
68
|
+
*/
|
|
69
|
+
export const MIN_MAX_MINUTES = 20;
|
|
70
|
+
|
|
60
71
|
export const DEFAULT_LIMITS = Object.freeze({
|
|
61
72
|
perProvider: Object.freeze({ 'claude-code': 3, codex: 4, opencode: 3 }),
|
|
62
73
|
perCodexAccount: 2,
|
|
@@ -64,16 +75,13 @@ export const DEFAULT_LIMITS = Object.freeze({
|
|
|
64
75
|
queuedPerSession: 20,
|
|
65
76
|
heldPerSession: 10,
|
|
66
77
|
maxFanOut: 6,
|
|
78
|
+
// max_minutes: the work time a run gets (MIN_MAX_MINUTES at least), not the wall clock.
|
|
67
79
|
defaultMaxMinutes: 45,
|
|
68
80
|
maxMaxMinutes: 240,
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
sessionSoftBudgetUsd: 10,
|
|
74
|
-
sessionWarnUsd: 8,
|
|
75
|
-
sessionHardBudgetUsd: 25,
|
|
76
|
-
brainBudgetUsd: 10,
|
|
81
|
+
// The money is the session budget (assistant-budget.js, read live by the budget planner): a
|
|
82
|
+
// dispatch's budget_usd is only an optional ceiling for that run, inside its provider's share.
|
|
83
|
+
minRunBudgetUsd: MIN_SPEND_USD,
|
|
84
|
+
maxRunBudgetUsd: BUDGET_MAX_USD,
|
|
77
85
|
permissionTimeoutMs: 10 * 60_000,
|
|
78
86
|
questionTimeoutMs: 30 * 60_000,
|
|
79
87
|
idleTimeoutMs: 20 * 60_000,
|
|
@@ -274,15 +282,15 @@ export function summarizeProviderEvent(payload = {}) {
|
|
|
274
282
|
return null;
|
|
275
283
|
}
|
|
276
284
|
|
|
285
|
+
// The old five-cap keys a config file may still hold: never limits (the budget planner owns the money).
|
|
286
|
+
const LEGACY_BUDGET_LIMITS = new Set(['defaultBudgetUsd', 'maxBudgetUsd', 'sessionWarnUsd', 'sessionSoftBudgetUsd', 'sessionHardBudgetUsd', 'brainBudgetUsd', 'budgetVersion', 'budgetUpdatedAt']);
|
|
277
287
|
function mergeLimits(overrides = {}) {
|
|
278
288
|
const limits = { ...DEFAULT_LIMITS, perProvider: { ...DEFAULT_LIMITS.perProvider } };
|
|
279
289
|
for (const [key, value] of Object.entries(overrides || {})) {
|
|
290
|
+
if (LEGACY_BUDGET_LIMITS.has(key)) continue;
|
|
280
291
|
if (key === 'perProvider' && value && typeof value === 'object') Object.assign(limits.perProvider, value);
|
|
281
292
|
else if (value !== undefined && value !== null) limits[key] = value;
|
|
282
293
|
}
|
|
283
|
-
// Older configs name a soft budget and warned at 80 % of it.
|
|
284
|
-
const soft = Number(overrides?.sessionSoftBudgetUsd);
|
|
285
|
-
if ((overrides?.sessionWarnUsd === undefined || overrides?.sessionWarnUsd === null) && Number.isFinite(soft) && soft > 0) limits.sessionWarnUsd = Math.round(soft * 80) / 100;
|
|
286
294
|
return limits;
|
|
287
295
|
}
|
|
288
296
|
|
|
@@ -331,6 +339,12 @@ export function createAssistantDispatcher({
|
|
|
331
339
|
getCodexAccount = () => null,
|
|
332
340
|
CODEX_DEFAULT_HOME = null,
|
|
333
341
|
claudeAccounts = null,
|
|
342
|
+
// The Claude account a Claude Code worker uses when its dispatch names none (the user's choice, read live).
|
|
343
|
+
claudeWorkerAccount = () => null,
|
|
344
|
+
// lib/codex-accounts.js (unusableReason) and the Codex account a worker gets when a dispatch names none.
|
|
345
|
+
codexAccounts = null,
|
|
346
|
+
accountUsage = null,
|
|
347
|
+
codexWorkerAccount = () => null,
|
|
334
348
|
isValidMcpProfileValue = () => true,
|
|
335
349
|
normalizeMcpProfileName = (value) => value,
|
|
336
350
|
readActiveMcpProfile = () => 'full',
|
|
@@ -341,6 +355,8 @@ export function createAssistantDispatcher({
|
|
|
341
355
|
detectProject = () => 'global',
|
|
342
356
|
limits: limitOverrides = {},
|
|
343
357
|
now = Date.now,
|
|
358
|
+
// The timer that stops a turn when its run's work time reaches max_minutes (tests fire it by hand).
|
|
359
|
+
workTimers = { set: (fn, ms) => { const timer = setTimeout(fn, ms); timer.unref?.(); return timer; }, clear: (timer) => clearTimeout(timer) },
|
|
344
360
|
randomId = () => randomBytes(16).toString('hex'),
|
|
345
361
|
strictResult = true,
|
|
346
362
|
// Routing (assistant-router.js) and the model catalog (assistant-catalog.js);
|
|
@@ -356,12 +372,17 @@ export function createAssistantDispatcher({
|
|
|
356
372
|
// Clarification (assistant-clarify.js): { forDispatch(sessionId, { briefId, independent }) }.
|
|
357
373
|
// A brain's dispatch waits while its questions are open and carries the user's brief.
|
|
358
374
|
clarifier = null,
|
|
359
|
-
//
|
|
360
|
-
//
|
|
375
|
+
// Live limit overrides (assistant-config.json `limits`, re-read on every check), the brain's own
|
|
376
|
+
// spend per assistant session (the runtime: its total, and by the provider it ran on) and list
|
|
361
377
|
// prices for Codex usage (assistant-budget.js createModelPricing).
|
|
362
378
|
readLimits = null,
|
|
363
379
|
brainSpend = () => 0,
|
|
380
|
+
brainSpendByProvider = null,
|
|
364
381
|
pricing = null,
|
|
382
|
+
// The session budget (assistant-budget-planner.js createBudgetPlanner): the setting, each
|
|
383
|
+
// session's provider shares, the checkpoints that divide what is left again. Without one the
|
|
384
|
+
// dispatcher keeps a planner of its own on the default budget ($25, no Jev).
|
|
385
|
+
budget: budgetPlanner = null,
|
|
365
386
|
// Exact token accounting (assistant-usage.js): the ledger (createUsageLedger) and the task of
|
|
366
387
|
// the brain turn in progress, (assistantSessionId) => taskId | null. Both optional: without
|
|
367
388
|
// them the meters still run for cost and the run's own numbers, and nothing is written.
|
|
@@ -374,6 +395,9 @@ export function createAssistantDispatcher({
|
|
|
374
395
|
// The project's Style Guide for a run's prompt (lib/style-guide/store.js): (cwd, taskClass) =>
|
|
375
396
|
// { summary, designPath, tokenFiles, proposals } | null. Reads <dataDir>/style-guides.
|
|
376
397
|
loadStyleGuide = (cwd, taskClass) => loadStyleGuideForRun(cwd, taskClass, { dataDir }),
|
|
398
|
+
// The session's Test density (assistant-test-density.js): (assistantSessionId) => level | null.
|
|
399
|
+
// Read when a prompt is composed; null or an unknown value is the default level.
|
|
400
|
+
testDensity = () => null,
|
|
377
401
|
} = {}) {
|
|
378
402
|
if (typeof getRuntime !== 'function') throw new Error('createAssistantDispatcher requires getRuntime()');
|
|
379
403
|
if (!dataDir) throw new Error('createAssistantDispatcher requires dataDir');
|
|
@@ -407,6 +431,10 @@ export function createAssistantDispatcher({
|
|
|
407
431
|
// Usage meters (assistant-usage.js), apart from the live state: a Claude result can arrive
|
|
408
432
|
// after its turn was aborted or its run left the list.
|
|
409
433
|
const meters = new Map(); // runId → { claude, codex, opencode }
|
|
434
|
+
// The session budget: the injected planner, else one of the dispatcher's own on the default budget.
|
|
435
|
+
const budget = budgetPlanner || createBudgetPlanner({ spendOf: (id) => providerSpend(id) });
|
|
436
|
+
// `${sessionId}:${provider}` → a share check in flight (a re-split may take Jev's timeout).
|
|
437
|
+
const shareChecks = new Map();
|
|
410
438
|
let persistTimer = null;
|
|
411
439
|
let stalledTimer = null;
|
|
412
440
|
let unsubscribeRuntime = null;
|
|
@@ -428,6 +456,11 @@ export function createAssistantDispatcher({
|
|
|
428
456
|
entry.turnState = 'terminal';
|
|
429
457
|
closeUsageState(entry);
|
|
430
458
|
}
|
|
459
|
+
// A turn cut off by the restart worked until the run was last updated (as far as anything shows).
|
|
460
|
+
if (entry.workingSince) {
|
|
461
|
+
entry.workMs = workUsedMs(entry, Math.max(Date.parse(entry.workingSince) || 0, Date.parse(entry.updatedAt || '') || 0));
|
|
462
|
+
entry.workingSince = null;
|
|
463
|
+
}
|
|
431
464
|
entries.set(entry.runId, entry);
|
|
432
465
|
}
|
|
433
466
|
}
|
|
@@ -461,16 +494,19 @@ export function createAssistantDispatcher({
|
|
|
461
494
|
function peek(runId) {
|
|
462
495
|
const entry = entries.get(String(runId || '')) || null;
|
|
463
496
|
if (!entry) return null;
|
|
497
|
+
const at = now();
|
|
464
498
|
return {
|
|
465
499
|
runId: entry.runId, title: entry.title || null, state: entry.state, turnState: entry.turnState || null,
|
|
466
500
|
taskId: entry.taskId || null, turnTaskId: turnTaskOf(entry), provider: entry.provider || null, model: entry.model || null,
|
|
501
|
+
maxMinutes: entry.maxMinutes ?? null, workMs: workUsedMs(entry, at), workLeftMs: workLeftMs(entry, at),
|
|
467
502
|
};
|
|
468
503
|
}
|
|
469
504
|
function view(entry) {
|
|
470
505
|
if (!entry) return null;
|
|
471
506
|
const runtimeRun = safeRuntime()?.get?.(entry.runId) || null;
|
|
507
|
+
const at = now();
|
|
472
508
|
const startedAt = entry.startedAt || entry.createdAt;
|
|
473
|
-
const end = entry.finishedAt ? new Date(entry.finishedAt).getTime() :
|
|
509
|
+
const end = entry.finishedAt ? new Date(entry.finishedAt).getTime() : at;
|
|
474
510
|
const elapsedMs = startedAt ? Math.max(0, end - new Date(startedAt).getTime()) : 0;
|
|
475
511
|
// The brief goes to the worker's prompt; views name it only. The meters' state stays inside.
|
|
476
512
|
const { rawSpec: _rawSpec, brief: _brief, usageState: _usageState, ...rest } = entry;
|
|
@@ -494,7 +530,13 @@ export function createAssistantDispatcher({
|
|
|
494
530
|
active: ACTIVE_STATES.has(entry.state),
|
|
495
531
|
terminal: TERMINAL_STATES.has(entry.state),
|
|
496
532
|
outcome: entry.lastResult?.status || null,
|
|
533
|
+
// The wall clock since the run started (reports use it); max_minutes counts work time only.
|
|
497
534
|
elapsedMs,
|
|
535
|
+
// Work time used and left of max_minutes; workingSince is when the turn in progress started
|
|
536
|
+
// working (null while the run is queued, idle between turns or waiting on a permission).
|
|
537
|
+
workMs: workUsedMs(entry, at),
|
|
538
|
+
workLeftMs: workLeftMs(entry, at),
|
|
539
|
+
workingSince: entry.workingSince || null,
|
|
498
540
|
turnCount: Array.isArray(entry.turns) ? entry.turns.length : 0,
|
|
499
541
|
// The turn whose result this view carries: what a brain has read once it sees it.
|
|
500
542
|
resultTurn: (Array.isArray(entry.turns) ? entry.turns.at(-1)?.n : null) ?? 0,
|
|
@@ -509,6 +551,44 @@ export function createAssistantDispatcher({
|
|
|
509
551
|
};
|
|
510
552
|
}
|
|
511
553
|
function safeRuntime() { try { return getRuntime(); } catch { return null; } }
|
|
554
|
+
|
|
555
|
+
// ── work clock (max_minutes) ───────────────────────────────────────────────
|
|
556
|
+
// A run's max_minutes counts the time it works: a turn, from its start to its end (result,
|
|
557
|
+
// failure, stop, cap), the result-retry turn included. Time queued before the start, idle between
|
|
558
|
+
// turns and blocked on a relayed permission or question (those waits have their own timeouts)
|
|
559
|
+
// does not count. entry.workMs holds the closed stretches; entry.workingSince the start of the
|
|
560
|
+
// open one. While a turn works, a timer on the work time left stops a turn that hangs.
|
|
561
|
+
function workUsedMs(entry, at = now()) {
|
|
562
|
+
const since = entry.workingSince ? Date.parse(entry.workingSince) : NaN;
|
|
563
|
+
return (Number(entry.workMs) || 0) + (Number.isFinite(since) ? Math.max(0, at - since) : 0);
|
|
564
|
+
}
|
|
565
|
+
function workLeftMs(entry, at = now()) {
|
|
566
|
+
const capMs = (Number(entry.maxMinutes) || limits.defaultMaxMinutes) * 60_000;
|
|
567
|
+
return Math.max(0, capMs - workUsedMs(entry, at));
|
|
568
|
+
}
|
|
569
|
+
function startWork(entry, liveState) {
|
|
570
|
+
if (!liveState || liveState.dead || entry.workingSince) return;
|
|
571
|
+
entry.workingSince = iso(now());
|
|
572
|
+
armWorkCap(entry, liveState);
|
|
573
|
+
}
|
|
574
|
+
function pauseWork(entry, liveState = live.get(entry.runId)) {
|
|
575
|
+
if (liveState?.workTimer) { workTimers.clear(liveState.workTimer); liveState.workTimer = null; }
|
|
576
|
+
if (!entry.workingSince) return;
|
|
577
|
+
entry.workMs = workUsedMs(entry);
|
|
578
|
+
entry.workingSince = null;
|
|
579
|
+
}
|
|
580
|
+
function armWorkCap(entry, liveState) {
|
|
581
|
+
if (liveState.workTimer) workTimers.clear(liveState.workTimer);
|
|
582
|
+
liveState.workTimer = workTimers.set(() => {
|
|
583
|
+
liveState.workTimer = null;
|
|
584
|
+
if (!entry.workingSince || liveState.dead || TERMINAL_STATES.has(entry.state)) return;
|
|
585
|
+
if (workLeftMs(entry) > 0) { armWorkCap(entry, liveState); return; }
|
|
586
|
+
pauseWork(entry, liveState);
|
|
587
|
+
entry.notes.push(`stopped: its ${entry.maxMinutes} minutes of work time were used up`);
|
|
588
|
+
stop(entry.runId, 'time_cap').catch(() => {});
|
|
589
|
+
}, workLeftMs(entry));
|
|
590
|
+
}
|
|
591
|
+
|
|
512
592
|
function touch(entry) { entry.version = (Number(entry.version) || 0) + 1; entry.updatedAt = iso(now()); }
|
|
513
593
|
function emit(entry, reason, extra = {}) {
|
|
514
594
|
touch(entry);
|
|
@@ -681,15 +761,44 @@ export function createAssistantDispatcher({
|
|
|
681
761
|
let claudeAccount = null;
|
|
682
762
|
const accountId = spec.accountId || spec.codexAccountId || spec.claudeAccountId || null;
|
|
683
763
|
if (provider === 'codex') {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
764
|
+
// The account the dispatch names, else the user's Codex worker account, else Default. A signed-out
|
|
765
|
+
// account (or one whose key cannot be read) is refused here, with why: the worker would only fail at
|
|
766
|
+
// its first turn. Its credential is added when the worker starts (lib/codex-accounts.js launchFor()).
|
|
767
|
+
let wanted = accountId;
|
|
768
|
+
if (!wanted) { try { wanted = codexWorkerAccount?.() || null; } catch { wanted = null; } }
|
|
769
|
+
wanted = wanted || 'default';
|
|
770
|
+
codexAccount = wanted === 'default' ? (findCodexAccount('default') || getCodexAccount(null)) : findCodexAccount(wanted);
|
|
771
|
+
if (!codexAccount || (wanted !== 'default' && codexAccount.id !== wanted)) throw new DispatchError('ACCOUNT_NOT_FOUND', `Codex account not found: ${wanted}. Pick one from agent_catalog's Codex accounts.`);
|
|
772
|
+
const unusable = typeof codexAccounts?.unusableReason === 'function' ? codexAccounts.unusableReason(wanted) : '';
|
|
773
|
+
if (unusable) throw new DispatchError('ACCOUNT_SIGNED_OUT', unusable);
|
|
774
|
+
}
|
|
775
|
+
if (provider === 'claude-code') {
|
|
776
|
+
// The account the dispatch names, else the user's worker account, else Default. A signed-out
|
|
777
|
+
// account (or one whose key cannot be read) is refused here, with why: the worker would only
|
|
778
|
+
// fail at its first turn. Its credential is added when the worker starts (launchFor()).
|
|
779
|
+
let wanted = accountId;
|
|
780
|
+
if (!wanted) { try { wanted = claudeWorkerAccount?.() || null; } catch { wanted = null; } }
|
|
781
|
+
wanted = wanted || 'default';
|
|
782
|
+
claudeAccount = typeof claudeAccounts?.describe === 'function'
|
|
783
|
+
? claudeAccounts.describe(wanted)
|
|
784
|
+
: (wanted === 'default' ? { id: 'default' } : claudeAccounts?.find?.(wanted) || null);
|
|
785
|
+
if (!claudeAccount || claudeAccount.id !== wanted) throw new DispatchError('ACCOUNT_NOT_FOUND', `Claude account not found: ${wanted}. Pick one from agent_catalog's Claude accounts.`);
|
|
786
|
+
const unusable = typeof claudeAccounts?.unusableReason === 'function' ? claudeAccounts.unusableReason(wanted) : '';
|
|
787
|
+
if (unusable) throw new DispatchError('ACCOUNT_SIGNED_OUT', unusable);
|
|
788
|
+
if (claudeAccount.id === 'default') claudeAccount = null;
|
|
789
|
+
}
|
|
790
|
+
const askedMinutes = Number(spec.maxMinutes) || limits.defaultMaxMinutes;
|
|
791
|
+
const maxMinutes = Math.max(MIN_MAX_MINUTES, Math.min(askedMinutes, limits.maxMaxMinutes));
|
|
792
|
+
// A configured default (assistant-config.json limits.defaultMaxMinutes) under the minimum is raised the same way, with its own note.
|
|
793
|
+
if (askedMinutes < MIN_MAX_MINUTES) notes.push(`${Number(spec.maxMinutes) ? 'max_minutes' : 'the default max_minutes'} ${askedMinutes} raised to the ${MIN_MAX_MINUTES}-minute minimum`);
|
|
794
|
+
// budget_usd: an optional ceiling for this one run, inside its provider's share of the session budget.
|
|
795
|
+
// None: the run may use what its share has left.
|
|
796
|
+
const askedBudget = Number(spec.budgetUsd);
|
|
797
|
+
const budgetUsd = Number.isFinite(askedBudget) && askedBudget > 0
|
|
798
|
+
? Math.min(Math.max(askedBudget, Number(limits.minRunBudgetUsd) || MIN_SPEND_USD), Number(limits.maxRunBudgetUsd) || BUDGET_MAX_USD)
|
|
799
|
+
: null;
|
|
800
|
+
// A free model (the catalog says so) needs no share of the budget.
|
|
801
|
+
const freeModel = provider === 'opencode' && isFreePrice(modelInfo?.price || null);
|
|
693
802
|
const idleTimeoutMs = Math.min(Math.max(Number(spec.idleTimeoutMs) || limits.idleTimeoutMs, limits.minIdleTimeoutMs), limits.maxIdleTimeoutMs);
|
|
694
803
|
const maxTurns = Math.min(Math.max(Number(spec.maxTurns) || limits.maxTurns, 1), limits.maxMaxTurns);
|
|
695
804
|
const outputSchema = spec.outputSchema && typeof spec.outputSchema === 'object' ? spec.outputSchema : null;
|
|
@@ -706,7 +815,7 @@ export function createAssistantDispatcher({
|
|
|
706
815
|
claudeAccountId: claudeAccount?.id || (provider === 'claude-code' ? 'default' : null),
|
|
707
816
|
claudeConfigDir: claudeAccount ? (claudeAccounts?.homeFor?.(claudeAccount.id) || null) : null,
|
|
708
817
|
accountId: provider === 'codex' ? (codexAccount?.id || 'default') : provider === 'claude-code' ? (claudeAccount?.id || 'default') : null,
|
|
709
|
-
maxMinutes, budgetUsd, idleTimeoutMs, maxTurns, outputSchema, title, tags,
|
|
818
|
+
maxMinutes, budgetUsd, freeModel, idleTimeoutMs, maxTurns, outputSchema, title, tags,
|
|
710
819
|
focus: spec.focus === true,
|
|
711
820
|
workflowId: spec.workflowId ? String(spec.workflowId) : null,
|
|
712
821
|
parentRunId: spec.parentRunId ? String(spec.parentRunId) : null,
|
|
@@ -747,49 +856,68 @@ export function createAssistantDispatcher({
|
|
|
747
856
|
if (!assistantSessionId) return 0;
|
|
748
857
|
try { return Math.max(0, Number(brainSpend(assistantSessionId)) || 0); } catch { return 0; }
|
|
749
858
|
}
|
|
750
|
-
/** The session's whole spend: the central brain plus every run.
|
|
859
|
+
/** The session's whole spend: the central brain plus every run. */
|
|
751
860
|
function sessionTotal(assistantSessionId) { return sessionSpend(assistantSessionId) + sessionBrainSpend(assistantSessionId); }
|
|
752
|
-
|
|
861
|
+
/** The brain's spend by the provider it ran on; an older runtime (one total) books it all on Claude Code. */
|
|
862
|
+
function brainByProvider(assistantSessionId) {
|
|
863
|
+
if (typeof brainSpendByProvider === 'function') {
|
|
864
|
+
try {
|
|
865
|
+
const rows = brainSpendByProvider(assistantSessionId);
|
|
866
|
+
if (rows && typeof rows === 'object') return rows;
|
|
867
|
+
} catch { /* fall through */ }
|
|
868
|
+
}
|
|
869
|
+
const total = sessionBrainSpend(assistantSessionId);
|
|
870
|
+
return total > 0 ? { 'claude-code': total } : {};
|
|
871
|
+
}
|
|
872
|
+
/** What a Claude turn in progress has spent so far (its calls at list price), not booked yet. */
|
|
873
|
+
function pendingRunUsd(entry) {
|
|
874
|
+
const liveState = live.get(entry.runId);
|
|
875
|
+
return liveState && liveState.turnOpen ? Math.max(0, Number(liveState.claudePendingUsd) || 0) : 0;
|
|
876
|
+
}
|
|
753
877
|
/**
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
878
|
+
* The session's spend by provider, for the budget planner: { providers: { [p]: { brainUsd, runsUsd,
|
|
879
|
+
* liveRuns, runningRuns } }, unpricedRuns, liveRuns: [{ provider, model, taskClass }] }. A run's
|
|
880
|
+
* spend counts on its provider (removed runs included), with what a Claude turn in progress has
|
|
881
|
+
* spent so far; the brain's on the provider it ran on.
|
|
757
882
|
*/
|
|
758
|
-
function
|
|
759
|
-
|
|
760
|
-
|
|
883
|
+
function providerSpend(assistantSessionId) {
|
|
884
|
+
const providers = {};
|
|
885
|
+
for (const provider of NATIVE_PROVIDERS) providers[provider] = { brainUsd: 0, runsUsd: 0, liveRuns: 0, runningRuns: 0 };
|
|
886
|
+
const liveRuns = [];
|
|
887
|
+
let unpricedRuns = 0;
|
|
888
|
+
if (!assistantSessionId) return { providers, unpricedRuns, liveRuns };
|
|
761
889
|
for (const entry of entries.values()) {
|
|
762
|
-
if (entry.assistantSessionId !== assistantSessionId
|
|
763
|
-
|
|
764
|
-
|
|
890
|
+
if (entry.assistantSessionId !== assistantSessionId) continue;
|
|
891
|
+
const row = providers[entry.provider];
|
|
892
|
+
if (Number(entry.unpricedTurns) > 0) unpricedRuns += 1;
|
|
893
|
+
if (!row) continue;
|
|
894
|
+
row.runsUsd += (Number(entry.costUsd) || 0) + pendingRunUsd(entry);
|
|
895
|
+
if (ACTIVE_STATES.has(entry.state) && !WAITING_STATES.has(entry.state)) {
|
|
896
|
+
row.liveRuns += 1;
|
|
897
|
+
if (entry.state === 'running' || entry.state === 'starting' || entry.state === 'awaiting_permission') row.runningRuns += 1;
|
|
898
|
+
liveRuns.push({ provider: entry.provider, model: entry.model || null, taskClass: entry.taskClass || null });
|
|
899
|
+
}
|
|
765
900
|
}
|
|
766
|
-
|
|
901
|
+
for (const [provider, usd] of Object.entries(brainByProvider(assistantSessionId))) {
|
|
902
|
+
if (providers[provider]) providers[provider].brainUsd += Math.max(0, Number(usd) || 0);
|
|
903
|
+
}
|
|
904
|
+
for (const row of Object.values(providers)) { row.runsUsd = r4(row.runsUsd); row.brainUsd = r4(row.brainUsd); }
|
|
905
|
+
return { providers, unpricedRuns, liveRuns };
|
|
767
906
|
}
|
|
768
|
-
/** The session's money view (Budget
|
|
907
|
+
/** The session's money view (the Budget modal, the spend chip, agent_list / agent_usage): budget-planner's view. */
|
|
769
908
|
function sessionBudget(assistantSessionId) {
|
|
770
909
|
refreshLimits();
|
|
771
|
-
|
|
772
|
-
const brainUsd = sessionBrainSpend(assistantSessionId);
|
|
773
|
-
const totalUsd = dispatchUsd + brainUsd;
|
|
774
|
-
const reservedUsd = sessionReserved(assistantSessionId);
|
|
775
|
-
let unpricedRuns = 0;
|
|
776
|
-
for (const entry of entries.values()) if (entry.assistantSessionId === assistantSessionId && Number(entry.unpricedTurns) > 0) unpricedRuns += 1;
|
|
777
|
-
return {
|
|
778
|
-
brainUsd: r4(brainUsd), dispatchUsd: r4(dispatchUsd), totalUsd: r4(totalUsd), reservedUsd: r4(reservedUsd),
|
|
779
|
-
availableUsd: r4(Math.max(0, limits.sessionHardBudgetUsd - totalUsd - reservedUsd)),
|
|
780
|
-
warnUsd: limits.sessionWarnUsd, hardUsd: limits.sessionHardBudgetUsd,
|
|
781
|
-
defaultRunUsd: limits.defaultBudgetUsd, maxRunUsd: limits.maxBudgetUsd, brainCapUsd: limits.brainBudgetUsd,
|
|
782
|
-
warned: totalUsd >= limits.sessionWarnUsd, exceeded: totalUsd >= limits.sessionHardBudgetUsd, unpricedRuns,
|
|
783
|
-
};
|
|
910
|
+
try { return budget.view(assistantSessionId); } catch { return null; }
|
|
784
911
|
}
|
|
912
|
+
/** Whether the session runs under a budget now (No limit: false). */
|
|
913
|
+
function limitedNow() { try { return budget.limited(); } catch { return true; } }
|
|
785
914
|
/**
|
|
786
|
-
* Why a run's spend could not be held to the
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
* model is fine).
|
|
915
|
+
* Why a run's spend could not be held to the budget (no price for its model), else null. Enforced
|
|
916
|
+
* only with a budget and a price source (`pricing`): Claude reports cost, Codex needs a list price,
|
|
917
|
+
* OpenCode a catalog price (a free model is fine).
|
|
790
918
|
*/
|
|
791
919
|
function unpricedReason(entry) {
|
|
792
|
-
if (!pricing || entry.provider === 'claude-code') return null;
|
|
920
|
+
if (!pricing || entry.provider === 'claude-code' || !limitedNow()) return null;
|
|
793
921
|
const model = entry.pricingModel || entry.model || null;
|
|
794
922
|
if (entry.provider === 'codex') {
|
|
795
923
|
let price = null;
|
|
@@ -801,65 +929,93 @@ export function createAssistantDispatcher({
|
|
|
801
929
|
function refuseUnpriced(entry) {
|
|
802
930
|
const why = unpricedReason(entry);
|
|
803
931
|
if (!why) return;
|
|
804
|
-
throw new DispatchError('BUDGET_UNPRICED', `Not dispatched: ${why}, so the budget
|
|
932
|
+
throw new DispatchError('BUDGET_UNPRICED', `Not dispatched: ${why}, so the session budget could not hold its spend. Pick a priced model or a free one (agent_catalog shows prices).`, { status: 409, provider: entry.provider, model: entry.pricingModel || entry.model || null });
|
|
933
|
+
}
|
|
934
|
+
const providerName = (provider) => PROVIDER_LABELS[provider] || provider;
|
|
935
|
+
/** The session budget's words for a provider whose share is used up ("codex share used up: $4.00 of $4.00 spent"). */
|
|
936
|
+
function shareText(sessionId, provider) {
|
|
937
|
+
const cov = budget.coverage(sessionId);
|
|
938
|
+
const row = cov?.providers?.[provider];
|
|
939
|
+
if (!row) return `the ${provider} share is used up`;
|
|
940
|
+
return `the ${provider} share of the session budget is used up (${usd(row.spentUsd)} spent of its ${usd(row.shareUsd ?? row.spentUsd)} share; ${usd(cov.leftUsd)} of the ${usd(cov.amountUsd)} budget left in the session)`;
|
|
941
|
+
}
|
|
942
|
+
/** The providers whose share is used up, named for an error ("codex, opencode"). */
|
|
943
|
+
function dryText(sessionId) {
|
|
944
|
+
let dry = [];
|
|
945
|
+
try { dry = budget.dry(sessionId) || []; } catch { dry = []; }
|
|
946
|
+
return dry.length ? dry.join(', ') : 'none';
|
|
805
947
|
}
|
|
806
|
-
/**
|
|
948
|
+
/**
|
|
949
|
+
* Why this run may not spend more, else null:
|
|
950
|
+
* BUDGET_UNPRICED its usage could not be priced (the budget cannot hold it)
|
|
951
|
+
* RUN_BUDGET_EXCEEDED it reached its own ceiling (budget_usd)
|
|
952
|
+
* BUDGET_EXCEEDED the session reached its budget
|
|
953
|
+
* BUDGET_SHARE_EXHAUSTED its provider's share is used up (a re-split may still move money to it)
|
|
954
|
+
* Under No limit: never anything.
|
|
955
|
+
*/
|
|
807
956
|
function budgetBlock(entry) {
|
|
957
|
+
if (!limitedNow()) return null;
|
|
808
958
|
if (pricing && Number(entry.unpricedTurns) > 0) {
|
|
809
|
-
return { code: 'BUDGET_UNPRICED', reason: 'budget_unpriced', message: `its usage could not be priced (${entry.unpricedReason || 'no price'}), so the budget
|
|
959
|
+
return { code: 'BUDGET_UNPRICED', reason: 'budget_unpriced', message: `its usage could not be priced (${entry.unpricedReason || 'no price'}), so the session budget cannot hold it`, detail: { unpricedTurns: entry.unpricedTurns } };
|
|
810
960
|
}
|
|
811
961
|
const cap = Number(entry.budgetUsd) || 0;
|
|
812
962
|
const cost = Number(entry.costUsd) || 0;
|
|
813
|
-
if (cap > 0 && cost >= cap) {
|
|
814
|
-
return { code: 'RUN_BUDGET_EXCEEDED', reason: 'budget_cap', message: `run spent ${usd(cost)} of its ${usd(cap)}
|
|
963
|
+
if (cap > 0 && cost + pendingRunUsd(entry) >= cap) {
|
|
964
|
+
return { code: 'RUN_BUDGET_EXCEEDED', reason: 'budget_cap', message: `run spent ${usd(cost + pendingRunUsd(entry))} of its ${usd(cap)} ceiling`, detail: { runSpent: r4(cost), runCap: cap } };
|
|
815
965
|
}
|
|
816
966
|
const sessionId = entry.assistantSessionId;
|
|
817
|
-
if (sessionId)
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
}
|
|
967
|
+
if (!sessionId) return null;
|
|
968
|
+
const cov = budget.coverage(sessionId);
|
|
969
|
+
if (!cov) return null;
|
|
970
|
+
if (cov.leftUsd < MIN_SPEND_USD) {
|
|
971
|
+
return { code: 'BUDGET_EXCEEDED', reason: 'session_budget_cap', message: `assistant session spent ${usd(cov.spentUsd)} (brain + agents), its whole budget of ${usd(cov.amountUsd)}`, detail: { sessionSpent: r4(cov.spentUsd), sessionBudget: cov.amountUsd } };
|
|
972
|
+
}
|
|
973
|
+
if (!entry.freeModel && (cov.providers[entry.provider]?.workerLeftUsd ?? 0) < MIN_SPEND_USD) {
|
|
974
|
+
return { code: 'BUDGET_SHARE_EXHAUSTED', reason: 'budget_share', provider: entry.provider, message: shareText(sessionId, entry.provider), detail: { provider: entry.provider, sessionLeft: r4(cov.leftUsd), dry: budget.dry(sessionId) } };
|
|
822
975
|
}
|
|
823
976
|
return null;
|
|
824
977
|
}
|
|
825
978
|
/**
|
|
826
|
-
*
|
|
827
|
-
*
|
|
979
|
+
* Can the run's provider pay for its next step? When its share is used up the session's unspent
|
|
980
|
+
* budget is divided again first (budget planner: Jev or the fallback). → true, or false when no
|
|
981
|
+
* money could move to it.
|
|
828
982
|
*/
|
|
829
|
-
function
|
|
983
|
+
async function coverShare(entry, { reason = 'share_used_up' } = {}) {
|
|
830
984
|
const sessionId = entry.assistantSessionId;
|
|
831
|
-
if (!sessionId) return;
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const hard = limits.sessionHardBudgetUsd;
|
|
835
|
-
// Whole cents: a run never gets a fraction of a cent the session does not have.
|
|
836
|
-
const available = Math.floor((hard - spent - reserved) * 100 + 1e-6) / 100;
|
|
837
|
-
if (available <= 0) {
|
|
838
|
-
throw new DispatchError('BUDGET_EXCEEDED', `No session budget left for another run: ${usd(spent)} spent (brain + agents) and ${usd(reserved)} reserved by live runs, against the hard cap of ${usd(hard)}. Complete finished runs (agent_complete) to release their reservation, or ask the user to raise the cap (Assistant → Budget).`, { status: 409, sessionSpent: r4(spent), sessionReserved: r4(reserved), sessionCap: hard });
|
|
839
|
-
}
|
|
840
|
-
if ((Number(entry.budgetUsd) || 0) > available) {
|
|
841
|
-
const from = Number(entry.budgetUsd);
|
|
842
|
-
entry.budgetUsd = available;
|
|
843
|
-
entry.budgetClamped = { from, to: entry.budgetUsd, at: iso(now()) };
|
|
844
|
-
entry.notes.push(`budget lowered from ${usd(from)} to ${usd(entry.budgetUsd)}: the session hard cap ${usd(hard)} minus ${usd(spent)} spent and ${usd(reserved)} reserved by live runs`);
|
|
845
|
-
}
|
|
985
|
+
if (!sessionId || !limitedNow() || entry.freeModel) return true;
|
|
986
|
+
try { return await budget.ensureCovered(sessionId, { provider: entry.provider, reason, task: { taskClass: entry.taskClass || null, summary: entry.title || null, provider: entry.provider, model: entry.model || null } }); }
|
|
987
|
+
catch { return false; }
|
|
846
988
|
}
|
|
847
989
|
/**
|
|
848
|
-
*
|
|
849
|
-
*
|
|
990
|
+
* The Claude CLI's own stop (--max-budget-usd) for a Claude worker under a budget: the run's ceiling,
|
|
991
|
+
* never more than the session has left when it starts. A backstop: the dispatcher stops the run at its
|
|
992
|
+
* provider's share between model calls. null under No limit (no CLI cap at all).
|
|
993
|
+
*/
|
|
994
|
+
function claudeBackstop(entry) {
|
|
995
|
+
if (!limitedNow()) return null;
|
|
996
|
+
const cap = Number(entry.budgetUsd) || 0;
|
|
997
|
+
const left = entry.assistantSessionId ? (budget.coverage(entry.assistantSessionId)?.leftUsd ?? null) : null;
|
|
998
|
+
const value = Math.min(cap > 0 ? cap : Infinity, left === null ? Infinity : left);
|
|
999
|
+
if (!Number.isFinite(value)) return cap > 0 ? cap : null;
|
|
1000
|
+
return Math.max(MIN_SPEND_USD, floorCents(value) / 100);
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* After money moved in a session: one warning at 80 % of the budget (per budget amount), and at
|
|
1004
|
+
* the budget every live, queued and held run of the session stops. Nothing under No limit.
|
|
850
1005
|
* Returns the session's budget view.
|
|
851
1006
|
*/
|
|
852
1007
|
function checkSession(assistantSessionId, { source = null } = {}) {
|
|
853
1008
|
if (!assistantSessionId) return null;
|
|
854
1009
|
const money = sessionBudget(assistantSessionId);
|
|
1010
|
+
if (!money || money.unlimited) return money;
|
|
855
1011
|
const anchor = source || [...entries.values()].filter((e) => e.assistantSessionId === assistantSessionId)
|
|
856
1012
|
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))[0] || null;
|
|
857
|
-
if (money.warned && !money.exceeded && sessionBudgetWarned.get(assistantSessionId) !== money.
|
|
858
|
-
sessionBudgetWarned.set(assistantSessionId, money.
|
|
1013
|
+
if (money.warned && !money.exceeded && sessionBudgetWarned.get(assistantSessionId) !== money.amountUsd) {
|
|
1014
|
+
sessionBudgetWarned.set(assistantSessionId, money.amountUsd);
|
|
859
1015
|
if (anchor) {
|
|
860
1016
|
emit(anchor, 'budget_warning', {
|
|
861
|
-
sessionSpent: money.totalUsd, warnAt: money.
|
|
862
|
-
text: `session budget warning: ${usd(money.totalUsd)} spent
|
|
1017
|
+
sessionSpent: money.totalUsd, warnAt: money.warnAtUsd, budgetUsd: money.amountUsd, leftUsd: money.leftUsd,
|
|
1018
|
+
text: `session budget warning: ${usd(money.totalUsd)} spent of the ${usd(money.amountUsd)} budget (brain ${usd(money.brainUsd)} + agents ${usd(money.dispatchUsd)}), ${usd(money.leftUsd)} left. Ask the user before spending more.`,
|
|
863
1019
|
});
|
|
864
1020
|
}
|
|
865
1021
|
}
|
|
@@ -867,19 +1023,34 @@ export function createAssistantDispatcher({
|
|
|
867
1023
|
const victims = [...entries.values()].filter((e) => e.assistantSessionId === assistantSessionId && ACTIVE_STATES.has(e.state));
|
|
868
1024
|
for (const victim of victims) {
|
|
869
1025
|
victim.completionReason = victim.completionReason || 'session_budget_cap';
|
|
870
|
-
if (!(victim.notes || []).some((note) => note.startsWith('stopped: session
|
|
1026
|
+
if (!(victim.notes || []).some((note) => note.startsWith('stopped: session budget'))) victim.notes.push(`stopped: session budget ${usd(money.amountUsd)} reached (${usd(money.totalUsd)} spent)`);
|
|
871
1027
|
stop(victim.runId, 'session_budget_cap').catch(() => {});
|
|
872
1028
|
}
|
|
873
|
-
if (anchor && sessionBudgetExceeded.get(assistantSessionId) !== money.
|
|
874
|
-
sessionBudgetExceeded.set(assistantSessionId, money.
|
|
1029
|
+
if (anchor && sessionBudgetExceeded.get(assistantSessionId) !== money.amountUsd) {
|
|
1030
|
+
sessionBudgetExceeded.set(assistantSessionId, money.amountUsd);
|
|
875
1031
|
emit(anchor, 'budget_exceeded', {
|
|
876
|
-
sessionSpent: money.totalUsd,
|
|
877
|
-
text: `session
|
|
1032
|
+
sessionSpent: money.totalUsd, budgetUsd: money.amountUsd, stopped: victims.map((e) => e.runId),
|
|
1033
|
+
text: `session budget reached: ${usd(money.totalUsd)} spent of ${usd(money.amountUsd)}; ${victims.length} run${victims.length === 1 ? '' : 's'} stopped, and dispatches, follow-ups and brain turns are refused until the user raises the budget or switches to No limit (Assistant → Budget).`,
|
|
878
1034
|
});
|
|
879
1035
|
}
|
|
880
1036
|
}
|
|
881
1037
|
return money;
|
|
882
1038
|
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Stop the working runs of `provider` in the session: its share is used up and no money could move
|
|
1041
|
+
* to it (budget planner). Idle runs hold no money and are left alone (a follow-up re-splits first and
|
|
1042
|
+
* is refused when nothing moves); queued and held ones are checked when they start.
|
|
1043
|
+
*/
|
|
1044
|
+
function stopProviderRuns(assistantSessionId, provider, text) {
|
|
1045
|
+
const working = new Set(['starting', 'running', 'awaiting_permission']);
|
|
1046
|
+
const victims = [...entries.values()].filter((e) => e.assistantSessionId === assistantSessionId && e.provider === provider && working.has(e.state) && !e.freeModel);
|
|
1047
|
+
for (const victim of victims) {
|
|
1048
|
+
victim.completionReason = victim.completionReason || 'budget_share';
|
|
1049
|
+
if (!(victim.notes || []).some((note) => note.startsWith('stopped: the'))) victim.notes.push(`stopped: ${text}; no unspent budget could be moved to it`);
|
|
1050
|
+
stop(victim.runId, 'budget_share').catch(() => {});
|
|
1051
|
+
}
|
|
1052
|
+
return victims.map((e) => e.runId);
|
|
1053
|
+
}
|
|
883
1054
|
|
|
884
1055
|
// ── dispatch ───────────────────────────────────────────────────────────────
|
|
885
1056
|
/** Entry fields that come straight from a normalized spec. */
|
|
@@ -890,7 +1061,7 @@ export function createAssistantDispatcher({
|
|
|
890
1061
|
cwd: spec.cwd, project: spec.project, mcpProfile: spec.mcpProfile,
|
|
891
1062
|
permissionPolicy: spec.permissionPolicy, capability: spec.capability, usesBrowser: spec.usesBrowser, usesComputer: !!spec.usesComputer,
|
|
892
1063
|
title: spec.title, task: spec.task, context: spec.context, tags: spec.tags,
|
|
893
|
-
maxMinutes: spec.maxMinutes, budgetUsd: spec.budgetUsd, idleTimeoutMs: spec.idleTimeoutMs, maxTurns: spec.maxTurns,
|
|
1064
|
+
maxMinutes: spec.maxMinutes, budgetUsd: spec.budgetUsd ?? null, freeModel: spec.freeModel === true, idleTimeoutMs: spec.idleTimeoutMs, maxTurns: spec.maxTurns,
|
|
894
1065
|
outputSchema: spec.outputSchema, focus: spec.focus, workflowId: spec.workflowId, parentRunId: spec.parentRunId,
|
|
895
1066
|
idempotencyKey: spec.idempotencyKey, requestedBrowserSessionId: spec.requestedBrowserSessionId,
|
|
896
1067
|
modelInfo: spec.modelInfo || null, modelContextWindow: spec.modelContextWindow || null, pricingModel: spec.pricingModel || spec.model || null,
|
|
@@ -901,6 +1072,7 @@ export function createAssistantDispatcher({
|
|
|
901
1072
|
runId, assistantSessionId: sessionId, origin,
|
|
902
1073
|
state: 'queued', turnState: null, turns: [], lastResult: null, costUsd: 0, usage: null, costBasis: 'none',
|
|
903
1074
|
pending: [], notes: [], createdAt: iso(now()), startedAt: null, finishedAt: null, completionReason: null,
|
|
1075
|
+
workMs: 0, workingSince: null,
|
|
904
1076
|
memoryId: null, memoryStored: false, browserSessionId: null, browserTabId: null, version: 0, updatedAt: iso(now()),
|
|
905
1077
|
runtime: null, providerSessionId: null, providerThreadId: null, degraded: null,
|
|
906
1078
|
route: null, escalation: null, escalationDepth: 0,
|
|
@@ -970,10 +1142,7 @@ export function createAssistantDispatcher({
|
|
|
970
1142
|
}
|
|
971
1143
|
}
|
|
972
1144
|
refreshLimits();
|
|
973
|
-
|
|
974
|
-
if (sessionId && spent >= limits.sessionHardBudgetUsd) {
|
|
975
|
-
throw new DispatchError('BUDGET_EXCEEDED', `Assistant session spent $${spent.toFixed(2)} (brain + agents), at or above the hard cap of $${limits.sessionHardBudgetUsd}. Ask the user to raise it (Assistant → Budget) before dispatching more.`, { status: 409, sessionSpent: r4(spent), sessionCap: limits.sessionHardBudgetUsd });
|
|
976
|
-
}
|
|
1145
|
+
refuseSessionOver(sessionId);
|
|
977
1146
|
// "true" (a string) is computer use too: normalizeSpec reads it so, and so must the gate.
|
|
978
1147
|
rawSpec = { ...rawSpec, usesComputer: rawSpec.usesComputer === true || rawSpec.usesComputer === 'true' };
|
|
979
1148
|
// A design dispatch's defaults fill what it leaves out, before any clamp; any dispatch's own browser
|
|
@@ -1012,7 +1181,21 @@ export function createAssistantDispatcher({
|
|
|
1012
1181
|
try {
|
|
1013
1182
|
// An approved route may have moved it to another provider: clamp for that one
|
|
1014
1183
|
// (a route approved for design brings design's defaults).
|
|
1015
|
-
|
|
1184
|
+
let input = routed?.spec ? clampRemote(withClassDefaults(routed.spec, knownClass(routed.route?.taskClass)), sessionId, remoteNotes) : rawSpec;
|
|
1185
|
+
// UI / callers without an Assistant session bypass the router, but may
|
|
1186
|
+
// still ask for Auto. A routed concrete account is never picked again here.
|
|
1187
|
+
if (!routed?.route && accountUsage && ['codex', 'claude-code'].includes(input.provider)) {
|
|
1188
|
+
await accountUsage.refresh(input.provider);
|
|
1189
|
+
const worker = input.provider === 'codex' ? codexWorkerAccount() : claudeWorkerAccount();
|
|
1190
|
+
const choice = input.accountId || input.codexAccountId || input.claudeAccountId || worker || 'default';
|
|
1191
|
+
const { row, reason } = chooseUsageAccount(accountUsage.list()[input.provider] || [], choice, worker || 'default');
|
|
1192
|
+
if (choice === 'auto' && !row) throw new DispatchError('ACCOUNT_NO_SUBSCRIPTION', 'Auto requires a signed-in subscription account.');
|
|
1193
|
+
if (row && reason) {
|
|
1194
|
+
input = { ...input, accountId: row.id, codexAccountId: null, claudeAccountId: null };
|
|
1195
|
+
const message = `${reason === 'accounts_all_limited' ? 'All subscription accounts limited; soonest reset' : reason === 'account_limited' ? `Account ${choice} out of usage` : 'Auto · most usage left'} → ${row.label || row.id}, ${row.usage?.remainingPercent == null ? 'usage unknown' : `${Math.round(row.usage.remainingPercent)}% left`}${row.usage?.resetsAt ? ` (resets ${row.usage.resetsAt})` : ''}.`;
|
|
1196
|
+
routed = { ...routed, route: { status: 'auto', target: { kind: 'dispatch', provider: input.provider, model: input.model || null, accountId: row.id, account: { id: row.id, label: row.label || row.id, mode: row.mode, usage: { remainingPercent: row.usage?.remainingPercent ?? null, limited: row.usage?.limited ?? null, resetsAt: row.usage?.resetsAt || null } } }, corrections: [{ reason, from: choice, to: row.id, message }] } };
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1016
1199
|
// A route approved for other work cannot carry image / video creation or design, nor the other way round:
|
|
1017
1200
|
// the class decides the model's capability check, the worker's media block or rules and what is collected.
|
|
1018
1201
|
const declaredClass = knownClass(rawSpec.taskClass);
|
|
@@ -1035,14 +1218,17 @@ export function createAssistantDispatcher({
|
|
|
1035
1218
|
if (brief) { entry.brief = brief; entry.notes.push(`carries the user's brief ${brief.briefId || ''}`.trim()); }
|
|
1036
1219
|
if (routed?.route) entry.route = routed.route;
|
|
1037
1220
|
entries.set(runId, entry);
|
|
1038
|
-
|
|
1039
|
-
entry.taskId = taskAtDispatch(sessionId);
|
|
1221
|
+
// Before the budget check awaits (it may re-split): a retry with the same key replays this run.
|
|
1040
1222
|
if (spec.idempotencyKey && sessionId) idempotency.set(`${sessionId}:${spec.idempotencyKey}`, { runId, at: now() });
|
|
1223
|
+
let budgetCorrections = [];
|
|
1224
|
+
try { budgetCorrections = await fitBudget(entry, { routed: !!routed?.route }); } catch (error) { entries.delete(runId); throw error; }
|
|
1225
|
+
entry.taskId = taskAtDispatch(sessionId);
|
|
1041
1226
|
const launched = await launchOrQueue(entry, spec, { queuedCount, strict: true });
|
|
1227
|
+
const withCorrections = budgetCorrections.length ? { ...launched, corrections: budgetCorrections } : launched;
|
|
1042
1228
|
// The route's own "decided" event (it had no run attached) still tells the brain to dispatch.
|
|
1043
1229
|
return routed?.approvedMeanwhile
|
|
1044
|
-
? { ...
|
|
1045
|
-
:
|
|
1230
|
+
? { ...withCorrections, next: 'The user picked this route while the dispatch was on its way, so this run started on it. Do not dispatch it again when the route_decided event for this route arrives.' }
|
|
1231
|
+
: withCorrections;
|
|
1046
1232
|
} catch (error) {
|
|
1047
1233
|
// A run that exists (it failed or stopped at start) used the route; a refusal before one did not.
|
|
1048
1234
|
if (routed?.claimed && !(runId && entries.has(runId))) router.releaseRoute?.(routed.route?.routeId);
|
|
@@ -1050,6 +1236,47 @@ export function createAssistantDispatcher({
|
|
|
1050
1236
|
}
|
|
1051
1237
|
}
|
|
1052
1238
|
|
|
1239
|
+
/** BUDGET_EXCEEDED (409) when the session has spent its whole budget (No limit: never). */
|
|
1240
|
+
function refuseSessionOver(sessionId) {
|
|
1241
|
+
if (!sessionId || !limitedNow()) return;
|
|
1242
|
+
const cov = budget.coverage(sessionId);
|
|
1243
|
+
if (cov && cov.leftUsd < MIN_SPEND_USD) {
|
|
1244
|
+
throw new DispatchError('BUDGET_EXCEEDED', `Assistant session spent ${usd(cov.spentUsd)} (brain + agents), its whole budget of ${usd(cov.amountUsd)}. Ask the user to raise the budget or switch to No limit (Assistant → Budget) before dispatching more.`, { status: 409, sessionSpent: r4(cov.spentUsd), sessionBudget: cov.amountUsd, reason: 'session_budget' });
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* A new run under the session budget. No limit: its budget_usd is dropped, with a note and a
|
|
1249
|
+
* correction. With a budget: an unpriced model is refused (BUDGET_UNPRICED), the session's split is
|
|
1250
|
+
* made when it has none (its first dispatch), and the run's provider must have share left, a re-split
|
|
1251
|
+
* first, unless its model is free; else BUDGET_SHARE_EXHAUSTED (409), which names the dry providers
|
|
1252
|
+
* and tells the brain to route again. Nothing is reserved: every run draws on its provider's share
|
|
1253
|
+
* as it spends. `routed`: the router already noted the task. → the corrections for the answer.
|
|
1254
|
+
*/
|
|
1255
|
+
async function fitBudget(entry, { routed = false } = {}) {
|
|
1256
|
+
const corrections = [];
|
|
1257
|
+
if (!limitedNow()) {
|
|
1258
|
+
if (entry.budgetUsd) {
|
|
1259
|
+
const text = `budget_usd ${usd(entry.budgetUsd)} ignored: this session has no budget (No limit)`;
|
|
1260
|
+
entry.notes.push(text);
|
|
1261
|
+
corrections.push({ field: 'budget_usd', from: entry.budgetUsd, to: null, reason: 'no_limit', text });
|
|
1262
|
+
entry.budgetIgnoredUsd = entry.budgetUsd;
|
|
1263
|
+
entry.budgetUsd = null;
|
|
1264
|
+
}
|
|
1265
|
+
return corrections;
|
|
1266
|
+
}
|
|
1267
|
+
refuseUnpriced(entry);
|
|
1268
|
+
const sessionId = entry.assistantSessionId;
|
|
1269
|
+
if (!sessionId) return corrections;
|
|
1270
|
+
const task = { taskClass: entry.taskClass || null, summary: entry.title || null, provider: entry.provider, model: entry.model || null };
|
|
1271
|
+
await budget.ensureSplit(sessionId, { task: routed ? null : task });
|
|
1272
|
+
refuseSessionOver(sessionId);
|
|
1273
|
+
if (entry.freeModel) return corrections;
|
|
1274
|
+
if (!(await budget.ensureCovered(sessionId, { provider: entry.provider, task: routed ? null : task }))) {
|
|
1275
|
+
throw new DispatchError('BUDGET_SHARE_EXHAUSTED', `Not dispatched: ${shareText(sessionId, entry.provider)}, and no unspent budget could be moved to it. Route the task again with agent_route (it moves the task to a provider with money left; providers out of share now: ${dryText(sessionId)}), pick a free model, or ask the user to raise the budget (Assistant → Budget).`, { status: 409, provider: entry.provider, dry: budget.dry(sessionId), reason: 'budget_share' });
|
|
1276
|
+
}
|
|
1277
|
+
return corrections;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1053
1280
|
/** The router decided this route as approved, for this session (after a hold was returned on its card). */
|
|
1054
1281
|
function routeApprovedFor(routeId, sessionId) {
|
|
1055
1282
|
let decided = null;
|
|
@@ -1147,7 +1374,7 @@ export function createAssistantDispatcher({
|
|
|
1147
1374
|
const entry = entries.get(runId);
|
|
1148
1375
|
if (!entry || entry.state !== 'awaiting_route' || !target?.provider) return { ok: false, code: 'NOT_HELD' };
|
|
1149
1376
|
const catalogValue = await catalogSnapshot();
|
|
1150
|
-
let input = { ...(entry.rawSpec || {}), provider: target.provider, model: target.model || null, effort: target.effort ?? entry.rawSpec?.effort ?? null };
|
|
1377
|
+
let input = { ...(entry.rawSpec || {}), provider: target.provider, model: target.model || null, effort: target.effort ?? entry.rawSpec?.effort ?? null, ...(target.accountId ? { accountId: target.accountId, codexAccountId: null, claudeAccountId: null } : {}) };
|
|
1151
1378
|
let spec;
|
|
1152
1379
|
// The picked provider may need another clamp (Codex under strict worker approvals), or the level fell meanwhile.
|
|
1153
1380
|
const remoteNotes = [];
|
|
@@ -1197,6 +1424,7 @@ export function createAssistantDispatcher({
|
|
|
1197
1424
|
const context = [entry.context, note].filter(Boolean).join('\n\n');
|
|
1198
1425
|
const result = await dispatch({
|
|
1199
1426
|
provider: to.provider, model: to.model || null, effort: to.effort || null, task: entry.task, cwd: entry.cwd, context,
|
|
1427
|
+
accountId: to.accountId || (to.provider === entry.provider ? entry.accountId : null),
|
|
1200
1428
|
title: clip(`${entry.title} ${retry ? '↻' : '↑'}`, 80), tags: entry.tags, workflowId: entry.workflowId, parentRunId: runId,
|
|
1201
1429
|
mcpProfile: entry.provider === to.provider ? entry.mcpProfile : undefined, capability: entry.capability,
|
|
1202
1430
|
permissionPolicy: to.provider === 'codex' && entry.permissionPolicy === 'ask' ? 'restricted' : entry.permissionPolicy,
|
|
@@ -1262,7 +1490,9 @@ export function createAssistantDispatcher({
|
|
|
1262
1490
|
const ask = {
|
|
1263
1491
|
status: noBlock && !entry.outputSchema,
|
|
1264
1492
|
cause: noBlock || parsed.status === 'blocked',
|
|
1265
|
-
|
|
1493
|
+
// At Test density None a "Not tested" result is what the brief asked for, not a claim to check.
|
|
1494
|
+
claim: (noBlock || parsed.status === 'done') && evidence.hasEvidence
|
|
1495
|
+
&& !claimCheckWaived({ level: entry.testDensity || null, taskClass: entry.taskClass || null, text, evidence }),
|
|
1266
1496
|
};
|
|
1267
1497
|
if (!ask.status && !ask.cause && !ask.claim) return null;
|
|
1268
1498
|
const judged = await bounded(judge.workerOutcome({
|
|
@@ -1289,7 +1519,7 @@ export function createAssistantDispatcher({
|
|
|
1289
1519
|
*/
|
|
1290
1520
|
function emitFailed(entry, evidenceAcc = null) {
|
|
1291
1521
|
const done = () => { delete entry.judging; entry.escalation = computeEscalation(entry); emit(entry, 'failed'); };
|
|
1292
|
-
if (!judge?.workerOutcome || entry.completionReason
|
|
1522
|
+
if (!judge?.workerOutcome || budgetStopReason(entry.completionReason)) { done(); return Promise.resolve(); }
|
|
1293
1523
|
entry.judging = true;
|
|
1294
1524
|
const evidence = evidenceSnapshot(evidenceAcc);
|
|
1295
1525
|
const error = [entry.error, ...(entry.notes || []).slice(-3)].filter(Boolean).map((line) => String(line)).join('\n');
|
|
@@ -1322,10 +1552,12 @@ export function createAssistantDispatcher({
|
|
|
1322
1552
|
async function start(entry) {
|
|
1323
1553
|
const runtime = safeRuntime();
|
|
1324
1554
|
if (!runtime) throw new DispatchError('RUNTIME_UNAVAILABLE', 'Native loop runtime is not available', { status: 503 });
|
|
1325
|
-
// A queued or held run starts under the
|
|
1555
|
+
// A queued or held run starts under the budget as it is now (its provider's share included).
|
|
1326
1556
|
refreshLimits();
|
|
1327
|
-
try {
|
|
1328
|
-
entry.state = 'stopped';
|
|
1557
|
+
try { await fitBudget(entry, { routed: true }); } catch (error) {
|
|
1558
|
+
entry.state = 'stopped';
|
|
1559
|
+
entry.completionReason = error?.code === 'BUDGET_UNPRICED' ? 'budget_unpriced' : error?.code === 'BUDGET_SHARE_EXHAUSTED' ? 'budget_share' : 'session_budget_cap';
|
|
1560
|
+
entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
|
|
1329
1561
|
entry.notes.push(`not started: ${error?.message || error}`);
|
|
1330
1562
|
emit(entry, 'stopped');
|
|
1331
1563
|
throw error;
|
|
@@ -1349,6 +1581,7 @@ export function createAssistantDispatcher({
|
|
|
1349
1581
|
entry, ctx: null, ring: [], ringChars: 0, followUps: [], waitResolve: null, idleTimer: null,
|
|
1350
1582
|
pendingResolvers: new Map(), dead: false, lastEventAt: now(), stalledFlagged: false, turnCounter: 0, abortReason: null,
|
|
1351
1583
|
evidence: null, // the current turn's commands / tool results / edited files (assistant-evidence.js)
|
|
1584
|
+
completed: false, // its turn loop ended on a completion (agent_complete, a closed finished tab, a removal)
|
|
1352
1585
|
};
|
|
1353
1586
|
live.set(entry.runId, liveState);
|
|
1354
1587
|
const extraEnv = {};
|
|
@@ -1371,7 +1604,8 @@ export function createAssistantDispatcher({
|
|
|
1371
1604
|
claudeAccountId: entry.claudeAccountId, claudeConfigDir: entry.claudeConfigDir,
|
|
1372
1605
|
driverType: 'native', runtimeType: 'native', surface: 'sidepanel', runId: entry.runId, cwd: entry.cwd,
|
|
1373
1606
|
runMode: 'task', assistantSessionId: entry.assistantSessionId, permissionPolicy: entry.permissionPolicy,
|
|
1374
|
-
|
|
1607
|
+
// A Claude worker's CLI stop (--max-budget-usd): a backstop under a budget, none under No limit.
|
|
1608
|
+
capability: entry.capability, budgetUsd: entry.budgetUsd, maxBudgetUsd: entry.provider === 'claude-code' ? claudeBackstop(entry) : null,
|
|
1375
1609
|
idleTimeoutMs: entry.idleTimeoutMs, maxTurns: entry.maxTurns, outputSchema: entry.outputSchema, project: entry.project,
|
|
1376
1610
|
title: entry.title, tags: entry.tags, workflowId: entry.workflowId, parentRunId: entry.parentRunId,
|
|
1377
1611
|
// The user's brief after a clarification (assistant-task-prompt.js briefBlock).
|
|
@@ -1403,7 +1637,7 @@ export function createAssistantDispatcher({
|
|
|
1403
1637
|
source: 'assistant',
|
|
1404
1638
|
focus: entry.focus === true,
|
|
1405
1639
|
title: entry.title,
|
|
1406
|
-
buildPrompt: () => buildTaskPrompt(
|
|
1640
|
+
buildPrompt: () => buildTaskPrompt({ ...state, ...(playbook ? { playbook } : {}), ...(styleGuide ? { styleGuide } : {}), testDensity: briefTestDensity(entry) }),
|
|
1407
1641
|
wrapAdapter: (adapter, ctx) => createTaskAdapter(adapter, ctx, liveState, state),
|
|
1408
1642
|
});
|
|
1409
1643
|
entry.runtime = { status: descriptor.status };
|
|
@@ -1428,6 +1662,27 @@ export function createAssistantDispatcher({
|
|
|
1428
1662
|
function styleGuideFor(entry) {
|
|
1429
1663
|
try { return loadStyleGuide(entry.cwd, entry.taskClass || null) || null; } catch { return null; }
|
|
1430
1664
|
}
|
|
1665
|
+
/** The session's Test density right now (the user's setting; the default when it cannot be read). */
|
|
1666
|
+
function testDensityFor(entry) {
|
|
1667
|
+
try { return normalizeTestDensity(testDensity(entry.assistantSessionId || null)); } catch { return normalizeTestDensity(null); }
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* The level a run's brief carries, read when the prompt is composed and kept on the entry: the
|
|
1671
|
+
* claim check and a later follow-up go by what the worker was told. null for a class with no
|
|
1672
|
+
* TESTING block.
|
|
1673
|
+
*/
|
|
1674
|
+
function briefTestDensity(entry) {
|
|
1675
|
+
entry.testDensity = testingApplies(entry.taskClass || null) ? testDensityFor(entry) : null;
|
|
1676
|
+
return entry.testDensity;
|
|
1677
|
+
}
|
|
1678
|
+
/** A follow-up turn names the level only when the user changed it since the run was last told. */
|
|
1679
|
+
function changedTestDensity(entry) {
|
|
1680
|
+
if (!testingApplies(entry.taskClass || null)) return null;
|
|
1681
|
+
const level = testDensityFor(entry);
|
|
1682
|
+
if (level === (entry.testDensity || null)) return null;
|
|
1683
|
+
entry.testDensity = level;
|
|
1684
|
+
return level;
|
|
1685
|
+
}
|
|
1431
1686
|
/**
|
|
1432
1687
|
* The rules a run of the entry's class follows (assistant-playbooks.js: the user's override
|
|
1433
1688
|
* at <dataDir>/playbooks/<name>.md, else the shipped copy), or null. The run's notes say
|
|
@@ -1471,6 +1726,8 @@ export function createAssistantDispatcher({
|
|
|
1471
1726
|
entry.pending = [...(entry.pending || []), pending];
|
|
1472
1727
|
entry.state = 'awaiting_permission';
|
|
1473
1728
|
entry.turnState = 'awaiting_permission';
|
|
1729
|
+
// Waiting on the answer is not work: the request's own timeout bounds it.
|
|
1730
|
+
pauseWork(entry, liveState);
|
|
1474
1731
|
liveState.ctx?.setTurnState?.('awaiting_permission');
|
|
1475
1732
|
liveState.ctx?.emit?.({ type: 'synabun.permission_request', requestId, kind, toolName: pending.toolName, input: pending.input, questions: pending.questions });
|
|
1476
1733
|
const timer = setTimeout(() => finishPending(runId, requestId, { behavior: 'deny', message: `No decision within ${Math.round(timeoutMs / 60000)} minutes.`, resolvedBy: 'timeout' }), timeoutMs);
|
|
@@ -1498,6 +1755,7 @@ export function createAssistantDispatcher({
|
|
|
1498
1755
|
if (entry.state === 'awaiting_permission' && entry.pending.length === 0) {
|
|
1499
1756
|
entry.state = 'running';
|
|
1500
1757
|
entry.turnState = 'running';
|
|
1758
|
+
startWork(entry, liveState);
|
|
1501
1759
|
liveState.ctx?.setTurnState?.('running');
|
|
1502
1760
|
}
|
|
1503
1761
|
const reply = { behavior: response?.behavior === 'allow' ? 'allow' : 'deny', ...response };
|
|
@@ -1553,6 +1811,8 @@ export function createAssistantDispatcher({
|
|
|
1553
1811
|
startUsagePolling(entry, liveState);
|
|
1554
1812
|
entry.state = 'running';
|
|
1555
1813
|
entry.turnState = 'running';
|
|
1814
|
+
// The work clock runs from here to the turn's end (its result-retry turn included).
|
|
1815
|
+
startWork(entry, liveState);
|
|
1556
1816
|
ctx.setTurnState('running');
|
|
1557
1817
|
emit(entry, 'turn_started', { turn: turnNumber });
|
|
1558
1818
|
// What this turn runs (commands, tool results, edited files): the evidence behind a "done".
|
|
@@ -1579,7 +1839,8 @@ export function createAssistantDispatcher({
|
|
|
1579
1839
|
parsed.judged = { status: judged.status, confidence: judged.statusConfidence };
|
|
1580
1840
|
if (judged.status === 'needs_input') parsed.question = clip(lastParagraph(text), 500);
|
|
1581
1841
|
if (evidence.files.length) { parsed.files = [...evidence.files]; parsed.changes = evidence.files.map((path) => ({ path, note: '' })); }
|
|
1582
|
-
} else if (noBlock && !liveState.dead) {
|
|
1842
|
+
} else if (noBlock && !liveState.dead && !liveState.abortReason) {
|
|
1843
|
+
// Never after a stop (user, cap): the turn ends as aborted instead (handleTurnError).
|
|
1583
1844
|
retried = true;
|
|
1584
1845
|
try {
|
|
1585
1846
|
const retry = await inner.runTurn(buildResultRetryPrompt(), { ...meta, turn: turnNumber, retry: true });
|
|
@@ -1589,6 +1850,9 @@ export function createAssistantDispatcher({
|
|
|
1589
1850
|
retryCost = accumulateCost(entry, retry);
|
|
1590
1851
|
if (entry.provider === 'codex') settleCodexTurn(entry, liveState, retry);
|
|
1591
1852
|
} catch (error) {
|
|
1853
|
+
// Stopped during the retry: the run ends here (handleTurnError books the retry), with no
|
|
1854
|
+
// turn_completed or follow-up after it. The first provider turn was paid for: book it first.
|
|
1855
|
+
if (liveState.abortReason) { accumulateCost(entry, raw); throw error; }
|
|
1592
1856
|
retryCost = accumulateCost(entry, error);
|
|
1593
1857
|
if (entry.provider === 'codex') settleCodexTurn(entry, liveState, error);
|
|
1594
1858
|
entry.notes.push(`result retry failed: ${error?.message || error}`);
|
|
@@ -1631,6 +1895,8 @@ export function createAssistantDispatcher({
|
|
|
1631
1895
|
entry.turns = [...(entry.turns || []), turn].slice(-50);
|
|
1632
1896
|
entry.lastResult = parsed;
|
|
1633
1897
|
entry.lastText = text.slice(0, 20_000);
|
|
1898
|
+
// The turn has ended: its result is in. Idle time from here on is not work.
|
|
1899
|
+
pauseWork(entry, liveState);
|
|
1634
1900
|
// Blocked or contract-less turns offer a one-tier escalation (the run
|
|
1635
1901
|
// itself stays warm for follow-ups; the brain or the user decides). A
|
|
1636
1902
|
// status Jev read from the message is a usable result.
|
|
@@ -1661,20 +1927,24 @@ export function createAssistantDispatcher({
|
|
|
1661
1927
|
try {
|
|
1662
1928
|
await runInnerTurn(prompt, meta, { origin: 'dispatch' });
|
|
1663
1929
|
} catch (error) {
|
|
1930
|
+
pauseWork(entry, liveState);
|
|
1664
1931
|
return handleTurnError(error, liveState, entry, ctx);
|
|
1665
1932
|
}
|
|
1666
1933
|
for (;;) {
|
|
1667
|
-
//
|
|
1934
|
+
// Stopped while the turn ran (a stop that did not fail it): never idle, never take a queued follow-up.
|
|
1935
|
+
if (liveState.abortReason) { entry.completionReason = entry.completionReason || liveState.abortReason; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
|
|
1936
|
+
// Out of money (its own ceiling, the session's budget, unpriced usage): stop instead of idling
|
|
1937
|
+
// warm. A provider share that is used up holds nothing from an idle run: it waits like any other.
|
|
1668
1938
|
refreshLimits();
|
|
1669
1939
|
const spentOut = budgetBlock(entry);
|
|
1670
|
-
if (spentOut) return budgetStop(entry, spentOut);
|
|
1940
|
+
if (spentOut && spentOut.code !== 'BUDGET_SHARE_EXHAUSTED') return budgetStop(entry, spentOut);
|
|
1671
1941
|
entry.state = 'idle';
|
|
1672
1942
|
entry.turnState = 'idle';
|
|
1673
1943
|
ctx.setTurnState('idle');
|
|
1674
1944
|
emit(entry, 'idle');
|
|
1675
1945
|
const next = await waitForFollowUp();
|
|
1676
1946
|
if (next.kind === 'abort') { entry.completionReason = entry.completionReason || liveState.abortReason || 'aborted'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
|
|
1677
|
-
if (next.kind === 'complete') { entry.completionReason = 'complete'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
|
|
1947
|
+
if (next.kind === 'complete') { entry.completionReason = next.reason || 'complete'; liveState.completed = true; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
|
|
1678
1948
|
if (next.kind === 'timeout') { entry.completionReason = 'idle_timeout'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
|
|
1679
1949
|
if (liveState.turnCounter >= entry.maxTurns) {
|
|
1680
1950
|
entry.completionReason = 'max_turns';
|
|
@@ -1682,15 +1952,24 @@ export function createAssistantDispatcher({
|
|
|
1682
1952
|
return { providerSessionId: inner.identity?.()?.providerSessionId };
|
|
1683
1953
|
}
|
|
1684
1954
|
refreshLimits();
|
|
1685
|
-
|
|
1955
|
+
let blocked = budgetBlock(entry);
|
|
1956
|
+
// Its provider's share is used up: the unspent budget is divided again first.
|
|
1957
|
+
if (blocked?.code === 'BUDGET_SHARE_EXHAUSTED' && await coverShare(entry)) blocked = budgetBlock(entry);
|
|
1686
1958
|
if (blocked) {
|
|
1687
|
-
entry.notes.push(`follow-up dropped: ${blocked.message}`);
|
|
1959
|
+
entry.notes.push(`follow-up dropped: ${blocked.message}${blocked.code === 'BUDGET_SHARE_EXHAUSTED' ? '; no unspent budget could be moved to it' : ''}`);
|
|
1688
1960
|
return budgetStop(entry, blocked);
|
|
1689
1961
|
}
|
|
1962
|
+
// No work time left: a turn started now would only be cut off.
|
|
1963
|
+
if (workLeftMs(entry) <= 0) {
|
|
1964
|
+
entry.completionReason = 'time_cap';
|
|
1965
|
+
entry.notes.push(`follow-up dropped: its ${entry.maxMinutes} minutes of work time are used up`);
|
|
1966
|
+
return { providerSessionId: inner.identity?.()?.providerSessionId };
|
|
1967
|
+
}
|
|
1690
1968
|
try {
|
|
1691
|
-
const followPrompt = buildFollowUpPrompt(next.text, state, { turn: liveState.turnCounter + 1, maxTurns: entry.maxTurns, origin: next.origin });
|
|
1969
|
+
const followPrompt = buildFollowUpPrompt(next.text, state, { turn: liveState.turnCounter + 1, maxTurns: entry.maxTurns, origin: next.origin, testDensity: changedTestDensity(entry) });
|
|
1692
1970
|
await runInnerTurn(followPrompt, meta, { origin: next.origin, label: clip(next.text, 80), followUp: next.text, taskId: next.taskId || entry.taskId || null });
|
|
1693
1971
|
} catch (error) {
|
|
1972
|
+
pauseWork(entry, liveState);
|
|
1694
1973
|
return handleTurnError(error, liveState, entry, ctx);
|
|
1695
1974
|
}
|
|
1696
1975
|
}
|
|
@@ -1789,7 +2068,7 @@ export function createAssistantDispatcher({
|
|
|
1789
2068
|
if (budgetStopReason(entry.completionReason)) return { providerSessionId: null };
|
|
1790
2069
|
if (error?.code === 'BUDGET_CAP') {
|
|
1791
2070
|
entry.completionReason = 'budget_cap';
|
|
1792
|
-
entry.notes.push('stopped:
|
|
2071
|
+
entry.notes.push('stopped: the Claude CLI reached the budget it started with (the run\'s ceiling, or what the session had left when it started)');
|
|
1793
2072
|
const runtime = safeRuntime();
|
|
1794
2073
|
runtime?.stop?.(entry.runId, 'budget_cap').catch(() => {});
|
|
1795
2074
|
return { providerSessionId: null };
|
|
@@ -1804,7 +2083,7 @@ export function createAssistantDispatcher({
|
|
|
1804
2083
|
entry.notes.push(`provider error: ${error?.message || error}`);
|
|
1805
2084
|
throw error;
|
|
1806
2085
|
}
|
|
1807
|
-
function budgetStopReason(reason) { return reason === 'budget_cap' || reason === 'session_budget_cap' || reason === 'budget_unpriced'; }
|
|
2086
|
+
function budgetStopReason(reason) { return reason === 'budget_cap' || reason === 'session_budget_cap' || reason === 'budget_unpriced' || reason === 'budget_share'; }
|
|
1808
2087
|
/** Stop a run that is out of money; the runtime ends it as stopped with this reason. */
|
|
1809
2088
|
function budgetStop(entry, block) {
|
|
1810
2089
|
entry.completionReason = block.reason;
|
|
@@ -1835,7 +2114,7 @@ export function createAssistantDispatcher({
|
|
|
1835
2114
|
function announceUnpriced(entry) {
|
|
1836
2115
|
if (!(Number(entry.unpricedTurns) > 0) || entry.unpricedAnnounced) return;
|
|
1837
2116
|
entry.unpricedAnnounced = true;
|
|
1838
|
-
emit(entry, 'budget_unpriced', { text: `this run's usage could not be priced (${entry.unpricedReason}); its tokens are recorded but the
|
|
2117
|
+
emit(entry, 'budget_unpriced', { text: `this run's usage could not be priced (${entry.unpricedReason}); its tokens are recorded but the session budget cannot meter it.` });
|
|
1839
2118
|
}
|
|
1840
2119
|
function codexThreadId(entry) {
|
|
1841
2120
|
try { return entry.providerThreadId || live.get(entry.runId)?.inner?.identity?.()?.providerThreadId || null; } catch { return entry.providerThreadId || null; }
|
|
@@ -2018,6 +2297,13 @@ export function createAssistantDispatcher({
|
|
|
2018
2297
|
if (TERMINAL_STATES.has(entry.state)) meter.clearPending();
|
|
2019
2298
|
const pending = meter.pending();
|
|
2020
2299
|
setPendingUsage(entry, pending.tokens, pending.byModel[0]?.model || null);
|
|
2300
|
+
// The calls of the turn in progress at list price: the budget sees them between model calls (the
|
|
2301
|
+
// turn's reported cost replaces them at its result).
|
|
2302
|
+
const liveState = live.get(entry.runId);
|
|
2303
|
+
if (liveState) {
|
|
2304
|
+
liveState.claudePendingUsd = pending.byModel.reduce((sum, row) => sum + (Number(claudeTokensCostUsd(row.model, row.tokens)) || 0), 0);
|
|
2305
|
+
if (pendingChanged && !settled && liveState.turnOpen) enforceMidTurn(entry, liveState);
|
|
2306
|
+
}
|
|
2021
2307
|
}
|
|
2022
2308
|
|
|
2023
2309
|
// Codex: the rollout files of the run's thread and its sub-agent threads.
|
|
@@ -2340,12 +2626,17 @@ export function createAssistantDispatcher({
|
|
|
2340
2626
|
const held = meters.get(entry.runId);
|
|
2341
2627
|
if (held) { held.codex = null; held.opencode = null; }
|
|
2342
2628
|
}
|
|
2343
|
-
/**
|
|
2629
|
+
/**
|
|
2630
|
+
* The budget checked between provider steps (Claude model calls, OpenCode steps, Codex rollout
|
|
2631
|
+
* records): the run stops as soon as it reaches its ceiling or the session its budget. Its
|
|
2632
|
+
* provider's share running dry divides the unspent budget again first (shareRanDry).
|
|
2633
|
+
*/
|
|
2344
2634
|
function enforceMidTurn(entry, liveState) {
|
|
2345
2635
|
if (TERMINAL_STATES.has(entry.state) || liveState.budgetStopping) return;
|
|
2346
2636
|
refreshLimits();
|
|
2347
2637
|
const block = budgetBlock(entry);
|
|
2348
|
-
if (block)
|
|
2638
|
+
if (block?.code === 'BUDGET_SHARE_EXHAUSTED') shareRanDry(entry);
|
|
2639
|
+
else if (block) {
|
|
2349
2640
|
liveState.budgetStopping = true;
|
|
2350
2641
|
entry.completionReason = block.reason;
|
|
2351
2642
|
entry.notes.push(`stopped mid-turn: ${block.message}`);
|
|
@@ -2353,6 +2644,25 @@ export function createAssistantDispatcher({
|
|
|
2353
2644
|
}
|
|
2354
2645
|
checkSession(entry.assistantSessionId, { source: entry });
|
|
2355
2646
|
}
|
|
2647
|
+
/**
|
|
2648
|
+
* A provider's share ran dry while a run of it works: the session's unspent budget is divided again
|
|
2649
|
+
* (budget planner). Only when no money could move to it are that provider's runs of the session
|
|
2650
|
+
* stopped (completion reason budget_share). One check per session and provider at a time.
|
|
2651
|
+
*/
|
|
2652
|
+
function shareRanDry(entry) {
|
|
2653
|
+
const sessionId = entry.assistantSessionId;
|
|
2654
|
+
if (!sessionId) return;
|
|
2655
|
+
const key = `${sessionId}:${entry.provider}`;
|
|
2656
|
+
if (shareChecks.has(key)) return;
|
|
2657
|
+
const check = coverShare(entry).then((covered) => {
|
|
2658
|
+
if (covered || TERMINAL_STATES.has(entry.state)) return;
|
|
2659
|
+
// Spent again meanwhile: the share as it is now decides.
|
|
2660
|
+
const still = budgetBlock(entry);
|
|
2661
|
+
if (still?.code !== 'BUDGET_SHARE_EXHAUSTED') return;
|
|
2662
|
+
stopProviderRuns(sessionId, entry.provider, still.message);
|
|
2663
|
+
}).catch(() => {}).finally(() => { if (shareChecks.get(key) === check) shareChecks.delete(key); });
|
|
2664
|
+
shareChecks.set(key, check);
|
|
2665
|
+
}
|
|
2356
2666
|
|
|
2357
2667
|
// ── run ↔ memory ───────────────────────────────────────────────────────────
|
|
2358
2668
|
/**
|
|
@@ -2393,7 +2703,16 @@ export function createAssistantDispatcher({
|
|
|
2393
2703
|
refreshLimits();
|
|
2394
2704
|
const blocked = budgetBlock(entry);
|
|
2395
2705
|
if (blocked) {
|
|
2396
|
-
|
|
2706
|
+
const next = blocked.reason === 'budget_cap' ? 'Dispatch a new run with its result as context.'
|
|
2707
|
+
: blocked.reason === 'budget_share' ? 'No unspent budget could be moved to it. Route the follow-up as a new task with agent_route (it moves the task to a provider with money left), or ask the user to raise the budget (Assistant → Budget).'
|
|
2708
|
+
: blocked.reason === 'budget_unpriced' ? 'Dispatch a new run on a priced or free model with its result as context.'
|
|
2709
|
+
: 'Ask the user to raise the budget or switch to No limit (Assistant → Budget).';
|
|
2710
|
+
throw new DispatchError(blocked.code, `Run ${runId} cannot take a follow-up: ${blocked.message}. ${next}`, { status: 409, reason: blocked.reason, ...blocked.detail });
|
|
2711
|
+
}
|
|
2712
|
+
// No work time left: the turn loop would drop the follow-up, so refuse it here (a follow-up
|
|
2713
|
+
// queued while a turn still works is checked again when that turn ends).
|
|
2714
|
+
if (workLeftMs(entry) <= 0) {
|
|
2715
|
+
throw new DispatchError('TIME_CAP', `Run ${runId} cannot take a follow-up: its ${entry.maxMinutes} minutes of work time are used up. Dispatch a new run with its result as context.`, { status: 409, reason: 'time_cap', maxMinutes: entry.maxMinutes ?? null, workMs: workUsedMs(entry) });
|
|
2397
2716
|
}
|
|
2398
2717
|
// A follow-up works for the task in progress when it is sent, which may be a later one than the run's.
|
|
2399
2718
|
const item = { kind: 'follow-up', text: message, origin, at: iso(now()), taskId: taskNow(entry.assistantSessionId) || entry.taskId || null };
|
|
@@ -2404,12 +2723,42 @@ export function createAssistantDispatcher({
|
|
|
2404
2723
|
emit(entry, 'follow_up_queued', { queued: liveState.followUps.length });
|
|
2405
2724
|
return { ok: true, queued: true, position: liveState.followUps.length, run: view(entry) };
|
|
2406
2725
|
}
|
|
2407
|
-
|
|
2726
|
+
/**
|
|
2727
|
+
* Before a follow-up: when the run's provider share is used up, the session's unspent budget is
|
|
2728
|
+
* divided again (Jev or the fallback), so sendTurn decides on the share as it is after that.
|
|
2729
|
+
* Never throws; resolves to whether the provider can pay now.
|
|
2730
|
+
*/
|
|
2731
|
+
async function coverFollowUp(runId) {
|
|
2732
|
+
const entry = entries.get(String(runId || ''));
|
|
2733
|
+
if (!entry || TERMINAL_STATES.has(entry.state)) return true;
|
|
2734
|
+
const blocked = budgetBlock(entry);
|
|
2735
|
+
if (blocked?.code !== 'BUDGET_SHARE_EXHAUSTED') return !blocked;
|
|
2736
|
+
return coverShare(entry);
|
|
2737
|
+
}
|
|
2738
|
+
function complete(runId, { reason = 'complete' } = {}) {
|
|
2408
2739
|
const entry = requireEntry(runId);
|
|
2409
2740
|
const liveState = live.get(runId);
|
|
2410
2741
|
if (TERMINAL_STATES.has(entry.state)) return { ok: true, run: view(entry) };
|
|
2411
|
-
if (liveState?.waitResolve) { liveState.waitResolve({ kind: 'complete' }); return { ok: true, run: view(entry) }; }
|
|
2412
|
-
return stop(runId,
|
|
2742
|
+
if (liveState?.waitResolve) { liveState.waitResolve({ kind: 'complete', reason }); return { ok: true, run: view(entry) }; }
|
|
2743
|
+
return stop(runId, reason);
|
|
2744
|
+
}
|
|
2745
|
+
/**
|
|
2746
|
+
* The user closed the sidepanel tab of a worker that reported its task done
|
|
2747
|
+
* and waits warm for a follow-up (its X, or Back to Assistant): the run is
|
|
2748
|
+
* completed the way agent_complete completes it, with the reason
|
|
2749
|
+
* `tab_closed`. Its result stays as the worker reported it, and the brain is
|
|
2750
|
+
* handed no stop to react to. Any other run is refused (RUN_NOT_FINISHED):
|
|
2751
|
+
* the tab then stops it as its X always did. A run that already ended is
|
|
2752
|
+
* left as it is.
|
|
2753
|
+
*/
|
|
2754
|
+
function completeClosedTab(runId) {
|
|
2755
|
+
const entry = requireEntry(runId);
|
|
2756
|
+
if (TERMINAL_STATES.has(entry.state)) return { ok: true, completed: false, alreadyTerminal: true, run: view(entry) };
|
|
2757
|
+
if (!finishedIdle(entry, { outcomes: ['done'] }) || !live.get(runId)?.waitResolve) {
|
|
2758
|
+
const outcome = entry.lastResult?.status || null;
|
|
2759
|
+
throw new DispatchError('RUN_NOT_FINISHED', `Run ${runId} is ${entry.state}${outcome ? ` (last result: ${outcome})` : ''}: closing its tab completes only a worker that reported its task done and waits for a follow-up.`, { status: 409, state: entry.state, outcome });
|
|
2760
|
+
}
|
|
2761
|
+
return { ...complete(runId, { reason: 'tab_closed' }), completed: true };
|
|
2413
2762
|
}
|
|
2414
2763
|
async function stop(runId, reason = 'assistant') {
|
|
2415
2764
|
const entry = requireEntry(runId);
|
|
@@ -2442,13 +2791,17 @@ export function createAssistantDispatcher({
|
|
|
2442
2791
|
// ── runtime feed → registry ────────────────────────────────────────────────
|
|
2443
2792
|
function finalize(entry, runtimeRun) {
|
|
2444
2793
|
if (TERMINAL_STATES.has(entry.state)) return;
|
|
2445
|
-
const
|
|
2794
|
+
const liveState = live.get(entry.runId);
|
|
2795
|
+
pauseWork(entry, liveState);
|
|
2796
|
+
let status = runtimeRun?.status || 'completed';
|
|
2797
|
+
// A completed worker whose loop file was swept meanwhile (a stale-file cleanup) reaches the
|
|
2798
|
+
// runtime's next check as "stopped": its turn loop had already ended on the completion.
|
|
2799
|
+
if (status === 'stopped' && liveState?.completed && /^state_(removed|inactive)$/.test(String(runtimeRun?.stoppedReason || ''))) status = 'completed';
|
|
2446
2800
|
entry.state = TERMINAL_STATES.has(status) ? status : 'completed';
|
|
2447
2801
|
entry.turnState = 'terminal';
|
|
2448
2802
|
entry.finishedAt = iso(now());
|
|
2449
2803
|
entry.completionReason = entry.completionReason || runtimeRun?.stoppedReason || (status === 'completed' ? 'complete' : status);
|
|
2450
2804
|
if (runtimeRun?.error) entry.error = runtimeRun.error;
|
|
2451
|
-
const liveState = live.get(entry.runId);
|
|
2452
2805
|
try { finishUsage(entry, liveState || null); } catch (error) { log(entry.runId, 'assistant:meter-error', error?.message || String(error)); }
|
|
2453
2806
|
if (liveState) {
|
|
2454
2807
|
liveState.dead = true;
|
|
@@ -2458,7 +2811,9 @@ export function createAssistantDispatcher({
|
|
|
2458
2811
|
}
|
|
2459
2812
|
entry.pending = [];
|
|
2460
2813
|
releaseDesktop(entry);
|
|
2461
|
-
|
|
2814
|
+
// A run that ended gives back what it did not spend: the session's split is made again (debounced).
|
|
2815
|
+
if (entry.assistantSessionId) { try { budget.onRunEnded(entry.assistantSessionId); } catch { /* the split stays as it is */ } }
|
|
2816
|
+
if (entry.state === 'failed' && !budgetStopReason(entry.completionReason) && judge?.workerOutcome) {
|
|
2462
2817
|
// State, queue and desktop move on now; the 'failed' event waits for its cause.
|
|
2463
2818
|
emitFailed(entry, liveState?.evidence || null);
|
|
2464
2819
|
processQueue().catch(() => {});
|
|
@@ -2654,9 +3009,9 @@ export function createAssistantDispatcher({
|
|
|
2654
3009
|
* failed run whose cause is still being judged is not finished yet.
|
|
2655
3010
|
*/
|
|
2656
3011
|
function removable(entry) { return TERMINAL_STATES.has(entry.state) && !entry.judging; }
|
|
2657
|
-
function finishedIdle(entry) {
|
|
3012
|
+
function finishedIdle(entry, { outcomes = ['done', 'blocked'] } = {}) {
|
|
2658
3013
|
return entry.state === 'idle' && !entry.judging
|
|
2659
|
-
&&
|
|
3014
|
+
&& outcomes.includes(entry.lastResult?.status)
|
|
2660
3015
|
&& !(live.get(entry.runId)?.followUps?.length);
|
|
2661
3016
|
}
|
|
2662
3017
|
async function finishIdleForRemoval(entry) {
|
|
@@ -2700,7 +3055,7 @@ export function createAssistantDispatcher({
|
|
|
2700
3055
|
&& (!assistantSessionId || entry.assistantSessionId === String(assistantSessionId))
|
|
2701
3056
|
&& (!workflowId || entry.workflowId === String(workflowId))
|
|
2702
3057
|
&& (!wanted || wanted.has(entry.runId)));
|
|
2703
|
-
await Promise.all(scoped.filter(finishedIdle).map((entry) => finishIdleForRemoval(entry).catch(() => {})));
|
|
3058
|
+
await Promise.all(scoped.filter((entry) => finishedIdle(entry)).map((entry) => finishIdleForRemoval(entry).catch(() => {})));
|
|
2704
3059
|
const targets = scoped.filter(removable);
|
|
2705
3060
|
return { ok: true, removed: markRemoved(targets), skipped: scoped.filter((entry) => !removable(entry)).map((entry) => entry.runId) };
|
|
2706
3061
|
}
|
|
@@ -2715,6 +3070,7 @@ export function createAssistantDispatcher({
|
|
|
2715
3070
|
if (unsubscribeRuntime) { try { unsubscribeRuntime(); } catch {} unsubscribeRuntime = null; }
|
|
2716
3071
|
for (const entry of entries.values()) {
|
|
2717
3072
|
if (ACTIVE_STATES.has(entry.state)) {
|
|
3073
|
+
pauseWork(entry);
|
|
2718
3074
|
entry.state = 'interrupted'; entry.completionReason = reason; entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
|
|
2719
3075
|
delete entry.rawSpec;
|
|
2720
3076
|
// What the run's meter can still read is booked now; nothing provisional outlives the process.
|
|
@@ -2731,9 +3087,9 @@ export function createAssistantDispatcher({
|
|
|
2731
3087
|
stalledTimer.unref?.();
|
|
2732
3088
|
|
|
2733
3089
|
return {
|
|
2734
|
-
dispatch, sendTurn, complete, stop, killAll, get, peek, list, totals, wait, transcript, pendingPermissions,
|
|
3090
|
+
dispatch, sendTurn, complete, completeClosedTab, stop, killAll, get, peek, list, totals, wait, transcript, pendingPermissions,
|
|
2735
3091
|
respondPermission, subscribe, shutdown, limits,
|
|
2736
|
-
sessionBudget, checkSession, refreshLimits,
|
|
3092
|
+
sessionBudget, checkSession, refreshLimits, providerSpend, coverFollowUp, budget,
|
|
2737
3093
|
resolveRoute, declineRoute, heldRouteRefusal, escalate,
|
|
2738
3094
|
markDelivered, deliveryState,
|
|
2739
3095
|
remove, removeFinished,
|