synabun 2.0.5 → 2.0.9
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 +78 -0
- package/README.md +2 -1
- package/THIRD-PARTY-LICENSES.md +13 -0
- package/docs/whatsapp.md +151 -16
- package/hooks/claude-code/post-plan.mjs +6 -30
- package/hooks/claude-code/session-start.mjs +1 -4
- package/hooks/claude-code/shared.mjs +112 -75
- 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/package-processes.js +670 -0
- package/lib/packaged-runtime.js +138 -11
- package/lib/runtime-dependencies.js +225 -0
- 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/browser-bench.d.ts +1 -1
- package/mcp-server/dist/services/browser-bench.d.ts.map +1 -1
- package/mcp-server/dist/services/browser-bench.js +1 -1
- package/mcp-server/dist/services/browser-bench.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/categories.d.ts +35 -2
- package/mcp-server/dist/services/categories.d.ts.map +1 -1
- package/mcp-server/dist/services/categories.js +77 -33
- package/mcp-server/dist/services/categories.js.map +1 -1
- 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/desktop-bench.d.ts +7 -7
- package/mcp-server/dist/services/desktop-bench.d.ts.map +1 -1
- package/mcp-server/dist/services/desktop-bench.js +1 -1
- package/mcp-server/dist/services/desktop-bench.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 +11 -8
- package/mcp-server/dist/tools/agents.d.ts.map +1 -1
- package/mcp-server/dist/tools/agents.js +35 -14
- package/mcp-server/dist/tools/agents.js.map +1 -1
- package/mcp-server/dist/tools/bluesky.js +1 -1
- package/mcp-server/dist/tools/bluesky.js.map +1 -1
- package/mcp-server/dist/tools/browser-advanced.d.ts +1 -1
- package/mcp-server/dist/tools/browser-advanced.d.ts.map +1 -1
- package/mcp-server/dist/tools/browser-advanced.js +1 -1
- package/mcp-server/dist/tools/browser-advanced.js.map +1 -1
- package/mcp-server/dist/tools/browser-batch.d.ts +1 -1
- package/mcp-server/dist/tools/browser-batch.d.ts.map +1 -1
- package/mcp-server/dist/tools/browser-batch.js +1 -1
- package/mcp-server/dist/tools/browser-batch.js.map +1 -1
- package/mcp-server/dist/tools/browser-cheatsheet.d.ts +1 -1
- package/mcp-server/dist/tools/browser-cheatsheet.d.ts.map +1 -1
- package/mcp-server/dist/tools/browser-cheatsheet.js +1 -1
- package/mcp-server/dist/tools/browser-cheatsheet.js.map +1 -1
- package/mcp-server/dist/tools/browser-interact.d.ts +1 -1
- package/mcp-server/dist/tools/browser-interact.d.ts.map +1 -1
- package/mcp-server/dist/tools/browser-interact.js +1 -1
- package/mcp-server/dist/tools/browser-interact.js.map +1 -1
- package/mcp-server/dist/tools/browser-navigate.d.ts +1 -1
- package/mcp-server/dist/tools/browser-navigate.d.ts.map +1 -1
- package/mcp-server/dist/tools/browser-navigate.js +1 -1
- package/mcp-server/dist/tools/browser-navigate.js.map +1 -1
- package/mcp-server/dist/tools/browser-observe.d.ts +1 -1
- package/mcp-server/dist/tools/browser-observe.d.ts.map +1 -1
- package/mcp-server/dist/tools/browser-observe.js +1 -1
- package/mcp-server/dist/tools/browser-observe.js.map +1 -1
- package/mcp-server/dist/tools/card-tools.d.ts +1 -1
- package/mcp-server/dist/tools/card-tools.d.ts.map +1 -1
- package/mcp-server/dist/tools/card-tools.js +1 -1
- package/mcp-server/dist/tools/card-tools.js.map +1 -1
- package/mcp-server/dist/tools/category.d.ts +1 -1
- package/mcp-server/dist/tools/category.d.ts.map +1 -1
- package/mcp-server/dist/tools/category.js +5 -1
- package/mcp-server/dist/tools/category.js.map +1 -1
- package/mcp-server/dist/tools/choice.d.ts +1 -1
- package/mcp-server/dist/tools/choice.d.ts.map +1 -1
- package/mcp-server/dist/tools/choice.js +1 -1
- package/mcp-server/dist/tools/choice.js.map +1 -1
- package/mcp-server/dist/tools/computer.d.ts +1 -1
- package/mcp-server/dist/tools/computer.d.ts.map +1 -1
- package/mcp-server/dist/tools/computer.js +1 -1
- package/mcp-server/dist/tools/computer.js.map +1 -1
- package/mcp-server/dist/tools/discord-channel.d.ts +1 -1
- package/mcp-server/dist/tools/discord-channel.d.ts.map +1 -1
- package/mcp-server/dist/tools/discord-channel.js +1 -1
- package/mcp-server/dist/tools/discord-channel.js.map +1 -1
- package/mcp-server/dist/tools/discord-guild.d.ts +1 -1
- package/mcp-server/dist/tools/discord-guild.d.ts.map +1 -1
- package/mcp-server/dist/tools/discord-guild.js +1 -1
- package/mcp-server/dist/tools/discord-guild.js.map +1 -1
- package/mcp-server/dist/tools/discord-member.d.ts +1 -1
- package/mcp-server/dist/tools/discord-member.d.ts.map +1 -1
- package/mcp-server/dist/tools/discord-member.js +1 -1
- package/mcp-server/dist/tools/discord-member.js.map +1 -1
- package/mcp-server/dist/tools/discord-message.d.ts +1 -1
- package/mcp-server/dist/tools/discord-message.d.ts.map +1 -1
- package/mcp-server/dist/tools/discord-message.js +1 -1
- package/mcp-server/dist/tools/discord-message.js.map +1 -1
- package/mcp-server/dist/tools/discord-onboarding.d.ts +1 -1
- package/mcp-server/dist/tools/discord-onboarding.d.ts.map +1 -1
- package/mcp-server/dist/tools/discord-onboarding.js +1 -1
- package/mcp-server/dist/tools/discord-onboarding.js.map +1 -1
- package/mcp-server/dist/tools/discord-role.d.ts +1 -1
- package/mcp-server/dist/tools/discord-role.d.ts.map +1 -1
- package/mcp-server/dist/tools/discord-role.js +1 -1
- package/mcp-server/dist/tools/discord-role.js.map +1 -1
- package/mcp-server/dist/tools/discord-thread.d.ts +1 -1
- package/mcp-server/dist/tools/discord-thread.d.ts.map +1 -1
- package/mcp-server/dist/tools/discord-thread.js +1 -1
- package/mcp-server/dist/tools/discord-thread.js.map +1 -1
- package/mcp-server/dist/tools/discord-webhook.d.ts +1 -1
- package/mcp-server/dist/tools/discord-webhook.d.ts.map +1 -1
- package/mcp-server/dist/tools/discord-webhook.js +1 -1
- package/mcp-server/dist/tools/discord-webhook.js.map +1 -1
- package/mcp-server/dist/tools/fb-groups.d.ts +1 -1
- package/mcp-server/dist/tools/fb-groups.d.ts.map +1 -1
- package/mcp-server/dist/tools/fb-groups.js +1 -1
- package/mcp-server/dist/tools/fb-groups.js.map +1 -1
- package/mcp-server/dist/tools/forget.d.ts +1 -1
- package/mcp-server/dist/tools/forget.d.ts.map +1 -1
- package/mcp-server/dist/tools/forget.js +1 -1
- package/mcp-server/dist/tools/forget.js.map +1 -1
- package/mcp-server/dist/tools/git-tools.d.ts +1 -1
- package/mcp-server/dist/tools/git-tools.d.ts.map +1 -1
- package/mcp-server/dist/tools/git-tools.js +1 -1
- package/mcp-server/dist/tools/git-tools.js.map +1 -1
- package/mcp-server/dist/tools/gsc/experience.d.ts +1 -1
- package/mcp-server/dist/tools/gsc/experience.d.ts.map +1 -1
- package/mcp-server/dist/tools/gsc/experience.js +1 -1
- package/mcp-server/dist/tools/gsc/experience.js.map +1 -1
- package/mcp-server/dist/tools/gsc/indexing.d.ts +1 -1
- package/mcp-server/dist/tools/gsc/indexing.d.ts.map +1 -1
- package/mcp-server/dist/tools/gsc/indexing.js +1 -1
- package/mcp-server/dist/tools/gsc/indexing.js.map +1 -1
- package/mcp-server/dist/tools/gsc/inspect.d.ts +1 -1
- package/mcp-server/dist/tools/gsc/inspect.d.ts.map +1 -1
- package/mcp-server/dist/tools/gsc/inspect.js +1 -1
- package/mcp-server/dist/tools/gsc/inspect.js.map +1 -1
- package/mcp-server/dist/tools/gsc/navigate.d.ts +1 -1
- package/mcp-server/dist/tools/gsc/navigate.d.ts.map +1 -1
- package/mcp-server/dist/tools/gsc/navigate.js +1 -1
- package/mcp-server/dist/tools/gsc/navigate.js.map +1 -1
- package/mcp-server/dist/tools/gsc/performance.d.ts +1 -1
- package/mcp-server/dist/tools/gsc/performance.d.ts.map +1 -1
- package/mcp-server/dist/tools/gsc/performance.js +1 -1
- package/mcp-server/dist/tools/gsc/performance.js.map +1 -1
- package/mcp-server/dist/tools/gsc/settings.d.ts +1 -1
- package/mcp-server/dist/tools/gsc/settings.d.ts.map +1 -1
- package/mcp-server/dist/tools/gsc/settings.js +1 -1
- package/mcp-server/dist/tools/gsc/settings.js.map +1 -1
- package/mcp-server/dist/tools/image-tools.d.ts +1 -1
- package/mcp-server/dist/tools/image-tools.d.ts.map +1 -1
- package/mcp-server/dist/tools/image-tools.js +1 -1
- package/mcp-server/dist/tools/image-tools.js.map +1 -1
- package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +1 -1
- package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +1 -1
- package/mcp-server/dist/tools/leonardo-browser-tools.js +1 -1
- package/mcp-server/dist/tools/leonardo-browser-tools.js.map +1 -1
- package/mcp-server/dist/tools/loop.d.ts +1 -1
- package/mcp-server/dist/tools/loop.d.ts.map +1 -1
- package/mcp-server/dist/tools/loop.js +1 -1
- package/mcp-server/dist/tools/loop.js.map +1 -1
- package/mcp-server/dist/tools/memories.d.ts +1 -1
- package/mcp-server/dist/tools/memories.d.ts.map +1 -1
- package/mcp-server/dist/tools/memories.js +1 -1
- package/mcp-server/dist/tools/memories.js.map +1 -1
- package/mcp-server/dist/tools/morelogin.d.ts +1 -1
- package/mcp-server/dist/tools/morelogin.d.ts.map +1 -1
- package/mcp-server/dist/tools/morelogin.js +1 -1
- package/mcp-server/dist/tools/morelogin.js.map +1 -1
- package/mcp-server/dist/tools/profile.d.ts +1 -1
- package/mcp-server/dist/tools/profile.d.ts.map +1 -1
- package/mcp-server/dist/tools/profile.js +1 -1
- package/mcp-server/dist/tools/profile.js.map +1 -1
- package/mcp-server/dist/tools/recall.d.ts +1 -1
- package/mcp-server/dist/tools/recall.d.ts.map +1 -1
- package/mcp-server/dist/tools/recall.js +1 -1
- package/mcp-server/dist/tools/recall.js.map +1 -1
- package/mcp-server/dist/tools/reflect.d.ts +1 -1
- package/mcp-server/dist/tools/reflect.d.ts.map +1 -1
- package/mcp-server/dist/tools/reflect.js +1 -1
- package/mcp-server/dist/tools/reflect.js.map +1 -1
- package/mcp-server/dist/tools/remember.d.ts +1 -1
- package/mcp-server/dist/tools/remember.d.ts.map +1 -1
- package/mcp-server/dist/tools/remember.js +13 -11
- package/mcp-server/dist/tools/remember.js.map +1 -1
- package/mcp-server/dist/tools/restore.d.ts +1 -1
- package/mcp-server/dist/tools/restore.d.ts.map +1 -1
- package/mcp-server/dist/tools/restore.js +1 -1
- package/mcp-server/dist/tools/restore.js.map +1 -1
- package/mcp-server/dist/tools/style-guide-tools.d.ts +1 -1
- package/mcp-server/dist/tools/style-guide-tools.d.ts.map +1 -1
- package/mcp-server/dist/tools/style-guide-tools.js +1 -1
- package/mcp-server/dist/tools/style-guide-tools.js.map +1 -1
- package/mcp-server/dist/tools/sync.d.ts +1 -1
- package/mcp-server/dist/tools/sync.d.ts.map +1 -1
- package/mcp-server/dist/tools/sync.js +1 -1
- package/mcp-server/dist/tools/sync.js.map +1 -1
- package/mcp-server/dist/tools/tictactoe-tools.d.ts +1 -1
- package/mcp-server/dist/tools/tictactoe-tools.d.ts.map +1 -1
- package/mcp-server/dist/tools/tictactoe-tools.js +1 -1
- package/mcp-server/dist/tools/tictactoe-tools.js.map +1 -1
- package/mcp-server/dist/tools/utils.d.ts +1 -1
- package/mcp-server/dist/tools/utils.d.ts.map +1 -1
- package/mcp-server/dist/tools/utils.js +1 -1
- package/mcp-server/dist/tools/utils.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/tools/whiteboard-tools.d.ts +3 -3
- package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -1
- package/mcp-server/dist/tools/whiteboard-tools.js +1 -1
- package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -1
- package/mcp-server/dist/tools/youtube/analytics.d.ts +1 -1
- package/mcp-server/dist/tools/youtube/analytics.d.ts.map +1 -1
- package/mcp-server/dist/tools/youtube/analytics.js +1 -1
- package/mcp-server/dist/tools/youtube/analytics.js.map +1 -1
- package/mcp-server/dist/tools/youtube/download.d.ts +1 -1
- package/mcp-server/dist/tools/youtube/download.d.ts.map +1 -1
- package/mcp-server/dist/tools/youtube/download.js +1 -1
- package/mcp-server/dist/tools/youtube/download.js.map +1 -1
- package/mcp-server/dist/tools/youtube/media.d.ts +1 -1
- package/mcp-server/dist/tools/youtube/media.d.ts.map +1 -1
- package/mcp-server/dist/tools/youtube/media.js +1 -1
- package/mcp-server/dist/tools/youtube/media.js.map +1 -1
- package/mcp-server/dist/tools/youtube/metadata.d.ts +1 -1
- package/mcp-server/dist/tools/youtube/metadata.d.ts.map +1 -1
- package/mcp-server/dist/tools/youtube/metadata.js +1 -1
- package/mcp-server/dist/tools/youtube/metadata.js.map +1 -1
- package/mcp-server/dist/tools/youtube/navigate.d.ts +1 -1
- package/mcp-server/dist/tools/youtube/navigate.d.ts.map +1 -1
- package/mcp-server/dist/tools/youtube/navigate.js +1 -1
- package/mcp-server/dist/tools/youtube/navigate.js.map +1 -1
- package/mcp-server/dist/tools/youtube/playlist.d.ts +1 -1
- package/mcp-server/dist/tools/youtube/playlist.d.ts.map +1 -1
- package/mcp-server/dist/tools/youtube/playlist.js +1 -1
- package/mcp-server/dist/tools/youtube/playlist.js.map +1 -1
- package/mcp-server/dist/tools/youtube/schedule.d.ts +1 -1
- package/mcp-server/dist/tools/youtube/schedule.d.ts.map +1 -1
- package/mcp-server/dist/tools/youtube/schedule.js +1 -1
- package/mcp-server/dist/tools/youtube/schedule.js.map +1 -1
- package/mcp-server/dist/tools/youtube/source.d.ts +1 -1
- package/mcp-server/dist/tools/youtube/source.d.ts.map +1 -1
- package/mcp-server/dist/tools/youtube/source.js +1 -1
- package/mcp-server/dist/tools/youtube/source.js.map +1 -1
- package/mcp-server/dist/tools/youtube/upload.d.ts +1 -1
- package/mcp-server/dist/tools/youtube/upload.d.ts.map +1 -1
- package/mcp-server/dist/tools/youtube/upload.js +4 -9
- package/mcp-server/dist/tools/youtube/upload.js.map +1 -1
- package/mcp-server/dist/tui.js +18 -5
- 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 +3 -3
- package/mcp-server/package.json +3 -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/browser-bench.ts +1 -1
- package/mcp-server/src/services/budget-judgments.ts +183 -0
- package/mcp-server/src/services/categories.ts +77 -32
- package/mcp-server/src/services/codex-config-heal.ts +35 -4
- package/mcp-server/src/services/desktop-bench.ts +1 -1
- 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 +34 -15
- package/mcp-server/src/tools/bluesky.ts +1 -1
- package/mcp-server/src/tools/browser-advanced.ts +1 -1
- package/mcp-server/src/tools/browser-batch.ts +1 -1
- package/mcp-server/src/tools/browser-cheatsheet.ts +1 -1
- package/mcp-server/src/tools/browser-interact.ts +1 -1
- package/mcp-server/src/tools/browser-navigate.ts +1 -1
- package/mcp-server/src/tools/browser-observe.ts +1 -1
- package/mcp-server/src/tools/card-tools.ts +1 -1
- package/mcp-server/src/tools/category.ts +6 -1
- package/mcp-server/src/tools/choice.ts +1 -1
- package/mcp-server/src/tools/computer.ts +1 -1
- package/mcp-server/src/tools/discord-channel.ts +1 -1
- package/mcp-server/src/tools/discord-guild.ts +1 -1
- package/mcp-server/src/tools/discord-member.ts +1 -1
- package/mcp-server/src/tools/discord-message.ts +1 -1
- package/mcp-server/src/tools/discord-onboarding.ts +1 -1
- package/mcp-server/src/tools/discord-role.ts +1 -1
- package/mcp-server/src/tools/discord-thread.ts +1 -1
- package/mcp-server/src/tools/discord-webhook.ts +1 -1
- package/mcp-server/src/tools/fb-groups.ts +1 -1
- package/mcp-server/src/tools/forget.ts +1 -1
- package/mcp-server/src/tools/git-tools.ts +1 -1
- package/mcp-server/src/tools/gsc/experience.ts +1 -1
- package/mcp-server/src/tools/gsc/indexing.ts +1 -1
- package/mcp-server/src/tools/gsc/inspect.ts +1 -1
- package/mcp-server/src/tools/gsc/navigate.ts +1 -1
- package/mcp-server/src/tools/gsc/performance.ts +1 -1
- package/mcp-server/src/tools/gsc/settings.ts +1 -1
- package/mcp-server/src/tools/image-tools.ts +1 -1
- package/mcp-server/src/tools/leonardo-browser-tools.ts +1 -1
- package/mcp-server/src/tools/loop.ts +1 -1
- package/mcp-server/src/tools/memories.ts +1 -1
- package/mcp-server/src/tools/morelogin.ts +1 -1
- package/mcp-server/src/tools/profile.ts +1 -1
- package/mcp-server/src/tools/recall.ts +1 -1
- package/mcp-server/src/tools/reflect.ts +1 -1
- package/mcp-server/src/tools/remember.ts +13 -12
- package/mcp-server/src/tools/restore.ts +1 -1
- package/mcp-server/src/tools/style-guide-tools.ts +1 -1
- package/mcp-server/src/tools/sync.ts +1 -1
- package/mcp-server/src/tools/tictactoe-tools.ts +1 -1
- package/mcp-server/src/tools/utils.ts +1 -1
- package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
- package/mcp-server/src/tools/whiteboard-tools.ts +1 -1
- package/mcp-server/src/tools/youtube/analytics.ts +1 -1
- package/mcp-server/src/tools/youtube/download.ts +1 -1
- package/mcp-server/src/tools/youtube/media.ts +1 -1
- package/mcp-server/src/tools/youtube/metadata.ts +1 -1
- package/mcp-server/src/tools/youtube/navigate.ts +1 -1
- package/mcp-server/src/tools/youtube/playlist.ts +1 -1
- package/mcp-server/src/tools/youtube/schedule.ts +1 -1
- package/mcp-server/src/tools/youtube/source.ts +1 -1
- package/mcp-server/src/tools/youtube/upload.ts +4 -12
- package/mcp-server/src/tui.ts +24 -10
- package/mcp-server/src/version.ts +22 -0
- package/neural-interface/i18n/en.json +645 -57
- package/neural-interface/i18n/pt-BR.json +644 -56
- package/neural-interface/lib/account-usage.js +163 -0
- package/neural-interface/lib/agent-sandbox-elevation.js +102 -0
- package/neural-interface/lib/agent-sandbox-setting.js +57 -0
- package/neural-interface/lib/assistant-api.js +274 -52
- package/neural-interface/lib/assistant-brains/claude.js +15 -2
- package/neural-interface/lib/assistant-brains/codex.js +3 -0
- package/neural-interface/lib/assistant-brains/opencode.js +17 -4
- package/neural-interface/lib/assistant-budget-planner.js +390 -0
- package/neural-interface/lib/assistant-budget-split.js +408 -0
- package/neural-interface/lib/assistant-budget.js +94 -95
- package/neural-interface/lib/assistant-config.js +62 -14
- package/neural-interface/lib/assistant-dispatch.js +432 -125
- package/neural-interface/lib/assistant-jev.js +26 -3
- package/neural-interface/lib/assistant-persona.js +68 -11
- package/neural-interface/lib/assistant-plan-permissions.js +8 -1
- package/neural-interface/lib/assistant-route-accounts.js +14 -0
- package/neural-interface/lib/assistant-route-gate-codex.js +3 -1
- 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 +312 -48
- package/neural-interface/lib/assistant-user-writer.js +4 -2
- package/neural-interface/lib/augmented-path.js +317 -53
- package/neural-interface/lib/claude-accounts.js +342 -31
- package/neural-interface/lib/claude-agent-bridge.js +90 -3
- package/neural-interface/lib/claude-executable.js +6 -1
- package/neural-interface/lib/claude-model-catalog.js +3 -3
- package/neural-interface/lib/claude-panel-auth.js +221 -0
- package/neural-interface/lib/claude-profile-api-key.js +62 -0
- package/neural-interface/lib/cli-install-api.js +137 -0
- package/neural-interface/lib/cli-install.js +797 -0
- package/neural-interface/lib/cli-path.js +130 -0
- package/neural-interface/lib/cli-resolver.js +744 -0
- package/neural-interface/lib/cli-update-planner.js +143 -7
- package/neural-interface/lib/codex-account-auth.js +180 -0
- package/neural-interface/lib/codex-accounts.js +502 -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 +122 -0
- package/neural-interface/lib/desktop/config.js +3 -0
- package/neural-interface/lib/exec-loop-command.js +3 -1
- package/neural-interface/lib/git-bash/bzip2.js +339 -0
- package/neural-interface/lib/git-bash/claude-settings.js +73 -0
- package/neural-interface/lib/git-bash/extract-worker.js +34 -0
- package/neural-interface/lib/git-bash/install.js +529 -0
- package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
- package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
- package/neural-interface/lib/git-bash/manager.js +268 -0
- package/neural-interface/lib/git-bash/pins.js +206 -0
- package/neural-interface/lib/git-bash/pins.json +34 -0
- package/neural-interface/lib/git-bash/resolver.js +194 -0
- package/neural-interface/lib/git-read.js +34 -2
- package/neural-interface/lib/install-details.js +163 -0
- package/neural-interface/lib/mcp-installer.js +14 -2
- package/neural-interface/lib/native-loop-providers.js +22 -4
- package/neural-interface/lib/native-loop-runtime.js +9 -4
- package/neural-interface/lib/os-path.js +319 -0
- package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
- package/neural-interface/lib/provider-panel-terminal-auth.js +96 -0
- package/neural-interface/lib/pty-host/core.js +2 -1
- package/neural-interface/lib/pty-host/manager.js +27 -2
- package/neural-interface/lib/remote-policy.js +20 -11
- package/neural-interface/lib/session-indexer.js +3 -1
- package/neural-interface/lib/session-title-generator.js +6 -3
- package/neural-interface/lib/system-about.js +340 -0
- package/neural-interface/lib/system-activity.js +66 -0
- package/neural-interface/lib/terminal-env.js +11 -3
- package/neural-interface/lib/terminal-path.js +55 -0
- package/neural-interface/lib/virtual-socket.js +12 -1
- package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
- package/neural-interface/lib/whatsapp/bridge.js +78 -34
- package/neural-interface/lib/whatsapp/commands.js +13 -14
- package/neural-interface/lib/whatsapp/config.js +134 -20
- package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
- package/neural-interface/lib/whatsapp/host-core.js +168 -35
- package/neural-interface/lib/whatsapp/inbound.js +5 -3
- package/neural-interface/lib/whatsapp/local-file.js +524 -0
- package/neural-interface/lib/whatsapp/manager.js +92 -32
- package/neural-interface/lib/whatsapp/paths.js +11 -1
- package/neural-interface/lib/whatsapp/protocol.js +65 -5
- package/neural-interface/lib/whatsapp/redact.js +63 -0
- package/neural-interface/lib/whatsapp/service.js +343 -91
- package/neural-interface/lib/whatsapp/transcription.js +463 -0
- package/neural-interface/package-lock.json +2 -2
- package/neural-interface/package.json +1 -1
- package/neural-interface/public/index.html +2 -0
- package/neural-interface/public/index2d.html +2 -0
- package/neural-interface/public/offline.html +16 -1
- package/neural-interface/public/onboarding.html +96 -2
- package/neural-interface/public/shared/account-i18n.js +7 -0
- package/neural-interface/public/shared/account-menu.js +542 -0
- package/neural-interface/public/shared/account-usage.js +79 -0
- package/neural-interface/public/shared/account-view.js +336 -0
- package/neural-interface/public/shared/agent-runtime-options.js +28 -8
- package/neural-interface/public/shared/assistant/asst-account-menu.js +298 -0
- package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
- package/neural-interface/public/shared/assistant/asst-api.js +39 -2
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +288 -32
- package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
- package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
- package/neural-interface/public/shared/assistant/asst-menu.js +219 -80
- package/neural-interface/public/shared/assistant/asst-model-chip.js +135 -0
- package/neural-interface/public/shared/assistant/asst-panel.js +87 -16
- package/neural-interface/public/shared/assistant/asst-render.js +38 -7
- package/neural-interface/public/shared/assistant/asst-route.js +72 -14
- package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
- package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
- package/neural-interface/public/shared/assistant/asst-state.js +1 -1
- package/neural-interface/public/shared/assistant/asst-styles.js +160 -29
- 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 +49 -108
- package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
- package/neural-interface/public/shared/cdx/cdx-styles.js +172 -111
- package/neural-interface/public/shared/cdx/cdx-tabs.js +264 -664
- package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
- package/neural-interface/public/shared/claude-account-view.js +3 -0
- package/neural-interface/public/shared/cli-install-view.js +392 -0
- package/neural-interface/public/shared/cli-status.js +282 -22
- package/neural-interface/public/shared/cp/cp-events.js +2 -1
- package/neural-interface/public/shared/cp/cp-styles.js +46 -0
- package/neural-interface/public/shared/desktop-bridge.js +39 -0
- package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
- package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
- package/neural-interface/public/shared/desktop-titlebar.js +153 -0
- package/neural-interface/public/shared/desktop-update-view.js +462 -0
- package/neural-interface/public/shared/editor/editor-panel.js +1 -1
- package/neural-interface/public/shared/explorer/fx-main.js +1 -1
- package/neural-interface/public/shared/git-bash-view.js +145 -0
- package/neural-interface/public/shared/html-shell.js +1 -0
- package/neural-interface/public/shared/i18n.js +19 -1
- package/neural-interface/public/shared/install-details-view.js +86 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -1
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +36 -1
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +169 -2
- package/neural-interface/public/shared/pwa-install-page.js +3 -1
- package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
- package/neural-interface/public/shared/settings/settings-about.js +254 -0
- package/neural-interface/public/shared/settings/settings-ia.js +15 -0
- package/neural-interface/public/shared/showcase-privacy.js +490 -0
- package/neural-interface/public/shared/storage.js +30 -3
- package/neural-interface/public/shared/styles.css +281 -32
- package/neural-interface/public/shared/synabun-mascot.js +48 -2
- package/neural-interface/public/shared/ui-claude-panel.js +262 -217
- package/neural-interface/public/shared/ui-cli-banner.js +355 -0
- package/neural-interface/public/shared/ui-explorer.js +2 -0
- package/neural-interface/public/shared/ui-git-bash.js +124 -0
- package/neural-interface/public/shared/ui-judgments.js +10 -1
- package/neural-interface/public/shared/ui-loading.js +30 -6
- package/neural-interface/public/shared/ui-menubar.js +13 -3
- package/neural-interface/public/shared/ui-navbar.js +3 -1
- package/neural-interface/public/shared/ui-provider-auth.js +104 -0
- package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
- package/neural-interface/public/shared/ui-sessions.js +9 -3
- package/neural-interface/public/shared/ui-settings.js +55 -3
- package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
- package/neural-interface/public/shared/ui-sync.js +8 -0
- package/neural-interface/public/shared/ui-terminal.js +17 -0
- package/neural-interface/public/shared/ui-tool-updates.js +5 -0
- package/neural-interface/public/shared/ui-update.js +598 -7
- package/neural-interface/public/shared/ui-whatsapp.js +21 -6
- package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
- package/neural-interface/public/shared/worker-end.js +2 -0
- package/neural-interface/public/sw.js +1 -1
- package/neural-interface/public/variant/2d/main.js +10 -1
- package/neural-interface/public/variant/3d/main.js +10 -1
- package/neural-interface/scripts/rebuild-pty.js +14 -2
- package/neural-interface/server.js +1384 -679
- package/npm-shrinkwrap.json +4424 -0
- package/package.json +57 -2
- package/postinstall.js +61 -47
- package/preuninstall.js +8 -35
- package/setup.js +80 -47
- package/skills/synabun/modules/idea.md +1 -1
- package/skills/synabun/modules/schedule.md +1 -1
- package/updater.cmd +2 -2
- package/updater.mjs +97 -3
|
@@ -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 {
|
|
@@ -32,7 +34,9 @@ import { loadStyleGuideForRun } from './style-guide/store.js';
|
|
|
32
34
|
import { claimCheckWaived, normalizeTestDensity, testingApplies } from './assistant-test-density.js';
|
|
33
35
|
import { normalizeEffort } from './effort-levels.js';
|
|
34
36
|
import { createTurnEvidence, recordProviderEvent, evidenceSnapshot, claimEvidenceLine } from './assistant-evidence.js';
|
|
35
|
-
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';
|
|
36
40
|
import { CLAUDE_ESTIMATE_SOURCE, CODEX_USAGE_KEYS, TOKEN_KEYS, addTokens, codexUsageTokens, createClaudeMeter, createCodexMeter, createOpenCodeMeter, totalTokens, withTotal, zeroTokens } from './assistant-usage.js';
|
|
37
41
|
import { claudeTokensCostUsd } from './assistant-pricing.js';
|
|
38
42
|
import { RemotePolicyError, clampDispatchSpec, getSessionPolicy, readRegisteredProjects } from './remote-policy.js';
|
|
@@ -58,6 +62,12 @@ export function runStatusFromState(state) {
|
|
|
58
62
|
return ACTIVE_STATES.has(value) ? 'running' : null;
|
|
59
63
|
}
|
|
60
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
|
+
|
|
61
71
|
export const DEFAULT_LIMITS = Object.freeze({
|
|
62
72
|
perProvider: Object.freeze({ 'claude-code': 3, codex: 4, opencode: 3 }),
|
|
63
73
|
perCodexAccount: 2,
|
|
@@ -65,16 +75,13 @@ export const DEFAULT_LIMITS = Object.freeze({
|
|
|
65
75
|
queuedPerSession: 20,
|
|
66
76
|
heldPerSession: 10,
|
|
67
77
|
maxFanOut: 6,
|
|
78
|
+
// max_minutes: the work time a run gets (MIN_MAX_MINUTES at least), not the wall clock.
|
|
68
79
|
defaultMaxMinutes: 45,
|
|
69
80
|
maxMaxMinutes: 240,
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
sessionSoftBudgetUsd: 10,
|
|
75
|
-
sessionWarnUsd: 8,
|
|
76
|
-
sessionHardBudgetUsd: 25,
|
|
77
|
-
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,
|
|
78
85
|
permissionTimeoutMs: 10 * 60_000,
|
|
79
86
|
questionTimeoutMs: 30 * 60_000,
|
|
80
87
|
idleTimeoutMs: 20 * 60_000,
|
|
@@ -275,15 +282,15 @@ export function summarizeProviderEvent(payload = {}) {
|
|
|
275
282
|
return null;
|
|
276
283
|
}
|
|
277
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']);
|
|
278
287
|
function mergeLimits(overrides = {}) {
|
|
279
288
|
const limits = { ...DEFAULT_LIMITS, perProvider: { ...DEFAULT_LIMITS.perProvider } };
|
|
280
289
|
for (const [key, value] of Object.entries(overrides || {})) {
|
|
290
|
+
if (LEGACY_BUDGET_LIMITS.has(key)) continue;
|
|
281
291
|
if (key === 'perProvider' && value && typeof value === 'object') Object.assign(limits.perProvider, value);
|
|
282
292
|
else if (value !== undefined && value !== null) limits[key] = value;
|
|
283
293
|
}
|
|
284
|
-
// Older configs name a soft budget and warned at 80 % of it.
|
|
285
|
-
const soft = Number(overrides?.sessionSoftBudgetUsd);
|
|
286
|
-
if ((overrides?.sessionWarnUsd === undefined || overrides?.sessionWarnUsd === null) && Number.isFinite(soft) && soft > 0) limits.sessionWarnUsd = Math.round(soft * 80) / 100;
|
|
287
294
|
return limits;
|
|
288
295
|
}
|
|
289
296
|
|
|
@@ -332,6 +339,12 @@ export function createAssistantDispatcher({
|
|
|
332
339
|
getCodexAccount = () => null,
|
|
333
340
|
CODEX_DEFAULT_HOME = null,
|
|
334
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,
|
|
335
348
|
isValidMcpProfileValue = () => true,
|
|
336
349
|
normalizeMcpProfileName = (value) => value,
|
|
337
350
|
readActiveMcpProfile = () => 'full',
|
|
@@ -342,6 +355,8 @@ export function createAssistantDispatcher({
|
|
|
342
355
|
detectProject = () => 'global',
|
|
343
356
|
limits: limitOverrides = {},
|
|
344
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) },
|
|
345
360
|
randomId = () => randomBytes(16).toString('hex'),
|
|
346
361
|
strictResult = true,
|
|
347
362
|
// Routing (assistant-router.js) and the model catalog (assistant-catalog.js);
|
|
@@ -357,12 +372,17 @@ export function createAssistantDispatcher({
|
|
|
357
372
|
// Clarification (assistant-clarify.js): { forDispatch(sessionId, { briefId, independent }) }.
|
|
358
373
|
// A brain's dispatch waits while its questions are open and carries the user's brief.
|
|
359
374
|
clarifier = null,
|
|
360
|
-
//
|
|
361
|
-
//
|
|
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
|
|
362
377
|
// prices for Codex usage (assistant-budget.js createModelPricing).
|
|
363
378
|
readLimits = null,
|
|
364
379
|
brainSpend = () => 0,
|
|
380
|
+
brainSpendByProvider = null,
|
|
365
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,
|
|
366
386
|
// Exact token accounting (assistant-usage.js): the ledger (createUsageLedger) and the task of
|
|
367
387
|
// the brain turn in progress, (assistantSessionId) => taskId | null. Both optional: without
|
|
368
388
|
// them the meters still run for cost and the run's own numbers, and nothing is written.
|
|
@@ -411,6 +431,10 @@ export function createAssistantDispatcher({
|
|
|
411
431
|
// Usage meters (assistant-usage.js), apart from the live state: a Claude result can arrive
|
|
412
432
|
// after its turn was aborted or its run left the list.
|
|
413
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();
|
|
414
438
|
let persistTimer = null;
|
|
415
439
|
let stalledTimer = null;
|
|
416
440
|
let unsubscribeRuntime = null;
|
|
@@ -432,6 +456,11 @@ export function createAssistantDispatcher({
|
|
|
432
456
|
entry.turnState = 'terminal';
|
|
433
457
|
closeUsageState(entry);
|
|
434
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
|
+
}
|
|
435
464
|
entries.set(entry.runId, entry);
|
|
436
465
|
}
|
|
437
466
|
}
|
|
@@ -465,16 +494,19 @@ export function createAssistantDispatcher({
|
|
|
465
494
|
function peek(runId) {
|
|
466
495
|
const entry = entries.get(String(runId || '')) || null;
|
|
467
496
|
if (!entry) return null;
|
|
497
|
+
const at = now();
|
|
468
498
|
return {
|
|
469
499
|
runId: entry.runId, title: entry.title || null, state: entry.state, turnState: entry.turnState || null,
|
|
470
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),
|
|
471
502
|
};
|
|
472
503
|
}
|
|
473
504
|
function view(entry) {
|
|
474
505
|
if (!entry) return null;
|
|
475
506
|
const runtimeRun = safeRuntime()?.get?.(entry.runId) || null;
|
|
507
|
+
const at = now();
|
|
476
508
|
const startedAt = entry.startedAt || entry.createdAt;
|
|
477
|
-
const end = entry.finishedAt ? new Date(entry.finishedAt).getTime() :
|
|
509
|
+
const end = entry.finishedAt ? new Date(entry.finishedAt).getTime() : at;
|
|
478
510
|
const elapsedMs = startedAt ? Math.max(0, end - new Date(startedAt).getTime()) : 0;
|
|
479
511
|
// The brief goes to the worker's prompt; views name it only. The meters' state stays inside.
|
|
480
512
|
const { rawSpec: _rawSpec, brief: _brief, usageState: _usageState, ...rest } = entry;
|
|
@@ -498,7 +530,13 @@ export function createAssistantDispatcher({
|
|
|
498
530
|
active: ACTIVE_STATES.has(entry.state),
|
|
499
531
|
terminal: TERMINAL_STATES.has(entry.state),
|
|
500
532
|
outcome: entry.lastResult?.status || null,
|
|
533
|
+
// The wall clock since the run started (reports use it); max_minutes counts work time only.
|
|
501
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,
|
|
502
540
|
turnCount: Array.isArray(entry.turns) ? entry.turns.length : 0,
|
|
503
541
|
// The turn whose result this view carries: what a brain has read once it sees it.
|
|
504
542
|
resultTurn: (Array.isArray(entry.turns) ? entry.turns.at(-1)?.n : null) ?? 0,
|
|
@@ -513,6 +551,44 @@ export function createAssistantDispatcher({
|
|
|
513
551
|
};
|
|
514
552
|
}
|
|
515
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
|
+
|
|
516
592
|
function touch(entry) { entry.version = (Number(entry.version) || 0) + 1; entry.updatedAt = iso(now()); }
|
|
517
593
|
function emit(entry, reason, extra = {}) {
|
|
518
594
|
touch(entry);
|
|
@@ -685,15 +761,44 @@ export function createAssistantDispatcher({
|
|
|
685
761
|
let claudeAccount = null;
|
|
686
762
|
const accountId = spec.accountId || spec.codexAccountId || spec.claudeAccountId || null;
|
|
687
763
|
if (provider === 'codex') {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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);
|
|
697
802
|
const idleTimeoutMs = Math.min(Math.max(Number(spec.idleTimeoutMs) || limits.idleTimeoutMs, limits.minIdleTimeoutMs), limits.maxIdleTimeoutMs);
|
|
698
803
|
const maxTurns = Math.min(Math.max(Number(spec.maxTurns) || limits.maxTurns, 1), limits.maxMaxTurns);
|
|
699
804
|
const outputSchema = spec.outputSchema && typeof spec.outputSchema === 'object' ? spec.outputSchema : null;
|
|
@@ -710,7 +815,7 @@ export function createAssistantDispatcher({
|
|
|
710
815
|
claudeAccountId: claudeAccount?.id || (provider === 'claude-code' ? 'default' : null),
|
|
711
816
|
claudeConfigDir: claudeAccount ? (claudeAccounts?.homeFor?.(claudeAccount.id) || null) : null,
|
|
712
817
|
accountId: provider === 'codex' ? (codexAccount?.id || 'default') : provider === 'claude-code' ? (claudeAccount?.id || 'default') : null,
|
|
713
|
-
maxMinutes, budgetUsd, idleTimeoutMs, maxTurns, outputSchema, title, tags,
|
|
818
|
+
maxMinutes, budgetUsd, freeModel, idleTimeoutMs, maxTurns, outputSchema, title, tags,
|
|
714
819
|
focus: spec.focus === true,
|
|
715
820
|
workflowId: spec.workflowId ? String(spec.workflowId) : null,
|
|
716
821
|
parentRunId: spec.parentRunId ? String(spec.parentRunId) : null,
|
|
@@ -751,49 +856,68 @@ export function createAssistantDispatcher({
|
|
|
751
856
|
if (!assistantSessionId) return 0;
|
|
752
857
|
try { return Math.max(0, Number(brainSpend(assistantSessionId)) || 0); } catch { return 0; }
|
|
753
858
|
}
|
|
754
|
-
/** The session's whole spend: the central brain plus every run.
|
|
859
|
+
/** The session's whole spend: the central brain plus every run. */
|
|
755
860
|
function sessionTotal(assistantSessionId) { return sessionSpend(assistantSessionId) + sessionBrainSpend(assistantSessionId); }
|
|
756
|
-
|
|
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
|
+
}
|
|
757
877
|
/**
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
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.
|
|
761
882
|
*/
|
|
762
|
-
function
|
|
763
|
-
|
|
764
|
-
|
|
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 };
|
|
765
889
|
for (const entry of entries.values()) {
|
|
766
|
-
if (entry.assistantSessionId !== assistantSessionId
|
|
767
|
-
|
|
768
|
-
|
|
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
|
+
}
|
|
769
900
|
}
|
|
770
|
-
|
|
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 };
|
|
771
906
|
}
|
|
772
|
-
/** 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. */
|
|
773
908
|
function sessionBudget(assistantSessionId) {
|
|
774
909
|
refreshLimits();
|
|
775
|
-
|
|
776
|
-
const brainUsd = sessionBrainSpend(assistantSessionId);
|
|
777
|
-
const totalUsd = dispatchUsd + brainUsd;
|
|
778
|
-
const reservedUsd = sessionReserved(assistantSessionId);
|
|
779
|
-
let unpricedRuns = 0;
|
|
780
|
-
for (const entry of entries.values()) if (entry.assistantSessionId === assistantSessionId && Number(entry.unpricedTurns) > 0) unpricedRuns += 1;
|
|
781
|
-
return {
|
|
782
|
-
brainUsd: r4(brainUsd), dispatchUsd: r4(dispatchUsd), totalUsd: r4(totalUsd), reservedUsd: r4(reservedUsd),
|
|
783
|
-
availableUsd: r4(Math.max(0, limits.sessionHardBudgetUsd - totalUsd - reservedUsd)),
|
|
784
|
-
warnUsd: limits.sessionWarnUsd, hardUsd: limits.sessionHardBudgetUsd,
|
|
785
|
-
defaultRunUsd: limits.defaultBudgetUsd, maxRunUsd: limits.maxBudgetUsd, brainCapUsd: limits.brainBudgetUsd,
|
|
786
|
-
warned: totalUsd >= limits.sessionWarnUsd, exceeded: totalUsd >= limits.sessionHardBudgetUsd, unpricedRuns,
|
|
787
|
-
};
|
|
910
|
+
try { return budget.view(assistantSessionId); } catch { return null; }
|
|
788
911
|
}
|
|
912
|
+
/** Whether the session runs under a budget now (No limit: false). */
|
|
913
|
+
function limitedNow() { try { return budget.limited(); } catch { return true; } }
|
|
789
914
|
/**
|
|
790
|
-
* Why a run's spend could not be held to the
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
* 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).
|
|
794
918
|
*/
|
|
795
919
|
function unpricedReason(entry) {
|
|
796
|
-
if (!pricing || entry.provider === 'claude-code') return null;
|
|
920
|
+
if (!pricing || entry.provider === 'claude-code' || !limitedNow()) return null;
|
|
797
921
|
const model = entry.pricingModel || entry.model || null;
|
|
798
922
|
if (entry.provider === 'codex') {
|
|
799
923
|
let price = null;
|
|
@@ -805,65 +929,93 @@ export function createAssistantDispatcher({
|
|
|
805
929
|
function refuseUnpriced(entry) {
|
|
806
930
|
const why = unpricedReason(entry);
|
|
807
931
|
if (!why) return;
|
|
808
|
-
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';
|
|
809
947
|
}
|
|
810
|
-
/**
|
|
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
|
+
*/
|
|
811
956
|
function budgetBlock(entry) {
|
|
957
|
+
if (!limitedNow()) return null;
|
|
812
958
|
if (pricing && Number(entry.unpricedTurns) > 0) {
|
|
813
|
-
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 } };
|
|
814
960
|
}
|
|
815
961
|
const cap = Number(entry.budgetUsd) || 0;
|
|
816
962
|
const cost = Number(entry.costUsd) || 0;
|
|
817
|
-
if (cap > 0 && cost >= cap) {
|
|
818
|
-
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 } };
|
|
819
965
|
}
|
|
820
966
|
const sessionId = entry.assistantSessionId;
|
|
821
|
-
if (sessionId)
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
}
|
|
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) } };
|
|
826
975
|
}
|
|
827
976
|
return null;
|
|
828
977
|
}
|
|
829
978
|
/**
|
|
830
|
-
*
|
|
831
|
-
*
|
|
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.
|
|
832
982
|
*/
|
|
833
|
-
function
|
|
983
|
+
async function coverShare(entry, { reason = 'share_used_up' } = {}) {
|
|
834
984
|
const sessionId = entry.assistantSessionId;
|
|
835
|
-
if (!sessionId) return;
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
const hard = limits.sessionHardBudgetUsd;
|
|
839
|
-
// Whole cents: a run never gets a fraction of a cent the session does not have.
|
|
840
|
-
const available = Math.floor((hard - spent - reserved) * 100 + 1e-6) / 100;
|
|
841
|
-
if (available <= 0) {
|
|
842
|
-
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 });
|
|
843
|
-
}
|
|
844
|
-
if ((Number(entry.budgetUsd) || 0) > available) {
|
|
845
|
-
const from = Number(entry.budgetUsd);
|
|
846
|
-
entry.budgetUsd = available;
|
|
847
|
-
entry.budgetClamped = { from, to: entry.budgetUsd, at: iso(now()) };
|
|
848
|
-
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`);
|
|
849
|
-
}
|
|
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; }
|
|
850
988
|
}
|
|
851
989
|
/**
|
|
852
|
-
*
|
|
853
|
-
*
|
|
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.
|
|
854
1005
|
* Returns the session's budget view.
|
|
855
1006
|
*/
|
|
856
1007
|
function checkSession(assistantSessionId, { source = null } = {}) {
|
|
857
1008
|
if (!assistantSessionId) return null;
|
|
858
1009
|
const money = sessionBudget(assistantSessionId);
|
|
1010
|
+
if (!money || money.unlimited) return money;
|
|
859
1011
|
const anchor = source || [...entries.values()].filter((e) => e.assistantSessionId === assistantSessionId)
|
|
860
1012
|
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))[0] || null;
|
|
861
|
-
if (money.warned && !money.exceeded && sessionBudgetWarned.get(assistantSessionId) !== money.
|
|
862
|
-
sessionBudgetWarned.set(assistantSessionId, money.
|
|
1013
|
+
if (money.warned && !money.exceeded && sessionBudgetWarned.get(assistantSessionId) !== money.amountUsd) {
|
|
1014
|
+
sessionBudgetWarned.set(assistantSessionId, money.amountUsd);
|
|
863
1015
|
if (anchor) {
|
|
864
1016
|
emit(anchor, 'budget_warning', {
|
|
865
|
-
sessionSpent: money.totalUsd, warnAt: money.
|
|
866
|
-
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.`,
|
|
867
1019
|
});
|
|
868
1020
|
}
|
|
869
1021
|
}
|
|
@@ -871,19 +1023,34 @@ export function createAssistantDispatcher({
|
|
|
871
1023
|
const victims = [...entries.values()].filter((e) => e.assistantSessionId === assistantSessionId && ACTIVE_STATES.has(e.state));
|
|
872
1024
|
for (const victim of victims) {
|
|
873
1025
|
victim.completionReason = victim.completionReason || 'session_budget_cap';
|
|
874
|
-
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)`);
|
|
875
1027
|
stop(victim.runId, 'session_budget_cap').catch(() => {});
|
|
876
1028
|
}
|
|
877
|
-
if (anchor && sessionBudgetExceeded.get(assistantSessionId) !== money.
|
|
878
|
-
sessionBudgetExceeded.set(assistantSessionId, money.
|
|
1029
|
+
if (anchor && sessionBudgetExceeded.get(assistantSessionId) !== money.amountUsd) {
|
|
1030
|
+
sessionBudgetExceeded.set(assistantSessionId, money.amountUsd);
|
|
879
1031
|
emit(anchor, 'budget_exceeded', {
|
|
880
|
-
sessionSpent: money.totalUsd,
|
|
881
|
-
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).`,
|
|
882
1034
|
});
|
|
883
1035
|
}
|
|
884
1036
|
}
|
|
885
1037
|
return money;
|
|
886
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
|
+
}
|
|
887
1054
|
|
|
888
1055
|
// ── dispatch ───────────────────────────────────────────────────────────────
|
|
889
1056
|
/** Entry fields that come straight from a normalized spec. */
|
|
@@ -894,7 +1061,7 @@ export function createAssistantDispatcher({
|
|
|
894
1061
|
cwd: spec.cwd, project: spec.project, mcpProfile: spec.mcpProfile,
|
|
895
1062
|
permissionPolicy: spec.permissionPolicy, capability: spec.capability, usesBrowser: spec.usesBrowser, usesComputer: !!spec.usesComputer,
|
|
896
1063
|
title: spec.title, task: spec.task, context: spec.context, tags: spec.tags,
|
|
897
|
-
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,
|
|
898
1065
|
outputSchema: spec.outputSchema, focus: spec.focus, workflowId: spec.workflowId, parentRunId: spec.parentRunId,
|
|
899
1066
|
idempotencyKey: spec.idempotencyKey, requestedBrowserSessionId: spec.requestedBrowserSessionId,
|
|
900
1067
|
modelInfo: spec.modelInfo || null, modelContextWindow: spec.modelContextWindow || null, pricingModel: spec.pricingModel || spec.model || null,
|
|
@@ -905,6 +1072,7 @@ export function createAssistantDispatcher({
|
|
|
905
1072
|
runId, assistantSessionId: sessionId, origin,
|
|
906
1073
|
state: 'queued', turnState: null, turns: [], lastResult: null, costUsd: 0, usage: null, costBasis: 'none',
|
|
907
1074
|
pending: [], notes: [], createdAt: iso(now()), startedAt: null, finishedAt: null, completionReason: null,
|
|
1075
|
+
workMs: 0, workingSince: null,
|
|
908
1076
|
memoryId: null, memoryStored: false, browserSessionId: null, browserTabId: null, version: 0, updatedAt: iso(now()),
|
|
909
1077
|
runtime: null, providerSessionId: null, providerThreadId: null, degraded: null,
|
|
910
1078
|
route: null, escalation: null, escalationDepth: 0,
|
|
@@ -974,10 +1142,7 @@ export function createAssistantDispatcher({
|
|
|
974
1142
|
}
|
|
975
1143
|
}
|
|
976
1144
|
refreshLimits();
|
|
977
|
-
|
|
978
|
-
if (sessionId && spent >= limits.sessionHardBudgetUsd) {
|
|
979
|
-
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 });
|
|
980
|
-
}
|
|
1145
|
+
refuseSessionOver(sessionId);
|
|
981
1146
|
// "true" (a string) is computer use too: normalizeSpec reads it so, and so must the gate.
|
|
982
1147
|
rawSpec = { ...rawSpec, usesComputer: rawSpec.usesComputer === true || rawSpec.usesComputer === 'true' };
|
|
983
1148
|
// A design dispatch's defaults fill what it leaves out, before any clamp; any dispatch's own browser
|
|
@@ -1016,7 +1181,21 @@ export function createAssistantDispatcher({
|
|
|
1016
1181
|
try {
|
|
1017
1182
|
// An approved route may have moved it to another provider: clamp for that one
|
|
1018
1183
|
// (a route approved for design brings design's defaults).
|
|
1019
|
-
|
|
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
|
+
}
|
|
1020
1199
|
// A route approved for other work cannot carry image / video creation or design, nor the other way round:
|
|
1021
1200
|
// the class decides the model's capability check, the worker's media block or rules and what is collected.
|
|
1022
1201
|
const declaredClass = knownClass(rawSpec.taskClass);
|
|
@@ -1039,14 +1218,17 @@ export function createAssistantDispatcher({
|
|
|
1039
1218
|
if (brief) { entry.brief = brief; entry.notes.push(`carries the user's brief ${brief.briefId || ''}`.trim()); }
|
|
1040
1219
|
if (routed?.route) entry.route = routed.route;
|
|
1041
1220
|
entries.set(runId, entry);
|
|
1042
|
-
|
|
1043
|
-
entry.taskId = taskAtDispatch(sessionId);
|
|
1221
|
+
// Before the budget check awaits (it may re-split): a retry with the same key replays this run.
|
|
1044
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);
|
|
1045
1226
|
const launched = await launchOrQueue(entry, spec, { queuedCount, strict: true });
|
|
1227
|
+
const withCorrections = budgetCorrections.length ? { ...launched, corrections: budgetCorrections } : launched;
|
|
1046
1228
|
// The route's own "decided" event (it had no run attached) still tells the brain to dispatch.
|
|
1047
1229
|
return routed?.approvedMeanwhile
|
|
1048
|
-
? { ...
|
|
1049
|
-
:
|
|
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;
|
|
1050
1232
|
} catch (error) {
|
|
1051
1233
|
// A run that exists (it failed or stopped at start) used the route; a refusal before one did not.
|
|
1052
1234
|
if (routed?.claimed && !(runId && entries.has(runId))) router.releaseRoute?.(routed.route?.routeId);
|
|
@@ -1054,6 +1236,47 @@ export function createAssistantDispatcher({
|
|
|
1054
1236
|
}
|
|
1055
1237
|
}
|
|
1056
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
|
+
|
|
1057
1280
|
/** The router decided this route as approved, for this session (after a hold was returned on its card). */
|
|
1058
1281
|
function routeApprovedFor(routeId, sessionId) {
|
|
1059
1282
|
let decided = null;
|
|
@@ -1151,7 +1374,7 @@ export function createAssistantDispatcher({
|
|
|
1151
1374
|
const entry = entries.get(runId);
|
|
1152
1375
|
if (!entry || entry.state !== 'awaiting_route' || !target?.provider) return { ok: false, code: 'NOT_HELD' };
|
|
1153
1376
|
const catalogValue = await catalogSnapshot();
|
|
1154
|
-
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 } : {}) };
|
|
1155
1378
|
let spec;
|
|
1156
1379
|
// The picked provider may need another clamp (Codex under strict worker approvals), or the level fell meanwhile.
|
|
1157
1380
|
const remoteNotes = [];
|
|
@@ -1201,6 +1424,7 @@ export function createAssistantDispatcher({
|
|
|
1201
1424
|
const context = [entry.context, note].filter(Boolean).join('\n\n');
|
|
1202
1425
|
const result = await dispatch({
|
|
1203
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),
|
|
1204
1428
|
title: clip(`${entry.title} ${retry ? '↻' : '↑'}`, 80), tags: entry.tags, workflowId: entry.workflowId, parentRunId: runId,
|
|
1205
1429
|
mcpProfile: entry.provider === to.provider ? entry.mcpProfile : undefined, capability: entry.capability,
|
|
1206
1430
|
permissionPolicy: to.provider === 'codex' && entry.permissionPolicy === 'ask' ? 'restricted' : entry.permissionPolicy,
|
|
@@ -1295,7 +1519,7 @@ export function createAssistantDispatcher({
|
|
|
1295
1519
|
*/
|
|
1296
1520
|
function emitFailed(entry, evidenceAcc = null) {
|
|
1297
1521
|
const done = () => { delete entry.judging; entry.escalation = computeEscalation(entry); emit(entry, 'failed'); };
|
|
1298
|
-
if (!judge?.workerOutcome || entry.completionReason
|
|
1522
|
+
if (!judge?.workerOutcome || budgetStopReason(entry.completionReason)) { done(); return Promise.resolve(); }
|
|
1299
1523
|
entry.judging = true;
|
|
1300
1524
|
const evidence = evidenceSnapshot(evidenceAcc);
|
|
1301
1525
|
const error = [entry.error, ...(entry.notes || []).slice(-3)].filter(Boolean).map((line) => String(line)).join('\n');
|
|
@@ -1328,10 +1552,12 @@ export function createAssistantDispatcher({
|
|
|
1328
1552
|
async function start(entry) {
|
|
1329
1553
|
const runtime = safeRuntime();
|
|
1330
1554
|
if (!runtime) throw new DispatchError('RUNTIME_UNAVAILABLE', 'Native loop runtime is not available', { status: 503 });
|
|
1331
|
-
// 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).
|
|
1332
1556
|
refreshLimits();
|
|
1333
|
-
try {
|
|
1334
|
-
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 = [];
|
|
1335
1561
|
entry.notes.push(`not started: ${error?.message || error}`);
|
|
1336
1562
|
emit(entry, 'stopped');
|
|
1337
1563
|
throw error;
|
|
@@ -1378,7 +1604,8 @@ export function createAssistantDispatcher({
|
|
|
1378
1604
|
claudeAccountId: entry.claudeAccountId, claudeConfigDir: entry.claudeConfigDir,
|
|
1379
1605
|
driverType: 'native', runtimeType: 'native', surface: 'sidepanel', runId: entry.runId, cwd: entry.cwd,
|
|
1380
1606
|
runMode: 'task', assistantSessionId: entry.assistantSessionId, permissionPolicy: entry.permissionPolicy,
|
|
1381
|
-
|
|
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,
|
|
1382
1609
|
idleTimeoutMs: entry.idleTimeoutMs, maxTurns: entry.maxTurns, outputSchema: entry.outputSchema, project: entry.project,
|
|
1383
1610
|
title: entry.title, tags: entry.tags, workflowId: entry.workflowId, parentRunId: entry.parentRunId,
|
|
1384
1611
|
// The user's brief after a clarification (assistant-task-prompt.js briefBlock).
|
|
@@ -1499,6 +1726,8 @@ export function createAssistantDispatcher({
|
|
|
1499
1726
|
entry.pending = [...(entry.pending || []), pending];
|
|
1500
1727
|
entry.state = 'awaiting_permission';
|
|
1501
1728
|
entry.turnState = 'awaiting_permission';
|
|
1729
|
+
// Waiting on the answer is not work: the request's own timeout bounds it.
|
|
1730
|
+
pauseWork(entry, liveState);
|
|
1502
1731
|
liveState.ctx?.setTurnState?.('awaiting_permission');
|
|
1503
1732
|
liveState.ctx?.emit?.({ type: 'synabun.permission_request', requestId, kind, toolName: pending.toolName, input: pending.input, questions: pending.questions });
|
|
1504
1733
|
const timer = setTimeout(() => finishPending(runId, requestId, { behavior: 'deny', message: `No decision within ${Math.round(timeoutMs / 60000)} minutes.`, resolvedBy: 'timeout' }), timeoutMs);
|
|
@@ -1526,6 +1755,7 @@ export function createAssistantDispatcher({
|
|
|
1526
1755
|
if (entry.state === 'awaiting_permission' && entry.pending.length === 0) {
|
|
1527
1756
|
entry.state = 'running';
|
|
1528
1757
|
entry.turnState = 'running';
|
|
1758
|
+
startWork(entry, liveState);
|
|
1529
1759
|
liveState.ctx?.setTurnState?.('running');
|
|
1530
1760
|
}
|
|
1531
1761
|
const reply = { behavior: response?.behavior === 'allow' ? 'allow' : 'deny', ...response };
|
|
@@ -1581,6 +1811,8 @@ export function createAssistantDispatcher({
|
|
|
1581
1811
|
startUsagePolling(entry, liveState);
|
|
1582
1812
|
entry.state = 'running';
|
|
1583
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);
|
|
1584
1816
|
ctx.setTurnState('running');
|
|
1585
1817
|
emit(entry, 'turn_started', { turn: turnNumber });
|
|
1586
1818
|
// What this turn runs (commands, tool results, edited files): the evidence behind a "done".
|
|
@@ -1607,7 +1839,8 @@ export function createAssistantDispatcher({
|
|
|
1607
1839
|
parsed.judged = { status: judged.status, confidence: judged.statusConfidence };
|
|
1608
1840
|
if (judged.status === 'needs_input') parsed.question = clip(lastParagraph(text), 500);
|
|
1609
1841
|
if (evidence.files.length) { parsed.files = [...evidence.files]; parsed.changes = evidence.files.map((path) => ({ path, note: '' })); }
|
|
1610
|
-
} 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).
|
|
1611
1844
|
retried = true;
|
|
1612
1845
|
try {
|
|
1613
1846
|
const retry = await inner.runTurn(buildResultRetryPrompt(), { ...meta, turn: turnNumber, retry: true });
|
|
@@ -1617,6 +1850,9 @@ export function createAssistantDispatcher({
|
|
|
1617
1850
|
retryCost = accumulateCost(entry, retry);
|
|
1618
1851
|
if (entry.provider === 'codex') settleCodexTurn(entry, liveState, retry);
|
|
1619
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; }
|
|
1620
1856
|
retryCost = accumulateCost(entry, error);
|
|
1621
1857
|
if (entry.provider === 'codex') settleCodexTurn(entry, liveState, error);
|
|
1622
1858
|
entry.notes.push(`result retry failed: ${error?.message || error}`);
|
|
@@ -1659,6 +1895,8 @@ export function createAssistantDispatcher({
|
|
|
1659
1895
|
entry.turns = [...(entry.turns || []), turn].slice(-50);
|
|
1660
1896
|
entry.lastResult = parsed;
|
|
1661
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);
|
|
1662
1900
|
// Blocked or contract-less turns offer a one-tier escalation (the run
|
|
1663
1901
|
// itself stays warm for follow-ups; the brain or the user decides). A
|
|
1664
1902
|
// status Jev read from the message is a usable result.
|
|
@@ -1689,13 +1927,17 @@ export function createAssistantDispatcher({
|
|
|
1689
1927
|
try {
|
|
1690
1928
|
await runInnerTurn(prompt, meta, { origin: 'dispatch' });
|
|
1691
1929
|
} catch (error) {
|
|
1930
|
+
pauseWork(entry, liveState);
|
|
1692
1931
|
return handleTurnError(error, liveState, entry, ctx);
|
|
1693
1932
|
}
|
|
1694
1933
|
for (;;) {
|
|
1695
|
-
//
|
|
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.
|
|
1696
1938
|
refreshLimits();
|
|
1697
1939
|
const spentOut = budgetBlock(entry);
|
|
1698
|
-
if (spentOut) return budgetStop(entry, spentOut);
|
|
1940
|
+
if (spentOut && spentOut.code !== 'BUDGET_SHARE_EXHAUSTED') return budgetStop(entry, spentOut);
|
|
1699
1941
|
entry.state = 'idle';
|
|
1700
1942
|
entry.turnState = 'idle';
|
|
1701
1943
|
ctx.setTurnState('idle');
|
|
@@ -1710,15 +1952,24 @@ export function createAssistantDispatcher({
|
|
|
1710
1952
|
return { providerSessionId: inner.identity?.()?.providerSessionId };
|
|
1711
1953
|
}
|
|
1712
1954
|
refreshLimits();
|
|
1713
|
-
|
|
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);
|
|
1714
1958
|
if (blocked) {
|
|
1715
|
-
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' : ''}`);
|
|
1716
1960
|
return budgetStop(entry, blocked);
|
|
1717
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
|
+
}
|
|
1718
1968
|
try {
|
|
1719
1969
|
const followPrompt = buildFollowUpPrompt(next.text, state, { turn: liveState.turnCounter + 1, maxTurns: entry.maxTurns, origin: next.origin, testDensity: changedTestDensity(entry) });
|
|
1720
1970
|
await runInnerTurn(followPrompt, meta, { origin: next.origin, label: clip(next.text, 80), followUp: next.text, taskId: next.taskId || entry.taskId || null });
|
|
1721
1971
|
} catch (error) {
|
|
1972
|
+
pauseWork(entry, liveState);
|
|
1722
1973
|
return handleTurnError(error, liveState, entry, ctx);
|
|
1723
1974
|
}
|
|
1724
1975
|
}
|
|
@@ -1817,7 +2068,7 @@ export function createAssistantDispatcher({
|
|
|
1817
2068
|
if (budgetStopReason(entry.completionReason)) return { providerSessionId: null };
|
|
1818
2069
|
if (error?.code === 'BUDGET_CAP') {
|
|
1819
2070
|
entry.completionReason = 'budget_cap';
|
|
1820
|
-
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)');
|
|
1821
2072
|
const runtime = safeRuntime();
|
|
1822
2073
|
runtime?.stop?.(entry.runId, 'budget_cap').catch(() => {});
|
|
1823
2074
|
return { providerSessionId: null };
|
|
@@ -1832,7 +2083,7 @@ export function createAssistantDispatcher({
|
|
|
1832
2083
|
entry.notes.push(`provider error: ${error?.message || error}`);
|
|
1833
2084
|
throw error;
|
|
1834
2085
|
}
|
|
1835
|
-
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'; }
|
|
1836
2087
|
/** Stop a run that is out of money; the runtime ends it as stopped with this reason. */
|
|
1837
2088
|
function budgetStop(entry, block) {
|
|
1838
2089
|
entry.completionReason = block.reason;
|
|
@@ -1863,7 +2114,7 @@ export function createAssistantDispatcher({
|
|
|
1863
2114
|
function announceUnpriced(entry) {
|
|
1864
2115
|
if (!(Number(entry.unpricedTurns) > 0) || entry.unpricedAnnounced) return;
|
|
1865
2116
|
entry.unpricedAnnounced = true;
|
|
1866
|
-
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.` });
|
|
1867
2118
|
}
|
|
1868
2119
|
function codexThreadId(entry) {
|
|
1869
2120
|
try { return entry.providerThreadId || live.get(entry.runId)?.inner?.identity?.()?.providerThreadId || null; } catch { return entry.providerThreadId || null; }
|
|
@@ -2046,6 +2297,13 @@ export function createAssistantDispatcher({
|
|
|
2046
2297
|
if (TERMINAL_STATES.has(entry.state)) meter.clearPending();
|
|
2047
2298
|
const pending = meter.pending();
|
|
2048
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
|
+
}
|
|
2049
2307
|
}
|
|
2050
2308
|
|
|
2051
2309
|
// Codex: the rollout files of the run's thread and its sub-agent threads.
|
|
@@ -2368,12 +2626,17 @@ export function createAssistantDispatcher({
|
|
|
2368
2626
|
const held = meters.get(entry.runId);
|
|
2369
2627
|
if (held) { held.codex = null; held.opencode = null; }
|
|
2370
2628
|
}
|
|
2371
|
-
/**
|
|
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
|
+
*/
|
|
2372
2634
|
function enforceMidTurn(entry, liveState) {
|
|
2373
2635
|
if (TERMINAL_STATES.has(entry.state) || liveState.budgetStopping) return;
|
|
2374
2636
|
refreshLimits();
|
|
2375
2637
|
const block = budgetBlock(entry);
|
|
2376
|
-
if (block)
|
|
2638
|
+
if (block?.code === 'BUDGET_SHARE_EXHAUSTED') shareRanDry(entry);
|
|
2639
|
+
else if (block) {
|
|
2377
2640
|
liveState.budgetStopping = true;
|
|
2378
2641
|
entry.completionReason = block.reason;
|
|
2379
2642
|
entry.notes.push(`stopped mid-turn: ${block.message}`);
|
|
@@ -2381,6 +2644,25 @@ export function createAssistantDispatcher({
|
|
|
2381
2644
|
}
|
|
2382
2645
|
checkSession(entry.assistantSessionId, { source: entry });
|
|
2383
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
|
+
}
|
|
2384
2666
|
|
|
2385
2667
|
// ── run ↔ memory ───────────────────────────────────────────────────────────
|
|
2386
2668
|
/**
|
|
@@ -2421,7 +2703,16 @@ export function createAssistantDispatcher({
|
|
|
2421
2703
|
refreshLimits();
|
|
2422
2704
|
const blocked = budgetBlock(entry);
|
|
2423
2705
|
if (blocked) {
|
|
2424
|
-
|
|
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) });
|
|
2425
2716
|
}
|
|
2426
2717
|
// A follow-up works for the task in progress when it is sent, which may be a later one than the run's.
|
|
2427
2718
|
const item = { kind: 'follow-up', text: message, origin, at: iso(now()), taskId: taskNow(entry.assistantSessionId) || entry.taskId || null };
|
|
@@ -2432,6 +2723,18 @@ export function createAssistantDispatcher({
|
|
|
2432
2723
|
emit(entry, 'follow_up_queued', { queued: liveState.followUps.length });
|
|
2433
2724
|
return { ok: true, queued: true, position: liveState.followUps.length, run: view(entry) };
|
|
2434
2725
|
}
|
|
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
|
+
}
|
|
2435
2738
|
function complete(runId, { reason = 'complete' } = {}) {
|
|
2436
2739
|
const entry = requireEntry(runId);
|
|
2437
2740
|
const liveState = live.get(runId);
|
|
@@ -2489,6 +2792,7 @@ export function createAssistantDispatcher({
|
|
|
2489
2792
|
function finalize(entry, runtimeRun) {
|
|
2490
2793
|
if (TERMINAL_STATES.has(entry.state)) return;
|
|
2491
2794
|
const liveState = live.get(entry.runId);
|
|
2795
|
+
pauseWork(entry, liveState);
|
|
2492
2796
|
let status = runtimeRun?.status || 'completed';
|
|
2493
2797
|
// A completed worker whose loop file was swept meanwhile (a stale-file cleanup) reaches the
|
|
2494
2798
|
// runtime's next check as "stopped": its turn loop had already ended on the completion.
|
|
@@ -2507,7 +2811,9 @@ export function createAssistantDispatcher({
|
|
|
2507
2811
|
}
|
|
2508
2812
|
entry.pending = [];
|
|
2509
2813
|
releaseDesktop(entry);
|
|
2510
|
-
|
|
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) {
|
|
2511
2817
|
// State, queue and desktop move on now; the 'failed' event waits for its cause.
|
|
2512
2818
|
emitFailed(entry, liveState?.evidence || null);
|
|
2513
2819
|
processQueue().catch(() => {});
|
|
@@ -2764,6 +3070,7 @@ export function createAssistantDispatcher({
|
|
|
2764
3070
|
if (unsubscribeRuntime) { try { unsubscribeRuntime(); } catch {} unsubscribeRuntime = null; }
|
|
2765
3071
|
for (const entry of entries.values()) {
|
|
2766
3072
|
if (ACTIVE_STATES.has(entry.state)) {
|
|
3073
|
+
pauseWork(entry);
|
|
2767
3074
|
entry.state = 'interrupted'; entry.completionReason = reason; entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
|
|
2768
3075
|
delete entry.rawSpec;
|
|
2769
3076
|
// What the run's meter can still read is booked now; nothing provisional outlives the process.
|
|
@@ -2782,7 +3089,7 @@ export function createAssistantDispatcher({
|
|
|
2782
3089
|
return {
|
|
2783
3090
|
dispatch, sendTurn, complete, completeClosedTab, stop, killAll, get, peek, list, totals, wait, transcript, pendingPermissions,
|
|
2784
3091
|
respondPermission, subscribe, shutdown, limits,
|
|
2785
|
-
sessionBudget, checkSession, refreshLimits,
|
|
3092
|
+
sessionBudget, checkSession, refreshLimits, providerSpend, coverFollowUp, budget,
|
|
2786
3093
|
resolveRoute, declineRoute, heldRouteRefusal, escalate,
|
|
2787
3094
|
markDelivered, deliveryState,
|
|
2788
3095
|
remove, removeFinished,
|