synabun 2.0.10 → 2.2.0
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 +207 -0
- package/README.md +25 -2
- package/THIRD-PARTY-LICENSES.md +22 -0
- package/bin/synabun.js +71 -24
- package/docs/whatsapp.md +13 -13
- package/hooks/claude-code/pre-websearch.mjs +12 -1
- package/hooks/claude-code/shared.mjs +5 -0
- package/hooks/claude-code/stop.mjs +1 -1
- package/lib/claude-hooks.js +172 -18
- package/lib/client-config-repair.js +30 -17
- package/lib/desktop-app.js +7 -1
- package/lib/external-tools.js +7 -6
- package/lib/first-launch-protection.js +57 -12
- package/lib/mcp-dist.js +176 -0
- package/lib/release-npm-ci.mjs +138 -0
- package/lib/release-publish.mjs +91 -0
- package/lib/release-scan.mjs +50 -0
- package/lib/server-crash-record.js +123 -0
- package/lib/server-exit.js +318 -0
- package/lib/server-supervisor.js +508 -0
- package/lib/start-launcher.js +37 -4
- package/lib/tools-cli.js +45 -0
- package/lib/update-snapshot.js +302 -0
- package/lib/virustotal.mjs +203 -0
- package/lib/windows-crash-event.js +269 -0
- package/mcp-server/dist/http.d.ts +20 -1
- package/mcp-server/dist/http.d.ts.map +1 -1
- package/mcp-server/dist/http.js +59 -12
- package/mcp-server/dist/http.js.map +1 -1
- package/mcp-server/dist/index.d.ts +33 -27
- package/mcp-server/dist/index.d.ts.map +1 -1
- package/mcp-server/dist/index.js +128 -143
- package/mcp-server/dist/index.js.map +1 -1
- package/mcp-server/dist/preload.js +1 -1
- package/mcp-server/dist/preload.js.map +1 -1
- package/mcp-server/dist/services/budget-judgments.d.ts +1 -1
- package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -1
- package/mcp-server/dist/services/budget-judgments.js +5 -3
- package/mcp-server/dist/services/budget-judgments.js.map +1 -1
- package/mcp-server/dist/services/codex-config-heal.d.ts +6 -2
- package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
- package/mcp-server/dist/services/codex-config-heal.js +81 -28
- package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
- package/mcp-server/dist/services/identity.d.ts +2 -2
- package/mcp-server/dist/services/identity.js +2 -2
- package/mcp-server/dist/services/memory-judge-backfill.d.ts +6 -0
- package/mcp-server/dist/services/memory-judge-backfill.d.ts.map +1 -1
- package/mcp-server/dist/services/memory-judge-backfill.js +7 -1
- package/mcp-server/dist/services/memory-judge-backfill.js.map +1 -1
- package/mcp-server/dist/services/memory-sessions.d.ts +1 -1
- package/mcp-server/dist/services/memory-sessions.d.ts.map +1 -1
- package/mcp-server/dist/services/memory-sessions.js +28 -2
- package/mcp-server/dist/services/memory-sessions.js.map +1 -1
- package/mcp-server/dist/services/neural-interface.d.ts +18 -11
- package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
- package/mcp-server/dist/services/neural-interface.js +67 -11
- package/mcp-server/dist/services/neural-interface.js.map +1 -1
- package/mcp-server/dist/services/server-instructions.d.ts +7 -2
- package/mcp-server/dist/services/server-instructions.d.ts.map +1 -1
- package/mcp-server/dist/services/server-instructions.js +22 -63
- package/mcp-server/dist/services/server-instructions.js.map +1 -1
- package/mcp-server/dist/services/tool-catalog.d.ts +120 -0
- package/mcp-server/dist/services/tool-catalog.d.ts.map +1 -0
- package/mcp-server/dist/services/tool-catalog.js +339 -0
- package/mcp-server/dist/services/tool-catalog.js.map +1 -0
- package/mcp-server/dist/services/tool-list-refresh-barrier.d.ts +12 -0
- package/mcp-server/dist/services/tool-list-refresh-barrier.d.ts.map +1 -0
- package/mcp-server/dist/services/tool-list-refresh-barrier.js +40 -0
- package/mcp-server/dist/services/tool-list-refresh-barrier.js.map +1 -0
- package/mcp-server/dist/services/tool-list.d.ts +108 -0
- package/mcp-server/dist/services/tool-list.d.ts.map +1 -0
- package/mcp-server/dist/services/tool-list.js +334 -0
- package/mcp-server/dist/services/tool-list.js.map +1 -0
- package/mcp-server/dist/services/tool-runtime.d.ts +161 -0
- package/mcp-server/dist/services/tool-runtime.d.ts.map +1 -0
- package/mcp-server/dist/services/tool-runtime.js +400 -0
- package/mcp-server/dist/services/tool-runtime.js.map +1 -0
- package/mcp-server/dist/services/typesafe-config.js +1 -1
- package/mcp-server/dist/services/typesafe-config.js.map +1 -1
- package/mcp-server/dist/services/whiteboard-geometry.d.ts +26 -2
- package/mcp-server/dist/services/whiteboard-geometry.d.ts.map +1 -1
- package/mcp-server/dist/services/whiteboard-geometry.js +91 -8
- package/mcp-server/dist/services/whiteboard-geometry.js.map +1 -1
- package/mcp-server/dist/tools/agents.d.ts +14 -14
- package/mcp-server/dist/tools/agents.d.ts.map +1 -1
- package/mcp-server/dist/tools/agents.js +29 -19
- package/mcp-server/dist/tools/agents.js.map +1 -1
- package/mcp-server/dist/tools/browser-batch.d.ts +3 -3
- package/mcp-server/dist/tools/choice.d.ts +6 -0
- package/mcp-server/dist/tools/choice.d.ts.map +1 -1
- package/mcp-server/dist/tools/choice.js +18 -13
- package/mcp-server/dist/tools/choice.js.map +1 -1
- package/mcp-server/dist/tools/git-tools.js +2 -2
- package/mcp-server/dist/tools/git-tools.js.map +1 -1
- package/mcp-server/dist/tools/image-tools.d.ts +1 -1
- package/mcp-server/dist/tools/image-tools.js +1 -1
- package/mcp-server/dist/tools/morelogin.d.ts.map +1 -1
- package/mcp-server/dist/tools/morelogin.js +9 -6
- package/mcp-server/dist/tools/morelogin.js.map +1 -1
- package/mcp-server/dist/tools/skill-tools.d.ts +505 -0
- package/mcp-server/dist/tools/skill-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/skill-tools.js +642 -0
- package/mcp-server/dist/tools/skill-tools.js.map +1 -0
- package/mcp-server/dist/tools/skill.d.ts +7 -0
- package/mcp-server/dist/tools/skill.d.ts.map +1 -0
- package/mcp-server/dist/tools/skill.js +11 -0
- package/mcp-server/dist/tools/skill.js.map +1 -0
- package/mcp-server/dist/tools/tool-groups.d.ts +68 -0
- package/mcp-server/dist/tools/tool-groups.d.ts.map +1 -0
- package/mcp-server/dist/tools/tool-groups.js +147 -0
- package/mcp-server/dist/tools/tool-groups.js.map +1 -0
- package/mcp-server/dist/tools/whiteboard-format.d.ts.map +1 -1
- package/mcp-server/dist/tools/whiteboard-format.js +7 -4
- package/mcp-server/dist/tools/whiteboard-format.js.map +1 -1
- package/mcp-server/dist/tools/whiteboard-tools.d.ts +64 -13
- package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -1
- package/mcp-server/dist/tools/whiteboard-tools.js +30 -5
- package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -1
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/src/http.ts +75 -12
- package/mcp-server/src/index.ts +147 -154
- package/mcp-server/src/preload.ts +12 -12
- package/mcp-server/src/services/budget-judgments.ts +5 -3
- package/mcp-server/src/services/codex-config-heal.ts +80 -29
- package/mcp-server/src/services/identity.ts +2 -2
- package/mcp-server/src/services/memory-judge-backfill.ts +7 -1
- package/mcp-server/src/services/memory-sessions.ts +24 -3
- package/mcp-server/src/services/neural-interface.ts +81 -21
- package/mcp-server/src/services/server-instructions.ts +29 -64
- package/mcp-server/src/services/tool-catalog.ts +403 -0
- package/mcp-server/src/services/tool-list-refresh-barrier.ts +33 -0
- package/mcp-server/src/services/tool-list.ts +410 -0
- package/mcp-server/src/services/tool-runtime.ts +457 -0
- package/mcp-server/src/services/typesafe-config.ts +1 -1
- package/mcp-server/src/services/whiteboard-geometry.ts +94 -9
- package/mcp-server/src/tools/agents.ts +29 -20
- package/mcp-server/src/tools/choice.ts +17 -12
- package/mcp-server/src/tools/git-tools.ts +2 -2
- package/mcp-server/src/tools/image-tools.ts +1 -1
- package/mcp-server/src/tools/morelogin.ts +6 -4
- package/mcp-server/src/tools/skill-tools.ts +592 -0
- package/mcp-server/src/tools/skill.ts +13 -0
- package/mcp-server/src/tools/tool-groups.ts +164 -0
- package/mcp-server/src/tools/whiteboard-format.ts +9 -3
- package/mcp-server/src/tools/whiteboard-tools.ts +39 -5
- package/neural-interface/i18n/en.json +3953 -228
- package/neural-interface/i18n/pt-BR.json +3901 -179
- package/neural-interface/lib/agent-sandbox-elevation.js +6 -0
- package/neural-interface/lib/antigravity/acp-client-host.js +554 -0
- package/neural-interface/lib/antigravity/acp-connection.js +299 -0
- package/neural-interface/lib/antigravity/bypass.js +100 -0
- package/neural-interface/lib/antigravity/catalog.js +273 -0
- package/neural-interface/lib/antigravity/home.js +260 -0
- package/neural-interface/lib/antigravity/migrate-gemini.js +139 -0
- package/neural-interface/lib/antigravity/panel-bridge.js +1058 -0
- package/neural-interface/lib/antigravity/policy-hook.mjs +78 -0
- package/neural-interface/lib/antigravity/policy.js +74 -0
- package/neural-interface/lib/antigravity/tool-call.js +178 -0
- package/neural-interface/lib/antigravity/transcripts.js +252 -0
- package/neural-interface/lib/antigravity/usage.js +43 -0
- package/neural-interface/lib/assistant-api.js +128 -28
- package/neural-interface/lib/assistant-brains/antigravity.js +423 -0
- package/neural-interface/lib/assistant-brains/claude.js +4 -3
- package/neural-interface/lib/assistant-brains/codex-browser-policy-hook.mjs +12 -2
- package/neural-interface/lib/assistant-brains/codex-route-gate-hook.mjs +32 -9
- package/neural-interface/lib/assistant-brains/codex.js +9 -5
- package/neural-interface/lib/assistant-brains/opencode.js +120 -54
- package/neural-interface/lib/assistant-brains/opencode2-browser-policy/index.js +28 -13
- package/neural-interface/lib/assistant-brains/opencode2-route-gate/index.js +59 -19
- package/neural-interface/lib/assistant-budget-planner.js +7 -3
- package/neural-interface/lib/assistant-budget-split.js +42 -15
- package/neural-interface/lib/assistant-catalog.js +81 -15
- package/neural-interface/lib/assistant-config.js +6 -3
- package/neural-interface/lib/assistant-dispatch.js +1073 -200
- package/neural-interface/lib/assistant-envelope.js +156 -2
- package/neural-interface/lib/assistant-evidence.js +80 -0
- package/neural-interface/lib/assistant-hidden-models.js +7 -5
- package/neural-interface/lib/assistant-persona.js +93 -19
- package/neural-interface/lib/assistant-plan-permissions.js +25 -9
- package/neural-interface/lib/assistant-route-gate-codex.js +3 -0
- package/neural-interface/lib/assistant-route-gate.js +6 -4
- package/neural-interface/lib/assistant-router.js +245 -38
- package/neural-interface/lib/assistant-runtime.js +214 -30
- package/neural-interface/lib/assistant-task-prompt.js +87 -21
- package/neural-interface/lib/assistant-tools.js +51 -0
- package/neural-interface/lib/assistant-usage.js +29 -0
- package/neural-interface/lib/backup-service.js +626 -4
- package/neural-interface/lib/backup-zip-reader.js +10 -5
- package/neural-interface/lib/backup-zip-writer.js +257 -0
- package/neural-interface/lib/browser-execution.js +10 -2
- package/neural-interface/lib/browser-guard-cdp.js +84 -0
- package/neural-interface/lib/browser-network-guard.js +476 -0
- package/neural-interface/lib/browser-policy-roots.js +64 -0
- package/neural-interface/lib/browser-tool-policy.js +198 -38
- package/neural-interface/lib/claude-agent-bridge.js +11 -2
- package/neural-interface/lib/claude-history-cache.js +210 -0
- package/neural-interface/lib/claude-hooks-status.js +103 -0
- package/neural-interface/lib/claude-model-catalog.js +5 -1
- package/neural-interface/lib/claude-temporary.js +5 -11
- package/neural-interface/lib/cli-install-api.js +1 -1
- package/neural-interface/lib/cli-install.js +163 -154
- package/neural-interface/lib/cli-resolver.js +13 -3
- package/neural-interface/lib/cli-update-planner.js +52 -41
- package/neural-interface/lib/cloudflared/tunnel.js +42 -2
- package/neural-interface/lib/codex-mcp-approvals.js +11 -0
- package/neural-interface/lib/codex-rollout-reader.js +52 -0
- package/neural-interface/lib/codex-worker-app-server.js +191 -0
- package/neural-interface/lib/confined-fs.js +30 -3
- package/neural-interface/lib/crash-notice-api.js +98 -0
- package/neural-interface/lib/editor-completion-opencode.js +68 -68
- package/neural-interface/lib/editor-git-api.js +3 -20
- package/neural-interface/lib/exec-loop-command.js +13 -10
- package/neural-interface/lib/file-explorer-api.js +74 -40
- package/neural-interface/lib/file-explorer-watch.js +9 -3
- package/neural-interface/lib/git-bash/install.js +52 -6
- package/neural-interface/lib/git-bash/manager.js +59 -17
- package/neural-interface/lib/git-bash/pins.js +12 -0
- package/neural-interface/lib/git-read.js +2 -2
- package/neural-interface/lib/git-status-runner.js +390 -0
- package/neural-interface/lib/git-status-watchdog.js +40 -0
- package/neural-interface/lib/greeting/budgets.js +1 -0
- package/neural-interface/lib/greeting/engine.js +2 -2
- package/neural-interface/lib/greeting/sources.js +10 -12
- package/neural-interface/lib/greeting/writer.js +1 -1
- package/neural-interface/lib/http-guards.js +8 -2
- package/neural-interface/lib/install-details.js +4 -1
- package/neural-interface/lib/mcp-agent-servers.js +67 -0
- package/neural-interface/lib/mcp-client-config.js +35 -33
- package/neural-interface/lib/mcp-external-servers.js +332 -0
- package/neural-interface/lib/mcp-installer.js +2 -1
- package/neural-interface/lib/{mcp-profile-refresh-coordinator.js → mcp-tools-refresh-coordinator.js} +4 -4
- package/neural-interface/lib/memory-session-capture.js +17 -3
- package/neural-interface/lib/morelogin.js +177 -25
- package/neural-interface/lib/native-loop-providers.js +965 -66
- package/neural-interface/lib/native-loop-runtime.js +290 -42
- package/neural-interface/lib/opencode-config.js +557 -0
- package/neural-interface/lib/opencode-db-schema.js +17 -34
- package/neural-interface/lib/opencode-history.js +48 -218
- package/neural-interface/lib/opencode-mcp-ready.js +108 -0
- package/neural-interface/lib/opencode-native-boundary.js +53 -0
- package/neural-interface/lib/opencode-protocol.js +249 -112
- package/neural-interface/lib/opencode-runtime-config.js +32 -22
- package/neural-interface/lib/opencode-serve-records.js +39 -21
- package/neural-interface/lib/opencode-v2-client.js +48 -153
- package/neural-interface/lib/opencode-v2-ws-requests.js +36 -210
- package/neural-interface/lib/opencode-worker-data.js +27 -0
- package/neural-interface/lib/opencode2-client.js +73 -37
- package/neural-interface/lib/opencode2-proxy.js +23 -63
- package/neural-interface/lib/opencode2-translate.js +51 -2
- package/neural-interface/lib/owner-authority.js +249 -0
- package/neural-interface/lib/page-session.js +275 -0
- package/neural-interface/lib/path-confine.js +12 -9
- package/neural-interface/lib/peer-identity.js +793 -0
- package/neural-interface/lib/pty-host/core.js +28 -1
- package/neural-interface/lib/pty-host/lifetime.js +12 -0
- package/neural-interface/lib/pty-host/manager.js +12 -1
- package/neural-interface/lib/remote-policy.js +1 -1
- package/neural-interface/lib/request-gate.js +20 -2
- package/neural-interface/lib/rulesets/api.js +7 -6
- package/neural-interface/lib/rulesets/fs-safe.js +1 -1
- package/neural-interface/lib/rulesets/installer.js +180 -36
- package/neural-interface/lib/rulesets/json-edit.js +1 -1
- package/neural-interface/lib/rulesets/render.js +1 -1
- package/neural-interface/lib/session-cache-builder.js +81 -31
- package/neural-interface/lib/session-title-generator.js +58 -36
- package/neural-interface/lib/skills/api-v2.js +495 -0
- package/neural-interface/lib/skills/bundle.js +418 -0
- package/neural-interface/lib/skills/catalog.js +298 -0
- package/neural-interface/lib/skills/creation-api.js +191 -0
- package/neural-interface/lib/skills/creation-brief.js +247 -0
- package/neural-interface/lib/skills/creation-sources.js +285 -0
- package/neural-interface/lib/skills/creation.js +843 -0
- package/neural-interface/lib/skills/deploy.js +569 -0
- package/neural-interface/lib/skills/digest.js +119 -0
- package/neural-interface/lib/skills/discover.js +111 -0
- package/neural-interface/lib/skills/draft-confinement.js +216 -0
- package/neural-interface/lib/skills/eval-confinement.js +399 -0
- package/neural-interface/lib/skills/eval-sandbox.js +280 -0
- package/neural-interface/lib/skills/eval-trace.js +135 -0
- package/neural-interface/lib/skills/evals-api.js +212 -0
- package/neural-interface/lib/skills/evals.js +1573 -0
- package/neural-interface/lib/skills/export.js +203 -0
- package/neural-interface/lib/skills/frontmatter.js +64 -0
- package/neural-interface/lib/skills/health.js +75 -0
- package/neural-interface/lib/skills/json-edit.js +165 -0
- package/neural-interface/lib/skills/known-bridge-digests.json +441 -0
- package/neural-interface/lib/skills/known-bridges.js +96 -0
- package/neural-interface/lib/skills/library.js +823 -0
- package/neural-interface/lib/skills/local-store.js +21 -0
- package/neural-interface/lib/skills/migration.js +433 -0
- package/neural-interface/lib/skills/operations.js +45 -0
- package/neural-interface/lib/skills/ownership.js +427 -0
- package/neural-interface/lib/skills/packages.js +88 -0
- package/neural-interface/lib/skills/reviews.js +212 -0
- package/neural-interface/lib/skills/revision.js +80 -0
- package/neural-interface/lib/skills/roots.js +269 -0
- package/neural-interface/lib/skills/runtime-checks.js +358 -0
- package/neural-interface/lib/skills/scan-assets.js +252 -0
- package/neural-interface/lib/skills/scan-context.js +26 -0
- package/neural-interface/lib/skills/scan-shell.js +324 -0
- package/neural-interface/lib/skills/scan.js +364 -0
- package/neural-interface/lib/skills/scanners.js +54 -0
- package/neural-interface/lib/skills/service.js +530 -0
- package/neural-interface/lib/skills/telemetry.js +119 -0
- package/neural-interface/lib/sqlite-integrity-worker.js +22 -0
- package/neural-interface/lib/synabun-version.js +1 -1
- package/neural-interface/lib/system-activity.js +1 -1
- package/neural-interface/lib/tool-lists.js +124 -0
- package/neural-interface/lib/whatsapp/brain.js +7 -3
- package/neural-interface/lib/whatsapp/bridge.js +6 -3
- package/neural-interface/lib/whatsapp/cards.js +22 -9
- package/neural-interface/lib/whatsapp/config.js +1 -1
- package/neural-interface/lib/worker-confinement.js +1268 -0
- package/neural-interface/lib/worker-guard-setting.js +56 -0
- package/neural-interface/lib/worker-mcp-channel.js +175 -0
- package/neural-interface/lib/worker-question.js +74 -0
- package/neural-interface/lib/worker-relay-ports.js +227 -0
- package/neural-interface/lib/worker-resume.js +238 -0
- package/neural-interface/lib/worker-sandbox.js +329 -0
- package/neural-interface/package-lock.json +34 -12
- package/neural-interface/package.json +3 -2
- package/neural-interface/public/index.html +3 -1
- package/neural-interface/public/index2d.html +19 -11
- package/neural-interface/public/onboarding.html +3 -3
- package/neural-interface/public/shared/account-menu.js +8 -5
- package/neural-interface/public/shared/agent-runtime-options.js +125 -31
- package/neural-interface/public/shared/agy/agy-bypass.js +47 -0
- package/neural-interface/public/shared/agy/agy-client.js +266 -0
- package/neural-interface/public/shared/agy/agy-commands.js +120 -0
- package/neural-interface/public/shared/agy/agy-copy.js +326 -0
- package/neural-interface/public/shared/agy/agy-diff.js +161 -0
- package/neural-interface/public/shared/agy/agy-events.js +469 -0
- package/neural-interface/public/shared/agy/agy-greeting.js +26 -0
- package/neural-interface/public/shared/agy/agy-options.js +232 -0
- package/neural-interface/public/shared/agy/agy-panel.js +2543 -0
- package/neural-interface/public/shared/agy/agy-render.js +392 -0
- package/neural-interface/public/shared/agy/agy-setup.js +149 -0
- package/neural-interface/public/shared/agy/agy-styles.js +604 -0
- package/neural-interface/public/shared/agy/agy-tabs-logic.js +181 -0
- package/neural-interface/public/shared/api.js +1527 -1336
- package/neural-interface/public/shared/app-frame.js +192 -0
- package/neural-interface/public/shared/assistant/asst-agents-dock.js +36 -6
- package/neural-interface/public/shared/assistant/asst-api.js +1 -1
- package/neural-interface/public/shared/assistant/asst-ask-styles.js +270 -0
- package/neural-interface/public/shared/assistant/asst-ask-tail.js +118 -0
- package/neural-interface/public/shared/assistant/asst-ask.js +397 -0
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +105 -44
- package/neural-interface/public/shared/assistant/asst-budget.js +5 -4
- package/neural-interface/public/shared/assistant/asst-control.js +205 -26
- package/neural-interface/public/shared/assistant/asst-followups-card.js +383 -0
- package/neural-interface/public/shared/assistant/asst-followups.js +417 -0
- package/neural-interface/public/shared/assistant/asst-hosts.js +16 -7
- package/neural-interface/public/shared/assistant/asst-limit-dock.js +45 -0
- package/neural-interface/public/shared/assistant/asst-limit-wait.js +327 -0
- package/neural-interface/public/shared/assistant/asst-limit.js +168 -0
- package/neural-interface/public/shared/assistant/asst-mascot.js +49 -12
- package/neural-interface/public/shared/assistant/asst-menu.js +7 -6
- package/neural-interface/public/shared/assistant/asst-panel.js +151 -19
- package/neural-interface/public/shared/assistant/asst-rack-styles.js +66 -49
- package/neural-interface/public/shared/assistant/asst-render.js +445 -82
- package/neural-interface/public/shared/assistant/asst-route.js +304 -53
- package/neural-interface/public/shared/assistant/asst-routes-editor.js +9 -8
- package/neural-interface/public/shared/assistant/asst-scenes.js +56 -1
- package/neural-interface/public/shared/assistant/asst-sidepanel.js +117 -10
- package/neural-interface/public/shared/assistant/asst-slash.js +17 -9
- package/neural-interface/public/shared/assistant/asst-state.js +87 -24
- package/neural-interface/public/shared/assistant/asst-stop.js +127 -0
- package/neural-interface/public/shared/assistant/asst-styles.js +93 -21
- package/neural-interface/public/shared/assistant/asst-tool-kinds.js +190 -23
- package/neural-interface/public/shared/assistant/asst-usage.js +2 -1
- package/neural-interface/public/shared/automation-context.js +5 -2
- package/neural-interface/public/shared/cc-dropdown.js +89 -19
- package/neural-interface/public/shared/cdx/cdx-context-menu.js +10 -9
- package/neural-interface/public/shared/cdx/cdx-copy.js +16 -0
- package/neural-interface/public/shared/cdx/cdx-output-model.js +22 -1
- package/neural-interface/public/shared/cdx/cdx-output.js +34 -10
- package/neural-interface/public/shared/cdx/cdx-panel.js +59 -151
- package/neural-interface/public/shared/cdx/cdx-preview.js +12 -0
- package/neural-interface/public/shared/cdx/cdx-protocol.js +39 -11
- package/neural-interface/public/shared/cdx/cdx-render.js +57 -38
- package/neural-interface/public/shared/cdx/cdx-requests.js +14 -4
- package/neural-interface/public/shared/cdx/cdx-skill-hints.js +54 -0
- package/neural-interface/public/shared/cdx/cdx-styles.js +15 -3
- package/neural-interface/public/shared/cdx/cdx-tabs.js +127 -44
- package/neural-interface/public/shared/cdx/cdx-ws.js +0 -1
- package/neural-interface/public/shared/cli-install-controller.js +218 -0
- package/neural-interface/public/shared/cli-install-view.js +11 -12
- package/neural-interface/public/shared/cli-status.js +11 -10
- package/neural-interface/public/shared/constants.js +5 -4
- package/neural-interface/public/shared/context-menu-areas.js +34 -0
- package/neural-interface/public/shared/context-menu-logic.js +243 -0
- package/neural-interface/public/shared/context-menu-model.js +367 -0
- package/neural-interface/public/shared/context-menu.js +372 -0
- package/neural-interface/public/shared/cp/cp-context-menu.js +10 -9
- package/neural-interface/public/shared/cp/cp-restore.js +162 -0
- package/neural-interface/public/shared/cp/cp-tool-cards.js +10 -3
- package/neural-interface/public/shared/crash-notice-view.js +168 -0
- package/neural-interface/public/shared/desktop-bridge.js +17 -0
- package/neural-interface/public/shared/desktop-titlebar-view.js +12 -0
- package/neural-interface/public/shared/desktop-titlebar.js +13 -4
- package/neural-interface/public/shared/desktop-update-view.js +21 -0
- package/neural-interface/public/shared/editor/editor-panel-logic.js +2 -2
- package/neural-interface/public/shared/explorer/fx-api.js +4 -4
- package/neural-interface/public/shared/explorer/fx-links.js +3 -3
- package/neural-interface/public/shared/explorer/fx-logic.js +1 -1
- package/neural-interface/public/shared/explorer/fx-main.js +48 -16
- package/neural-interface/public/shared/explorer/fx-tree.js +1 -0
- package/neural-interface/public/shared/greeting/greeting-card.js +2 -2
- package/neural-interface/public/shared/greeting/greeting-view.js +1 -0
- package/neural-interface/public/shared/html-shell.js +30 -15
- package/neural-interface/public/shared/i18n.js +2 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-approvals.js +7 -7
- package/neural-interface/public/shared/ocp-v2/ocp-v2-binding.js +4 -4
- package/neural-interface/public/shared/ocp-v2/ocp-v2-changes-logic.js +3 -101
- package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +2 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-compact-button.js +1 -3
- package/neural-interface/public/shared/ocp-v2/ocp-v2-composer-logic.js +2 -6
- package/neural-interface/public/shared/ocp-v2/ocp-v2-context-menu.js +11 -11
- package/neural-interface/public/shared/ocp-v2/ocp-v2-context-model.js +0 -3
- package/neural-interface/public/shared/ocp-v2/ocp-v2-events.js +8 -4
- package/neural-interface/public/shared/ocp-v2/ocp-v2-manager.js +7 -7
- package/neural-interface/public/shared/ocp-v2/ocp-v2-outcome-notifications.js +16 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +74 -259
- package/neural-interface/public/shared/ocp-v2/ocp-v2-plan.js +1 -2
- package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +95 -202
- package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +13 -4
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +13 -22
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +4 -4
- package/neural-interface/public/shared/ocp-v2/ocp-v2-session-actions.js +8 -61
- package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +2 -91
- package/neural-interface/public/shared/ocp-v2/ocp-v2-slash-hints.js +16 -15
- package/neural-interface/public/shared/ocp-v2/ocp-v2-state.js +8 -21
- package/neural-interface/public/shared/ocp-v2/ocp-v2-status-logic.js +1 -99
- package/neural-interface/public/shared/ocp-v2/ocp-v2-status.js +5 -35
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +17 -21
- package/neural-interface/public/shared/ocp-v2/{ocp-v2-profile-inheritance.js → ocp-v2-tools-inheritance.js} +16 -16
- package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +60 -50
- package/neural-interface/public/shared/page-session.js +96 -0
- package/neural-interface/public/shared/provider-icons.js +4 -2
- package/neural-interface/public/shared/registry.js +5 -3
- package/neural-interface/public/shared/retired-2d.js +3 -0
- package/neural-interface/public/shared/schedules-view.js +195 -61
- package/neural-interface/public/shared/settings/settings-claude-hooks.js +135 -0
- package/neural-interface/public/shared/settings/settings-data.js +3 -1
- package/neural-interface/public/shared/settings/settings-ia.js +6 -6
- package/neural-interface/public/shared/settings/settings-opencode.js +89 -0
- package/neural-interface/public/shared/settings/settings-rules.js +2 -2
- package/neural-interface/public/shared/settings/settings-skill-folders-pane.js +265 -0
- package/neural-interface/public/shared/settings/settings-skill-folders.js +130 -0
- package/neural-interface/public/shared/settings/settings-worker-guard.js +72 -0
- package/neural-interface/public/shared/showcase-privacy.js +6 -1
- package/neural-interface/public/shared/sidepanel-session-status.js +3 -3
- package/neural-interface/public/shared/skills/ss-args.js +96 -0
- package/neural-interface/public/shared/skills/ss-creation.js +222 -0
- package/neural-interface/public/shared/skills/ss-dialog.js +133 -0
- package/neural-interface/public/shared/skills/ss-doc.js +173 -0
- package/neural-interface/public/shared/skills/ss-eval-adapters.js +269 -0
- package/neural-interface/public/shared/skills/ss-eval-grade.js +359 -0
- package/neural-interface/public/shared/skills/ss-eval-optimise.js +153 -0
- package/neural-interface/public/shared/skills/ss-eval-plan.js +315 -0
- package/neural-interface/public/shared/skills/ss-evals.js +417 -0
- package/neural-interface/public/shared/skills/ss-form.js +159 -0
- package/neural-interface/public/shared/skills/ss-frontmatter.js +376 -0
- package/neural-interface/public/shared/skills/ss-ids.js +32 -0
- package/neural-interface/public/shared/skills/ss-lint.js +149 -0
- package/neural-interface/public/shared/skills/ss-presets.js +117 -0
- package/neural-interface/public/shared/skills/ss-profiles.js +513 -0
- package/neural-interface/public/shared/skills/ss-resources.js +165 -0
- package/neural-interface/public/shared/skills/ss-review-view.js +190 -0
- package/neural-interface/public/shared/skills/ss-tabs.js +79 -0
- package/neural-interface/public/shared/skills/ss-unicode.js +135 -0
- package/neural-interface/public/shared/skills/studio/sx-actions.js +222 -0
- package/neural-interface/public/shared/skills/studio/sx-cm.js +101 -0
- package/neural-interface/public/shared/skills/studio/sx-create-ai-view.js +749 -0
- package/neural-interface/public/shared/skills/studio/sx-create-ai.js +237 -0
- package/neural-interface/public/shared/skills/studio/sx-create-view.js +254 -0
- package/neural-interface/public/shared/skills/studio/sx-create.js +126 -0
- package/neural-interface/public/shared/skills/studio/sx-diff-view.js +43 -0
- package/neural-interface/public/shared/skills/studio/sx-diff.js +133 -0
- package/neural-interface/public/shared/skills/studio/sx-docs.js +480 -0
- package/neural-interface/public/shared/skills/studio/sx-dom.js +230 -0
- package/neural-interface/public/shared/skills/studio/sx-edit-view.js +848 -0
- package/neural-interface/public/shared/skills/studio/sx-editor-model.js +193 -0
- package/neural-interface/public/shared/skills/studio/sx-errors.js +22 -0
- package/neural-interface/public/shared/skills/studio/sx-fixes-view.js +359 -0
- package/neural-interface/public/shared/skills/studio/sx-fixes.js +327 -0
- package/neural-interface/public/shared/skills/studio/sx-geometry.js +82 -0
- package/neural-interface/public/shared/skills/studio/sx-import.js +262 -0
- package/neural-interface/public/shared/skills/studio/sx-inspector.js +308 -0
- package/neural-interface/public/shared/skills/studio/sx-install-flow.js +282 -0
- package/neural-interface/public/shared/skills/studio/sx-install.js +313 -0
- package/neural-interface/public/shared/skills/studio/sx-library-view.js +748 -0
- package/neural-interface/public/shared/skills/studio/sx-library.js +479 -0
- package/neural-interface/public/shared/skills/studio/sx-main.js +690 -0
- package/neural-interface/public/shared/skills/studio/sx-operations-view.js +98 -0
- package/neural-interface/public/shared/skills/studio/sx-operations.js +11 -0
- package/neural-interface/public/shared/skills/studio/sx-plan.js +158 -0
- package/neural-interface/public/shared/skills/studio/sx-publish-view.js +621 -0
- package/neural-interface/public/shared/skills/studio/sx-review-view.js +369 -0
- package/neural-interface/public/shared/skills/studio/sx-review.js +148 -0
- package/neural-interface/public/shared/skills/studio/sx-scan.js +35 -0
- package/neural-interface/public/shared/skills/studio/sx-snippets.js +96 -0
- package/neural-interface/public/shared/skills/studio/sx-subject.js +249 -0
- package/neural-interface/public/shared/skills/studio/sx-test-view.js +994 -0
- package/neural-interface/public/shared/skills/studio/sx-test.js +328 -0
- package/neural-interface/public/shared/skills/studio/sx-versions-view.js +241 -0
- package/neural-interface/public/shared/skills/studio/sx-workspace-view.js +426 -0
- package/neural-interface/public/shared/state.js +1 -1
- package/neural-interface/public/shared/streaming-markdown.js +58 -0
- package/neural-interface/public/shared/styleguide/sg-panel.js +4 -2
- package/neural-interface/public/shared/styles.css +1822 -1756
- package/neural-interface/public/shared/synabun-mascot.js +1115 -9
- package/neural-interface/public/shared/text-cache.js +29 -0
- package/neural-interface/public/shared/tool-list-picker.js +55 -0
- package/neural-interface/public/shared/tool-list-storage.js +24 -0
- package/neural-interface/public/shared/tool-list-view.js +112 -0
- package/neural-interface/public/shared/topright-overflow-logic.js +54 -0
- package/neural-interface/public/shared/tour/tour-actor.js +268 -0
- package/neural-interface/public/shared/tour/tour-beats.js +336 -0
- package/neural-interface/public/shared/tour/tour-engine.js +884 -0
- package/neural-interface/public/shared/tour/tour-ink.js +390 -0
- package/neural-interface/public/shared/tour/tour-stage.js +440 -0
- package/neural-interface/public/shared/tour/tour-state.js +40 -0
- package/neural-interface/public/shared/tour/tour-tips.js +99 -0
- package/neural-interface/public/shared/tour/tour.css +144 -0
- package/neural-interface/public/shared/ui-antigravity-panel.js +19 -0
- package/neural-interface/public/shared/ui-assistant.js +2 -0
- package/neural-interface/public/shared/ui-automation-studio.js +111 -140
- package/neural-interface/public/shared/ui-claude-panel.js +376 -82
- package/neural-interface/public/shared/ui-cli-banner.js +23 -167
- package/neural-interface/public/shared/ui-command-runner.js +6 -3
- package/neural-interface/public/shared/ui-context-menu.js +295 -0
- package/neural-interface/public/shared/ui-cost-widget.js +57 -59
- package/neural-interface/public/shared/ui-crash-notice.js +187 -0
- package/neural-interface/public/shared/ui-detail.js +15 -3
- package/neural-interface/public/shared/ui-file-explorer.js +48 -6
- package/neural-interface/public/shared/ui-file-links.js +2 -2
- package/neural-interface/public/shared/ui-games.js +2 -2
- package/neural-interface/public/shared/ui-image-gallery.js +50 -47
- package/neural-interface/public/shared/ui-judgments.js +1 -1
- package/neural-interface/public/shared/ui-keybinds.js +33 -2
- package/neural-interface/public/shared/ui-link.js +3 -3
- package/neural-interface/public/shared/ui-menubar.js +10 -20
- package/neural-interface/public/shared/ui-native-loop-router-state.js +5 -0
- package/neural-interface/public/shared/ui-native-loop-router.js +11 -1
- package/neural-interface/public/shared/ui-navbar.js +49 -24
- package/neural-interface/public/shared/ui-notifications.js +1 -0
- package/neural-interface/public/shared/ui-panels.js +96 -65
- package/neural-interface/public/shared/ui-resume.js +9 -7
- package/neural-interface/public/shared/ui-schedules-studio.js +546 -431
- package/neural-interface/public/shared/ui-scroll-follow.js +16 -5
- package/neural-interface/public/shared/ui-sessions.js +27 -15
- package/neural-interface/public/shared/ui-settings.js +683 -526
- package/neural-interface/public/shared/ui-sidepanel-session-entry.js +9 -2
- package/neural-interface/public/shared/ui-sidepanel-sessions.js +4 -4
- package/neural-interface/public/shared/ui-sidepanel-state.js +23 -12
- package/neural-interface/public/shared/ui-sidepanel-tray.js +48 -7
- package/neural-interface/public/shared/ui-sidepanel-windows.js +18 -13
- package/neural-interface/public/shared/ui-skills.js +33 -2830
- package/neural-interface/public/shared/ui-styleguide.js +5 -2
- package/neural-interface/public/shared/ui-sync.js +15 -5
- package/neural-interface/public/shared/ui-terminal.js +270 -190
- package/neural-interface/public/shared/ui-tool-updates.js +6 -4
- package/neural-interface/public/shared/ui-tooltip.js +81 -51
- package/neural-interface/public/shared/ui-topright-overflow.js +233 -0
- package/neural-interface/public/shared/ui-trash.js +6 -4
- package/neural-interface/public/shared/ui-tutorial-layout.js +3 -117
- package/neural-interface/public/shared/ui-tutorial-preview.js +103 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +3 -65
- package/neural-interface/public/shared/ui-tutorial.css +47 -100
- package/neural-interface/public/shared/ui-tutorial.js +285 -509
- package/neural-interface/public/shared/ui-update.js +38 -2
- package/neural-interface/public/shared/ui-whiteboard.js +312 -59
- package/neural-interface/public/shared/ui-worker-end.js +5 -1
- package/neural-interface/public/shared/ui-workspaces.js +3 -2
- package/neural-interface/public/shared/view-variant.js +66 -0
- package/neural-interface/public/shared/whatsapp/wa-view.js +3 -3
- package/neural-interface/public/shared/whiteboard-list.js +172 -0
- package/neural-interface/public/shared/worker-end.js +10 -3
- package/neural-interface/public/shared/worker-questions.js +34 -0
- package/neural-interface/public/shared/worker-restart.js +12 -0
- package/neural-interface/public/sidepanel-session.html +2 -2
- package/neural-interface/public/variant/2d/background.js +2 -1
- package/neural-interface/public/variant/2d/context-menu.js +8 -2
- package/neural-interface/public/variant/2d/graph.js +42 -20
- package/neural-interface/public/variant/2d/hulls.js +14 -9
- package/neural-interface/public/variant/2d/lasso.js +11 -7
- package/neural-interface/public/variant/2d/main.js +32 -1
- package/neural-interface/public/variant/2d/minimap.js +11 -13
- package/neural-interface/public/variant/3d/main.js +69 -6
- package/neural-interface/public/variant/3d/map-controls.js +24 -9
- package/neural-interface/public/vendor/caveat/OFL.txt +93 -0
- package/neural-interface/public/vendor/caveat/caveat-latin-400-normal.woff2 +0 -0
- package/neural-interface/public/vendor/caveat/caveat-latin-700-normal.woff2 +0 -0
- package/neural-interface/public/vendor/caveat/caveat.css +17 -0
- package/neural-interface/public/vendor/caveat/manifest.json +12 -0
- package/neural-interface/public/vendor/yaml/LICENSE +13 -0
- package/neural-interface/public/vendor/yaml/manifest.json +4 -0
- package/neural-interface/public/vendor/yaml/yaml-2.9.1.min.js +137 -0
- package/neural-interface/scripts/build-vendor.mjs +32 -0
- package/neural-interface/server.js +4553 -2855
- package/neural-interface/templates/rulesets/README.md +1 -1
- package/neural-interface/templates/rulesets/core.md +1 -1
- package/neural-interface/templates/rulesets/legacy-hashes.json +23 -5
- package/neural-interface/templates/rulesets/manifest.json +8 -8
- package/npm-shrinkwrap.json +34 -12
- package/package.json +17 -3
- package/postinstall.js +9 -4
- package/preuninstall.js +1 -1
- package/setup.js +97 -322
- package/skills/skill-forge/SKILL.md +177 -0
- package/skills/skill-forge/evals/evals.json +54 -0
- package/skills/skill-forge/references/package.md +105 -0
- package/skills/skill-forge/references/review-and-recovery.md +63 -0
- package/skills/skill-forge/references/runtime-profiles.md +20 -0
- package/skills/skill-forge/references/sources-and-capabilities.md +51 -0
- package/skills/synabun/SKILL.md +10 -7
- package/skills/synabun/modules/audit.md +1 -5
- package/skills/synabun/modules/changelog.md +1 -5
- package/skills/synabun/modules/idea.md +1 -1
- package/skills/synabun/modules/memorize.md +6 -9
- package/skills/synabun/modules/schedule.md +6 -6
- package/mcp-server/dist/services/profiles.d.ts +0 -120
- package/mcp-server/dist/services/profiles.d.ts.map +0 -1
- package/mcp-server/dist/services/profiles.js +0 -387
- package/mcp-server/dist/services/profiles.js.map +0 -1
- package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +0 -97
- package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +0 -1
- package/mcp-server/dist/tools/leonardo-browser-tools.js +0 -228
- package/mcp-server/dist/tools/leonardo-browser-tools.js.map +0 -1
- package/mcp-server/dist/tools/leonardo.d.ts +0 -13
- package/mcp-server/dist/tools/leonardo.d.ts.map +0 -1
- package/mcp-server/dist/tools/leonardo.js +0 -21
- package/mcp-server/dist/tools/leonardo.js.map +0 -1
- package/mcp-server/dist/tools/profile.d.ts +0 -17
- package/mcp-server/dist/tools/profile.d.ts.map +0 -1
- package/mcp-server/dist/tools/profile.js +0 -114
- package/mcp-server/dist/tools/profile.js.map +0 -1
- package/mcp-server/src/services/profiles.ts +0 -428
- package/mcp-server/src/tools/leonardo-browser-tools.ts +0 -291
- package/mcp-server/src/tools/leonardo.ts +0 -30
- package/mcp-server/src/tools/profile.ts +0 -122
- package/neural-interface/lib/assistant-brains/opencode-browser-policy.js +0 -47
- package/neural-interface/lib/assistant-brains/opencode-route-gate.js +0 -62
- package/neural-interface/lib/mcp-profile-servers.js +0 -120
- package/neural-interface/public/shared/ui-tutorial-draw.js +0 -311
- package/skills/leonardo/SKILL.md +0 -168
- package/skills/leonardo/icon.svg +0 -1
- package/skills/leonardo/modules/browser-guide.md +0 -378
- package/skills/leonardo/modules/image-prompter.md +0 -283
- package/skills/leonardo/modules/model-advisor.md +0 -100
- package/skills/leonardo/modules/prompt-library.md +0 -110
- package/skills/leonardo/modules/style-guide.md +0 -186
- package/skills/leonardo/modules/video-prompter.md +0 -317
- /package/neural-interface/templates/rulesets/{gemini.md → antigravity.md} +0 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { retainExternalRefs } from './mcp-external-servers.js';
|
|
2
|
+
import { readStoredToolListRecord } from './tool-lists.js';
|
|
1
3
|
// ═══════════════════════════════════════════
|
|
2
4
|
// SynaBun — Assistant dispatcher (task-mode runs on NativeLoopRuntime)
|
|
3
5
|
// ═══════════════════════════════════════════
|
|
4
6
|
//
|
|
5
7
|
// A "dispatch" is one worker run launched by the central assistant: a single
|
|
6
|
-
// provider conversation (Claude / Codex / OpenCode) that executes one task,
|
|
8
|
+
// provider conversation (Claude / Codex / OpenCode / Antigravity) that executes one task,
|
|
7
9
|
// then stays warm for follow-up turns until the assistant completes it or the
|
|
8
10
|
// idle timeout fires. It rides on NativeLoopRuntime (ledger, claims, sidepanel
|
|
9
11
|
// attach) through the per-launch buildPrompt/wrapAdapter overrides, so the
|
|
@@ -18,14 +20,22 @@
|
|
|
18
20
|
// It never touches server.js internals directly — every server dependency is
|
|
19
21
|
// injected.
|
|
20
22
|
|
|
23
|
+
import { isBrowserGroup } from '../../mcp-server/dist/services/tool-catalog.js';
|
|
24
|
+
import { browserToolsConflict, dispatchTools, launchTools, toolsView } from './assistant-tools.js';
|
|
25
|
+
import { storedToolList, toolListRecord } from './tool-lists.js';
|
|
21
26
|
import { randomBytes } from 'node:crypto';
|
|
22
27
|
import { chooseUsageAccount } from './account-usage.js';
|
|
23
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
|
|
24
29
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
25
30
|
import {
|
|
26
|
-
RESULT_STATUSES, buildTaskPrompt, buildFollowUpPrompt, buildResultRetryPrompt, parseResultContract,
|
|
31
|
+
RESULT_STATUSES, buildTaskPrompt, buildFollowUpPrompt, buildResultRetryPrompt, buildResumePrompt, parseResultContract,
|
|
27
32
|
} from './assistant-task-prompt.js';
|
|
33
|
+
import { MAX_RESUMES, clockTime, endOrphanProcess, endProcessTree, findClaudeTranscript, isInside, readCostState, resumeRefusal } from './worker-resume.js';
|
|
28
34
|
import { normalizeCapability, normalizePermissionPolicy } from './native-loop-providers.js';
|
|
35
|
+
import { WORKER_GUARD_DEFAULT, describeConfinementEvent, normalizeWorkerGuardMode } from './worker-confinement.js';
|
|
36
|
+
import { draftConfinementSupport } from './skills/draft-confinement.js';
|
|
37
|
+
import { EVAL_CONFINEMENT, EVAL_ROLES, evalConfinementSupport } from './skills/eval-confinement.js';
|
|
38
|
+
import { initOf as evalInitOf, toolCallsOf as evalToolCallsOf } from './skills/eval-trace.js';
|
|
29
39
|
import { contextVariantOf, defaultIsHidden, defaultModelRow, findModel, hiddenModelId, providerAllHidden, providerEmpty, rowMakes } from './assistant-catalog.js';
|
|
30
40
|
import { TASK_CLASS_META, classPlaybook, collectedMedia, describeTarget, designFit, requiredOutput, requiresVision, runContract } from './assistant-router.js';
|
|
31
41
|
import { MAX_MEDIA_BYTES, MAX_RUN_MEDIA_BYTES, copyRunMedia, mediaType, mediaUrl, servableRunMedia } from './assistant-media.js';
|
|
@@ -33,32 +43,41 @@ import { loadPlaybook } from './assistant-playbooks.js';
|
|
|
33
43
|
import { loadStyleGuideForRun } from './style-guide/store.js';
|
|
34
44
|
import { claimCheckWaived, normalizeTestDensity, testingApplies } from './assistant-test-density.js';
|
|
35
45
|
import { normalizeEffort } from './effort-levels.js';
|
|
36
|
-
import { createTurnEvidence, recordProviderEvent, evidenceSnapshot, claimEvidenceLine } from './assistant-evidence.js';
|
|
46
|
+
import { acpCommandOf, createTurnEvidence, recordProviderEvent, evidenceSnapshot, claimEvidenceLine } from './assistant-evidence.js';
|
|
37
47
|
import { BUDGET_MAX_USD, codexUsageCostUsd, floorCents } from './assistant-budget.js';
|
|
38
|
-
import { MIN_SPEND_USD, PROVIDER_LABELS, isFreePrice } from './assistant-budget-split.js';
|
|
48
|
+
import { MIN_SPEND_USD, PROVIDER_LABELS, UNPRICED_PROVIDER_REASON, isFreePrice, isUnpricedProvider } from './assistant-budget-split.js';
|
|
39
49
|
import { createBudgetPlanner } from './assistant-budget-planner.js';
|
|
40
|
-
import { CLAUDE_ESTIMATE_SOURCE, CODEX_USAGE_KEYS, TOKEN_KEYS, addTokens, codexUsageTokens, createClaudeMeter, createCodexMeter, createOpenCodeMeter, totalTokens, withTotal, zeroTokens } from './assistant-usage.js';
|
|
50
|
+
import { CLAUDE_ESTIMATE_SOURCE, CODEX_USAGE_KEYS, TOKEN_KEYS, addTokens, antigravityUsageTokens, codexUsageTokens, createClaudeMeter, createCodexMeter, createOpenCodeMeter, totalTokens, withTotal, zeroTokens } from './assistant-usage.js';
|
|
41
51
|
import { claudeTokensCostUsd } from './assistant-pricing.js';
|
|
42
52
|
import { RemotePolicyError, clampDispatchSpec, getSessionPolicy, readRegisteredProjects } from './remote-policy.js';
|
|
53
|
+
import { validateWorkerReply } from './worker-question.js';
|
|
54
|
+
import { limitFromText } from '../public/shared/assistant/asst-limit.js';
|
|
43
55
|
|
|
44
|
-
|
|
56
|
+
// Antigravity (Google's `agy`, over ACP: lib/native-loop-providers.js createAntigravityNativeLoopAdapter) is a worker
|
|
57
|
+
// provider only: never a brain, no accounts, its usage reported in tokens and never priced.
|
|
58
|
+
export const NATIVE_PROVIDERS = new Set(['claude-code', 'codex', 'opencode', 'antigravity']);
|
|
45
59
|
// awaiting_route: an un-routed dispatch held until the user picks a model on
|
|
46
60
|
// the route card (ask modes). It is active but consumes no rails.
|
|
47
|
-
|
|
61
|
+
// suspended: a native worker the server suspended as it went down (its CLI
|
|
62
|
+
// ended); resuming: the next start continues it (resumeRun). Neither is an end.
|
|
63
|
+
export const ACTIVE_STATES = new Set(['queued', 'awaiting_route', 'starting', 'running', 'idle', 'awaiting_permission', 'suspended', 'resuming']);
|
|
48
64
|
export const TERMINAL_STATES = new Set(['completed', 'failed', 'stopped', 'interrupted']);
|
|
49
65
|
const WAITING_STATES = new Set(['queued', 'awaiting_route']);
|
|
66
|
+
// What a run can be suspended from at a shutdown: its worker is up, in a turn or between turns.
|
|
67
|
+
const SUSPENDABLE_STATES = new Set(['starting', 'running', 'idle', 'awaiting_permission']);
|
|
50
68
|
|
|
51
69
|
/**
|
|
52
70
|
* The run status a view reports beside its state (the agent_status contract:
|
|
53
71
|
* queued, starting, running, completed, failed, stopped, interrupted). A warm
|
|
54
|
-
* worker between turns (idle, awaiting_permission) is still running
|
|
55
|
-
* held for the user's model
|
|
72
|
+
* worker between turns (idle, awaiting_permission) is still running, and so is
|
|
73
|
+
* one the server suspended while it restarts; a run held for the user's model
|
|
74
|
+
* choice is still queued; one being resumed after a restart is starting.
|
|
56
75
|
*/
|
|
57
76
|
export function runStatusFromState(state) {
|
|
58
77
|
const value = String(state || '');
|
|
59
78
|
if (TERMINAL_STATES.has(value)) return value;
|
|
60
79
|
if (WAITING_STATES.has(value)) return 'queued';
|
|
61
|
-
if (value === 'starting') return 'starting';
|
|
80
|
+
if (value === 'starting' || value === 'resuming') return 'starting';
|
|
62
81
|
return ACTIVE_STATES.has(value) ? 'running' : null;
|
|
63
82
|
}
|
|
64
83
|
|
|
@@ -69,7 +88,7 @@ export function runStatusFromState(state) {
|
|
|
69
88
|
export const MIN_MAX_MINUTES = 20;
|
|
70
89
|
|
|
71
90
|
export const DEFAULT_LIMITS = Object.freeze({
|
|
72
|
-
perProvider: Object.freeze({ 'claude-code': 3, codex: 4, opencode: 3 }),
|
|
91
|
+
perProvider: Object.freeze({ 'claude-code': 3, codex: 4, opencode: 3, antigravity: 3 }),
|
|
73
92
|
perCodexAccount: 2,
|
|
74
93
|
perSession: 6,
|
|
75
94
|
queuedPerSession: 20,
|
|
@@ -105,47 +124,13 @@ export const DEFAULT_LIMITS = Object.freeze({
|
|
|
105
124
|
mediaRunBytes: MAX_RUN_MEDIA_BYTES,
|
|
106
125
|
});
|
|
107
126
|
|
|
108
|
-
/**
|
|
109
|
-
* What a class's run gets when the dispatch leaves a field out (undefined, null
|
|
110
|
-
* or ''): a design run works in its project with the SynaBun browser for an hour.
|
|
111
|
-
* Explicit values win. The browser profile comes with the browser: an explicit
|
|
112
|
-
* uses_browser:false keeps the run off it (no `browser` profile, which would turn
|
|
113
|
-
* browser use back on, and no tab), on the normal default profile. The browser
|
|
114
|
-
* rule for capability "full" (QUARANTINE_VIOLATION) still applies. Any class's
|
|
115
|
-
* browser run on Codex / OpenCode without an mcp_profile gets BROWSER_PROFILE
|
|
116
|
-
* (normalizeSpec): the stock default profiles have no browser tools.
|
|
117
|
-
*/
|
|
127
|
+
/** Class defaults; the shared resolver owns automatic groups. */
|
|
118
128
|
export const CLASS_DEFAULTS = Object.freeze({
|
|
119
|
-
design: Object.freeze({ capability: 'workspace', usesBrowser: true,
|
|
129
|
+
design: Object.freeze({ capability: 'workspace', usesBrowser: true, maxMinutes: 60 }),
|
|
120
130
|
});
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// and the stock presets with no browser tools (profiles.ts: core, standard).
|
|
125
|
-
const BROWSER_PROFILE_RE = /^(browser|twitter|facebook|tiktok|whatsapp|instagram|linkedin|bluesky|gsc|youtube)$/i;
|
|
126
|
-
const NO_BROWSER_PRESETS = new Set(['core', 'standard']);
|
|
127
|
-
function turnsBrowserOn(profile) {
|
|
128
|
-
const name = String(profile || '');
|
|
129
|
-
return BROWSER_PROFILE_RE.test(name) || /browser/i.test(name);
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Browser settings of a run (any class) that cannot work together, after CLASS_DEFAULTS (so a
|
|
133
|
-
* uses_browser false and any mcp_profile are the brain's own): the text of a
|
|
134
|
-
* BROWSER_SETTINGS_CONFLICT (400) that says what to change, else null. An explicit browser
|
|
135
|
-
* profile would turn an explicit uses_browser:false back on; a stock profile without the browser
|
|
136
|
-
* tools leaves a Codex / OpenCode worker unable to use the browser the run gets (Claude workers
|
|
137
|
-
* always get the full catalog). `provider` null: only the first check (it does not depend on it).
|
|
138
|
-
*/
|
|
139
|
-
export function browserSettingsConflict(spec, provider = null) {
|
|
140
|
-
const profile = String(spec?.mcpProfile || '').trim();
|
|
141
|
-
if (!profile) return null;
|
|
142
|
-
if (spec.usesBrowser === false && turnsBrowserOn(profile)) {
|
|
143
|
-
return `uses_browser is false, but mcp_profile "${profile}" is a browser profile, which turns browser use back on (the run would take a browser tab). Drop mcp_profile or pick one without the browser (such as "standard"), or set uses_browser to true.`;
|
|
144
|
-
}
|
|
145
|
-
if (provider && provider !== 'claude-code' && spec.usesBrowser === true && NO_BROWSER_PRESETS.has(profile.toLowerCase())) {
|
|
146
|
-
return `This run uses the SynaBun browser (uses_browser is on), but mcp_profile "${profile}" has no browser tools, so the ${provider} worker could not use it. Use mcp_profile "browser" or drop mcp_profile, or set uses_browser to false.`;
|
|
147
|
-
}
|
|
148
|
-
return null;
|
|
131
|
+
export function browserSettingsConflict(spec) {
|
|
132
|
+
try { return browserToolsConflict(spec); }
|
|
133
|
+
catch (error) { throw new DispatchError(error.code, error.message, { status: error.status || 400, problems: error.problems }); }
|
|
149
134
|
}
|
|
150
135
|
/** `spec` with its class's defaults filled in where it leaves a field out. */
|
|
151
136
|
export function withClassDefaults(spec, taskClass) {
|
|
@@ -160,7 +145,6 @@ export function withClassDefaults(spec, taskClass) {
|
|
|
160
145
|
if (!defaults) return out;
|
|
161
146
|
for (const [key, value] of Object.entries(defaults)) {
|
|
162
147
|
if (!(out[key] === undefined || out[key] === null || out[key] === '')) continue;
|
|
163
|
-
if (key === 'mcpProfile' && out.usesBrowser === false) continue;
|
|
164
148
|
out[key] = value;
|
|
165
149
|
}
|
|
166
150
|
return out;
|
|
@@ -218,6 +202,43 @@ function clip(value, max = 160) {
|
|
|
218
202
|
}
|
|
219
203
|
function isDirectory(path) { try { return statSync(path).isDirectory(); } catch { return false; } }
|
|
220
204
|
|
|
205
|
+
/** Internal Skills workers own their runtime selection and confinement. */
|
|
206
|
+
function isSkillsRun(entry) { return ['skill-eval', 'skill-draft'].includes(entry?.confinement?.kind); }
|
|
207
|
+
|
|
208
|
+
function requireConfinementProvider(entry) {
|
|
209
|
+
if (entry.confinement && entry.confinement.provider !== entry.provider) {
|
|
210
|
+
throw new DispatchError('CONFINEMENT_PROVIDER_MISMATCH', `Confinement was built for ${entry.confinement.provider || 'an unknown provider'}, but the run uses ${entry.provider}. The run was refused.`, { status: 409 });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A skill-evaluation run: its own prompt, result, memory and escalation rules. */
|
|
215
|
+
export function isEvalRun(entry) { return entry?.confinement?.kind === EVAL_CONFINEMENT; }
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Keep an evaluation run's tool calls (one entry per call id: a later update of the same call replaces its earlier
|
|
219
|
+
* one, so a command's "done" with its exit code wins over its "started") and the catalog its init named. Bounded.
|
|
220
|
+
*/
|
|
221
|
+
export function recordEvalCalls(liveState, payload = {}) {
|
|
222
|
+
const init = evalInitOf(payload);
|
|
223
|
+
if (init && !liveState.evalInit) liveState.evalInit = init;
|
|
224
|
+
const calls = evalToolCallsOf(payload);
|
|
225
|
+
if (!calls.length) return;
|
|
226
|
+
if (!liveState.evalCalls) { liveState.evalCalls = []; liveState.evalCallIndex = new Map(); }
|
|
227
|
+
for (const call of calls) {
|
|
228
|
+
const key = call.id ? `${call.tool}:${call.id}` : null;
|
|
229
|
+
const at = key ? liveState.evalCallIndex.get(key) : undefined;
|
|
230
|
+
if (at !== undefined) {
|
|
231
|
+
const prev = liveState.evalCalls[at];
|
|
232
|
+
// An update without the input (an OpenCode part's later state) keeps the input seen first.
|
|
233
|
+
liveState.evalCalls[at] = { ...prev, ...call, input: call.input && Object.keys(call.input).length ? call.input : prev.input };
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (liveState.evalCalls.length >= 300) return;
|
|
237
|
+
if (key) liveState.evalCallIndex.set(key, liveState.evalCalls.length);
|
|
238
|
+
liveState.evalCalls.push(call);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
221
242
|
/** One-line human summary of a provider stream event (used for transcript tails). */
|
|
222
243
|
export function summarizeProviderEvent(payload = {}) {
|
|
223
244
|
const event = payload.event || {};
|
|
@@ -226,6 +247,7 @@ export function summarizeProviderEvent(payload = {}) {
|
|
|
226
247
|
if (eventType === 'synabun.permission_request') return `permission requested: ${event.toolName || event.kind || 'tool'} ${clip(JSON.stringify(event.input || {}), 120)}`;
|
|
227
248
|
if (eventType === 'synabun.permission_resolved') return `permission ${event.behavior || 'resolved'}: ${event.toolName || ''}`;
|
|
228
249
|
if (eventType === 'synabun.error') return `error: ${clip(event.message, 200)}`;
|
|
250
|
+
if (eventType === 'synabun.confinement') return describeConfinementEvent(event);
|
|
229
251
|
if (payload.provider === 'claude-code') {
|
|
230
252
|
if (event.type === 'assistant') {
|
|
231
253
|
const blocks = Array.isArray(event.message?.content) ? event.message.content : [];
|
|
@@ -279,9 +301,55 @@ export function summarizeProviderEvent(payload = {}) {
|
|
|
279
301
|
if (/question\.asked/i.test(eventType)) return `question asked: ${clip((event.questions || []).map((q) => q.question).join(' | '), 200)}`;
|
|
280
302
|
return null;
|
|
281
303
|
}
|
|
304
|
+
if (payload.provider === 'antigravity') {
|
|
305
|
+
// eventType is the ACP session/update kind; event the update as the agent sent it.
|
|
306
|
+
if (eventType === 'agent_message_chunk') {
|
|
307
|
+
const text = event.content?.type === 'text' ? event.content.text : '';
|
|
308
|
+
return text && String(text).trim() ? `assistant: ${clip(text, 200)}` : null;
|
|
309
|
+
}
|
|
310
|
+
if (eventType === 'tool_call') return `${acpToolNoun(event)} started: ${acpToolDetail(event)}`;
|
|
311
|
+
if (eventType === 'tool_call_update') {
|
|
312
|
+
// Only the end of a call: its start was the tool_call line, its progress says nothing new.
|
|
313
|
+
if (event.status !== 'completed' && event.status !== 'failed') return null;
|
|
314
|
+
return `${acpToolNoun(event)} ${event.status === 'failed' ? 'failed' : 'done'}: ${acpToolDetail(event)}`;
|
|
315
|
+
}
|
|
316
|
+
if (eventType === 'plan') {
|
|
317
|
+
const entries = Array.isArray(event.entries) ? event.entries : [];
|
|
318
|
+
if (!entries.length) return null;
|
|
319
|
+
const done = entries.filter((step) => step?.status === 'completed').length;
|
|
320
|
+
const current = entries.find((step) => step?.status === 'in_progress');
|
|
321
|
+
return `plan: ${done}/${entries.length} done${current?.content ? `, now ${clip(current.content, 120)}` : ''}`;
|
|
322
|
+
}
|
|
323
|
+
if (eventType === 'synabun.provider_exit') {
|
|
324
|
+
const how = event.signal ? ` (${event.signal})` : event.code !== null && event.code !== undefined ? ` (code ${event.code})` : '';
|
|
325
|
+
return `error: Antigravity exited${how}${event.error ? `: ${clip(event.error, 160)}` : ''}`;
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
282
329
|
return null;
|
|
283
330
|
}
|
|
284
331
|
|
|
332
|
+
/** What an ACP tool call is, for a transcript line: command, file_change or tool (Codex's words). */
|
|
333
|
+
function acpToolNoun(call = {}) {
|
|
334
|
+
const kind = String(call.kind || '');
|
|
335
|
+
// agy names an MCP call call_mcp_tool, kind `execute`: it is a tool, never a command.
|
|
336
|
+
if (call.name === 'call_mcp_tool' || (call.rawInput && typeof call.rawInput === 'object' && call.rawInput.ServerName && call.rawInput.ToolName)) return 'tool';
|
|
337
|
+
if (kind === 'execute') return 'command';
|
|
338
|
+
if (kind === 'edit' || kind === 'delete' || kind === 'move') return 'file_change';
|
|
339
|
+
return 'tool';
|
|
340
|
+
}
|
|
341
|
+
/** The command, the paths or the title of an ACP tool call; its id when an update carries none of them. */
|
|
342
|
+
function acpToolDetail(call = {}) {
|
|
343
|
+
const input = call.rawInput && typeof call.rawInput === 'object' ? call.rawInput : {};
|
|
344
|
+
if (input.ServerName && input.ToolName) return clip(`${input.ServerName}_${input.ToolName}`, 140);
|
|
345
|
+
const command = call.kind === 'execute' ? acpCommandOf(call) : '';
|
|
346
|
+
if (command) return clip(command, 140);
|
|
347
|
+
const paths = (Array.isArray(call.locations) ? call.locations : []).map((location) => location?.path).filter((path) => typeof path === 'string' && path);
|
|
348
|
+
if (paths.length && call.kind !== 'execute' && call.kind !== 'fetch') return clip(paths.join(', '), 160);
|
|
349
|
+
if (call.title) return clip(call.title, 140);
|
|
350
|
+
return call.toolCallId ? `call ${String(call.toolCallId).slice(0, 12)}` : (call.kind || 'tool');
|
|
351
|
+
}
|
|
352
|
+
|
|
285
353
|
// The old five-cap keys a config file may still hold: never limits (the budget planner owns the money).
|
|
286
354
|
const LEGACY_BUDGET_LIMITS = new Set(['defaultBudgetUsd', 'maxBudgetUsd', 'sessionWarnUsd', 'sessionSoftBudgetUsd', 'sessionHardBudgetUsd', 'brainBudgetUsd', 'budgetVersion', 'budgetUpdatedAt']);
|
|
287
355
|
function mergeLimits(overrides = {}) {
|
|
@@ -330,9 +398,12 @@ function parseJsonObject(text) {
|
|
|
330
398
|
*/
|
|
331
399
|
export function createAssistantDispatcher({
|
|
332
400
|
getRuntime,
|
|
401
|
+
resolveExternalTools = () => ({ names: [], disabled: [] }),
|
|
333
402
|
dataDir,
|
|
334
403
|
loopDir = null,
|
|
335
404
|
broadcastSync = () => {},
|
|
405
|
+
observeSkillEvent = () => {},
|
|
406
|
+
completeSkillUsage = () => {},
|
|
336
407
|
log = () => {},
|
|
337
408
|
PACKAGE_ROOT = process.cwd(),
|
|
338
409
|
findCodexAccount = () => null,
|
|
@@ -345,9 +416,13 @@ export function createAssistantDispatcher({
|
|
|
345
416
|
codexAccounts = null,
|
|
346
417
|
accountUsage = null,
|
|
347
418
|
codexWorkerAccount = () => null,
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
419
|
+
// The Protect workers setting (lib/worker-guard-setting.js) as a run starts: 'enforce' | 'warn' | 'off'.
|
|
420
|
+
workerGuard = () => WORKER_GUARD_DEFAULT,
|
|
421
|
+
// SynaBun's own port: a guarded worker's shell may not call it (lib/worker-confinement.js).
|
|
422
|
+
serverPort = null,
|
|
423
|
+
// The ports SynaBun keeps for its OpenCode serves ({ from, to }): a guarded worker's shell calls none of them.
|
|
424
|
+
localPortRange = null,
|
|
425
|
+
toolListOptions = () => ({}),
|
|
351
426
|
acquireLoopBrowserAndTab = null,
|
|
352
427
|
releaseSharedBrowserTab = async () => {},
|
|
353
428
|
reconcileKeepAwake = () => {},
|
|
@@ -398,6 +473,9 @@ export function createAssistantDispatcher({
|
|
|
398
473
|
// The session's Test density (assistant-test-density.js): (assistantSessionId) => level | null.
|
|
399
474
|
// Read when a prompt is composed; null or an unknown value is the default level.
|
|
400
475
|
testDensity = () => null,
|
|
476
|
+
// After a restart, resolves once this server answers its workers (a resumed CLI connects to SynaBun's MCP as it
|
|
477
|
+
// starts): runs are resumed only then. null: at once.
|
|
478
|
+
serverReady = null,
|
|
401
479
|
} = {}) {
|
|
402
480
|
if (typeof getRuntime !== 'function') throw new Error('createAssistantDispatcher requires getRuntime()');
|
|
403
481
|
if (!dataDir) throw new Error('createAssistantDispatcher requires dataDir');
|
|
@@ -419,6 +497,8 @@ export function createAssistantDispatcher({
|
|
|
419
497
|
const registryPath = resolve(dataDir, 'assistant-dispatches.json');
|
|
420
498
|
// What image / video creation runs generated, and design runs' screenshots: <dataDir>/media/<runId>/<n>-<name>.
|
|
421
499
|
const mediaRoot = resolve(dataDir, 'media');
|
|
500
|
+
// A guarded worker's own scratch folder (lib/worker-confinement.js): <dataDir>/scratch/<runId>, removed when empty.
|
|
501
|
+
const scratchRoot = resolve(dataDir, 'scratch');
|
|
422
502
|
/** A known task class, else null. */
|
|
423
503
|
const knownClass = (value) => (Object.prototype.hasOwnProperty.call(TASK_CLASS_META, String(value || '')) ? String(value) : null);
|
|
424
504
|
const entries = new Map(); // runId → entry (persisted shape)
|
|
@@ -438,29 +518,95 @@ export function createAssistantDispatcher({
|
|
|
438
518
|
let persistTimer = null;
|
|
439
519
|
let stalledTimer = null;
|
|
440
520
|
let unsubscribeRuntime = null;
|
|
521
|
+
// A server restart: the runs this start resumes (resumeOnBoot), and the ones it ends whose end is said once the
|
|
522
|
+
// Assistant listens (both filled by loadRegistry). Follow-ups sent to a run while it is away wait here for it.
|
|
523
|
+
const bootResumes = [];
|
|
524
|
+
const bootInterrupts = [];
|
|
525
|
+
const awaitingResume = new Map(); // runId → follow-up items (sendTurn) for a suspended or resuming run
|
|
526
|
+
const resumeInProgress = new Set(); // runIds resumeRun works on (they hold their slot; the others waiting do not)
|
|
527
|
+
// The server is going down (suspendForShutdown ran): a resume under way stops where it is, for the next start.
|
|
528
|
+
let closing = false;
|
|
441
529
|
|
|
442
530
|
// ── persistence ────────────────────────────────────────────────────────────
|
|
531
|
+
/** Why the original provider account of a run cannot be used now ('' when it can, or the provider has no accounts). */
|
|
532
|
+
function providerAccountUnusable(accountId, provider = 'claude-code', entry = null) {
|
|
533
|
+
// OpenCode and Antigravity sign in inside their own CLI: SynaBun keeps no account of theirs.
|
|
534
|
+
if (provider !== 'codex' && provider !== 'claude-code') return '';
|
|
535
|
+
if (provider === 'codex') {
|
|
536
|
+
const home = codexAccounts?.homeFor?.(accountId);
|
|
537
|
+
if (home && entry?.codexHome && resolve(home) !== resolve(entry.codexHome)) return 'its CODEX_HOME changed';
|
|
538
|
+
return codexAccounts?.unusableReason?.(accountId) || '';
|
|
539
|
+
}
|
|
540
|
+
try { return typeof claudeAccounts?.unusableReason === 'function' ? String(claudeAccounts.unusableReason(accountId || 'default') || '') : ''; } catch (error) { return error?.message || 'it could not be read'; }
|
|
541
|
+
}
|
|
443
542
|
function loadRegistry() {
|
|
444
543
|
const rows = readJson(registryPath, []);
|
|
445
544
|
if (!Array.isArray(rows)) return;
|
|
446
545
|
for (const row of rows) {
|
|
447
546
|
if (!row?.runId) continue;
|
|
448
|
-
|
|
547
|
+
let entry = { ...row };
|
|
548
|
+
try {
|
|
549
|
+
entry = readStoredToolListRecord(row, toolListOptions());
|
|
550
|
+
entry.toolsRequested = row.toolsRequested !== undefined ? row.toolsRequested : storedToolList(row, toolListOptions());
|
|
551
|
+
dispatchTools({ tools: entry.toolsRequested ?? undefined });
|
|
552
|
+
if (row.rawSpec) entry.rawSpec = toolListRecord(row.rawSpec, toolListOptions());
|
|
553
|
+
if (!entry.tools) entry.tools = launchTools(dispatchTools({ ...entry, tools: entry.toolsRequested ?? undefined }));
|
|
554
|
+
} catch (error) {
|
|
555
|
+
entry.tools = []; entry.toolsRequested = [];
|
|
556
|
+
entry.toolListError = { code: error.code, message: error.message, original: row.tools ?? row.mcpProfile };
|
|
557
|
+
delete entry.mcpProfile; delete entry.rawSpec;
|
|
558
|
+
entry.state = 'failed'; entry.completionReason = 'tools_invalid';
|
|
559
|
+
}
|
|
449
560
|
// A failure cause being judged when the server went down is never coming.
|
|
450
561
|
delete entry.judging;
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
entry.pending = [];
|
|
456
|
-
entry.turnState = 'terminal';
|
|
457
|
-
closeUsageState(entry);
|
|
458
|
-
}
|
|
459
|
-
// A turn cut off by the restart worked until the run was last updated (as far as anything shows).
|
|
562
|
+
// When the server was last seen with the run live: its last update, or the last beat() after it.
|
|
563
|
+
const lastSeen = Math.max(Date.parse(entry.updatedAt || '') || 0, Math.min(now(), Date.parse(entry.aliveAt || '') || 0));
|
|
564
|
+
delete entry.aliveAt;
|
|
565
|
+
// A turn cut off by the restart worked until then (as far as anything shows).
|
|
460
566
|
if (entry.workingSince) {
|
|
461
|
-
entry.workMs = workUsedMs(entry, Math.max(Date.parse(entry.workingSince) || 0,
|
|
567
|
+
entry.workMs = workUsedMs(entry, Math.max(Date.parse(entry.workingSince) || 0, lastSeen));
|
|
462
568
|
entry.workingSince = null;
|
|
463
569
|
}
|
|
570
|
+
// A run the last shutdown could not suspend ended then (suspendForShutdown): it is said now, as a refusal here is.
|
|
571
|
+
if (entry.refusedAtShutdown) {
|
|
572
|
+
delete entry.refusedAtShutdown;
|
|
573
|
+
if (entry.resumeRefused && TERMINAL_STATES.has(entry.state)) bootInterrupts.push(entry.runId);
|
|
574
|
+
}
|
|
575
|
+
if (ACTIVE_STATES.has(entry.state)) {
|
|
576
|
+
// What the restart cut off: a run the last shutdown suspended, one a crash left working (no shutdown ran), or
|
|
577
|
+
// one a restart caught while it resumed. A native worker that may continue is resumed (resumeOnBoot);
|
|
578
|
+
// everything else ends as interrupted, as every cut-off run did before, with why in its notes.
|
|
579
|
+
const candidate = entry.state === 'suspended' || entry.state === 'resuming' || SUSPENDABLE_STATES.has(entry.state);
|
|
580
|
+
// A crash suspended nothing: such a run was left when the server was last seen with it.
|
|
581
|
+
const crashed = candidate && entry.state !== 'suspended' && entry.state !== 'resuming';
|
|
582
|
+
const leftAt = crashed ? iso(lastSeen || now()) : null;
|
|
583
|
+
const why = candidate
|
|
584
|
+
? resumeRefusal(crashed ? { ...entry, suspendedAt: leftAt } : entry, { now: now(), workLeftMs: workLeftMs(entry), isDirectory, accountUnusable: providerAccountUnusable })
|
|
585
|
+
: null;
|
|
586
|
+
if (candidate && !why) {
|
|
587
|
+
if (crashed) {
|
|
588
|
+
entry.suspendedAt = leftAt;
|
|
589
|
+
entry.suspendReason = 'server_restart';
|
|
590
|
+
entry.crashed = true;
|
|
591
|
+
}
|
|
592
|
+
entry.state = 'resuming';
|
|
593
|
+
entry.turnState = 'resuming';
|
|
594
|
+
entry.pending = [];
|
|
595
|
+
bootResumes.push(entry.runId);
|
|
596
|
+
} else {
|
|
597
|
+
entry.state = 'interrupted';
|
|
598
|
+
entry.completionReason = entry.completionReason || 'server_restart';
|
|
599
|
+
entry.finishedAt = entry.finishedAt || iso(now());
|
|
600
|
+
entry.pending = [];
|
|
601
|
+
entry.turnState = 'terminal';
|
|
602
|
+
if (why) {
|
|
603
|
+
entry.notes = [...(Array.isArray(entry.notes) ? entry.notes : []), `not resumed: ${why}`];
|
|
604
|
+
entry.resumeRefused = why;
|
|
605
|
+
bootInterrupts.push(entry.runId);
|
|
606
|
+
}
|
|
607
|
+
closeUsageState(entry);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
464
610
|
entries.set(entry.runId, entry);
|
|
465
611
|
}
|
|
466
612
|
}
|
|
@@ -480,6 +626,21 @@ export function createAssistantDispatcher({
|
|
|
480
626
|
persistTimer = setTimeout(() => { persistTimer = null; write(); }, 200);
|
|
481
627
|
persistTimer.unref?.();
|
|
482
628
|
}
|
|
629
|
+
/**
|
|
630
|
+
* Every minute, on each live run (a worker up or about to be): the server is alive with it now (aliveAt, saved, not an
|
|
631
|
+
* update). After a crash loadRegistry measures from it, which can be long after the run's last update (a warm worker
|
|
632
|
+
* idle for a follow-up, a long silent turn).
|
|
633
|
+
*/
|
|
634
|
+
function beat() {
|
|
635
|
+
const at = iso(now());
|
|
636
|
+
let alive = 0;
|
|
637
|
+
for (const entry of entries.values()) {
|
|
638
|
+
if (!ACTIVE_STATES.has(entry.state) || WAITING_STATES.has(entry.state)) continue;
|
|
639
|
+
entry.aliveAt = at;
|
|
640
|
+
alive += 1;
|
|
641
|
+
}
|
|
642
|
+
if (alive) persist();
|
|
643
|
+
}
|
|
483
644
|
|
|
484
645
|
// ── views + events ─────────────────────────────────────────────────────────
|
|
485
646
|
/** The task of a live run's turn in progress (or the last one it started); null for a run that ended or has not started one. */
|
|
@@ -508,10 +669,13 @@ export function createAssistantDispatcher({
|
|
|
508
669
|
const startedAt = entry.startedAt || entry.createdAt;
|
|
509
670
|
const end = entry.finishedAt ? new Date(entry.finishedAt).getTime() : at;
|
|
510
671
|
const elapsedMs = startedAt ? Math.max(0, end - new Date(startedAt).getTime()) : 0;
|
|
511
|
-
// The brief goes to the worker's prompt; views name it only. The meters' state stays inside
|
|
512
|
-
|
|
672
|
+
// The brief goes to the worker's prompt; views name it only. The meters' state stays inside, and so does what a
|
|
673
|
+
// resume after a restart needs (resumeInfo: the CLI's pid, the turn in progress); `resumes` counts them.
|
|
674
|
+
const { rawSpec: _rawSpec, brief: _brief, usageState: _usageState, resumeInfo: _resumeInfo, crashed: _crashed, refusedAtShutdown: _refusedAtShutdown, aliveAt: _aliveAt, ...rest } = entry;
|
|
513
675
|
return {
|
|
514
676
|
...rest,
|
|
677
|
+
tools: toolsView({ ...entry, tools: runtimeRun?.tools ?? entry.tools }),
|
|
678
|
+
resumes: Number(entry.resumes) || 0,
|
|
515
679
|
briefId: entry.brief?.briefId || null,
|
|
516
680
|
taskId: entry.taskId || null,
|
|
517
681
|
// The task of the turn in progress (or the last one started): a follow-up may work for a
|
|
@@ -545,9 +709,11 @@ export function createAssistantDispatcher({
|
|
|
545
709
|
status: runtimeRun.status, turnState: runtimeRun.turnState, stoppedReason: runtimeRun.stoppedReason,
|
|
546
710
|
error: runtimeRun.error, claimedBy: runtimeRun.claimedBy, providerSessionId: runtimeRun.providerSessionId,
|
|
547
711
|
providerThreadId: runtimeRun.providerThreadId,
|
|
712
|
+
...((runtimeRun.antigravityHome || entry.resumeInfo?.antigravityHome || entry.antigravityHome) ? { antigravityHome: runtimeRun.antigravityHome || entry.resumeInfo?.antigravityHome || entry.antigravityHome } : {}),
|
|
548
713
|
} : (entry.runtime || null),
|
|
549
714
|
providerSessionId: runtimeRun?.providerSessionId || entry.providerSessionId || null,
|
|
550
715
|
providerThreadId: runtimeRun?.providerThreadId || entry.providerThreadId || null,
|
|
716
|
+
...((runtimeRun?.antigravityHome || entry.resumeInfo?.antigravityHome || entry.antigravityHome) ? { antigravityHome: runtimeRun?.antigravityHome || entry.resumeInfo?.antigravityHome || entry.antigravityHome } : {}),
|
|
551
717
|
};
|
|
552
718
|
}
|
|
553
719
|
function safeRuntime() { try { return getRuntime(); } catch { return null; } }
|
|
@@ -594,6 +760,7 @@ export function createAssistantDispatcher({
|
|
|
594
760
|
touch(entry);
|
|
595
761
|
persist({ force: TERMINAL_STATES.has(entry.state) });
|
|
596
762
|
const payload = { type: 'assistant:dispatch', reason, run: view(entry), ...extra };
|
|
763
|
+
if (['turn_completed', 'idle', 'failed', 'stopped', 'completed'].includes(reason)) { try { completeSkillUsage(entry); } catch {} }
|
|
597
764
|
try { broadcastSync(payload); } catch {}
|
|
598
765
|
for (const listener of listeners) { try { listener(payload); } catch {} }
|
|
599
766
|
notifyWaiters(entry.runId, reason);
|
|
@@ -657,7 +824,7 @@ export function createAssistantDispatcher({
|
|
|
657
824
|
}
|
|
658
825
|
|
|
659
826
|
/** `taskClass`: the run's class (its route's, else the spec's); image / video creation needs a model that makes the medium. */
|
|
660
|
-
function normalizeSpec(spec = {}, { catalogValue = null,
|
|
827
|
+
function normalizeSpec(spec = {}, { catalogValue = null, taskClass = null } = {}) {
|
|
661
828
|
const provider = String(spec.provider || '').trim().toLowerCase();
|
|
662
829
|
if (!NATIVE_PROVIDERS.has(provider)) throw new DispatchError('INVALID_PROVIDER', `provider must be one of ${[...NATIVE_PROVIDERS].join(', ')}`);
|
|
663
830
|
const task = String(spec.task || '').trim();
|
|
@@ -666,14 +833,8 @@ export function createAssistantDispatcher({
|
|
|
666
833
|
const cwd = String(spec.cwd || spec.project || PACKAGE_ROOT).trim();
|
|
667
834
|
if (!cwd || !isDirectory(cwd)) throw new DispatchError('CWD_INVALID', `cwd is not a directory: ${cwd}`);
|
|
668
835
|
let permissionPolicy = normalizePermissionPolicy(spec.permissionPolicy);
|
|
669
|
-
|
|
836
|
+
let capability = normalizeCapability(spec.capability || 'full');
|
|
670
837
|
const notes = [];
|
|
671
|
-
if (provider === 'codex' && permissionPolicy === 'ask') {
|
|
672
|
-
// A route card may move an 'ask' dispatch onto Codex: degrade instead of failing.
|
|
673
|
-
if (!codexAskFallback) throw new DispatchError('PERMISSION_POLICY_UNSUPPORTED', 'Codex dispatches cannot use permissionPolicy "ask" (the Codex SDK has no approval channel). Use "restricted" for a workspace-write sandbox or "auto".', { suggested: 'restricted' });
|
|
674
|
-
permissionPolicy = 'restricted';
|
|
675
|
-
notes.push('permission policy "ask" became "restricted" (Codex has no approval channel)');
|
|
676
|
-
}
|
|
677
838
|
// "auto" (or nothing) = let routing pick / use the provider default.
|
|
678
839
|
let model = spec.model && String(spec.model).trim() && String(spec.model).trim().toLowerCase() !== 'auto' ? String(spec.model).trim() : null;
|
|
679
840
|
let modelInfo = null;
|
|
@@ -687,6 +848,7 @@ export function createAssistantDispatcher({
|
|
|
687
848
|
throw new DispatchError('PROVIDER_NOT_CONNECTED', `No ${provider} model is connected. Pick another provider from agent_catalog, or ask the user to connect one.`, { status: 400 });
|
|
688
849
|
}
|
|
689
850
|
if (!model && catalogValue && defaultIsHidden(catalogValue, provider)) {
|
|
851
|
+
if (isSkillsRun(spec)) throw new DispatchError('MODEL_DISABLED', `The ${provider} default model is disabled; the Skills run cannot substitute another model.`, { status: 400 });
|
|
690
852
|
// No model = the provider default, which the user hid: pin the first visible model.
|
|
691
853
|
const pinned = defaultModelRow(catalogValue, provider);
|
|
692
854
|
if (pinned) {
|
|
@@ -721,16 +883,46 @@ export function createAssistantDispatcher({
|
|
|
721
883
|
// an unsupported level becomes the nearest one it runs, recorded on the run.
|
|
722
884
|
const checked = normalizeEffort({ provider, model, effort: spec.effort, catalog: catalogValue });
|
|
723
885
|
const effortCorrection = checked.corrected || null;
|
|
886
|
+
if (isSkillsRun(spec) && effortCorrection) throw new DispatchError('EFFORT_UNSUPPORTED', `The Skills run requested effort ${spec.effort}, which ${provider}/${model || 'default'} cannot run.`, { status: 400 });
|
|
724
887
|
if (effortCorrection) notes.push(`effort "${effortCorrection.from}" is not supported by ${model || `the ${provider} default model`}; using ${effortCorrection.to ? `"${effortCorrection.to}"` : 'the model default'}`);
|
|
725
888
|
// The run's own browser settings must agree on the provider it gets, whatever its class.
|
|
726
889
|
const conflict = browserSettingsConflict(spec, provider);
|
|
727
890
|
if (conflict) throw new DispatchError('BROWSER_SETTINGS_CONFLICT', conflict, { status: 400 });
|
|
728
|
-
const
|
|
891
|
+
const selection = dispatchTools({ ...spec, taskClass, provider });
|
|
892
|
+
const externalServers = resolveExternalTools({ ...spec, provider }, selection.servers);
|
|
893
|
+
const usesBrowser = spec.usesBrowser === true || selection.groups.some(isBrowserGroup);
|
|
729
894
|
const usesComputer = spec.usesComputer === true || spec.usesComputer === 'true';
|
|
730
895
|
const tags = Array.isArray(spec.tags) ? spec.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 10) : [];
|
|
731
896
|
if ((usesBrowser || usesComputer) && capability === 'full' && !tags.includes('user-authorized-full')) {
|
|
732
897
|
throw new DispatchError('QUARANTINE_VIOLATION', `${usesComputer ? 'Computer-use' : 'Browser-reading'} runs must use capability "read-only" or "workspace" unless tagged "user-authorized-full" by explicit user request.`);
|
|
733
898
|
}
|
|
899
|
+
// A skill-drafting run (lib/skills/creation.js): held to its folder by lib/skills/draft-confinement.js, each
|
|
900
|
+
// provider the way it can (draftConfinementSupport); one that cannot is refused, never started unconfined.
|
|
901
|
+
// It reads no page, drives no computer and is never asked anything.
|
|
902
|
+
let confinement = null;
|
|
903
|
+
if (spec.confinement !== undefined && spec.confinement !== null) {
|
|
904
|
+
if (spec.confinement.provider) requireConfinementProvider({ provider, confinement: spec.confinement });
|
|
905
|
+
if (spec.confinement?.kind === EVAL_CONFINEMENT) {
|
|
906
|
+
// A skill-evaluation run (lib/skills/evals.js): held to its job folder by lib/skills/eval-confinement.js, the
|
|
907
|
+
// same way on every provider that can hold it; its task is sent as it is (the case's own words), and it has
|
|
908
|
+
// no Result contract, no Jev reading, no result retry and no escalation.
|
|
909
|
+
const support = evalConfinementSupport(provider);
|
|
910
|
+
if (!support.ok) throw new DispatchError('CONFINEMENT_UNSUPPORTED', `A skill-evaluation run cannot run on ${provider}: ${support.reason}`, { status: 400 });
|
|
911
|
+
if (usesBrowser || usesComputer) throw new DispatchError('CONFINEMENT_UNSUPPORTED', 'A skill-evaluation run reads no page and drives no computer.', { status: 400 });
|
|
912
|
+
const role = EVAL_ROLES.includes(spec.confinement.role) ? spec.confinement.role : null;
|
|
913
|
+
if (!role) throw new DispatchError('CONFINEMENT_INVALID', 'Unknown evaluation role.', { status: 400 });
|
|
914
|
+
const skill = typeof spec.confinement.skill === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(spec.confinement.skill) ? spec.confinement.skill : null;
|
|
915
|
+
confinement = { kind: EVAL_CONFINEMENT, provider, role, shell: role === 'trial' && spec.confinement.shell === true, skill };
|
|
916
|
+
} else {
|
|
917
|
+
if (spec.confinement?.kind !== 'skill-draft') throw new DispatchError('CONFINEMENT_INVALID', 'Unknown confinement.', { status: 400 });
|
|
918
|
+
const support = draftConfinementSupport(provider);
|
|
919
|
+
if (!support.ok) throw new DispatchError('CONFINEMENT_UNSUPPORTED', `A skill-drafting run cannot run on ${provider}: ${support.reason}`, { status: 400 });
|
|
920
|
+
if (usesBrowser || usesComputer) throw new DispatchError('CONFINEMENT_UNSUPPORTED', 'A skill-drafting run reads no page and drives no computer.', { status: 400 });
|
|
921
|
+
confinement = { kind: 'skill-draft', provider };
|
|
922
|
+
}
|
|
923
|
+
capability = 'workspace';
|
|
924
|
+
permissionPolicy = 'auto';
|
|
925
|
+
}
|
|
734
926
|
if (usesComputer) {
|
|
735
927
|
if (!desktop || !desktop.isSupported?.()) throw new DispatchError('COMPUTER_UNAVAILABLE', 'Computer use is not available on this machine (macOS only).', { status: 409 });
|
|
736
928
|
if (!desktop.isReady?.()) throw new DispatchError('COMPUTER_UNAVAILABLE', `Computer use is not set up yet (${desktop.setupState?.() || 'setup required'}). Ask the user to finish the one-time setup in the assistant panel.`, { status: 409 });
|
|
@@ -750,13 +942,6 @@ export function createAssistantDispatcher({
|
|
|
750
942
|
const sightRefusal = designRefusal(catalogValue, provider, model, taskClass);
|
|
751
943
|
if (sightRefusal) throw sightRefusal;
|
|
752
944
|
const agent = provider === 'opencode' && spec.agent ? String(spec.agent).trim().slice(0, 60) || null : null;
|
|
753
|
-
let mcpProfile = null;
|
|
754
|
-
if (provider === 'claude-code') mcpProfile = 'full';
|
|
755
|
-
else {
|
|
756
|
-
// A browser run needs the browser tools: the active profile (core by default) has none.
|
|
757
|
-
mcpProfile = normalizeMcpProfileName(spec.mcpProfile || (usesBrowser ? BROWSER_PROFILE : readActiveMcpProfile()));
|
|
758
|
-
if (mcpProfile && !isValidMcpProfileValue(mcpProfile)) throw new DispatchError('MCP_PROFILE_INVALID', `Invalid MCP profile: ${mcpProfile}`);
|
|
759
|
-
}
|
|
760
945
|
let codexAccount = null;
|
|
761
946
|
let claudeAccount = null;
|
|
762
947
|
const accountId = spec.accountId || spec.codexAccountId || spec.claudeAccountId || null;
|
|
@@ -809,7 +994,7 @@ export function createAssistantDispatcher({
|
|
|
809
994
|
model, modelContextWindow, pricingModel,
|
|
810
995
|
effort: checked.effort || null, effortCorrection,
|
|
811
996
|
agent, modelInfo, notes,
|
|
812
|
-
|
|
997
|
+
toolsRequested: selection.requested, tools: launchTools(selection), externalMcpVersion: 2, externalServers, permissionPolicy, capability, usesBrowser, usesComputer, confinement,
|
|
813
998
|
codexAccountId: codexAccount?.id || null,
|
|
814
999
|
codexHome: codexAccount ? (codexAccount.home || CODEX_DEFAULT_HOME) : null,
|
|
815
1000
|
claudeAccountId: claudeAccount?.id || (provider === 'claude-code' ? 'default' : null),
|
|
@@ -826,21 +1011,39 @@ export function createAssistantDispatcher({
|
|
|
826
1011
|
}
|
|
827
1012
|
|
|
828
1013
|
// ── rails ──────────────────────────────────────────────────────────────────
|
|
1014
|
+
// A suspended run holds no slot (its worker is gone with the server); one being resumed holds its own again.
|
|
829
1015
|
function activeEntries(filter = () => true) {
|
|
830
|
-
return [...entries.values()].filter((entry) => ACTIVE_STATES.has(entry.state) && !WAITING_STATES.has(entry.state) && filter(entry));
|
|
1016
|
+
return [...entries.values()].filter((entry) => ACTIVE_STATES.has(entry.state) && !WAITING_STATES.has(entry.state) && entry.state !== 'suspended' && filter(entry));
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* The runs that hold a provider's slots, as a queued note says them: every Assistant session's runs
|
|
1020
|
+
* count (a warm worker waiting for a follow-up too; never a brain, which is no run), so a slot taken
|
|
1021
|
+
* in another session is named as such.
|
|
1022
|
+
*/
|
|
1023
|
+
function slotHolders(holders, assistantSessionId) {
|
|
1024
|
+
const named = holders.map((e) => `${String(e.runId).slice(0, 8)}${(e.assistantSessionId || null) !== (assistantSessionId || null) ? ' (another session)' : ''}${e.state === 'idle' ? ' (idle, waiting for a follow-up)' : ''}`);
|
|
1025
|
+
return `held by ${named.join(', ')}`;
|
|
831
1026
|
}
|
|
832
|
-
|
|
1027
|
+
/**
|
|
1028
|
+
* `except`: a run being resumed, which counts the others but not itself, nor the runs still waiting for their resume
|
|
1029
|
+
* (resumeRun has not taken them up): runs that wait for a slot never hold each other back. A new dispatch counts them,
|
|
1030
|
+
* so it waits behind them.
|
|
1031
|
+
*/
|
|
1032
|
+
function railBlock(spec, assistantSessionId, { except = null } = {}) {
|
|
1033
|
+
const others = (filter) => activeEntries((e) => e.runId !== except && (!except || e.state !== 'resuming' || resumeInProgress.has(e.runId)) && filter(e));
|
|
833
1034
|
// One desktop, one controller: computer-use runs never overlap.
|
|
834
|
-
if (spec.usesComputer &&
|
|
1035
|
+
if (spec.usesComputer && others((e) => e.usesComputer).length >= 1) return 'another computer-use run is active (one at a time)';
|
|
835
1036
|
const perProvider = limits.perProvider[spec.provider] ?? 3;
|
|
836
|
-
|
|
837
|
-
if (
|
|
838
|
-
|
|
1037
|
+
const providerRuns = others((e) => e.provider === spec.provider);
|
|
1038
|
+
if (providerRuns.length >= perProvider) return `provider ${spec.provider} at its concurrency limit (${perProvider}), ${slotHolders(providerRuns, assistantSessionId)}`;
|
|
1039
|
+
const accountRuns = spec.provider === 'codex' ? others((e) => e.provider === 'codex' && e.accountId === spec.accountId) : [];
|
|
1040
|
+
if (spec.provider === 'codex' && accountRuns.length >= limits.perCodexAccount) {
|
|
1041
|
+
return `Codex account ${spec.accountId} at its concurrency limit (${limits.perCodexAccount}), ${slotHolders(accountRuns, assistantSessionId)}`;
|
|
839
1042
|
}
|
|
840
|
-
if (assistantSessionId &&
|
|
1043
|
+
if (assistantSessionId && others((e) => e.assistantSessionId === assistantSessionId).length >= limits.perSession) {
|
|
841
1044
|
return `assistant session at its concurrency limit (${limits.perSession})`;
|
|
842
1045
|
}
|
|
843
|
-
if (spec.workflowId &&
|
|
1046
|
+
if (spec.workflowId && others((e) => e.workflowId === spec.workflowId).length >= limits.maxFanOut) {
|
|
844
1047
|
return `workflow ${spec.workflowId} at its fan-out limit (${limits.maxFanOut})`;
|
|
845
1048
|
}
|
|
846
1049
|
return null;
|
|
@@ -914,10 +1117,11 @@ export function createAssistantDispatcher({
|
|
|
914
1117
|
/**
|
|
915
1118
|
* Why a run's spend could not be held to the budget (no price for its model), else null. Enforced
|
|
916
1119
|
* 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).
|
|
1120
|
+
* OpenCode a catalog price (a free model is fine), Antigravity never has one (tokens, not prices).
|
|
918
1121
|
*/
|
|
919
1122
|
function unpricedReason(entry) {
|
|
920
1123
|
if (!pricing || entry.provider === 'claude-code' || !limitedNow()) return null;
|
|
1124
|
+
if (isUnpricedProvider(entry.provider)) return UNPRICED_PROVIDER_REASON;
|
|
921
1125
|
const model = entry.pricingModel || entry.model || null;
|
|
922
1126
|
if (entry.provider === 'codex') {
|
|
923
1127
|
let price = null;
|
|
@@ -929,7 +1133,10 @@ export function createAssistantDispatcher({
|
|
|
929
1133
|
function refuseUnpriced(entry) {
|
|
930
1134
|
const why = unpricedReason(entry);
|
|
931
1135
|
if (!why) return;
|
|
932
|
-
|
|
1136
|
+
const next = isUnpricedProvider(entry.provider)
|
|
1137
|
+
? `Dispatch it to another provider (agent_catalog shows prices), or ask the user to switch the session budget to No limit (Assistant → Budget) to run it on ${providerName(entry.provider)}.`
|
|
1138
|
+
: 'Pick a priced model or a free one (agent_catalog shows prices).';
|
|
1139
|
+
throw new DispatchError('BUDGET_UNPRICED', `Not dispatched: ${why}, so the session budget could not hold its spend. ${next}`, { status: 409, provider: entry.provider, model: entry.pricingModel || entry.model || null });
|
|
933
1140
|
}
|
|
934
1141
|
const providerName = (provider) => PROVIDER_LABELS[provider] || provider;
|
|
935
1142
|
/** The session budget's words for a provider whose share is used up ("codex share used up: $4.00 of $4.00 spent"). */
|
|
@@ -989,16 +1196,29 @@ export function createAssistantDispatcher({
|
|
|
989
1196
|
/**
|
|
990
1197
|
* The Claude CLI's own stop (--max-budget-usd) for a Claude worker under a budget: the run's ceiling,
|
|
991
1198
|
* 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).
|
|
1199
|
+
* provider's share between model calls. null under No limit (no CLI cap at all). `spentUsd`: what the run
|
|
1200
|
+
* spent already (a resume after a restart): its ceiling less that is what it may still spend.
|
|
993
1201
|
*/
|
|
994
|
-
function claudeBackstop(entry) {
|
|
1202
|
+
function claudeBackstop(entry, { spentUsd = 0 } = {}) {
|
|
995
1203
|
if (!limitedNow()) return null;
|
|
996
1204
|
const cap = Number(entry.budgetUsd) || 0;
|
|
1205
|
+
const capLeft = cap > 0 ? Math.max(0, cap - Math.max(0, Number(spentUsd) || 0)) : Infinity;
|
|
997
1206
|
const left = entry.assistantSessionId ? (budget.coverage(entry.assistantSessionId)?.leftUsd ?? null) : null;
|
|
998
|
-
const value = Math.min(
|
|
1207
|
+
const value = Math.min(capLeft, left === null ? Infinity : left);
|
|
999
1208
|
if (!Number.isFinite(value)) return cap > 0 ? cap : null;
|
|
1000
1209
|
return Math.max(MIN_SPEND_USD, floorCents(value) / 100);
|
|
1001
1210
|
}
|
|
1211
|
+
/**
|
|
1212
|
+
* The Claude CLI's stop for a run: claudeBackstop() under a budget; a skill-drafting run (confined) also stops at its
|
|
1213
|
+
* own ceiling whatever the session's mode, No limit included (its budget_usd, or what No limit set aside).
|
|
1214
|
+
*/
|
|
1215
|
+
function claudeRunCap(entry, options = {}) {
|
|
1216
|
+
const backstop = claudeBackstop(entry, options);
|
|
1217
|
+
if (!entry.confinement) return backstop;
|
|
1218
|
+
const own = Number(entry.budgetUsd) || Number(entry.budgetIgnoredUsd) || 0;
|
|
1219
|
+
const caps = [backstop, own > 0 ? own : null].filter((v) => Number(v) > 0);
|
|
1220
|
+
return caps.length ? Math.min(...caps) : null;
|
|
1221
|
+
}
|
|
1002
1222
|
/**
|
|
1003
1223
|
* After money moved in a session: one warning at 80 % of the budget (per budget amount), and at
|
|
1004
1224
|
* the budget every live, queued and held run of the session stops. Nothing under No limit.
|
|
@@ -1058,8 +1278,9 @@ export function createAssistantDispatcher({
|
|
|
1058
1278
|
return {
|
|
1059
1279
|
provider: spec.provider, model: spec.model, effort: spec.effort, effortCorrection: spec.effortCorrection || null, agent: spec.agent || null, accountId: spec.accountId,
|
|
1060
1280
|
codexAccountId: spec.codexAccountId, codexHome: spec.codexHome, claudeAccountId: spec.claudeAccountId, claudeConfigDir: spec.claudeConfigDir,
|
|
1061
|
-
cwd: spec.cwd, project: spec.project,
|
|
1281
|
+
cwd: spec.cwd, project: spec.project, toolsRequested: spec.toolsRequested, tools: spec.tools, externalMcpVersion: 2, externalServers: spec.externalServers,
|
|
1062
1282
|
permissionPolicy: spec.permissionPolicy, capability: spec.capability, usesBrowser: spec.usesBrowser, usesComputer: !!spec.usesComputer,
|
|
1283
|
+
confinement: spec.confinement || null,
|
|
1063
1284
|
title: spec.title, task: spec.task, context: spec.context, tags: spec.tags,
|
|
1064
1285
|
maxMinutes: spec.maxMinutes, budgetUsd: spec.budgetUsd ?? null, freeModel: spec.freeModel === true, idleTimeoutMs: spec.idleTimeoutMs, maxTurns: spec.maxTurns,
|
|
1065
1286
|
outputSchema: spec.outputSchema, focus: spec.focus, workflowId: spec.workflowId, parentRunId: spec.parentRunId,
|
|
@@ -1163,7 +1384,8 @@ export function createAssistantDispatcher({
|
|
|
1163
1384
|
const catalogValue = await catalogSnapshot();
|
|
1164
1385
|
// Routing (ask modes may hold the run until the user picks a model).
|
|
1165
1386
|
let routed = null;
|
|
1166
|
-
|
|
1387
|
+
// Skills owns the runtime selection and its provider-specific workspace. Never bind Assistant routes.
|
|
1388
|
+
if (router && !isSkillsRun(rawSpec) && origin !== 'ui' && sessionId) {
|
|
1167
1389
|
routed = await router.resolveDispatch({ sessionId, spec: rawSpec, origin });
|
|
1168
1390
|
// A hold names an open card, but an answer that was already in flight (it waited on the same
|
|
1169
1391
|
// catalog read) can decide that card before this continuation runs. Approved for this
|
|
@@ -1330,6 +1552,12 @@ export function createAssistantDispatcher({
|
|
|
1330
1552
|
};
|
|
1331
1553
|
const heldClass = knownClass(routed.route?.taskClass) || knownClass(input.taskClass);
|
|
1332
1554
|
if (heldClass) entry.taskClass = heldClass;
|
|
1555
|
+
const selection = dispatchTools({ ...input, taskClass: heldClass });
|
|
1556
|
+
entry.externalServers = resolveExternalTools({ ...entry, ...input }, selection.servers);
|
|
1557
|
+
entry.externalMcpVersion = 2;
|
|
1558
|
+
entry.toolsRequested = selection.requested;
|
|
1559
|
+
entry.tools = launchTools(selection);
|
|
1560
|
+
entry.usesBrowser = input.usesBrowser === true || selection.groups.some(isBrowserGroup);
|
|
1333
1561
|
if (brief) entry.brief = brief;
|
|
1334
1562
|
// A held run belongs to the task that asked for it, whenever the user picks its model.
|
|
1335
1563
|
entry.taskId = taskAtDispatch(sessionId);
|
|
@@ -1345,7 +1573,7 @@ export function createAssistantDispatcher({
|
|
|
1345
1573
|
emit(entry, 'route_pending', { routeId: routed.routeId });
|
|
1346
1574
|
return {
|
|
1347
1575
|
ok: true, queued: true, awaitingRoute: true, routeId: routed.routeId, run: view(entry),
|
|
1348
|
-
next: 'This run is waiting for the user to pick its model on the route card. Do not dispatch it again. It starts automatically after the pick; the decision arrives as a [SynaBun Mailbox] route_decided event.',
|
|
1576
|
+
next: 'This run is waiting for the user to pick its model on the route card. Do not dispatch it again. It starts automatically after the pick; the decision arrives as a [SynaBun Mailbox] route_decided event. If the user picks "here" instead, this run never starts and that event asks you to do the task yourself.',
|
|
1349
1577
|
};
|
|
1350
1578
|
}
|
|
1351
1579
|
|
|
@@ -1374,11 +1602,11 @@ export function createAssistantDispatcher({
|
|
|
1374
1602
|
const entry = entries.get(runId);
|
|
1375
1603
|
if (!entry || entry.state !== 'awaiting_route' || !target?.provider) return { ok: false, code: 'NOT_HELD' };
|
|
1376
1604
|
const catalogValue = await catalogSnapshot();
|
|
1377
|
-
let input = { ...(entry.rawSpec || {}), provider: target.provider, model: target.model || null, effort: target.effort ??
|
|
1605
|
+
let input = { ...(entry.rawSpec || {}), provider: target.provider, model: target.model || null, effort: target.effort ?? null, accountId: target.accountId || null, codexAccountId: null, claudeAccountId: null };
|
|
1378
1606
|
let spec;
|
|
1379
1607
|
// The picked provider may need another clamp (Codex under strict worker approvals), or the level fell meanwhile.
|
|
1380
1608
|
const remoteNotes = [];
|
|
1381
|
-
try { input = clampRemote(input, entry.assistantSessionId, remoteNotes); spec = normalizeSpec(input, { catalogValue,
|
|
1609
|
+
try { input = clampRemote(input, entry.assistantSessionId, remoteNotes); spec = normalizeSpec(input, { catalogValue, taskClass: entry.taskClass || null }); }
|
|
1382
1610
|
catch (error) {
|
|
1383
1611
|
entry.state = 'failed'; entry.completionReason = 'route_invalid'; entry.finishedAt = iso(now()); entry.turnState = 'terminal';
|
|
1384
1612
|
entry.error = error?.message || String(error);
|
|
@@ -1404,7 +1632,10 @@ export function createAssistantDispatcher({
|
|
|
1404
1632
|
const entry = entries.get(runId);
|
|
1405
1633
|
if (!entry || entry.state !== 'awaiting_route') return false;
|
|
1406
1634
|
entry.state = 'stopped'; entry.completionReason = reason; entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
|
|
1407
|
-
|
|
1635
|
+
// route_here: the user picked "here" on its card, so the assistant does the task itself and this worker never starts.
|
|
1636
|
+
const status = reason === 'route_expired' ? 'expired' : reason === 'route_here' ? 'here' : 'declined';
|
|
1637
|
+
entry.route = { ...(entry.route || {}), status, decidedBy: reason === 'route_expired' ? 'timeout' : 'user', decidedAt: iso(now()) };
|
|
1638
|
+
if (reason === 'route_here') entry.notes.push('the user chose to do this task here, in the conversation: this worker was not started');
|
|
1408
1639
|
delete entry.rawSpec;
|
|
1409
1640
|
emit(entry, 'stopped');
|
|
1410
1641
|
return true;
|
|
@@ -1413,6 +1644,7 @@ export function createAssistantDispatcher({
|
|
|
1413
1644
|
/** Re-dispatch a failed/blocked run one tier up, or on the same model after a transient failure (the user pressed Escalate / Retry). */
|
|
1414
1645
|
async function escalate(runId, { target = null } = {}) {
|
|
1415
1646
|
const entry = requireEntry(runId);
|
|
1647
|
+
if (isSkillsRun(entry)) throw new DispatchError('NO_ESCALATION', 'Skills runs cannot change runtime or model through escalation.', { status: 409 });
|
|
1416
1648
|
const to = target?.provider ? target : entry.escalation?.to;
|
|
1417
1649
|
if (!to?.provider) throw new DispatchError('NO_ESCALATION', `Run ${runId} has no escalation target`, { status: 409 });
|
|
1418
1650
|
// A transient failure (timeout, rate limit, outage) is retried as it was, not escalated.
|
|
@@ -1426,21 +1658,55 @@ export function createAssistantDispatcher({
|
|
|
1426
1658
|
provider: to.provider, model: to.model || null, effort: to.effort || null, task: entry.task, cwd: entry.cwd, context,
|
|
1427
1659
|
accountId: to.accountId || (to.provider === entry.provider ? entry.accountId : null),
|
|
1428
1660
|
title: clip(`${entry.title} ${retry ? '↻' : '↑'}`, 80), tags: entry.tags, workflowId: entry.workflowId, parentRunId: runId,
|
|
1429
|
-
|
|
1430
|
-
permissionPolicy:
|
|
1661
|
+
tools: entry.tools?.some(item => item.startsWith('mcp:')) ? retainExternalRefs(entry.toolsRequested ?? dispatchTools({}).items, entry.tools) : entry.toolsRequested ?? undefined, capability: entry.capability,
|
|
1662
|
+
permissionPolicy: entry.permissionPolicy,
|
|
1431
1663
|
usesBrowser: entry.usesBrowser, usesComputer: entry.usesComputer, maxMinutes: entry.maxMinutes, budgetUsd: entry.budgetUsd,
|
|
1432
1664
|
outputSchema: entry.outputSchema, taskClass: entry.taskClass || null,
|
|
1433
1665
|
}, { assistantSessionId: entry.assistantSessionId, origin: 'ui', brief: entry.brief || null });
|
|
1434
1666
|
const child = entries.get(result?.run?.runId);
|
|
1435
1667
|
if (child) { child.escalationDepth = (Number(entry.escalationDepth) || 0) + 1; child.escalatedFrom = runId; }
|
|
1436
1668
|
entry.notes.push(`${retry ? 'retried on' : 'escalated to'} ${to.provider}/${to.model || 'default'} as ${result?.run?.runId || '?'}`);
|
|
1669
|
+
if (entry.limit && typeof entry.limit === 'object') entry.limit = { ...entry.limit, retriedAt: iso(now()) };
|
|
1437
1670
|
emit(entry, 'escalated', { childRunId: result?.run?.runId || null });
|
|
1438
1671
|
return result;
|
|
1439
1672
|
}
|
|
1440
1673
|
function computeEscalation(entry, stateOverride = null) {
|
|
1441
|
-
if (!router?.escalationFor) return null;
|
|
1442
|
-
|
|
1674
|
+
if (isSkillsRun(entry) || !router?.escalationFor) return null;
|
|
1675
|
+
let found = null;
|
|
1676
|
+
try { found = router.escalationFor({ run: stateOverride ? { ...entry, state: stateOverride } : entry, depth: Number(entry.escalationDepth) || 0 }) || null; }
|
|
1443
1677
|
catch { return null; }
|
|
1678
|
+
return found;
|
|
1679
|
+
}
|
|
1680
|
+
/** The Protect workers setting now, or the default when the host cannot say. */
|
|
1681
|
+
function safeWorkerGuard() {
|
|
1682
|
+
try { return workerGuard?.() ?? WORKER_GUARD_DEFAULT; } catch { return WORKER_GUARD_DEFAULT; }
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* A run a provider's usage limit stopped (failed, or a turn that ended blocked or without a result):
|
|
1686
|
+
* entry.limit = { provider, accountId, resetsAt (ISO | null), source, detectedAt } for the panel's
|
|
1687
|
+
* waiting card and the brain's mailbox line, else null. The reset is the account's own usage window
|
|
1688
|
+
* when SynaBun reads one (account-usage.js), else the time the worker's message names (asst-limit.js).
|
|
1689
|
+
*/
|
|
1690
|
+
function noteLimit(entry) {
|
|
1691
|
+
const prev = entry.limit && typeof entry.limit === 'object' ? entry.limit : null;
|
|
1692
|
+
const text = [entry.error, entry.lastResult?.summary, entry.lastResult?.question, lastParagraph(entry.lastText), ...(entry.notes || []).slice(-3)]
|
|
1693
|
+
.filter(Boolean).map((line) => String(line)).join('\n');
|
|
1694
|
+
const read = limitFromText(text, { now: now() });
|
|
1695
|
+
let usage = null;
|
|
1696
|
+
try { usage = accountUsage?.peek?.(entry.provider, entry.accountId || 'default', { stale: true }) || null; } catch { usage = null; }
|
|
1697
|
+
const limited = usage?.limited === true;
|
|
1698
|
+
if (!read && !limited) { entry.limit = null; return null; }
|
|
1699
|
+
const accountReset = Date.parse(usage?.resetsAt || '');
|
|
1700
|
+
const fromAccount = Number.isFinite(accountReset) && accountReset > now() && (limited || read?.resetsAt == null);
|
|
1701
|
+
const resetsAt = fromAccount ? accountReset : read?.resetsAt ?? null;
|
|
1702
|
+
entry.limit = {
|
|
1703
|
+
provider: entry.provider || null,
|
|
1704
|
+
accountId: entry.accountId || 'default',
|
|
1705
|
+
resetsAt: Number.isFinite(resetsAt) ? new Date(resetsAt).toISOString() : null,
|
|
1706
|
+
source: fromAccount ? 'account' : resetsAt != null ? 'message' : null,
|
|
1707
|
+
detectedAt: prev?.detectedAt || iso(now()),
|
|
1708
|
+
};
|
|
1709
|
+
return entry.limit;
|
|
1444
1710
|
}
|
|
1445
1711
|
|
|
1446
1712
|
// ── Jev (worker-outcome, rider worker-claim) ────────────────────────────────
|
|
@@ -1518,8 +1784,8 @@ export function createAssistantDispatcher({
|
|
|
1518
1784
|
* for it — at most the surface timeout. Without Jev it is emitted at once.
|
|
1519
1785
|
*/
|
|
1520
1786
|
function emitFailed(entry, evidenceAcc = null) {
|
|
1521
|
-
const done = () => { delete entry.judging; entry.escalation = computeEscalation(entry); emit(entry, 'failed'); };
|
|
1522
|
-
if (!judge?.workerOutcome || budgetStopReason(entry.completionReason)) { done(); return Promise.resolve(); }
|
|
1787
|
+
const done = () => { delete entry.judging; noteLimit(entry); entry.escalation = computeEscalation(entry); emit(entry, 'failed'); };
|
|
1788
|
+
if (!judge?.workerOutcome || budgetStopReason(entry.completionReason) || isEvalRun(entry)) { done(); return Promise.resolve(); }
|
|
1523
1789
|
entry.judging = true;
|
|
1524
1790
|
const evidence = evidenceSnapshot(evidenceAcc);
|
|
1525
1791
|
const error = [entry.error, ...(entry.notes || []).slice(-3)].filter(Boolean).map((line) => String(line)).join('\n');
|
|
@@ -1541,6 +1807,7 @@ export function createAssistantDispatcher({
|
|
|
1541
1807
|
}
|
|
1542
1808
|
} catch { /* the failure stays unjudged */ }
|
|
1543
1809
|
delete entry.judging;
|
|
1810
|
+
noteLimit(entry);
|
|
1544
1811
|
entry.escalation = computeEscalation(entry);
|
|
1545
1812
|
if (judged?.logId) {
|
|
1546
1813
|
try { judge.annotate?.(judged.logId, { run: entry.runId, turn: null, source: 'failure', retried: false, status: 'failed', cause: judged.cause || null, claim: null, escalation: entry.escalation?.kind || null, judged: view, applied: { cause: !!judged.cause } }); } catch {}
|
|
@@ -1550,6 +1817,12 @@ export function createAssistantDispatcher({
|
|
|
1550
1817
|
}
|
|
1551
1818
|
|
|
1552
1819
|
async function start(entry) {
|
|
1820
|
+
try { requireConfinementProvider(entry); } catch (error) {
|
|
1821
|
+
entry.state = 'failed'; entry.error = error.message; entry.completionReason = 'confinement_provider_mismatch';
|
|
1822
|
+
entry.finishedAt = iso(now()); entry.turnState = 'terminal';
|
|
1823
|
+
emit(entry, 'failed');
|
|
1824
|
+
throw error;
|
|
1825
|
+
}
|
|
1553
1826
|
const runtime = safeRuntime();
|
|
1554
1827
|
if (!runtime) throw new DispatchError('RUNTIME_UNAVAILABLE', 'Native loop runtime is not available', { status: 503 });
|
|
1555
1828
|
// A queued or held run starts under the budget as it is now (its provider's share included).
|
|
@@ -1577,50 +1850,19 @@ export function createAssistantDispatcher({
|
|
|
1577
1850
|
}
|
|
1578
1851
|
entry.browserSessionId = browserSessionId;
|
|
1579
1852
|
entry.browserTabId = browserTabId;
|
|
1580
|
-
const liveState =
|
|
1581
|
-
entry, ctx: null, ring: [], ringChars: 0, followUps: [], waitResolve: null, idleTimer: null,
|
|
1582
|
-
pendingResolvers: new Map(), dead: false, lastEventAt: now(), stalledFlagged: false, turnCounter: 0, abortReason: null,
|
|
1583
|
-
evidence: null, // the current turn's commands / tool results / edited files (assistant-evidence.js)
|
|
1584
|
-
completed: false, // its turn loop ended on a completion (agent_complete, a closed finished tab, a removal)
|
|
1585
|
-
};
|
|
1853
|
+
const liveState = newLiveState(entry);
|
|
1586
1854
|
live.set(entry.runId, liveState);
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
active: true, task: entry.task, context: entry.context, totalIterations: 1, currentIteration: 0,
|
|
1599
|
-
maxMinutes: entry.maxMinutes, startedAt: iso(now()), lastIterationAt: null, retries: 0, pending: false,
|
|
1600
|
-
terminalSessionId: entry.runId, usesBrowser: entry.usesBrowser, usesComputer: !!entry.usesComputer, browserSessionId, browserTabId,
|
|
1601
|
-
profile: entry.provider, provider: entry.provider, agent: entry.agent || null,
|
|
1602
|
-
model: entry.model, modelContextWindow: entry.modelContextWindow || null, effort: entry.effort, mcpProfile: entry.mcpProfile,
|
|
1603
|
-
codexAccountId: entry.codexAccountId, codexHome: entry.provider === 'codex' ? (entryCodexHome(entry)) : null,
|
|
1604
|
-
claudeAccountId: entry.claudeAccountId, claudeConfigDir: entry.claudeConfigDir,
|
|
1605
|
-
driverType: 'native', runtimeType: 'native', surface: 'sidepanel', runId: entry.runId, cwd: entry.cwd,
|
|
1606
|
-
runMode: 'task', assistantSessionId: entry.assistantSessionId, permissionPolicy: entry.permissionPolicy,
|
|
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,
|
|
1609
|
-
idleTimeoutMs: entry.idleTimeoutMs, maxTurns: entry.maxTurns, outputSchema: entry.outputSchema, project: entry.project,
|
|
1610
|
-
title: entry.title, tags: entry.tags, workflowId: entry.workflowId, parentRunId: entry.parentRunId,
|
|
1611
|
-
// The user's brief after a clarification (assistant-task-prompt.js briefBlock).
|
|
1612
|
-
brief: entry.brief || null,
|
|
1613
|
-
// Image / video creation: the prompt's media block and what the adapter collects ('image' | 'video');
|
|
1614
|
-
// design collects its screenshots and images the same way ('image').
|
|
1615
|
-
taskClass: entry.taskClass || null, collectMedia: collectedMedia(entry.taskClass),
|
|
1616
|
-
// extraEnv is a plain object, so it IS written to the loop state file;
|
|
1617
|
-
// keep secrets out of it. The desktop grant rides as a function instead.
|
|
1618
|
-
extraEnv,
|
|
1619
|
-
desktopGrantProvider: desktopGrant ? () => desktopGrant : null,
|
|
1620
|
-
// What the adapter reports while it starts (a Codex worker whose browser-policy hook is not trusted).
|
|
1621
|
-
onNote: (text) => { const note = String(text || '').trim(); if (note && !entry.notes.includes(note)) entry.notes.push(note); },
|
|
1622
|
-
};
|
|
1623
|
-
if (entry.permissionPolicy === 'ask') state.permissionBroker = createBroker(entry.runId);
|
|
1855
|
+
// The guard a read-only / workspace run starts under (never for full, never for a drafting run): the setting
|
|
1856
|
+
// now, and a scratch folder of its own. Recorded on the run so the panel and the brain can see it.
|
|
1857
|
+
entry.guardMode = normalizeWorkerGuardMode(safeWorkerGuard());
|
|
1858
|
+
if (entry.provider === 'opencode' && !entry.confinement) resumeInfoOf(entry).persistentOpenCode = true;
|
|
1859
|
+
if ((!entry.confinement || isEvalRun(entry)) && entry.guardMode !== 'off' && (entry.capability === 'read-only' || entry.capability === 'workspace')) {
|
|
1860
|
+
const scratch = resolve(scratchRoot, entry.runId);
|
|
1861
|
+
try { mkdirSync(scratch, { recursive: true }); entry.scratchDir = scratch; } catch (error) { entry.scratchDir = null; entry.notes.push(`scratch folder not made: ${error?.message || error}`); }
|
|
1862
|
+
} else entry.scratchDir = null;
|
|
1863
|
+
const desktopGrant = mintDesktopGrant(entry);
|
|
1864
|
+
const state = runState(entry, { browserSessionId, browserTabId, desktopGrant, startedAt: iso(now()) });
|
|
1865
|
+
state.permissionBroker = createBroker(entry.runId);
|
|
1624
1866
|
// A class's rules (design), read now so an edited override applies without a restart;
|
|
1625
1867
|
// they go to the prompt only, not into the loop state file.
|
|
1626
1868
|
const playbook = playbookFor(entry);
|
|
@@ -1637,7 +1879,9 @@ export function createAssistantDispatcher({
|
|
|
1637
1879
|
source: 'assistant',
|
|
1638
1880
|
focus: entry.focus === true,
|
|
1639
1881
|
title: entry.title,
|
|
1640
|
-
|
|
1882
|
+
// A skill-evaluation run gets its task as written (a case's request, a grader's brief): the worker preamble and
|
|
1883
|
+
// the Result contract would change what is measured.
|
|
1884
|
+
buildPrompt: () => (isEvalRun(entry) ? entry.task : buildTaskPrompt({ ...state, ...(playbook ? { playbook } : {}), ...(styleGuide ? { styleGuide } : {}), testDensity: briefTestDensity(entry) })),
|
|
1641
1885
|
wrapAdapter: (adapter, ctx) => createTaskAdapter(adapter, ctx, liveState, state),
|
|
1642
1886
|
});
|
|
1643
1887
|
entry.runtime = { status: descriptor.status };
|
|
@@ -1654,6 +1898,104 @@ export function createAssistantDispatcher({
|
|
|
1654
1898
|
throw error;
|
|
1655
1899
|
}
|
|
1656
1900
|
}
|
|
1901
|
+
/** What a run's worker keeps in memory while it lives (never saved): its turn loop, transcript tail, waits. */
|
|
1902
|
+
function newLiveState(entry) {
|
|
1903
|
+
return {
|
|
1904
|
+
entry, ctx: null, ring: [], ringChars: 0, followUps: [], waitResolve: null, idleTimer: null,
|
|
1905
|
+
pendingResolvers: new Map(), dead: false, lastEventAt: now(), stalledFlagged: false, turnCounter: 0, abortReason: null,
|
|
1906
|
+
evidence: null, // the current turn's commands / tool results / edited files (assistant-evidence.js)
|
|
1907
|
+
completed: false, // its turn loop ended on a completion (agent_complete, a closed finished tab, a removal)
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* Computer use: an unforgeable per-run grant unlocks the `computer` MCP group for this worker only. Passed as a
|
|
1912
|
+
* function so the loop runtime's JSON state file never contains it. null for a run without computer use.
|
|
1913
|
+
*/
|
|
1914
|
+
function mintDesktopGrant(entry) {
|
|
1915
|
+
if (!entry.usesComputer || !desktop?.mintGrant) return null;
|
|
1916
|
+
try { return desktop.mintGrant({ kind: 'run', assistantSessionId: entry.assistantSessionId, runId: entry.runId, provider: entry.provider, model: entry.model, vision: entry.modelInfo?.vision ?? null }) || null; }
|
|
1917
|
+
catch (error) { entry.notes.push(`desktop grant failed: ${error?.message || error}`); return null; }
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* The loop state a run starts (or resumes, with `extra`) with: what its provider factory and its prompt read. Its
|
|
1921
|
+
* functions (notes, the CLI's process, the desktop grant) stay in memory: the runtime writes the rest to the run's
|
|
1922
|
+
* state file.
|
|
1923
|
+
*/
|
|
1924
|
+
function runState(entry, { browserSessionId = null, browserTabId = null, desktopGrant = null, startedAt = iso(now()), extra = {} } = {}) {
|
|
1925
|
+
const extraEnv = {};
|
|
1926
|
+
if (entry.claudeConfigDir) extraEnv.CLAUDE_CONFIG_DIR = entry.claudeConfigDir;
|
|
1927
|
+
return {
|
|
1928
|
+
active: true, task: entry.task, context: entry.context, totalIterations: 1, currentIteration: 0,
|
|
1929
|
+
maxMinutes: entry.maxMinutes, startedAt, lastIterationAt: null, retries: 0, pending: false,
|
|
1930
|
+
terminalSessionId: entry.runId, usesBrowser: entry.usesBrowser, usesComputer: !!entry.usesComputer, browserSessionId, browserTabId,
|
|
1931
|
+
profile: entry.provider, provider: entry.provider, agent: entry.agent || null,
|
|
1932
|
+
model: entry.model, modelContextWindow: entry.modelContextWindow || null, effort: entry.effort, tools: entry.tools,
|
|
1933
|
+
codexAccountId: entry.codexAccountId, codexHome: entry.provider === 'codex' ? (entryCodexHome(entry)) : null,
|
|
1934
|
+
claudeAccountId: entry.claudeAccountId, claudeConfigDir: entry.claudeConfigDir,
|
|
1935
|
+
driverType: 'native', runtimeType: 'native', surface: 'sidepanel', runId: entry.runId, cwd: entry.cwd,
|
|
1936
|
+
runMode: 'task', assistantSessionId: entry.assistantSessionId, permissionPolicy: entry.permissionPolicy,
|
|
1937
|
+
// A Claude worker's CLI stop (--max-budget-usd): a backstop under a budget, none under No limit.
|
|
1938
|
+
capability: entry.capability, budgetUsd: entry.budgetUsd, maxBudgetUsd: entry.provider === 'claude-code' ? claudeRunCap(entry) : null,
|
|
1939
|
+
// A skill-drafting run's confinement (lib/skills/draft-confinement.js): its adapter enforces it, or refuses to start.
|
|
1940
|
+
confinement: entry.confinement || null,
|
|
1941
|
+
// The worker confinement (lib/worker-confinement.js): the Protect workers setting as the run starts, kept for
|
|
1942
|
+
// its life, and the run's own scratch folder (writable beside the working directory and the temp folder).
|
|
1943
|
+
persistentOpenCode: !!entry.resumeInfo?.persistentOpenCode,
|
|
1944
|
+
sandboxPlan: entry.resumeInfo?.sandboxPlan || null,
|
|
1945
|
+
providerTurnBase: Number(entry.resumeInfo?.providerTurn) || 0,
|
|
1946
|
+
adminSandbox: entry.resumeInfo?.adminSandbox === true,
|
|
1947
|
+
onCodexSandbox: (value) => { resumeInfoOf(entry).adminSandbox = value === true; persist({ force: true }); },
|
|
1948
|
+
onSandboxPlan: (plan) => { resumeInfoOf(entry).sandboxPlan = plan; persist({ force: true }); },
|
|
1949
|
+
// Antigravity counts tokens per session (ACP usage): what its session had counted, kept for a resume after a
|
|
1950
|
+
// restart, so the resumed run's first turn books only its own.
|
|
1951
|
+
sessionUsageBase: entry.provider === 'antigravity' ? entry.resumeInfo?.acpSessionUsage || null : null,
|
|
1952
|
+
onSessionUsage: (value) => { if (entry.provider === 'antigravity' && value && typeof value === 'object') resumeInfoOf(entry).acpSessionUsage = value; },
|
|
1953
|
+
guardMode: entry.guardMode, scratchDir: entry.scratchDir || null, serverPort: Number(serverPort) > 0 ? Number(serverPort) : null,
|
|
1954
|
+
localPortRange: localPortRange && Number(localPortRange.from) > 0 && Number(localPortRange.to) >= Number(localPortRange.from) ? { from: Number(localPortRange.from), to: Number(localPortRange.to) } : null,
|
|
1955
|
+
questionTimeoutMs: limits.questionTimeoutMs,
|
|
1956
|
+
idleTimeoutMs: entry.idleTimeoutMs, maxTurns: entry.maxTurns, outputSchema: entry.outputSchema, project: entry.project,
|
|
1957
|
+
title: entry.title, tags: entry.tags, workflowId: entry.workflowId, parentRunId: entry.parentRunId,
|
|
1958
|
+
// The user's brief after a clarification (assistant-task-prompt.js briefBlock).
|
|
1959
|
+
brief: entry.brief || null,
|
|
1960
|
+
// Image / video creation: the prompt's media block and what the adapter collects ('image' | 'video');
|
|
1961
|
+
// design collects its screenshots and images the same way ('image').
|
|
1962
|
+
taskClass: entry.taskClass || null, collectMedia: collectedMedia(entry.taskClass),
|
|
1963
|
+
// extraEnv is a plain object, so it IS written to the loop state file;
|
|
1964
|
+
// keep secrets out of it. The desktop grant rides as a function instead.
|
|
1965
|
+
extraEnv,
|
|
1966
|
+
desktopGrantProvider: desktopGrant ? () => desktopGrant : null,
|
|
1967
|
+
// What the adapter reports while it starts (a Codex worker whose browser-policy hook is not trusted).
|
|
1968
|
+
onNote: (text) => { const note = String(text || '').trim(); if (note && !entry.notes.includes(note)) entry.notes.push(note); },
|
|
1969
|
+
// A Claude Code worker's CLI (lib/native-loop-providers.js onProcess): saved with the run at once, so the next
|
|
1970
|
+
// start can end it should it outlive this server (a crash ends no process) before the session is resumed.
|
|
1971
|
+
onProcess: (info) => {
|
|
1972
|
+
const pid = Number(info?.pid);
|
|
1973
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
1974
|
+
resumeInfoOf(entry).pid = pid;
|
|
1975
|
+
entry.resumeInfo.pidStartedAt = info.startedAt || iso(now());
|
|
1976
|
+
// An Antigravity run's own home (lib/antigravity/home.js): where its conversation is, for a resume.
|
|
1977
|
+
if (typeof info.home === 'string' && info.home) { entry.resumeInfo.antigravityHome = info.home; entry.antigravityHome = info.home; }
|
|
1978
|
+
persist({ force: true });
|
|
1979
|
+
},
|
|
1980
|
+
...extra,
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
/** What a resume after a restart needs that is saved with the run (the CLI's pid, the turn in progress). */
|
|
1984
|
+
function resumeInfoOf(entry) {
|
|
1985
|
+
if (!entry.resumeInfo || typeof entry.resumeInfo !== 'object') entry.resumeInfo = {};
|
|
1986
|
+
return entry.resumeInfo;
|
|
1987
|
+
}
|
|
1988
|
+
/** The number of the run's last recorded turn (0 before its first): a resumed run's turns go on from it. */
|
|
1989
|
+
function lastTurnNumber(entry) {
|
|
1990
|
+
return (Array.isArray(entry.turns) ? entry.turns : []).reduce((max, turn) => Math.max(max, Number(turn?.n) || 0), 0);
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* The turns a run away for a restart will have had when a follow-up reaches it (as turnCounter counts a turn in
|
|
1994
|
+
* progress): its recorded turns, the one the restart cut off (its resume continues it), or its first, not yet run.
|
|
1995
|
+
*/
|
|
1996
|
+
function awayTurns(entry) {
|
|
1997
|
+
return Math.max(lastTurnNumber(entry), Number(entry.resumeInfo?.inFlight?.turn) || 0, 1);
|
|
1998
|
+
}
|
|
1657
1999
|
/**
|
|
1658
2000
|
* What the run's prompt says about its project's Style Guide (lib/style-guide/store.js): the
|
|
1659
2001
|
* summary for the run's class, DESIGN.md and the token files. null when the project has no saved
|
|
@@ -1709,36 +2051,38 @@ export function createAssistantDispatcher({
|
|
|
1709
2051
|
return account?.home || CODEX_DEFAULT_HOME || null;
|
|
1710
2052
|
}
|
|
1711
2053
|
|
|
1712
|
-
// ──
|
|
2054
|
+
// ── request broker (questions in every policy; tool approvals under 'ask') ──────────────────────────────────────
|
|
1713
2055
|
function createBroker(runId) {
|
|
1714
2056
|
return {
|
|
1715
2057
|
request: (request = {}) => new Promise((resolvePermission) => {
|
|
1716
2058
|
const entry = entries.get(runId);
|
|
1717
2059
|
const liveState = live.get(runId);
|
|
1718
|
-
if (!entry || !liveState) { resolvePermission({ behavior: 'deny', message: 'Run is no longer tracked.' }); return; }
|
|
2060
|
+
if (!entry || !liveState || liveState.dead || liveState.suspending || TERMINAL_STATES.has(entry.state) || request.signal?.aborted) { resolvePermission({ behavior: 'deny', message: 'Run is no longer tracked.' }); return; }
|
|
1719
2061
|
const requestId = `perm-${randomBytes(6).toString('hex')}`;
|
|
1720
2062
|
const kind = request.kind === 'ask' ? 'question' : 'permission';
|
|
1721
2063
|
const timeoutMs = kind === 'question' ? limits.questionTimeoutMs : limits.permissionTimeoutMs;
|
|
1722
2064
|
const pending = {
|
|
1723
2065
|
requestId, kind, toolName: request.toolName || null, input: request.input ?? null,
|
|
2066
|
+
protocol: request.protocol || null,
|
|
1724
2067
|
questions: Array.isArray(request.questions) ? request.questions : [], createdAt: iso(now()), expiresAt: iso(now() + timeoutMs),
|
|
1725
2068
|
};
|
|
1726
2069
|
entry.pending = [...(entry.pending || []), pending];
|
|
1727
2070
|
entry.state = 'awaiting_permission';
|
|
1728
2071
|
entry.turnState = 'awaiting_permission';
|
|
2072
|
+
// Install the resolver before publishing: a synchronous listener can answer at once.
|
|
2073
|
+
const timer = setTimeout(() => finishPending(runId, requestId, { behavior: 'deny', message: `No decision within ${Math.round(timeoutMs / 60000)} minutes.`, resolvedBy: 'timeout' }), timeoutMs);
|
|
2074
|
+
timer.unref?.();
|
|
2075
|
+
const abortSignal = request.signal;
|
|
2076
|
+
const onAbort = () => finishPending(runId, requestId, { behavior: 'deny', message: 'Run aborted.', resolvedBy: 'abort' });
|
|
2077
|
+
liveState.pendingResolvers.set(requestId, { resolve: resolvePermission, timer, cleanup: () => abortSignal?.removeEventListener?.('abort', onAbort) });
|
|
2078
|
+
abortSignal?.addEventListener?.('abort', onAbort, { once: true });
|
|
1729
2079
|
// Waiting on the answer is not work: the request's own timeout bounds it.
|
|
1730
2080
|
pauseWork(entry, liveState);
|
|
2081
|
+
liveState.ctx?.setPendingRequests?.(entry.pending);
|
|
1731
2082
|
liveState.ctx?.setTurnState?.('awaiting_permission');
|
|
1732
2083
|
liveState.ctx?.emit?.({ type: 'synabun.permission_request', requestId, kind, toolName: pending.toolName, input: pending.input, questions: pending.questions });
|
|
1733
|
-
const timer = setTimeout(() => finishPending(runId, requestId, { behavior: 'deny', message: `No decision within ${Math.round(timeoutMs / 60000)} minutes.`, resolvedBy: 'timeout' }), timeoutMs);
|
|
1734
|
-
timer.unref?.();
|
|
1735
|
-
liveState.pendingResolvers.set(requestId, { resolve: resolvePermission, timer });
|
|
1736
2084
|
try { broadcastSync({ type: 'assistant:permission-request', runId, assistantSessionId: entry.assistantSessionId, provider: entry.provider, request_id: requestId, request: pending }); } catch {}
|
|
1737
2085
|
emit(entry, kind === 'question' ? 'needs_input' : 'permission_request', { request: pending });
|
|
1738
|
-
const abortSignal = request.signal;
|
|
1739
|
-
if (abortSignal && typeof abortSignal.addEventListener === 'function') {
|
|
1740
|
-
abortSignal.addEventListener('abort', () => finishPending(runId, requestId, { behavior: 'deny', message: 'Run aborted.', resolvedBy: 'abort' }), { once: true });
|
|
1741
|
-
}
|
|
1742
2086
|
}),
|
|
1743
2087
|
};
|
|
1744
2088
|
}
|
|
@@ -1749,10 +2093,12 @@ export function createAssistantDispatcher({
|
|
|
1749
2093
|
const resolver = liveState.pendingResolvers.get(requestId);
|
|
1750
2094
|
if (!resolver) return false;
|
|
1751
2095
|
clearTimeout(resolver.timer);
|
|
2096
|
+
resolver.cleanup?.();
|
|
1752
2097
|
liveState.pendingResolvers.delete(requestId);
|
|
1753
2098
|
const request = (entry.pending || []).find((item) => item.requestId === requestId) || null;
|
|
1754
2099
|
entry.pending = (entry.pending || []).filter((item) => item.requestId !== requestId);
|
|
1755
|
-
|
|
2100
|
+
liveState.ctx?.setPendingRequests?.(entry.pending);
|
|
2101
|
+
if (entry.state === 'awaiting_permission' && entry.pending.length === 0 && !liveState.dead && !liveState.suspending && !liveState.abortReason) {
|
|
1756
2102
|
entry.state = 'running';
|
|
1757
2103
|
entry.turnState = 'running';
|
|
1758
2104
|
startWork(entry, liveState);
|
|
@@ -1775,9 +2121,14 @@ export function createAssistantDispatcher({
|
|
|
1775
2121
|
throw new DispatchError(known ? 'PERMISSION_ALREADY_RESOLVED' : 'NO_PENDING_REQUEST', known ? `Request ${requestId} was already resolved` : `No pending request ${requestId} on run ${runId}`, { status: 409 });
|
|
1776
2122
|
}
|
|
1777
2123
|
const behavior = response.behavior === 'allow' || response.decision === 'allow' || response.decision === 'answer' ? 'allow' : 'deny';
|
|
2124
|
+
// Antigravity saves an "always" answer for every later session of the user's, outside the run: a worker's
|
|
2125
|
+
// answer is for this request only.
|
|
2126
|
+
const always = entry.provider === 'antigravity' ? false : !!response.always;
|
|
2127
|
+
try { validateWorkerReply((entry.pending || []).find((p) => p.requestId === requestId), { ...response, behavior }); }
|
|
2128
|
+
catch (error) { throw new DispatchError('INVALID_QUESTION_ANSWER', error.message); }
|
|
1778
2129
|
finishPending(runId, requestId, {
|
|
1779
|
-
behavior, updatedInput: response.updatedInput, message: response.message || response.note, always
|
|
1780
|
-
answers: response.answers, resolvedBy: origin,
|
|
2130
|
+
behavior, updatedInput: response.updatedInput, message: response.message || response.note, always,
|
|
2131
|
+
answers: response.answers ?? response.updatedInput?.answers, result: response.result, content: response.content, action: response.action, error: response.error, resolvedBy: origin,
|
|
1781
2132
|
}, { origin });
|
|
1782
2133
|
return view(entry);
|
|
1783
2134
|
}
|
|
@@ -1805,6 +2156,8 @@ export function createAssistantDispatcher({
|
|
|
1805
2156
|
// Saved with the run: a result that arrives after a restart belongs to the turn that was
|
|
1806
2157
|
// started, whether or not it was ever recorded.
|
|
1807
2158
|
usageStateOf(entry).turn = { n: turnNumber, taskId };
|
|
2159
|
+
// So is the turn in progress: a restart in it is resumed with the continuation prompt (resumeRun).
|
|
2160
|
+
resumeInfoOf(entry).inFlight = { turn: turnNumber, taskId, origin, startedAt };
|
|
1808
2161
|
liveState.turnUsage = null;
|
|
1809
2162
|
liveState.turnTokens = zeroTokens();
|
|
1810
2163
|
liveState.turnOpen = true;
|
|
@@ -1815,21 +2168,32 @@ export function createAssistantDispatcher({
|
|
|
1815
2168
|
startWork(entry, liveState);
|
|
1816
2169
|
ctx.setTurnState('running');
|
|
1817
2170
|
emit(entry, 'turn_started', { turn: turnNumber });
|
|
2171
|
+
persist({ force: true });
|
|
1818
2172
|
// What this turn runs (commands, tool results, edited files): the evidence behind a "done".
|
|
1819
2173
|
liveState.evidence = createTurnEvidence();
|
|
1820
2174
|
// Money booked while this turn runs (OpenCode steps, Codex rollout records).
|
|
1821
2175
|
liveState.turnMeteredUsd = 0;
|
|
1822
2176
|
let raw = await inner.runTurn(prompt, { ...meta, turn: turnNumber, outputSchema: entry.outputSchema || undefined });
|
|
1823
2177
|
if (entry.provider === 'codex') settleCodexTurn(entry, liveState, raw);
|
|
2178
|
+
else if (entry.provider === 'antigravity') settleAntigravityTurn(entry, liveState, raw);
|
|
1824
2179
|
let text = typeof raw?.text === 'string' ? raw.text : '';
|
|
1825
|
-
|
|
2180
|
+
const evalRun = isEvalRun(entry);
|
|
2181
|
+
let parsed = evalRun ? { found: false } : parseResultContract(text);
|
|
1826
2182
|
parsed.source = parsed.found ? 'contract' : null;
|
|
1827
2183
|
// A run with an output schema answers with bare JSON: that object is its result, no retry turn.
|
|
1828
2184
|
if (!parsed.found) parsed = structuredResult(entry, raw, text) || parsed;
|
|
2185
|
+
// A skill-evaluation run's answer is what is measured: it is its result as it is (no contract to find).
|
|
2186
|
+
if (evalRun && !parsed.found) parsed = { found: true, parseFailed: false, status: 'done', summary: clip(text, 2000), changes: [], files: [], follow_ups: [], media: [], question: '', json: null, jsonError: null, raw: text, source: 'eval' };
|
|
2187
|
+
// The server is going down (suspendForShutdown) as the turn ended without its ## Result block: no retry turn can
|
|
2188
|
+
// run now. The turn stays in flight, so the resumed worker is asked to finish it; what it spent is booked.
|
|
2189
|
+
if (liveState.suspending && !parsed.found) {
|
|
2190
|
+
accumulateCost(entry, raw);
|
|
2191
|
+
throw Object.assign(new Error('suspended for a server restart'), { name: 'AbortError' });
|
|
2192
|
+
}
|
|
1829
2193
|
const noBlock = !parsed.found && strictResult && !!text.trim() && !liveState.dead;
|
|
1830
2194
|
const evidence = evidenceSnapshot(liveState.evidence);
|
|
1831
|
-
// One Jev request before any retry turn (null without Jev: today's behaviour).
|
|
1832
|
-
const judged = await judgeTurn(entry, { text, parsed, noBlock, evidence, followUp });
|
|
2195
|
+
// One Jev request before any retry turn (null without Jev: today's behaviour). Never for an evaluation run.
|
|
2196
|
+
const judged = evalRun ? null : await judgeTurn(entry, { text, parsed, noBlock, evidence, followUp });
|
|
1833
2197
|
let retried = false;
|
|
1834
2198
|
let retryCost = 0;
|
|
1835
2199
|
if (judged?.status) {
|
|
@@ -1849,12 +2213,15 @@ export function createAssistantDispatcher({
|
|
|
1849
2213
|
if (retryParsed.found) { parsed = { ...retryParsed, source: 'retry' }; text = `${text}\n\n${retryText}`; }
|
|
1850
2214
|
retryCost = accumulateCost(entry, retry);
|
|
1851
2215
|
if (entry.provider === 'codex') settleCodexTurn(entry, liveState, retry);
|
|
2216
|
+
else if (entry.provider === 'antigravity') settleAntigravityTurn(entry, liveState, retry);
|
|
1852
2217
|
} catch (error) {
|
|
1853
2218
|
// Stopped during the retry: the run ends here (handleTurnError books the retry), with no
|
|
1854
2219
|
// turn_completed or follow-up after it. The first provider turn was paid for: book it first.
|
|
1855
|
-
|
|
2220
|
+
// Suspended for a server restart during it: the turn stays in flight for the resume.
|
|
2221
|
+
if (liveState.abortReason || liveState.suspending) { accumulateCost(entry, raw); throw error; }
|
|
1856
2222
|
retryCost = accumulateCost(entry, error);
|
|
1857
2223
|
if (entry.provider === 'codex') settleCodexTurn(entry, liveState, error);
|
|
2224
|
+
else if (entry.provider === 'antigravity') settleAntigravityTurn(entry, liveState, error);
|
|
1858
2225
|
entry.notes.push(`result retry failed: ${error?.message || error}`);
|
|
1859
2226
|
}
|
|
1860
2227
|
}
|
|
@@ -1880,7 +2247,8 @@ export function createAssistantDispatcher({
|
|
|
1880
2247
|
liveState.turnOpen = false;
|
|
1881
2248
|
stopUsagePolling(liveState);
|
|
1882
2249
|
let turnUsage = raw?.usage || null;
|
|
1883
|
-
|
|
2250
|
+
// Codex and Antigravity: what their provider turns (the retry included) reported, in provider keys.
|
|
2251
|
+
if (entry.provider === 'codex' || entry.provider === 'antigravity') turnUsage = liveState.turnUsage || null;
|
|
1884
2252
|
else if (entry.provider === 'opencode') turnUsage = (await settleOpenCodeTurn(entry, liveState)) || turnUsage;
|
|
1885
2253
|
// A reported cost below an earlier turn's estimate can make this negative: a turn never reads below $0.
|
|
1886
2254
|
const turnCost = Math.max(0, accumulateCost(entry, raw) + retryCost + (Number(liveState.turnMeteredUsd) || 0));
|
|
@@ -1893,6 +2261,7 @@ export function createAssistantDispatcher({
|
|
|
1893
2261
|
// Rows that settle from here on (a late rollout record) go to the record itself.
|
|
1894
2262
|
liveState.turnTokens = null;
|
|
1895
2263
|
entry.turns = [...(entry.turns || []), turn].slice(-50);
|
|
2264
|
+
if (entry.resumeInfo) delete entry.resumeInfo.inFlight;
|
|
1896
2265
|
entry.lastResult = parsed;
|
|
1897
2266
|
// The run's own descriptor (lib/native-loop-runtime.js) carries it too, for the surfaces that read that one
|
|
1898
2267
|
// (the OpenCode panel's pill): a warm worker whose turn ended asking waits on the user, it does not rest.
|
|
@@ -1904,6 +2273,8 @@ export function createAssistantDispatcher({
|
|
|
1904
2273
|
// itself stays warm for follow-ups; the brain or the user decides). A
|
|
1905
2274
|
// status Jev read from the message is a usable result.
|
|
1906
2275
|
entry.escalation = parsed.status === 'blocked' || (!parsed.found && parsed.source !== 'jev') ? computeEscalation(entry, 'idle') : null;
|
|
2276
|
+
// A provider's usage limit that stopped it: when it resets (the panel's waiting card counts to it).
|
|
2277
|
+
if (parsed.status === 'blocked' || !parsed.found) noteLimit(entry); else entry.limit = null;
|
|
1907
2278
|
if (judged?.logId) {
|
|
1908
2279
|
try {
|
|
1909
2280
|
judge.annotate?.(judged.logId, {
|
|
@@ -1927,13 +2298,24 @@ export function createAssistantDispatcher({
|
|
|
1927
2298
|
isAlive: () => !liveState.dead && (typeof inner.isAlive === 'function' ? inner.isAlive() : true),
|
|
1928
2299
|
describe: () => ({ ...(typeof inner.describe === 'function' ? inner.describe() : {}), runMode: 'task' }),
|
|
1929
2300
|
async runTurn(prompt, meta = {}) {
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
2301
|
+
// A run resumed after a server restart (resumeRun): one that was in a turn sends the continuation prompt (the
|
|
2302
|
+
// runtime's buildPrompt for it) as a turn of its own; one that waited for a follow-up waits again, with a fresh
|
|
2303
|
+
// idle timer; one that never got its first turn going sends its task as a new run would.
|
|
2304
|
+
const resume = liveState.resume || null;
|
|
2305
|
+
liveState.resume = null;
|
|
2306
|
+
if (resume?.mode !== 'idle') {
|
|
2307
|
+
try {
|
|
2308
|
+
await runInnerTurn(prompt, meta, resume?.mode === 'mid_turn'
|
|
2309
|
+
? { origin: 'resume', label: 'resumed after a server restart', taskId: resume.taskId || entry.taskId || null }
|
|
2310
|
+
: { origin: 'dispatch' });
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
pauseWork(entry, liveState);
|
|
2313
|
+
return handleTurnError(error, liveState, entry, ctx);
|
|
2314
|
+
}
|
|
1935
2315
|
}
|
|
1936
2316
|
for (;;) {
|
|
2317
|
+
// Suspended for a server shutdown (suspendForShutdown): the next start resumes it; nothing ends here.
|
|
2318
|
+
if (liveState.suspending) return { providerSessionId: null };
|
|
1937
2319
|
// Stopped while the turn ran (a stop that did not fail it): never idle, never take a queued follow-up.
|
|
1938
2320
|
if (liveState.abortReason) { entry.completionReason = entry.completionReason || liveState.abortReason; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
|
|
1939
2321
|
// Out of money (its own ceiling, the session's budget, unpriced usage): stop instead of idling
|
|
@@ -1946,6 +2328,7 @@ export function createAssistantDispatcher({
|
|
|
1946
2328
|
ctx.setTurnState('idle');
|
|
1947
2329
|
emit(entry, 'idle');
|
|
1948
2330
|
const next = await waitForFollowUp();
|
|
2331
|
+
if (liveState.suspending) return { providerSessionId: null };
|
|
1949
2332
|
if (next.kind === 'abort') { entry.completionReason = entry.completionReason || liveState.abortReason || 'aborted'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
|
|
1950
2333
|
if (next.kind === 'complete') { entry.completionReason = next.reason || 'complete'; liveState.completed = true; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
|
|
1951
2334
|
if (next.kind === 'timeout') { entry.completionReason = 'idle_timeout'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
|
|
@@ -2057,6 +2440,14 @@ export function createAssistantDispatcher({
|
|
|
2057
2440
|
}
|
|
2058
2441
|
|
|
2059
2442
|
function handleTurnError(error, liveState, entry, ctx) {
|
|
2443
|
+
// Suspended for a server shutdown (suspendForShutdown): the turn the shutdown cut off is resumed on the next start
|
|
2444
|
+
// (its in-flight mark stays), and what it spent was settled when it was suspended. Nothing ends here.
|
|
2445
|
+
if (liveState.suspending) {
|
|
2446
|
+
liveState.turnOpen = false;
|
|
2447
|
+
stopUsagePolling(liveState);
|
|
2448
|
+
return { providerSessionId: null };
|
|
2449
|
+
}
|
|
2450
|
+
if (entry.resumeInfo) delete entry.resumeInfo.inFlight;
|
|
2060
2451
|
// A failed turn was still paid for (a budget-capped run used to read $0).
|
|
2061
2452
|
liveState.turnOpen = false;
|
|
2062
2453
|
stopUsagePolling(liveState);
|
|
@@ -2066,6 +2457,8 @@ export function createAssistantDispatcher({
|
|
|
2066
2457
|
if (entry.provider === 'codex') settleCodexTurn(entry, liveState, error);
|
|
2067
2458
|
// OpenCode: what streamed before the failure, as it stands (its serve may be gone: no reconcile).
|
|
2068
2459
|
else if (entry.provider === 'opencode') bookOpenCodeTurn(entry, liveState, { reconciled: false });
|
|
2460
|
+
// Antigravity: a stopped turn's answer still carried its tokens (the adapter puts them on the abort).
|
|
2461
|
+
else if (entry.provider === 'antigravity') settleAntigravityTurn(entry, liveState, error);
|
|
2069
2462
|
announceUnpriced(entry);
|
|
2070
2463
|
checkSession(entry.assistantSessionId, { source: entry });
|
|
2071
2464
|
if (budgetStopReason(entry.completionReason)) return { providerSessionId: null };
|
|
@@ -2148,7 +2541,8 @@ export function createAssistantDispatcher({
|
|
|
2148
2541
|
if (!raw) return 0;
|
|
2149
2542
|
const cost = Number(raw.costUsd);
|
|
2150
2543
|
const added = bookCost(entry, cost, raw.basis || 'reported');
|
|
2151
|
-
|
|
2544
|
+
// Codex and Antigravity usage reaches entry.usage when it is settled (settleCodexTurn, settleAntigravityTurn).
|
|
2545
|
+
if (entry.provider !== 'codex' && entry.provider !== 'antigravity') addLegacyUsage(entry, raw.usage);
|
|
2152
2546
|
return added;
|
|
2153
2547
|
}
|
|
2154
2548
|
|
|
@@ -2435,11 +2829,43 @@ export function createAssistantDispatcher({
|
|
|
2435
2829
|
persist({ force: true });
|
|
2436
2830
|
}
|
|
2437
2831
|
|
|
2832
|
+
// Antigravity: each provider turn's answer carries that turn's own ACP usage (the adapter takes it off the
|
|
2833
|
+
// session's running totals; a failed or stopped turn has none, so the next answer carries it). Tokens only:
|
|
2834
|
+
// Antigravity is billed by its Google sign-in and reports no price, so every row is unpriced, never $0.
|
|
2835
|
+
/**
|
|
2836
|
+
* Book one Antigravity provider turn: its five token classes into the ledger (exact when the agent's own total
|
|
2837
|
+
* agrees with one reading of its classes, else partial: 'antigravity-total-mismatch'), its usage in provider keys
|
|
2838
|
+
* into the run's and the turn's legacy totals, and the run marked unpriced. Nothing without usage.
|
|
2839
|
+
*/
|
|
2840
|
+
function settleAntigravityTurn(entry, liveState, raw) {
|
|
2841
|
+
if (entry.usageState?.closed) return;
|
|
2842
|
+
const usage = raw?.usage && typeof raw.usage === 'object' ? raw.usage : null;
|
|
2843
|
+
if (!usage) return;
|
|
2844
|
+
const { tokens, mismatch } = antigravityUsageTokens(usage);
|
|
2845
|
+
if (totalTokens(tokens) === 0) return;
|
|
2846
|
+
const used = { input_tokens: tokens.input, output_tokens: tokens.output, reasoning_tokens: tokens.reasoning, cache_read_input_tokens: tokens.cacheRead, cache_write_input_tokens: tokens.cacheWrite };
|
|
2847
|
+
addLegacyUsage(entry, used);
|
|
2848
|
+
if (liveState) {
|
|
2849
|
+
const prev = liveState.turnUsage || {};
|
|
2850
|
+
liveState.turnUsage = Object.fromEntries(Object.entries(used).map(([key, value]) => [key, (Number(prev[key]) || 0) + value]));
|
|
2851
|
+
}
|
|
2852
|
+
markUnpriced(entry, UNPRICED_PROVIDER_REASON);
|
|
2853
|
+
// No model named: the one the session runs (the adapter reads it off the session's config options).
|
|
2854
|
+
let model = entry.model || null;
|
|
2855
|
+
if (!model) { try { model = liveState?.inner?.describe?.()?.model || null; } catch { model = null; } }
|
|
2856
|
+
settleUsage(entry, {
|
|
2857
|
+
model, part: 'main', tokens, costUsd: null, costBasis: 'unpriced',
|
|
2858
|
+
fidelity: mismatch ? 'partial' : 'exact', reason: mismatch ? 'antigravity-total-mismatch' : null, source: 'antigravity-acp-usage',
|
|
2859
|
+
});
|
|
2860
|
+
// At once, not debounced: a kill must not leave the saved run behind the ledger.
|
|
2861
|
+
persist({ force: true });
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2438
2864
|
// OpenCode: every session of the run's own serve (the root and the `task` tool's child sessions).
|
|
2439
2865
|
function openCodeMeterOf(entry) {
|
|
2440
2866
|
const held = metersOf(entry);
|
|
2441
|
-
//
|
|
2442
|
-
if (!held.opencode) held.opencode = createOpenCodeMeter();
|
|
2867
|
+
// Message and mark snapshots survive with the session; replayed history must not be charged again.
|
|
2868
|
+
if (!held.opencode) held.opencode = createOpenCodeMeter({ state: entry.usageState?.opencode });
|
|
2443
2869
|
return held.opencode;
|
|
2444
2870
|
}
|
|
2445
2871
|
/** The run's own session id, for a message that names none. */
|
|
@@ -2497,7 +2923,7 @@ export function createAssistantDispatcher({
|
|
|
2497
2923
|
function bookOpenCodeMessage(entry, liveState, info) {
|
|
2498
2924
|
// A finished run keeps no message book: what comes late is dropped, not booked from zero.
|
|
2499
2925
|
if (entry.usageState?.closed) return;
|
|
2500
|
-
const book = liveState.meter || (liveState.meter = new Map());
|
|
2926
|
+
const book = liveState.meter || (liveState.meter = new Map(Object.entries(entry.usageState?.opencodeMessages || {})));
|
|
2501
2927
|
const key = `${info.sessionID || ''}:${info.id}`;
|
|
2502
2928
|
const prev = book.get(key) || { cost: 0, usage: null, completed: false, estimate: 0 };
|
|
2503
2929
|
const reported = Number(info.cost);
|
|
@@ -2528,6 +2954,8 @@ export function createAssistantDispatcher({
|
|
|
2528
2954
|
} else markUnpriced(entry, `OpenCode reported no cost for ${model || 'its default model'} and it has no list price`);
|
|
2529
2955
|
}
|
|
2530
2956
|
book.set(key, { cost, usage: used, completed, estimate });
|
|
2957
|
+
usageStateOf(entry).opencodeMessages = Object.fromEntries(book);
|
|
2958
|
+
usageStateOf(entry).opencode = openCodeMeterOf(entry).state();
|
|
2531
2959
|
if (delta > 0 || completed) enforceMidTurn(entry, liveState);
|
|
2532
2960
|
}
|
|
2533
2961
|
/**
|
|
@@ -2539,6 +2967,7 @@ export function createAssistantDispatcher({
|
|
|
2539
2967
|
if (entry.usageState?.closed) return null;
|
|
2540
2968
|
const meter = openCodeMeterOf(entry);
|
|
2541
2969
|
const delta = meter.markTurn();
|
|
2970
|
+
usageStateOf(entry).opencode = meter.state();
|
|
2542
2971
|
const fidelity = reconciled ? 'exact' : 'partial';
|
|
2543
2972
|
const reason = reconciled ? null : 'opencode-not-reconciled';
|
|
2544
2973
|
const source = reconciled ? 'opencode-messages' : 'opencode-events';
|
|
@@ -2687,6 +3116,336 @@ export function createAssistantDispatcher({
|
|
|
2687
3116
|
return true;
|
|
2688
3117
|
}
|
|
2689
3118
|
|
|
3119
|
+
// ── server restart: suspend, then resume ───────────────────────────────────
|
|
3120
|
+
// Every restart ends the provider processes of its workers (they are its children). A native worker that may
|
|
3121
|
+
// continue (lib/worker-resume.js resumeRefusal) is suspended as the server goes down and resumed on the next start
|
|
3122
|
+
// as the same run: its runId, provider conversation and sidepanel tab. See docs/assistant.md.
|
|
3123
|
+
|
|
3124
|
+
/**
|
|
3125
|
+
* The server is going down (`reason`: the shutdown's): every eligible native worker is suspended
|
|
3126
|
+
* (state 'suspended', no end, no end card), and its runId returned for the runtime to keep (native-loop-runtime.js
|
|
3127
|
+
* shutdown's `keep`: its CLI is ended, its record kept). Called first by the graceful shutdown (server.js), and by
|
|
3128
|
+
* shutdown() for an exit that skipped it. Idempotent.
|
|
3129
|
+
*/
|
|
3130
|
+
function suspendForShutdown(reason = 'server_shutdown') {
|
|
3131
|
+
closing = true;
|
|
3132
|
+
const kept = [];
|
|
3133
|
+
for (const entry of entries.values()) {
|
|
3134
|
+
if (!SUSPENDABLE_STATES.has(entry.state)) continue;
|
|
3135
|
+
const liveState = live.get(entry.runId) || null;
|
|
3136
|
+
// Only a run whose worker is up, or whose resume is starting it (its session is known): one still starting for
|
|
3137
|
+
// the first time has no session to resume yet.
|
|
3138
|
+
if (!liveState || liveState.dead || (!liveState.inner && !liveState.resuming)) continue;
|
|
3139
|
+
// Skill evaluation and drafting runs end as before (the runtime stops them).
|
|
3140
|
+
if (entry.confinement) continue;
|
|
3141
|
+
const refused = resumeRefusal(entry, { atShutdown: true, workLeftMs: workLeftMs(entry) });
|
|
3142
|
+
if (refused) {
|
|
3143
|
+
if (entry.refusedAtShutdown) continue;
|
|
3144
|
+
// A Claude Code worker that may not continue (resumed too often, out of work time) ends interrupted with why
|
|
3145
|
+
// when the runtime stops it (finalize), and is said again once the server is back (loadRegistry): the brain's
|
|
3146
|
+
// mailbox goes down with this server.
|
|
3147
|
+
entry.resumeRefused = refused;
|
|
3148
|
+
entry.refusedAtShutdown = true;
|
|
3149
|
+
entry.completionReason = entry.completionReason || 'server_restart';
|
|
3150
|
+
entry.error = `Not resumed after the server restart: ${refused}`;
|
|
3151
|
+
entry.notes.push(`not resumed: ${refused}`);
|
|
3152
|
+
continue;
|
|
3153
|
+
}
|
|
3154
|
+
// A resume still starting keeps what it was resuming: the turn the earlier restart cut off (resumeInfo.inFlight)
|
|
3155
|
+
// or the wait for a follow-up.
|
|
3156
|
+
const midTurn = entry.state !== 'idle' && liveState.resume?.mode !== 'idle';
|
|
3157
|
+
pauseWork(entry, liveState);
|
|
3158
|
+
liveState.suspending = true;
|
|
3159
|
+
if (liveState.idleTimer) { clearTimeout(liveState.idleTimer); liveState.idleTimer = null; }
|
|
3160
|
+
entry.state = 'suspended';
|
|
3161
|
+
entry.turnState = 'suspended';
|
|
3162
|
+
entry.suspendedAt = iso(now());
|
|
3163
|
+
entry.suspendReason = String(reason || 'server_shutdown');
|
|
3164
|
+
delete entry.crashed;
|
|
3165
|
+
// An open permission request goes with the process: the resumed worker asks again.
|
|
3166
|
+
for (const requestId of [...liveState.pendingResolvers.keys()]) finishPending(entry.runId, requestId, { behavior: 'deny', message: 'Server restarting; ask again after resume.', resolvedBy: 'server_restart' });
|
|
3167
|
+
entry.pending = [];
|
|
3168
|
+
// What the cut-off turn spent so far is booked now (an estimate per call the stream showed: no result will
|
|
3169
|
+
// count them); the meter takes the resumed CLI's counters as its new baseline (rebaseClaudeUsage).
|
|
3170
|
+
try { suspendUsage(entry, liveState); } catch (error) { log(entry.runId, 'assistant:meter-error', error?.message || String(error)); }
|
|
3171
|
+
entry.notes.push(`suspended: the server went down (${entry.suspendReason}) ${midTurn ? 'during a turn' : 'while it waited for a follow-up'}; it resumes when the server is back`);
|
|
3172
|
+
emit(entry, 'suspended', { text: 'suspended: SynaBun is restarting; this run resumes as soon as the server is back (no action needed)' });
|
|
3173
|
+
kept.push(entry.runId);
|
|
3174
|
+
}
|
|
3175
|
+
if (kept.length) persist({ force: true });
|
|
3176
|
+
return kept;
|
|
3177
|
+
}
|
|
3178
|
+
/** Book pending provider usage and save the meter baseline before the process ends. */
|
|
3179
|
+
function suspendUsage(entry, liveState) {
|
|
3180
|
+
if (entry.provider === 'codex') pollCodex(entry, liveState, { discover: true });
|
|
3181
|
+
if (entry.provider === 'opencode' && liveState) bookOpenCodeTurn(entry, liveState);
|
|
3182
|
+
stopUsagePolling(liveState);
|
|
3183
|
+
if (liveState) liveState.turnOpen = false;
|
|
3184
|
+
const claude = meters.get(entry.runId)?.claude;
|
|
3185
|
+
if (claude && typeof claude.settlePending === 'function') {
|
|
3186
|
+
const rows = claude.settlePending({ reason: 'no-result' });
|
|
3187
|
+
if (rows.length) settleClaudeRows(entry, rows);
|
|
3188
|
+
usageStateOf(entry).claude = claude.state();
|
|
3189
|
+
}
|
|
3190
|
+
if (ledger && entry.assistantSessionId) { try { ledger.clearPending(entry.assistantSessionId, entry.runId); } catch { /* nothing pending */ } }
|
|
3191
|
+
}
|
|
3192
|
+
/**
|
|
3193
|
+
* The process of a suspended run's CLI, if it still runs: told to stop (an exit handler cannot wait for it). Not
|
|
3194
|
+
* when the runtime is ending it already (its record is suspended): a second signal could cut its clean exit short.
|
|
3195
|
+
* Windows has no SIGTERM (a signal ends the CLI alone and leaves its tool's processes): the tree is ended with
|
|
3196
|
+
* taskkill, which returns once it is done.
|
|
3197
|
+
*/
|
|
3198
|
+
function signalWorkerProcess(entry) {
|
|
3199
|
+
try { if (safeRuntime()?.get?.(entry.runId)?.status === 'suspended') return; } catch { /* no runtime: signal it */ }
|
|
3200
|
+
let pid = null;
|
|
3201
|
+
try { pid = live.get(entry.runId)?.inner?.processInfo?.()?.pid || null; } catch { pid = null; }
|
|
3202
|
+
if (!pid) return;
|
|
3203
|
+
if (process.platform === 'win32') { try { endProcessTree(pid); } catch { /* gone, or not allowed */ } return; }
|
|
3204
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
/**
|
|
3208
|
+
* The resumed CLI starts its counters where its transcript's last cost-state left them (lib/worker-resume.js
|
|
3209
|
+
* readCostState): the Claude meter takes that as its baseline and the adapter its cost counter, so the first turn
|
|
3210
|
+
* after the resume is charged for itself only. What the cut-off turn spent was booked when the run was suspended; a
|
|
3211
|
+
* crash booked nothing, so what the CLI counted after the run's last settled result is settled now, from its own
|
|
3212
|
+
* counts (one result made of the cost-state). Returns the cost base for the adapter (costBaseUsd).
|
|
3213
|
+
*/
|
|
3214
|
+
function rebaseClaudeUsage(entry) {
|
|
3215
|
+
let costState = null;
|
|
3216
|
+
try {
|
|
3217
|
+
const transcript = findClaudeTranscript({ sessionId: entry.providerSessionId, configDir: entry.claudeConfigDir || null });
|
|
3218
|
+
costState = transcript ? readCostState(transcript, entry.providerSessionId) : null;
|
|
3219
|
+
} catch { costState = null; }
|
|
3220
|
+
const state = usageStateOf(entry);
|
|
3221
|
+
if (entry.crashed && costState?.modelUsage) {
|
|
3222
|
+
try {
|
|
3223
|
+
const meter = claudeMeterOf(entry);
|
|
3224
|
+
const before = meter.state().snapshot;
|
|
3225
|
+
if (!before || countersCover(costState.modelUsage, before)) {
|
|
3226
|
+
const { settled } = meter.onEvent({ type: 'result', subtype: 'success', uuid: `resume:${entry.runId}:${Number(entry.resumes) || 0}`, session_id: entry.providerSessionId, modelUsage: costState.modelUsage, usage: {} });
|
|
3227
|
+
if (settled) { settleClaudeRows(entry, settled.rows, settled); bookCost(entry, Number(settled.costUsd) || 0, 'reported'); }
|
|
3228
|
+
}
|
|
3229
|
+
} catch (error) { log(entry.runId, 'assistant:meter-error', error?.message || String(error)); }
|
|
3230
|
+
}
|
|
3231
|
+
const held = metersOf(entry);
|
|
3232
|
+
const saved = held.claude ? held.claude.state() : (state.claude || {});
|
|
3233
|
+
state.claude = { ...saved, snapshot: costState?.modelUsage || null, unbaselined: null, history: false };
|
|
3234
|
+
held.claude = null;
|
|
3235
|
+
delete entry.crashed;
|
|
3236
|
+
persist({ force: true });
|
|
3237
|
+
return costState ? costState.totalCostUsd : 0;
|
|
3238
|
+
}
|
|
3239
|
+
/** Whether per-model counters `now` are at or above `before` for every model and counter (the same process went on). */
|
|
3240
|
+
function countersCover(nowUsage, before) {
|
|
3241
|
+
const keys = ['inputTokens', 'outputTokens', 'cacheReadInputTokens', 'cacheCreationInputTokens'];
|
|
3242
|
+
return Object.entries(before || {}).every(([model, counters]) => {
|
|
3243
|
+
const current = nowUsage?.[model];
|
|
3244
|
+
return !!current && keys.every((key) => (Number(current[key]) || 0) >= (Number(counters?.[key]) || 0));
|
|
3245
|
+
});
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
/**
|
|
3249
|
+
* After a restart (loadRegistry filled bootResumes / bootInterrupts): the runs that will not resume are said
|
|
3250
|
+
* ('interrupted', with why) and their runtime records ended; the others are resumed one at a time, in the order they
|
|
3251
|
+
* were dispatched, ahead of every queued dispatch. Runs a lowered concurrency limit holds back wait at the head of
|
|
3252
|
+
* the queue (processQueue resumes them).
|
|
3253
|
+
*/
|
|
3254
|
+
async function resumeOnBoot() {
|
|
3255
|
+
// The limits as the user set them (assistant-config.json), not the defaults.
|
|
3256
|
+
refreshLimits();
|
|
3257
|
+
const runtime = safeRuntime();
|
|
3258
|
+
const ids = bootResumes.splice(0)
|
|
3259
|
+
.map((runId) => entries.get(runId)).filter((entry) => entry?.state === 'resuming')
|
|
3260
|
+
.sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')))
|
|
3261
|
+
.map((entry) => entry.runId);
|
|
3262
|
+
// Held for this dispatcher from here on: the runtime's own hold no longer ends them.
|
|
3263
|
+
for (const runId of ids) { try { runtime?.keepSuspended?.(runId); } catch { /* ended meanwhile */ } }
|
|
3264
|
+
for (const runId of bootInterrupts.splice(0)) {
|
|
3265
|
+
const entry = entries.get(runId);
|
|
3266
|
+
if (!entry) continue;
|
|
3267
|
+
// A CLI a crash left behind goes with the run.
|
|
3268
|
+
await endOldProcess(entry);
|
|
3269
|
+
try { await runtime?.endSuspended?.(runId, 'server_restart', { error: entry.resumeRefused ? `not resumed: ${entry.resumeRefused}` : null }); } catch { /* not held */ }
|
|
3270
|
+
emit(entry, 'interrupted', { text: `not resumed after the server restart: ${entry.resumeRefused || 'it could not continue'}` });
|
|
3271
|
+
}
|
|
3272
|
+
// A resumed CLI connects to SynaBun's MCP as it starts: not before this server answers.
|
|
3273
|
+
if (ids.length && typeof serverReady === 'function') { try { await serverReady(); } catch { /* resume anyway */ } }
|
|
3274
|
+
const held = [];
|
|
3275
|
+
for (const runId of ids) {
|
|
3276
|
+
const entry = entries.get(runId);
|
|
3277
|
+
if (!entry || entry.state !== 'resuming') continue;
|
|
3278
|
+
const spec = { provider: entry.provider, accountId: entry.accountId, workflowId: entry.workflowId, usesComputer: !!entry.usesComputer };
|
|
3279
|
+
const block = railBlock(spec, entry.assistantSessionId, { except: runId });
|
|
3280
|
+
if (block) { entry.notes.push(`resume queued: ${block}`); held.push(runId); persist(); continue; }
|
|
3281
|
+
// What it threw (a folder that cannot be made, a full disk) ends it, with why: nothing else would.
|
|
3282
|
+
try { await resumeRun(entry); } catch (error) { log(runId, 'assistant:resume-error', error?.message || String(error)); notResumed(entry, error?.message || String(error)); }
|
|
3283
|
+
}
|
|
3284
|
+
if (held.length) { queue.unshift(...held); processQueue().catch(() => {}); }
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
/**
|
|
3288
|
+
* Continue a run a server restart cut off (state 'resuming'): what start() does for a new run (the budget as it is
|
|
3289
|
+
* now, a browser tab, the desktop grant, the permission broker), then the runtime resumes the run's record with its
|
|
3290
|
+
* Claude session (resumeSessionId). A run that was in a turn gets the continuation prompt (buildResumePrompt) as its
|
|
3291
|
+
* next turn; one that waited for a follow-up waits again; one that never got its first turn going is sent its task.
|
|
3292
|
+
* A run that cannot be resumed ends as interrupted, with why. It holds its provider's slot from the call on (the
|
|
3293
|
+
* callers check the rails just before it, in the same tick).
|
|
3294
|
+
*/
|
|
3295
|
+
async function resumeRun(entry) {
|
|
3296
|
+
if (entry.state !== 'resuming') return null;
|
|
3297
|
+
resumeInProgress.add(entry.runId);
|
|
3298
|
+
try { return await continueRun(entry); } finally { resumeInProgress.delete(entry.runId); }
|
|
3299
|
+
}
|
|
3300
|
+
async function continueRun(entry) {
|
|
3301
|
+
if (closing) return null;
|
|
3302
|
+
const runtime = safeRuntime();
|
|
3303
|
+
if (!runtime?.resume) return notResumed(entry, 'the native loop runtime is not available');
|
|
3304
|
+
refreshLimits();
|
|
3305
|
+
const refusal = resumeRefusal(entry, { now: now(), workLeftMs: workLeftMs(entry), isDirectory, accountUnusable: providerAccountUnusable });
|
|
3306
|
+
if (refusal) return notResumed(entry, refusal);
|
|
3307
|
+
try { await fitBudget(entry, { routed: true }); } catch (error) { return notResumed(entry, `the budget does not allow it (${error?.message || error})`); }
|
|
3308
|
+
if (entry.state !== 'resuming' || closing) return null; // stopped meanwhile, or the server is going down again
|
|
3309
|
+
// Its own ceiling (budget_usd), with what its cut-off turn was booked at the suspension (fitBudget checks the
|
|
3310
|
+
// session and the share only): a run out of money is not resumed, as a turn that ends out of money does not idle.
|
|
3311
|
+
const spentOut = budgetBlock(entry);
|
|
3312
|
+
if (spentOut && spentOut.code !== 'BUDGET_SHARE_EXHAUSTED') {
|
|
3313
|
+
entry.completionReason = spentOut.reason;
|
|
3314
|
+
return notResumed(entry, `the budget does not allow it (${spentOut.message})`);
|
|
3315
|
+
}
|
|
3316
|
+
if (!runtime.isSuspended?.(entry.runId)) return notResumed(entry, 'its runtime record is gone');
|
|
3317
|
+
// A crash ended no process: the CLI the run had may still be there, finishing the cut-off tool call. It is ended
|
|
3318
|
+
// first; one that cannot be checked keeps the run from resuming (two processes on one session would both write its
|
|
3319
|
+
// transcript).
|
|
3320
|
+
const orphan = await endOldProcess(entry);
|
|
3321
|
+
if (orphan.state === 'unknown' || orphan.state === 'still_running') {
|
|
3322
|
+
return notResumed(entry, `its old ${providerName(entry.provider)} process (pid ${orphan.pid}) ${orphan.state === 'unknown' ? 'may still be running and could not be checked' : 'did not stop'}`);
|
|
3323
|
+
}
|
|
3324
|
+
if (entry.state !== 'resuming' || closing) return null;
|
|
3325
|
+
const costBaseUsd = entry.provider === 'claude-code' ? rebaseClaudeUsage(entry) : 0;
|
|
3326
|
+
if (loopDir && !existsSync(loopDir)) mkdirSync(loopDir, { recursive: true });
|
|
3327
|
+
// Its browser session went with the server: a new one (the resume prompt names it).
|
|
3328
|
+
let browserSessionId = null;
|
|
3329
|
+
let browserTabId = null;
|
|
3330
|
+
if (entry.usesBrowser && typeof acquireLoopBrowserAndTab === 'function') {
|
|
3331
|
+
const acq = await acquireLoopBrowserAndTab({ requestedBrowserSessionId: entry.requestedBrowserSessionId, terminalSessionId: entry.runId, logTag: 'assistant:browser' }).catch((error) => ({ error: error?.message || String(error) }));
|
|
3332
|
+
if (acq?.error) return notResumed(entry, `no browser tab: ${acq.error}`);
|
|
3333
|
+
browserSessionId = acq.browserSessionId || null;
|
|
3334
|
+
browserTabId = acq.browserTabId || null;
|
|
3335
|
+
}
|
|
3336
|
+
if (entry.state !== 'resuming' || closing) {
|
|
3337
|
+
if (browserSessionId) await releaseSharedBrowserTab(browserSessionId, entry.runId).catch(() => {});
|
|
3338
|
+
return null;
|
|
3339
|
+
}
|
|
3340
|
+
entry.browserSessionId = browserSessionId;
|
|
3341
|
+
entry.browserTabId = browserTabId;
|
|
3342
|
+
if (entry.scratchDir) { try { mkdirSync(entry.scratchDir, { recursive: true }); } catch { /* the guard says so when it needs it */ } }
|
|
3343
|
+
const inFlight = entry.resumeInfo?.inFlight || null;
|
|
3344
|
+
const turned = lastTurnNumber(entry);
|
|
3345
|
+
const mode = inFlight ? 'mid_turn' : turned > 0 ? 'idle' : 'fresh';
|
|
3346
|
+
const liveState = newLiveState(entry);
|
|
3347
|
+
liveState.turnCounter = turned;
|
|
3348
|
+
liveState.resume = { mode, taskId: inFlight?.taskId || null };
|
|
3349
|
+
// Follow-ups sent while it was away wait for it like any queued follow-up.
|
|
3350
|
+
const waiting = awaitingResume.get(entry.runId) || [];
|
|
3351
|
+
awaitingResume.delete(entry.runId);
|
|
3352
|
+
liveState.followUps.push(...waiting);
|
|
3353
|
+
live.set(entry.runId, liveState);
|
|
3354
|
+
const desktopGrant = mintDesktopGrant(entry);
|
|
3355
|
+
const state = runState(entry, {
|
|
3356
|
+
browserSessionId, browserTabId, desktopGrant,
|
|
3357
|
+
startedAt: entry.startedAt || iso(now()),
|
|
3358
|
+
extra: { resumeSessionId: entry.providerSessionId, resumeThreadId: entry.providerThreadId, costBaseUsd, resumingWorker: true, antigravityHome: entry.resumeInfo?.antigravityHome || null },
|
|
3359
|
+
});
|
|
3360
|
+
// The CLI's spend stop (--max-budget-usd) counts from the total the resumed CLI restores (costBaseUsd): what the run
|
|
3361
|
+
// may still spend (its ceiling less what it spent, never more than the session has left) goes on top of that. A
|
|
3362
|
+
// backstop only: the dispatcher holds the run to its ceiling and its share between calls.
|
|
3363
|
+
const allowanceUsd = claudeRunCap(entry, { spentUsd: entry.costUsd });
|
|
3364
|
+
if (Number(allowanceUsd) > 0) state.maxBudgetUsd = Number((Number(allowanceUsd) + Math.max(0, Number(costBaseUsd) || 0)).toFixed(2));
|
|
3365
|
+
state.permissionBroker = createBroker(entry.runId);
|
|
3366
|
+
const at = clockTime(entry.suspendedAt);
|
|
3367
|
+
const selfRestarts = isInside(entry.cwd, PACKAGE_ROOT);
|
|
3368
|
+
const playbook = mode === 'fresh' ? playbookFor(entry) : null;
|
|
3369
|
+
const styleGuide = mode === 'fresh' ? styleGuideFor(entry) : null;
|
|
3370
|
+
const prompt = () => (mode === 'mid_turn'
|
|
3371
|
+
? buildResumePrompt(state, { at, selfRestarts })
|
|
3372
|
+
: buildTaskPrompt({ ...state, ...(playbook ? { playbook } : {}), ...(styleGuide ? { styleGuide } : {}), testDensity: briefTestDensity(entry) }));
|
|
3373
|
+
entry.resumes = (Number(entry.resumes) || 0) + 1;
|
|
3374
|
+
entry.resumedAt = iso(now());
|
|
3375
|
+
entry.state = 'starting';
|
|
3376
|
+
entry.turnState = 'starting';
|
|
3377
|
+
entry.notes.push(`resumed after the server restart${at ? ` at ${at}` : ''} (${entry.resumes} of ${MAX_RESUMES}): ${mode === 'mid_turn' ? 'it continues the turn the restart cut off' : mode === 'idle' ? 'it waits for a follow-up again' : 'its first turn starts again'}`);
|
|
3378
|
+
// A shutdown while its CLI starts suspends it again (suspendForShutdown): the next start resumes it.
|
|
3379
|
+
liveState.resuming = true;
|
|
3380
|
+
try {
|
|
3381
|
+
const descriptor = await runtime.resume({
|
|
3382
|
+
runId: entry.runId,
|
|
3383
|
+
state,
|
|
3384
|
+
buildPrompt: prompt,
|
|
3385
|
+
wrapAdapter: (adapter, ctx) => createTaskAdapter(adapter, ctx, liveState, state),
|
|
3386
|
+
});
|
|
3387
|
+
entry.runtime = { status: descriptor.status };
|
|
3388
|
+
} catch (error) {
|
|
3389
|
+
// Suspended again by a shutdown while its CLI started: the start was cancelled, nothing ended.
|
|
3390
|
+
if (entry.state === 'suspended') return null;
|
|
3391
|
+
// The runtime ended its record (resume_failed): its run-stopped event ended the run here too.
|
|
3392
|
+
const why = error?.message || String(error);
|
|
3393
|
+
if (browserSessionId) await releaseSharedBrowserTab(browserSessionId, entry.runId).catch(() => {});
|
|
3394
|
+
if (!TERMINAL_STATES.has(entry.state)) return notResumed(entry, why);
|
|
3395
|
+
entry.notes.push(`not resumed: ${why}`);
|
|
3396
|
+
persist();
|
|
3397
|
+
return null;
|
|
3398
|
+
}
|
|
3399
|
+
if (entry.state === 'suspended') return null;
|
|
3400
|
+
delete entry.suspendedAt;
|
|
3401
|
+
delete entry.suspendReason;
|
|
3402
|
+
emit(entry, 'resumed', {
|
|
3403
|
+
resumes: entry.resumes,
|
|
3404
|
+
text: `resumed after the server restart${at ? ` at ${at}` : ''} (${entry.resumes} of ${MAX_RESUMES}): ${mode === 'mid_turn' ? 'it continues the turn the restart cut off; its result arrives as usual' : mode === 'idle' ? 'it waits for a follow-up again' : 'it starts its task again'}. Nothing to do.`,
|
|
3405
|
+
});
|
|
3406
|
+
reconcileKeepAwake();
|
|
3407
|
+
return view(entry);
|
|
3408
|
+
}
|
|
3409
|
+
/**
|
|
3410
|
+
* The CLI a run had before a crash may still be there, finishing its cut-off tool call (a crash ends no process): it
|
|
3411
|
+
* is ended once its command line shows it is this run's (lib/worker-resume.js endOrphanProcess), before a resume and
|
|
3412
|
+
* when a run a restart cut off ends without one. → { state, pid } ('gone' without a saved pid).
|
|
3413
|
+
*/
|
|
3414
|
+
async function endOldProcess(entry) {
|
|
3415
|
+
const pid = Number(entry?.resumeInfo?.pid) || null;
|
|
3416
|
+
if (!pid) return { state: 'gone', pid: null };
|
|
3417
|
+
let orphan = null;
|
|
3418
|
+
try { orphan = await endOrphanProcess({ pid, runId: entry.runId }); } catch { orphan = { state: 'unknown' }; }
|
|
3419
|
+
if (orphan.state === 'ended' || orphan.state === 'killed') entry.notes.push(`its old ${providerName(entry.provider)} process (pid ${pid}) still ran after the restart and was ${orphan.state}`);
|
|
3420
|
+
if (orphan.state !== 'unknown' && orphan.state !== 'still_running' && entry.resumeInfo) {
|
|
3421
|
+
delete entry.resumeInfo.pid;
|
|
3422
|
+
delete entry.resumeInfo.pidStartedAt;
|
|
3423
|
+
}
|
|
3424
|
+
return { ...orphan, pid };
|
|
3425
|
+
}
|
|
3426
|
+
/** A run that could not be resumed after a restart ends as interrupted, with why (its notes, its error, its event). */
|
|
3427
|
+
function notResumed(entry, why) {
|
|
3428
|
+
if (TERMINAL_STATES.has(entry.state)) return null;
|
|
3429
|
+
const index = queue.indexOf(entry.runId);
|
|
3430
|
+
if (index !== -1) queue.splice(index, 1);
|
|
3431
|
+
// Follow-ups accepted for it while it was away (agent_send) reach nobody: its end says so, with the first of them.
|
|
3432
|
+
const waiting = awaitingResume.get(entry.runId) || [];
|
|
3433
|
+
awaitingResume.delete(entry.runId);
|
|
3434
|
+
const undelivered = waiting.length ? `; ${waiting.length} follow-up${waiting.length === 1 ? '' : 's'} sent while it was away ${waiting.length === 1 ? 'was' : 'were'} not delivered (${waiting.length === 1 ? '' : 'the first: '}"${clip(waiting[0].text, 160)}")` : '';
|
|
3435
|
+
// Nothing of it may go on: a CLI a crash left behind is ended too.
|
|
3436
|
+
endOldProcess(entry).then(() => persist()).catch(() => {});
|
|
3437
|
+
entry.notes.push(`not resumed: ${why}${undelivered}`);
|
|
3438
|
+
entry.resumeRefused = why;
|
|
3439
|
+
entry.completionReason = entry.completionReason || 'server_restart';
|
|
3440
|
+
const error = `Not resumed after the server restart: ${why}${undelivered}`;
|
|
3441
|
+
let ended = false;
|
|
3442
|
+
try { ended = !!safeRuntime()?.isSuspended?.(entry.runId); } catch { ended = false; }
|
|
3443
|
+
// The runtime's run-stopped event finalizes the run here (onRuntimeMessage); without a record it is done here.
|
|
3444
|
+
if (ended) safeRuntime().endSuspended(entry.runId, 'server_restart', { error }).catch(() => {});
|
|
3445
|
+
if (!TERMINAL_STATES.has(entry.state)) finalize(entry, { status: 'interrupted', stoppedReason: 'server_restart', error });
|
|
3446
|
+
return null;
|
|
3447
|
+
}
|
|
3448
|
+
|
|
2690
3449
|
// ── follow-ups / lifecycle controls ────────────────────────────────────────
|
|
2691
3450
|
function requireEntry(runId) {
|
|
2692
3451
|
const entry = entries.get(runId);
|
|
@@ -2700,9 +3459,12 @@ export function createAssistantDispatcher({
|
|
|
2700
3459
|
if (message.length > 20_000) throw new DispatchError('MESSAGE_TOO_LONG', 'text must be at most 20000 characters');
|
|
2701
3460
|
if (TERMINAL_STATES.has(entry.state)) throw new DispatchError('RUN_NOT_ACTIVE', `Run ${runId} is ${entry.state}; dispatch a new run with the previous result as context.`, { status: 409, resumable: false });
|
|
2702
3461
|
if (entry.state === 'queued') throw new DispatchError('RUN_NOT_STARTED', `Run ${runId} is still queued`, { status: 409 });
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
3462
|
+
// Away for a server restart (suspended, or being resumed): the follow-up waits for the run and goes to it after
|
|
3463
|
+
// its resume, like a follow-up queued behind a turn. Should the run not resume, it ends and says why.
|
|
3464
|
+
const away = entry.state === 'suspended' || entry.state === 'resuming';
|
|
3465
|
+
const liveState = away ? null : live.get(runId);
|
|
3466
|
+
if (!away && !liveState) throw new DispatchError('RUN_NOT_ACTIVE', `Run ${runId} has no live worker`, { status: 409 });
|
|
3467
|
+
if ((away ? awayTurns(entry) : liveState.turnCounter) >= entry.maxTurns) throw new DispatchError('MAX_TURNS', `Run ${runId} reached its max turns (${entry.maxTurns})`, { status: 409 });
|
|
2706
3468
|
refreshLimits();
|
|
2707
3469
|
const blocked = budgetBlock(entry);
|
|
2708
3470
|
if (blocked) {
|
|
@@ -2720,6 +3482,14 @@ export function createAssistantDispatcher({
|
|
|
2720
3482
|
// A follow-up works for the task in progress when it is sent, which may be a later one than the run's.
|
|
2721
3483
|
const item = { kind: 'follow-up', text: message, origin, at: iso(now()), taskId: taskNow(entry.assistantSessionId) || entry.taskId || null };
|
|
2722
3484
|
if (origin === 'user') emit(entry, 'user_intervened', { text: clip(message, 200) });
|
|
3485
|
+
if (away) {
|
|
3486
|
+
if (!allowQueue) throw new DispatchError('TURN_ACTIVE', `Run ${runId} is ${entry.state === 'suspended' ? 'suspended while the server restarts' : 'being resumed after a server restart'}; retry when it is idle or pass queue:true`, { status: 409 });
|
|
3487
|
+
const waiting = awaitingResume.get(runId) || [];
|
|
3488
|
+
waiting.push(item);
|
|
3489
|
+
awaitingResume.set(runId, waiting);
|
|
3490
|
+
emit(entry, 'follow_up_queued', { queued: waiting.length });
|
|
3491
|
+
return { ok: true, queued: true, position: waiting.length, run: view(entry), note: 'The run resumes after the server restart; this follow-up goes to it then.' };
|
|
3492
|
+
}
|
|
2723
3493
|
if (liveState.waitResolve) { liveState.waitResolve(item); return { ok: true, queued: false, turn: liveState.turnCounter + 1, run: view(entry) }; }
|
|
2724
3494
|
if (!allowQueue) throw new DispatchError('TURN_ACTIVE', `Run ${runId} is busy; retry when idle or pass queue:true`, { status: 409 });
|
|
2725
3495
|
liveState.followUps.push(item);
|
|
@@ -2777,6 +3547,15 @@ export function createAssistantDispatcher({
|
|
|
2777
3547
|
}
|
|
2778
3548
|
if (TERMINAL_STATES.has(entry.state)) return { ok: true, run: view(entry), alreadyTerminal: true };
|
|
2779
3549
|
entry.completionReason = entry.completionReason || reason;
|
|
3550
|
+
// A run waiting for its resume after a restart leaves the queue and drops the follow-ups that waited for it; its
|
|
3551
|
+
// runtime record (suspended) ends as stopped.
|
|
3552
|
+
if (entry.state === 'resuming' || entry.state === 'suspended') {
|
|
3553
|
+
const index = queue.indexOf(runId);
|
|
3554
|
+
if (index !== -1) queue.splice(index, 1);
|
|
3555
|
+
awaitingResume.delete(runId);
|
|
3556
|
+
// A CLI a crash left behind (this start has not ended it yet) goes with the run.
|
|
3557
|
+
if (entry.state === 'resuming') await endOldProcess(entry);
|
|
3558
|
+
}
|
|
2780
3559
|
const runtime = safeRuntime();
|
|
2781
3560
|
const stopped = runtime ? await runtime.stop(runId, reason) : false;
|
|
2782
3561
|
if (!stopped) finalize(entry, { status: 'stopped', stoppedReason: reason });
|
|
@@ -2795,11 +3574,15 @@ export function createAssistantDispatcher({
|
|
|
2795
3574
|
function finalize(entry, runtimeRun) {
|
|
2796
3575
|
if (TERMINAL_STATES.has(entry.state)) return;
|
|
2797
3576
|
const liveState = live.get(entry.runId);
|
|
3577
|
+
// Follow-ups that waited for its resume after a restart go with it.
|
|
3578
|
+
awaitingResume.delete(entry.runId);
|
|
2798
3579
|
pauseWork(entry, liveState);
|
|
2799
3580
|
let status = runtimeRun?.status || 'completed';
|
|
2800
3581
|
// A completed worker whose loop file was swept meanwhile (a stale-file cleanup) reaches the
|
|
2801
3582
|
// runtime's next check as "stopped": its turn loop had already ended on the completion.
|
|
2802
3583
|
if (status === 'stopped' && liveState?.completed && /^state_(removed|inactive)$/.test(String(runtimeRun?.stoppedReason || ''))) status = 'completed';
|
|
3584
|
+
// A Claude Code worker the shutdown could not suspend (suspendForShutdown) ends interrupted, with why.
|
|
3585
|
+
if (status === 'stopped' && entry.refusedAtShutdown) status = 'interrupted';
|
|
2803
3586
|
entry.state = TERMINAL_STATES.has(status) ? status : 'completed';
|
|
2804
3587
|
entry.turnState = 'terminal';
|
|
2805
3588
|
entry.finishedAt = iso(now());
|
|
@@ -2814,18 +3597,21 @@ export function createAssistantDispatcher({
|
|
|
2814
3597
|
}
|
|
2815
3598
|
entry.pending = [];
|
|
2816
3599
|
releaseDesktop(entry);
|
|
3600
|
+
// The run's scratch folder goes when it left nothing there (what it wrote is the user's to look at).
|
|
3601
|
+
if (entry.scratchDir) { try { rmdirSync(entry.scratchDir); } catch { /* not empty, or already gone */ } }
|
|
2817
3602
|
// A run that ended gives back what it did not spend: the session's split is made again (debounced).
|
|
2818
3603
|
if (entry.assistantSessionId) { try { budget.onRunEnded(entry.assistantSessionId); } catch { /* the split stays as it is */ } }
|
|
2819
|
-
if (entry.state === 'failed' && !budgetStopReason(entry.completionReason) && judge?.workerOutcome) {
|
|
3604
|
+
if (entry.state === 'failed' && !budgetStopReason(entry.completionReason) && judge?.workerOutcome && !isEvalRun(entry)) {
|
|
2820
3605
|
// State, queue and desktop move on now; the 'failed' event waits for its cause.
|
|
2821
3606
|
emitFailed(entry, liveState?.evidence || null);
|
|
2822
3607
|
processQueue().catch(() => {});
|
|
2823
3608
|
return;
|
|
2824
3609
|
}
|
|
2825
|
-
if (entry.state === 'failed' || entry.lastResult?.status === 'blocked') entry.escalation = computeEscalation(entry);
|
|
3610
|
+
if (entry.state === 'failed' || entry.lastResult?.status === 'blocked') { noteLimit(entry); entry.escalation = computeEscalation(entry); }
|
|
2826
3611
|
emit(entry, entry.state);
|
|
2827
3612
|
// A memory already stored for the run (by the worker or the brain) is the run's memory.
|
|
2828
|
-
|
|
3613
|
+
// An evaluation run (lib/skills/evals.js) is a measurement, not work to remember: it stores no memory.
|
|
3614
|
+
if (memory?.rememberDispatch && !isEvalRun(entry) && !syncMemory(entry, { announce: true }) && (entry.state === 'completed' || (entry.state === 'stopped' && entry.lastResult))) {
|
|
2829
3615
|
Promise.resolve(memory.rememberDispatch(view(entry))).then((memoryId) => {
|
|
2830
3616
|
if (!memoryId) return;
|
|
2831
3617
|
entry.memoryId = memoryId;
|
|
@@ -2835,11 +3621,64 @@ export function createAssistantDispatcher({
|
|
|
2835
3621
|
}
|
|
2836
3622
|
processQueue().catch(() => {});
|
|
2837
3623
|
}
|
|
3624
|
+
/** One transcript line into a live run's ring (bounded by lines and characters). */
|
|
3625
|
+
function pushRingLine(liveState, line) {
|
|
3626
|
+
liveState.ring.push(line);
|
|
3627
|
+
liveState.ringChars += line.length;
|
|
3628
|
+
// The message line still growing is no longer the last one.
|
|
3629
|
+
liveState.messageLine = null;
|
|
3630
|
+
while (liveState.ring.length > limits.ringSize || liveState.ringChars > limits.ringChars) {
|
|
3631
|
+
const removed = liveState.ring.shift();
|
|
3632
|
+
liveState.ringChars -= removed?.length || 0;
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
/**
|
|
3636
|
+
* An Antigravity message chunk (ACP agent_message_chunk): the text joins the message's line when that line is
|
|
3637
|
+
* still the ring's last one (same messageId), else it starts a new "assistant:" line. Clipped as every line is.
|
|
3638
|
+
*/
|
|
3639
|
+
function growMessageLine(liveState, update = {}) {
|
|
3640
|
+
const text = update?.content?.type === 'text' ? String(update.content.text || '') : '';
|
|
3641
|
+
if (!text) return;
|
|
3642
|
+
const id = update.messageId ?? null;
|
|
3643
|
+
const open = liveState.messageLine;
|
|
3644
|
+
if (open && open.id === id && liveState.ring.length && liveState.ring[liveState.ring.length - 1] === open.line) {
|
|
3645
|
+
open.text += text;
|
|
3646
|
+
const line = `${open.stamp} assistant: ${clip(open.text, 200)}`;
|
|
3647
|
+
if (line !== open.line) {
|
|
3648
|
+
liveState.ring[liveState.ring.length - 1] = line;
|
|
3649
|
+
liveState.ringChars += line.length - open.line.length;
|
|
3650
|
+
open.line = line;
|
|
3651
|
+
}
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
3654
|
+
if (!text.trim()) return;
|
|
3655
|
+
const stamp = iso(now()).slice(11, 19);
|
|
3656
|
+
const line = `${stamp} assistant: ${clip(text, 200)}`;
|
|
3657
|
+
pushRingLine(liveState, line);
|
|
3658
|
+
liveState.messageLine = { id, text, stamp, line };
|
|
3659
|
+
}
|
|
3660
|
+
/**
|
|
3661
|
+
* An ACP tool_call_update carries only what changed: the call it updates, as the run has seen it so far, so its
|
|
3662
|
+
* end is said with the call's kind, command and title. Other payloads pass as they are. Bounded per run.
|
|
3663
|
+
*/
|
|
3664
|
+
function withAcpCall(liveState, payload) {
|
|
3665
|
+
if ((payload.eventType !== 'tool_call' && payload.eventType !== 'tool_call_update') || !payload.event?.toolCallId) return payload;
|
|
3666
|
+
const calls = liveState.acpCalls || (liveState.acpCalls = new Map());
|
|
3667
|
+
const id = String(payload.event.toolCallId);
|
|
3668
|
+
const call = { ...(calls.get(id) || {}) };
|
|
3669
|
+
for (const [key, value] of Object.entries(payload.event)) if (value !== undefined && value !== null) call[key] = value;
|
|
3670
|
+
calls.delete(id);
|
|
3671
|
+
calls.set(id, call);
|
|
3672
|
+
if (calls.size > 200) calls.delete(calls.keys().next().value);
|
|
3673
|
+
return { ...payload, event: call };
|
|
3674
|
+
}
|
|
2838
3675
|
function onRuntimeMessage(message) {
|
|
2839
3676
|
const runId = message?.run?.runId;
|
|
2840
3677
|
if (!runId || !entries.has(runId)) return;
|
|
2841
3678
|
const entry = entries.get(runId);
|
|
2842
3679
|
if (message.type === 'sidepanel:provider-event') {
|
|
3680
|
+
if (!isEvalRun(entry)) { try { observeSkillEvent(entry, message.event || {}); } catch {} }
|
|
3681
|
+
if (entry.provider === 'codex' && Number(message.event?.providerTurn) > 0) resumeInfoOf(entry).providerTurn = Number(message.event.providerTurn);
|
|
2843
3682
|
const liveState = live.get(runId);
|
|
2844
3683
|
// Not tied to the live turn: a result that arrives after an abort is still settled.
|
|
2845
3684
|
if (entry.provider === 'claude-code') { try { meterClaude(entry, message.event?.event); } catch (error) { log(runId, 'assistant:meter-error', error?.message || String(error)); } }
|
|
@@ -2847,24 +3686,42 @@ export function createAssistantDispatcher({
|
|
|
2847
3686
|
liveState.lastEventAt = now();
|
|
2848
3687
|
liveState.stalledFlagged = false;
|
|
2849
3688
|
if (liveState.evidence) { try { recordProviderEvent(liveState.evidence, message.event); } catch {} }
|
|
3689
|
+
// A skill-evaluation run: its tool calls (one entry per call id) and the catalog it started with, for its
|
|
3690
|
+
// trigger reading (lib/skills/eval-trace.js; read with transcript format 'tools').
|
|
3691
|
+
if (isEvalRun(entry)) { try { recordEvalCalls(liveState, message.event || {}); } catch {} }
|
|
2850
3692
|
if (entry.provider === 'opencode') { try { meterOpenCode(entry, liveState, message.event || {}); } catch (error) { log(runId, 'assistant:meter-error', error?.message || String(error)); } }
|
|
2851
|
-
|
|
2852
|
-
if (
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
3693
|
+
// Antigravity streams each message in small chunks: one transcript line per message, grown as it streams.
|
|
3694
|
+
if (entry.provider === 'antigravity' && message.event?.eventType === 'agent_message_chunk') { growMessageLine(liveState, message.event.event); return; }
|
|
3695
|
+
const summary = summarizeProviderEvent(entry.provider === 'antigravity' ? withAcpCall(liveState, message.event || {}) : message.event || {});
|
|
3696
|
+
// A guard decision (lib/worker-confinement.js) is also a note of the run: what was refused or warned about,
|
|
3697
|
+
// with its reason, so the brain and the panel see it without the transcript. The first twenty.
|
|
3698
|
+
if ((message.event?.eventType || message.event?.event?.type) === 'synabun.confinement') {
|
|
3699
|
+
const count = (liveState.guardEvents = (liveState.guardEvents || 0) + 1);
|
|
3700
|
+
if (count <= 20) entry.notes.push(`guard ${message.event.event?.action || 'refused'}: ${clip(summary, 300)}`);
|
|
3701
|
+
else if (count === 21) entry.notes.push('guard: more decisions follow in the transcript');
|
|
2860
3702
|
}
|
|
3703
|
+
if (summary) pushRingLine(liveState, `${iso(now()).slice(11, 19)} ${summary}`);
|
|
2861
3704
|
}
|
|
2862
3705
|
return;
|
|
2863
3706
|
}
|
|
2864
3707
|
const run = message.run;
|
|
2865
|
-
|
|
3708
|
+
if (message.reason === 'mcp-tools' && Array.isArray(run.tools)) {
|
|
3709
|
+
entry.tools = storedToolList({ tools: retainExternalRefs(run.tools, entry.tools) }, toolListOptions());
|
|
3710
|
+
// Keep the original request for escalation, and the current selection for restart.
|
|
3711
|
+
persist({ force: true });
|
|
3712
|
+
emit(entry, 'tools_changed');
|
|
3713
|
+
}
|
|
3714
|
+
const runHome = run.antigravityHome || entry.resumeInfo?.antigravityHome || entry.antigravityHome || null;
|
|
3715
|
+
entry.runtime = { status: run.status, turnState: run.turnState, stoppedReason: run.stoppedReason, error: run.error, claimedBy: run.claimedBy, providerSessionId: run.providerSessionId, providerThreadId: run.providerThreadId, ...(runHome ? { antigravityHome: runHome } : {}) };
|
|
3716
|
+
// A new provider identity is saved at once: a crash before the run's next write (its first alive mark comes up to
|
|
3717
|
+
// a minute later) would otherwise leave no session to resume.
|
|
3718
|
+
const newIdentity = (run.providerSessionId && run.providerSessionId !== entry.providerSessionId)
|
|
3719
|
+
|| (run.providerThreadId && run.providerThreadId !== entry.providerThreadId)
|
|
3720
|
+
|| (run.antigravityHome && run.antigravityHome !== entry.antigravityHome);
|
|
2866
3721
|
if (run.providerSessionId) entry.providerSessionId = run.providerSessionId;
|
|
2867
3722
|
if (run.providerThreadId) entry.providerThreadId = run.providerThreadId;
|
|
3723
|
+
if (run.antigravityHome) entry.antigravityHome = run.antigravityHome;
|
|
3724
|
+
if (newIdentity) persist({ force: true });
|
|
2868
3725
|
if (message.type === 'sidepanel:run-updated' && message.reason === 'started' && entry.state === 'starting') {
|
|
2869
3726
|
entry.state = 'running';
|
|
2870
3727
|
entry.turnState = 'running';
|
|
@@ -2886,10 +3743,13 @@ export function createAssistantDispatcher({
|
|
|
2886
3743
|
refreshLimits();
|
|
2887
3744
|
for (const runId of [...queue]) {
|
|
2888
3745
|
const entry = entries.get(runId);
|
|
2889
|
-
|
|
3746
|
+
// A run a restart cut off and a lowered limit held back (resumeOnBoot) waits at the head of the queue.
|
|
3747
|
+
const resuming = entry?.state === 'resuming';
|
|
3748
|
+
if (!entry || (entry.state !== 'queued' && !resuming)) { queue.splice(queue.indexOf(runId), 1); continue; }
|
|
2890
3749
|
const spec = { provider: entry.provider, accountId: entry.accountId, workflowId: entry.workflowId, usesComputer: !!entry.usesComputer };
|
|
2891
|
-
if (railBlock(spec, entry.assistantSessionId)) continue;
|
|
3750
|
+
if (railBlock(spec, entry.assistantSessionId, resuming ? { except: runId } : {})) continue;
|
|
2892
3751
|
queue.splice(queue.indexOf(runId), 1);
|
|
3752
|
+
if (resuming) { try { await resumeRun(entry); } catch (error) { notResumed(entry, error?.message || String(error)); } continue; }
|
|
2893
3753
|
try { await start(entry); emit(entry, 'queue_started'); } catch (error) { entry.notes.push(`queue start failed: ${error?.message || error}`); }
|
|
2894
3754
|
}
|
|
2895
3755
|
}
|
|
@@ -2962,6 +3822,8 @@ export function createAssistantDispatcher({
|
|
|
2962
3822
|
return { ...base, text: text.length > cap ? text.slice(-cap) : text, truncated: text.length > cap };
|
|
2963
3823
|
}
|
|
2964
3824
|
if (format === 'files') return { ...base, files: entry.lastResult?.files || [] };
|
|
3825
|
+
// A skill-evaluation run's tool calls and its start catalog (kept while the run lives).
|
|
3826
|
+
if (format === 'tools') return { ...base, calls: liveState?.evalCalls ? [...liveState.evalCalls] : [], init: liveState?.evalInit || null, unavailable: !liveState ? 'tool calls are only kept for live runs' : null };
|
|
2965
3827
|
if (format === 'events' || format === 'tail') {
|
|
2966
3828
|
const lines = liveState ? liveState.ring.slice(-Math.max(1, Math.min(500, Number(tail) || 40))) : [];
|
|
2967
3829
|
const joined = lines.join('\n');
|
|
@@ -3071,7 +3933,13 @@ export function createAssistantDispatcher({
|
|
|
3071
3933
|
async function shutdown(reason = 'server_shutdown') {
|
|
3072
3934
|
if (stalledTimer) clearInterval(stalledTimer);
|
|
3073
3935
|
if (unsubscribeRuntime) { try { unsubscribeRuntime(); } catch {} unsubscribeRuntime = null; }
|
|
3936
|
+
// An exit the graceful shutdown did not run before (a crash, an exit handler): the runs that may continue are
|
|
3937
|
+
// suspended here as well, and their CLIs told to stop (an exit handler cannot wait for them; the next start ends
|
|
3938
|
+
// what is left). A run waiting for its resume stays as it is, for the next start.
|
|
3939
|
+
suspendForShutdown(reason);
|
|
3074
3940
|
for (const entry of entries.values()) {
|
|
3941
|
+
if (entry.state === 'suspended') { signalWorkerProcess(entry); continue; }
|
|
3942
|
+
if (entry.state === 'resuming') continue;
|
|
3075
3943
|
if (ACTIVE_STATES.has(entry.state)) {
|
|
3076
3944
|
pauseWork(entry);
|
|
3077
3945
|
entry.state = 'interrupted'; entry.completionReason = reason; entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
|
|
@@ -3086,12 +3954,17 @@ export function createAssistantDispatcher({
|
|
|
3086
3954
|
|
|
3087
3955
|
loadRegistry();
|
|
3088
3956
|
attachRuntime();
|
|
3089
|
-
stalledTimer = setInterval(() => { attachRuntime(); checkStalled(); }, 60_000);
|
|
3957
|
+
stalledTimer = setInterval(() => { attachRuntime(); checkStalled(); beat(); }, 60_000);
|
|
3090
3958
|
stalledTimer.unref?.();
|
|
3959
|
+
// After a restart: the cut-off runs are resumed, or said to have ended, once the Assistant listens (server.js makes
|
|
3960
|
+
// its runtime, which subscribes, right after this dispatcher, in the same tick).
|
|
3961
|
+
if (bootResumes.length || bootInterrupts.length) {
|
|
3962
|
+
setTimeout(() => { resumeOnBoot().catch((error) => log(null, 'assistant:resume-error', error?.message || String(error))); }, 0);
|
|
3963
|
+
}
|
|
3091
3964
|
|
|
3092
3965
|
return {
|
|
3093
3966
|
dispatch, sendTurn, complete, completeClosedTab, stop, killAll, get, peek, list, totals, wait, transcript, pendingPermissions,
|
|
3094
|
-
respondPermission, subscribe, shutdown, limits,
|
|
3967
|
+
respondPermission, subscribe, shutdown, limits, suspendForShutdown,
|
|
3095
3968
|
sessionBudget, checkSession, refreshLimits, providerSpend, coverFollowUp, budget,
|
|
3096
3969
|
resolveRoute, declineRoute, heldRouteRefusal, escalate,
|
|
3097
3970
|
markDelivered, deliveryState,
|