synabun 1.3.0 → 2.0.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 +205 -662
- package/LICENSE-COMMERCIAL.md +33 -0
- package/NOTICE +17 -0
- package/README.md +175 -853
- package/THIRD-PARTY-LICENSES.md +136 -0
- package/benchmarks/browser-live-results.json +79 -0
- package/benchmarks/browser-v2-results.json +258 -0
- package/benchmarks/fixtures/browser/README.md +57 -0
- package/benchmarks/fixtures/browser/page-state.json +132 -0
- package/benchmarks/fixtures/browser/social.json +92 -0
- package/benchmarks/fixtures/browser/target.json +319 -0
- package/benchmarks/fixtures/desktop/README.md +58 -0
- package/benchmarks/fixtures/desktop/abstention.json +59 -0
- package/benchmarks/fixtures/desktop/adversarial.json +22 -0
- package/benchmarks/fixtures/desktop/forbidden.json +93 -0
- package/benchmarks/fixtures/desktop/generate.mjs +1023 -0
- package/benchmarks/fixtures/desktop/target.json +136 -0
- package/bin/synabun.js +30 -0
- package/docs/whatsapp.md +325 -0
- package/hooks/claude-code/post-plan.mjs +613 -391
- package/hooks/claude-code/post-remember.mjs +246 -122
- package/hooks/claude-code/pre-compact.mjs +171 -110
- package/hooks/claude-code/pre-task.mjs +133 -0
- package/hooks/claude-code/pre-websearch.mjs +15 -4
- package/hooks/claude-code/prompt-origin.mjs +94 -0
- package/hooks/claude-code/prompt-submit.mjs +1244 -861
- package/hooks/claude-code/session-start.mjs +114 -156
- package/hooks/claude-code/shared.mjs +1228 -368
- package/hooks/claude-code/state.mjs +167 -0
- package/hooks/claude-code/stop.mjs +515 -140
- package/hooks/claude-code/subagent-start.mjs +154 -0
- package/hooks/claude-code/subagent-stop.mjs +102 -0
- package/hooks/claude-code/task-gate.mjs +150 -0
- package/launcher.mjs +40 -0
- package/lib/claude-hooks.js +137 -0
- package/lib/claude-usage-audit.js +141 -0
- package/lib/client-config-repair.js +145 -0
- package/lib/data-home-migration.js +502 -0
- package/lib/desktop-pwa.js +206 -0
- package/lib/external-tools.js +221 -0
- package/lib/first-launch-protection.js +245 -0
- package/lib/packaged-runtime.js +110 -0
- package/lib/paths.d.ts +21 -0
- package/lib/paths.js +103 -0
- package/lib/start-launcher.js +1309 -0
- package/mcp-server/dist/config.d.ts +22 -0
- package/mcp-server/dist/config.d.ts.map +1 -0
- package/mcp-server/dist/config.js +109 -0
- package/mcp-server/dist/config.js.map +1 -0
- package/mcp-server/dist/http.d.ts +30 -0
- package/mcp-server/dist/http.d.ts.map +1 -0
- package/mcp-server/dist/http.js +276 -0
- package/mcp-server/dist/http.js.map +1 -0
- package/mcp-server/dist/index.d.ts +48 -0
- package/mcp-server/dist/index.d.ts.map +1 -0
- package/mcp-server/dist/index.js +538 -0
- package/mcp-server/dist/index.js.map +1 -0
- package/mcp-server/dist/preload-http.d.ts +3 -0
- package/mcp-server/dist/preload-http.d.ts.map +1 -0
- package/mcp-server/dist/preload-http.js +28 -0
- package/mcp-server/dist/preload-http.js.map +1 -0
- package/mcp-server/dist/preload.d.ts +3 -0
- package/mcp-server/dist/preload.d.ts.map +1 -0
- package/mcp-server/dist/preload.js +32 -0
- package/mcp-server/dist/preload.js.map +1 -0
- package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.d.ts +19 -0
- package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.d.ts.map +1 -0
- package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.js +403 -0
- package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.js.map +1 -0
- package/mcp-server/dist/scripts/setup-embeddings.d.ts +3 -0
- package/mcp-server/dist/scripts/setup-embeddings.d.ts.map +1 -0
- package/mcp-server/dist/scripts/setup-embeddings.js +15 -0
- package/mcp-server/dist/scripts/setup-embeddings.js.map +1 -0
- package/mcp-server/dist/services/assistant-judgments.d.ts +79 -0
- package/mcp-server/dist/services/assistant-judgments.d.ts.map +1 -0
- package/mcp-server/dist/services/assistant-judgments.js +128 -0
- package/mcp-server/dist/services/assistant-judgments.js.map +1 -0
- package/mcp-server/dist/services/browser-assist-gate.d.ts +314 -0
- package/mcp-server/dist/services/browser-assist-gate.d.ts.map +1 -0
- package/mcp-server/dist/services/browser-assist-gate.js +344 -0
- package/mcp-server/dist/services/browser-assist-gate.js.map +1 -0
- package/mcp-server/dist/services/browser-assist.d.ts +104 -0
- package/mcp-server/dist/services/browser-assist.d.ts.map +1 -0
- package/mcp-server/dist/services/browser-assist.js +282 -0
- package/mcp-server/dist/services/browser-assist.js.map +1 -0
- package/mcp-server/dist/services/browser-bench.d.ts +772 -0
- package/mcp-server/dist/services/browser-bench.d.ts.map +1 -0
- package/mcp-server/dist/services/browser-bench.js +424 -0
- package/mcp-server/dist/services/browser-bench.js.map +1 -0
- package/mcp-server/dist/services/browser-judgments.d.ts +196 -0
- package/mcp-server/dist/services/browser-judgments.d.ts.map +1 -0
- package/mcp-server/dist/services/browser-judgments.js +363 -0
- package/mcp-server/dist/services/browser-judgments.js.map +1 -0
- package/mcp-server/dist/services/browser-risk.d.ts +166 -0
- package/mcp-server/dist/services/browser-risk.d.ts.map +1 -0
- package/mcp-server/dist/services/browser-risk.js +458 -0
- package/mcp-server/dist/services/browser-risk.js.map +1 -0
- package/mcp-server/dist/services/categories.d.ts +61 -0
- package/mcp-server/dist/services/categories.d.ts.map +1 -0
- package/mcp-server/dist/services/categories.js +377 -0
- package/mcp-server/dist/services/categories.js.map +1 -0
- package/mcp-server/dist/services/codex-config-heal.d.ts +49 -0
- package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -0
- package/mcp-server/dist/services/codex-config-heal.js +346 -0
- package/mcp-server/dist/services/codex-config-heal.js.map +1 -0
- package/mcp-server/dist/services/desktop-assist-gate.d.ts +198 -0
- package/mcp-server/dist/services/desktop-assist-gate.d.ts.map +1 -0
- package/mcp-server/dist/services/desktop-assist-gate.js +202 -0
- package/mcp-server/dist/services/desktop-assist-gate.js.map +1 -0
- package/mcp-server/dist/services/desktop-assist.d.ts +104 -0
- package/mcp-server/dist/services/desktop-assist.d.ts.map +1 -0
- package/mcp-server/dist/services/desktop-assist.js +266 -0
- package/mcp-server/dist/services/desktop-assist.js.map +1 -0
- package/mcp-server/dist/services/desktop-bench.d.ts +553 -0
- package/mcp-server/dist/services/desktop-bench.d.ts.map +1 -0
- package/mcp-server/dist/services/desktop-bench.js +311 -0
- package/mcp-server/dist/services/desktop-bench.js.map +1 -0
- package/mcp-server/dist/services/desktop-client.d.ts +23 -0
- package/mcp-server/dist/services/desktop-client.d.ts.map +1 -0
- package/mcp-server/dist/services/desktop-client.js +79 -0
- package/mcp-server/dist/services/desktop-client.js.map +1 -0
- package/mcp-server/dist/services/desktop-judgments.d.ts +125 -0
- package/mcp-server/dist/services/desktop-judgments.d.ts.map +1 -0
- package/mcp-server/dist/services/desktop-judgments.js +177 -0
- package/mcp-server/dist/services/desktop-judgments.js.map +1 -0
- package/mcp-server/dist/services/desktop-risk.d.ts +283 -0
- package/mcp-server/dist/services/desktop-risk.d.ts.map +1 -0
- package/mcp-server/dist/services/desktop-risk.js +605 -0
- package/mcp-server/dist/services/desktop-risk.js.map +1 -0
- package/mcp-server/dist/services/discord.d.ts +112 -0
- package/mcp-server/dist/services/discord.d.ts.map +1 -0
- package/mcp-server/dist/services/discord.js +477 -0
- package/mcp-server/dist/services/discord.js.map +1 -0
- package/mcp-server/dist/services/edit-stale.d.ts +81 -0
- package/mcp-server/dist/services/edit-stale.d.ts.map +1 -0
- package/mcp-server/dist/services/edit-stale.js +358 -0
- package/mcp-server/dist/services/edit-stale.js.map +1 -0
- package/mcp-server/dist/services/embedding-service.d.ts +16 -0
- package/mcp-server/dist/services/embedding-service.d.ts.map +1 -0
- package/mcp-server/dist/services/embedding-service.js +126 -0
- package/mcp-server/dist/services/embedding-service.js.map +1 -0
- package/mcp-server/dist/services/embedding-worker.d.ts +2 -0
- package/mcp-server/dist/services/embedding-worker.d.ts.map +1 -0
- package/mcp-server/dist/services/embedding-worker.js +52 -0
- package/mcp-server/dist/services/embedding-worker.js.map +1 -0
- package/mcp-server/dist/services/fb-regions.d.ts +71 -0
- package/mcp-server/dist/services/fb-regions.d.ts.map +1 -0
- package/mcp-server/dist/services/fb-regions.js +211 -0
- package/mcp-server/dist/services/fb-regions.js.map +1 -0
- package/mcp-server/dist/services/file-checksums.d.ts +22 -0
- package/mcp-server/dist/services/file-checksums.d.ts.map +1 -0
- package/mcp-server/dist/services/file-checksums.js +55 -0
- package/mcp-server/dist/services/file-checksums.js.map +1 -0
- package/mcp-server/dist/services/file-excerpt.d.ts +24 -0
- package/mcp-server/dist/services/file-excerpt.d.ts.map +1 -0
- package/mcp-server/dist/services/file-excerpt.js +65 -0
- package/mcp-server/dist/services/file-excerpt.js.map +1 -0
- package/mcp-server/dist/services/identity.d.ts +124 -0
- package/mcp-server/dist/services/identity.d.ts.map +1 -0
- package/mcp-server/dist/services/identity.js +241 -0
- package/mcp-server/dist/services/identity.js.map +1 -0
- package/mcp-server/dist/services/local-embeddings.d.ts +2 -0
- package/mcp-server/dist/services/local-embeddings.d.ts.map +1 -0
- package/mcp-server/dist/services/local-embeddings.js +3 -0
- package/mcp-server/dist/services/local-embeddings.js.map +1 -0
- package/mcp-server/dist/services/memory-client.d.ts +4 -0
- package/mcp-server/dist/services/memory-client.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-client.js +27 -0
- package/mcp-server/dist/services/memory-client.js.map +1 -0
- package/mcp-server/dist/services/memory-judge-backfill.d.ts +135 -0
- package/mcp-server/dist/services/memory-judge-backfill.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-judge-backfill.js +510 -0
- package/mcp-server/dist/services/memory-judge-backfill.js.map +1 -0
- package/mcp-server/dist/services/memory-judgments.d.ts +362 -0
- package/mcp-server/dist/services/memory-judgments.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-judgments.js +674 -0
- package/mcp-server/dist/services/memory-judgments.js.map +1 -0
- package/mcp-server/dist/services/memory-maintenance.d.ts +108 -0
- package/mcp-server/dist/services/memory-maintenance.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-maintenance.js +403 -0
- package/mcp-server/dist/services/memory-maintenance.js.map +1 -0
- package/mcp-server/dist/services/memory-retrieval.d.ts +66 -0
- package/mcp-server/dist/services/memory-retrieval.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-retrieval.js +412 -0
- package/mcp-server/dist/services/memory-retrieval.js.map +1 -0
- package/mcp-server/dist/services/memory-schema.d.ts +16 -0
- package/mcp-server/dist/services/memory-schema.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-schema.js +282 -0
- package/mcp-server/dist/services/memory-schema.js.map +1 -0
- package/mcp-server/dist/services/memory-sessions.d.ts +24 -0
- package/mcp-server/dist/services/memory-sessions.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-sessions.js +110 -0
- package/mcp-server/dist/services/memory-sessions.js.map +1 -0
- package/mcp-server/dist/services/memory-triage.d.ts +60 -0
- package/mcp-server/dist/services/memory-triage.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-triage.js +189 -0
- package/mcp-server/dist/services/memory-triage.js.map +1 -0
- package/mcp-server/dist/services/memory-verification.d.ts +24 -0
- package/mcp-server/dist/services/memory-verification.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-verification.js +92 -0
- package/mcp-server/dist/services/memory-verification.js.map +1 -0
- package/mcp-server/dist/services/memory-writer.d.ts +26 -0
- package/mcp-server/dist/services/memory-writer.d.ts.map +1 -0
- package/mcp-server/dist/services/memory-writer.js +126 -0
- package/mcp-server/dist/services/memory-writer.js.map +1 -0
- package/mcp-server/dist/services/neural-interface.d.ts +439 -0
- package/mcp-server/dist/services/neural-interface.d.ts.map +1 -0
- package/mcp-server/dist/services/neural-interface.js +1135 -0
- package/mcp-server/dist/services/neural-interface.js.map +1 -0
- package/mcp-server/dist/services/profiles.d.ts +120 -0
- package/mcp-server/dist/services/profiles.d.ts.map +1 -0
- package/mcp-server/dist/services/profiles.js +387 -0
- package/mcp-server/dist/services/profiles.js.map +1 -0
- package/mcp-server/dist/services/secret-gate.d.ts +29 -0
- package/mcp-server/dist/services/secret-gate.d.ts.map +1 -0
- package/mcp-server/dist/services/secret-gate.js +43 -0
- package/mcp-server/dist/services/secret-gate.js.map +1 -0
- package/mcp-server/dist/services/server-instructions.d.ts +10 -0
- package/mcp-server/dist/services/server-instructions.d.ts.map +1 -0
- package/mcp-server/dist/services/server-instructions.js +101 -0
- package/mcp-server/dist/services/server-instructions.js.map +1 -0
- package/mcp-server/dist/services/sqlite.d.ts +235 -0
- package/mcp-server/dist/services/sqlite.d.ts.map +1 -0
- package/mcp-server/dist/services/sqlite.js +1185 -0
- package/mcp-server/dist/services/sqlite.js.map +1 -0
- package/mcp-server/dist/services/typesafe-bench.d.ts +99 -0
- package/mcp-server/dist/services/typesafe-bench.d.ts.map +1 -0
- package/mcp-server/dist/services/typesafe-bench.js +218 -0
- package/mcp-server/dist/services/typesafe-bench.js.map +1 -0
- package/mcp-server/dist/services/typesafe-config.d.ts +342 -0
- package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -0
- package/mcp-server/dist/services/typesafe-config.js +746 -0
- package/mcp-server/dist/services/typesafe-config.js.map +1 -0
- package/mcp-server/dist/services/typesafe.d.ts +213 -0
- package/mcp-server/dist/services/typesafe.d.ts.map +1 -0
- package/mcp-server/dist/services/typesafe.js +330 -0
- package/mcp-server/dist/services/typesafe.js.map +1 -0
- package/mcp-server/dist/services/vector-cache.d.ts +68 -0
- package/mcp-server/dist/services/vector-cache.d.ts.map +1 -0
- package/mcp-server/dist/services/vector-cache.js +271 -0
- package/mcp-server/dist/services/vector-cache.js.map +1 -0
- package/mcp-server/dist/services/whiteboard-geometry.d.ts +305 -0
- package/mcp-server/dist/services/whiteboard-geometry.d.ts.map +1 -0
- package/mcp-server/dist/services/whiteboard-geometry.js +666 -0
- package/mcp-server/dist/services/whiteboard-geometry.js.map +1 -0
- package/mcp-server/dist/tools/agents.d.ts +212 -0
- package/mcp-server/dist/tools/agents.d.ts.map +1 -0
- package/mcp-server/dist/tools/agents.js +407 -0
- package/mcp-server/dist/tools/agents.js.map +1 -0
- package/mcp-server/dist/tools/bluesky.d.ts +39 -0
- package/mcp-server/dist/tools/bluesky.d.ts.map +1 -0
- package/mcp-server/dist/tools/bluesky.js +1144 -0
- package/mcp-server/dist/tools/bluesky.js.map +1 -0
- package/mcp-server/dist/tools/browser-advanced.d.ts +58 -0
- package/mcp-server/dist/tools/browser-advanced.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-advanced.js +106 -0
- package/mcp-server/dist/tools/browser-advanced.js.map +1 -0
- package/mcp-server/dist/tools/browser-batch.d.ts +99 -0
- package/mcp-server/dist/tools/browser-batch.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-batch.js +187 -0
- package/mcp-server/dist/tools/browser-batch.js.map +1 -0
- package/mcp-server/dist/tools/browser-cheatsheet.d.ts +21 -0
- package/mcp-server/dist/tools/browser-cheatsheet.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-cheatsheet.js +144 -0
- package/mcp-server/dist/tools/browser-cheatsheet.js.map +1 -0
- package/mcp-server/dist/tools/browser-interact.d.ts +239 -0
- package/mcp-server/dist/tools/browser-interact.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-interact.js +300 -0
- package/mcp-server/dist/tools/browser-interact.js.map +1 -0
- package/mcp-server/dist/tools/browser-navigate.d.ts +90 -0
- package/mcp-server/dist/tools/browser-navigate.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-navigate.js +142 -0
- package/mcp-server/dist/tools/browser-navigate.js.map +1 -0
- package/mcp-server/dist/tools/browser-observe.d.ts +561 -0
- package/mcp-server/dist/tools/browser-observe.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser-observe.js +2009 -0
- package/mcp-server/dist/tools/browser-observe.js.map +1 -0
- package/mcp-server/dist/tools/browser.d.ts +34 -0
- package/mcp-server/dist/tools/browser.d.ts.map +1 -0
- package/mcp-server/dist/tools/browser.js +112 -0
- package/mcp-server/dist/tools/browser.js.map +1 -0
- package/mcp-server/dist/tools/card-tools.d.ts +82 -0
- package/mcp-server/dist/tools/card-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/card-tools.js +137 -0
- package/mcp-server/dist/tools/card-tools.js.map +1 -0
- package/mcp-server/dist/tools/card.d.ts +8 -0
- package/mcp-server/dist/tools/card.d.ts.map +1 -0
- package/mcp-server/dist/tools/card.js +16 -0
- package/mcp-server/dist/tools/card.js.map +1 -0
- package/mcp-server/dist/tools/category.d.ts +32 -0
- package/mcp-server/dist/tools/category.d.ts.map +1 -0
- package/mcp-server/dist/tools/category.js +290 -0
- package/mcp-server/dist/tools/category.js.map +1 -0
- package/mcp-server/dist/tools/choice.d.ts +160 -0
- package/mcp-server/dist/tools/choice.d.ts.map +1 -0
- package/mcp-server/dist/tools/choice.js +80 -0
- package/mcp-server/dist/tools/choice.js.map +1 -0
- package/mcp-server/dist/tools/computer.d.ts +71 -0
- package/mcp-server/dist/tools/computer.d.ts.map +1 -0
- package/mcp-server/dist/tools/computer.js +174 -0
- package/mcp-server/dist/tools/computer.js.map +1 -0
- package/mcp-server/dist/tools/discord-channel.d.ts +44 -0
- package/mcp-server/dist/tools/discord-channel.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-channel.js +236 -0
- package/mcp-server/dist/tools/discord-channel.js.map +1 -0
- package/mcp-server/dist/tools/discord-guild.d.ts +20 -0
- package/mcp-server/dist/tools/discord-guild.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-guild.js +138 -0
- package/mcp-server/dist/tools/discord-guild.js.map +1 -0
- package/mcp-server/dist/tools/discord-member.d.ts +26 -0
- package/mcp-server/dist/tools/discord-member.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-member.js +122 -0
- package/mcp-server/dist/tools/discord-member.js.map +1 -0
- package/mcp-server/dist/tools/discord-message.d.ts +36 -0
- package/mcp-server/dist/tools/discord-message.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-message.js +184 -0
- package/mcp-server/dist/tools/discord-message.js.map +1 -0
- package/mcp-server/dist/tools/discord-onboarding.d.ts +32 -0
- package/mcp-server/dist/tools/discord-onboarding.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-onboarding.js +179 -0
- package/mcp-server/dist/tools/discord-onboarding.js.map +1 -0
- package/mcp-server/dist/tools/discord-role.d.ts +34 -0
- package/mcp-server/dist/tools/discord-role.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-role.js +178 -0
- package/mcp-server/dist/tools/discord-role.js.map +1 -0
- package/mcp-server/dist/tools/discord-thread.d.ts +28 -0
- package/mcp-server/dist/tools/discord-thread.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-thread.js +88 -0
- package/mcp-server/dist/tools/discord-thread.js.map +1 -0
- package/mcp-server/dist/tools/discord-webhook.d.ts +32 -0
- package/mcp-server/dist/tools/discord-webhook.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord-webhook.js +154 -0
- package/mcp-server/dist/tools/discord-webhook.js.map +1 -0
- package/mcp-server/dist/tools/discord.d.ts +7 -0
- package/mcp-server/dist/tools/discord.d.ts.map +1 -0
- package/mcp-server/dist/tools/discord.js +25 -0
- package/mcp-server/dist/tools/discord.js.map +1 -0
- package/mcp-server/dist/tools/fb-groups.d.ts +98 -0
- package/mcp-server/dist/tools/fb-groups.d.ts.map +1 -0
- package/mcp-server/dist/tools/fb-groups.js +369 -0
- package/mcp-server/dist/tools/fb-groups.js.map +1 -0
- package/mcp-server/dist/tools/forget.d.ts +14 -0
- package/mcp-server/dist/tools/forget.d.ts.map +1 -0
- package/mcp-server/dist/tools/forget.js +26 -0
- package/mcp-server/dist/tools/forget.js.map +1 -0
- package/mcp-server/dist/tools/git-tools.d.ts +24 -0
- package/mcp-server/dist/tools/git-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/git-tools.js +107 -0
- package/mcp-server/dist/tools/git-tools.js.map +1 -0
- package/mcp-server/dist/tools/git.d.ts +7 -0
- package/mcp-server/dist/tools/git.d.ts.map +1 -0
- package/mcp-server/dist/tools/git.js +11 -0
- package/mcp-server/dist/tools/git.js.map +1 -0
- package/mcp-server/dist/tools/gsc/experience.d.ts +135 -0
- package/mcp-server/dist/tools/gsc/experience.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/experience.js +351 -0
- package/mcp-server/dist/tools/gsc/experience.js.map +1 -0
- package/mcp-server/dist/tools/gsc/helpers.d.ts +78 -0
- package/mcp-server/dist/tools/gsc/helpers.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/helpers.js +258 -0
- package/mcp-server/dist/tools/gsc/helpers.js.map +1 -0
- package/mcp-server/dist/tools/gsc/indexing.d.ts +121 -0
- package/mcp-server/dist/tools/gsc/indexing.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/indexing.js +357 -0
- package/mcp-server/dist/tools/gsc/indexing.js.map +1 -0
- package/mcp-server/dist/tools/gsc/inspect.d.ts +78 -0
- package/mcp-server/dist/tools/gsc/inspect.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/inspect.js +321 -0
- package/mcp-server/dist/tools/gsc/inspect.js.map +1 -0
- package/mcp-server/dist/tools/gsc/navigate.d.ts +44 -0
- package/mcp-server/dist/tools/gsc/navigate.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/navigate.js +197 -0
- package/mcp-server/dist/tools/gsc/navigate.js.map +1 -0
- package/mcp-server/dist/tools/gsc/performance.d.ts +84 -0
- package/mcp-server/dist/tools/gsc/performance.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/performance.js +274 -0
- package/mcp-server/dist/tools/gsc/performance.js.map +1 -0
- package/mcp-server/dist/tools/gsc/settings.d.ts +153 -0
- package/mcp-server/dist/tools/gsc/settings.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc/settings.js +425 -0
- package/mcp-server/dist/tools/gsc/settings.js.map +1 -0
- package/mcp-server/dist/tools/gsc.d.ts +7 -0
- package/mcp-server/dist/tools/gsc.d.ts.map +1 -0
- package/mcp-server/dist/tools/gsc.js +51 -0
- package/mcp-server/dist/tools/gsc.js.map +1 -0
- package/mcp-server/dist/tools/image-tools.d.ts +24 -0
- package/mcp-server/dist/tools/image-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/image-tools.js +80 -0
- package/mcp-server/dist/tools/image-tools.js.map +1 -0
- package/mcp-server/dist/tools/image.d.ts +10 -0
- package/mcp-server/dist/tools/image.d.ts.map +1 -0
- package/mcp-server/dist/tools/image.js +14 -0
- package/mcp-server/dist/tools/image.js.map +1 -0
- package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +97 -0
- package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/leonardo-browser-tools.js +228 -0
- package/mcp-server/dist/tools/leonardo-browser-tools.js.map +1 -0
- package/mcp-server/dist/tools/leonardo.d.ts +13 -0
- package/mcp-server/dist/tools/leonardo.d.ts.map +1 -0
- package/mcp-server/dist/tools/leonardo.js +21 -0
- package/mcp-server/dist/tools/leonardo.js.map +1 -0
- package/mcp-server/dist/tools/loop.d.ts +30 -0
- package/mcp-server/dist/tools/loop.d.ts.map +1 -0
- package/mcp-server/dist/tools/loop.js +401 -0
- package/mcp-server/dist/tools/loop.js.map +1 -0
- package/mcp-server/dist/tools/memories.d.ts +56 -0
- package/mcp-server/dist/tools/memories.d.ts.map +1 -0
- package/mcp-server/dist/tools/memories.js +171 -0
- package/mcp-server/dist/tools/memories.js.map +1 -0
- package/mcp-server/dist/tools/morelogin.d.ts +24 -0
- package/mcp-server/dist/tools/morelogin.d.ts.map +1 -0
- package/mcp-server/dist/tools/morelogin.js +79 -0
- package/mcp-server/dist/tools/morelogin.js.map +1 -0
- package/mcp-server/dist/tools/profile.d.ts +17 -0
- package/mcp-server/dist/tools/profile.d.ts.map +1 -0
- package/mcp-server/dist/tools/profile.js +114 -0
- package/mcp-server/dist/tools/profile.js.map +1 -0
- package/mcp-server/dist/tools/recall.d.ts +71 -0
- package/mcp-server/dist/tools/recall.d.ts.map +1 -0
- package/mcp-server/dist/tools/recall.js +147 -0
- package/mcp-server/dist/tools/recall.js.map +1 -0
- package/mcp-server/dist/tools/reflect.d.ts +56 -0
- package/mcp-server/dist/tools/reflect.d.ts.map +1 -0
- package/mcp-server/dist/tools/reflect.js +157 -0
- package/mcp-server/dist/tools/reflect.js.map +1 -0
- package/mcp-server/dist/tools/remember.d.ts +62 -0
- package/mcp-server/dist/tools/remember.d.ts.map +1 -0
- package/mcp-server/dist/tools/remember.js +201 -0
- package/mcp-server/dist/tools/remember.js.map +1 -0
- package/mcp-server/dist/tools/response.d.ts +23 -0
- package/mcp-server/dist/tools/response.d.ts.map +1 -0
- package/mcp-server/dist/tools/response.js +24 -0
- package/mcp-server/dist/tools/response.js.map +1 -0
- package/mcp-server/dist/tools/restore.d.ts +14 -0
- package/mcp-server/dist/tools/restore.d.ts.map +1 -0
- package/mcp-server/dist/tools/restore.js +23 -0
- package/mcp-server/dist/tools/restore.js.map +1 -0
- package/mcp-server/dist/tools/style-guide-tools.d.ts +51 -0
- package/mcp-server/dist/tools/style-guide-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/style-guide-tools.js +231 -0
- package/mcp-server/dist/tools/style-guide-tools.js.map +1 -0
- package/mcp-server/dist/tools/style-guide.d.ts +8 -0
- package/mcp-server/dist/tools/style-guide.d.ts.map +1 -0
- package/mcp-server/dist/tools/style-guide.js +12 -0
- package/mcp-server/dist/tools/style-guide.js.map +1 -0
- package/mcp-server/dist/tools/sync.d.ts +20 -0
- package/mcp-server/dist/tools/sync.d.ts.map +1 -0
- package/mcp-server/dist/tools/sync.js +156 -0
- package/mcp-server/dist/tools/sync.js.map +1 -0
- package/mcp-server/dist/tools/tictactoe-tools.d.ts +18 -0
- package/mcp-server/dist/tools/tictactoe-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/tictactoe-tools.js +78 -0
- package/mcp-server/dist/tools/tictactoe-tools.js.map +1 -0
- package/mcp-server/dist/tools/tictactoe.d.ts +7 -0
- package/mcp-server/dist/tools/tictactoe.d.ts.map +1 -0
- package/mcp-server/dist/tools/tictactoe.js +11 -0
- package/mcp-server/dist/tools/tictactoe.js.map +1 -0
- package/mcp-server/dist/tools/utils.d.ts +19 -0
- package/mcp-server/dist/tools/utils.d.ts.map +1 -0
- package/mcp-server/dist/tools/utils.js +54 -0
- package/mcp-server/dist/tools/utils.js.map +1 -0
- package/mcp-server/dist/tools/whiteboard-format.d.ts +88 -0
- package/mcp-server/dist/tools/whiteboard-format.d.ts.map +1 -0
- package/mcp-server/dist/tools/whiteboard-format.js +337 -0
- package/mcp-server/dist/tools/whiteboard-format.js.map +1 -0
- package/mcp-server/dist/tools/whiteboard-tools.d.ts +326 -0
- package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/whiteboard-tools.js +222 -0
- package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -0
- package/mcp-server/dist/tools/whiteboard.d.ts +8 -0
- package/mcp-server/dist/tools/whiteboard.d.ts.map +1 -0
- package/mcp-server/dist/tools/whiteboard.js +16 -0
- package/mcp-server/dist/tools/whiteboard.js.map +1 -0
- package/mcp-server/dist/tools/youtube/analytics.d.ts +46 -0
- package/mcp-server/dist/tools/youtube/analytics.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/analytics.js +83 -0
- package/mcp-server/dist/tools/youtube/analytics.js.map +1 -0
- package/mcp-server/dist/tools/youtube/dedup.d.ts +35 -0
- package/mcp-server/dist/tools/youtube/dedup.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/dedup.js +56 -0
- package/mcp-server/dist/tools/youtube/dedup.js.map +1 -0
- package/mcp-server/dist/tools/youtube/download.d.ts +31 -0
- package/mcp-server/dist/tools/youtube/download.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/download.js +30 -0
- package/mcp-server/dist/tools/youtube/download.js.map +1 -0
- package/mcp-server/dist/tools/youtube/helpers.d.ts +66 -0
- package/mcp-server/dist/tools/youtube/helpers.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/helpers.js +143 -0
- package/mcp-server/dist/tools/youtube/helpers.js.map +1 -0
- package/mcp-server/dist/tools/youtube/media.d.ts +43 -0
- package/mcp-server/dist/tools/youtube/media.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/media.js +97 -0
- package/mcp-server/dist/tools/youtube/media.js.map +1 -0
- package/mcp-server/dist/tools/youtube/metadata.d.ts +33 -0
- package/mcp-server/dist/tools/youtube/metadata.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/metadata.js +106 -0
- package/mcp-server/dist/tools/youtube/metadata.js.map +1 -0
- package/mcp-server/dist/tools/youtube/navigate.d.ts +30 -0
- package/mcp-server/dist/tools/youtube/navigate.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/navigate.js +71 -0
- package/mcp-server/dist/tools/youtube/navigate.js.map +1 -0
- package/mcp-server/dist/tools/youtube/playlist.d.ts +29 -0
- package/mcp-server/dist/tools/youtube/playlist.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/playlist.js +128 -0
- package/mcp-server/dist/tools/youtube/playlist.js.map +1 -0
- package/mcp-server/dist/tools/youtube/schedule.d.ts +31 -0
- package/mcp-server/dist/tools/youtube/schedule.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/schedule.js +147 -0
- package/mcp-server/dist/tools/youtube/schedule.js.map +1 -0
- package/mcp-server/dist/tools/youtube/source.d.ts +36 -0
- package/mcp-server/dist/tools/youtube/source.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/source.js +85 -0
- package/mcp-server/dist/tools/youtube/source.js.map +1 -0
- package/mcp-server/dist/tools/youtube/upload.d.ts +53 -0
- package/mcp-server/dist/tools/youtube/upload.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube/upload.js +255 -0
- package/mcp-server/dist/tools/youtube/upload.js.map +1 -0
- package/mcp-server/dist/tools/youtube.d.ts +12 -0
- package/mcp-server/dist/tools/youtube.d.ts.map +1 -0
- package/mcp-server/dist/tools/youtube.js +38 -0
- package/mcp-server/dist/tools/youtube.js.map +1 -0
- package/mcp-server/dist/tui.d.ts +3 -0
- package/mcp-server/dist/tui.d.ts.map +1 -0
- package/mcp-server/dist/tui.js +806 -0
- package/mcp-server/dist/tui.js.map +1 -0
- package/mcp-server/dist/types.d.ts +77 -0
- package/mcp-server/dist/types.d.ts.map +1 -0
- package/mcp-server/dist/types.js +2 -0
- package/mcp-server/dist/types.js.map +1 -0
- package/mcp-server/package-lock.json +4798 -0
- package/mcp-server/package.json +55 -38
- package/mcp-server/run.mjs +12 -2
- package/mcp-server/src/config.ts +48 -3
- package/mcp-server/src/http.ts +296 -93
- package/mcp-server/src/index.ts +546 -203
- package/mcp-server/src/preload-http.ts +19 -18
- package/mcp-server/src/preload.ts +19 -18
- package/mcp-server/src/scripts/setup-embeddings.ts +9 -0
- package/mcp-server/src/services/assistant-judgments.ts +157 -0
- package/mcp-server/src/services/browser-assist-gate.ts +383 -0
- package/mcp-server/src/services/browser-assist.ts +267 -0
- package/mcp-server/src/services/browser-bench.ts +477 -0
- package/mcp-server/src/services/browser-judgments.ts +414 -0
- package/mcp-server/src/services/browser-risk.ts +484 -0
- package/mcp-server/src/services/categories.ts +36 -19
- package/mcp-server/src/services/codex-config-heal.ts +403 -0
- package/mcp-server/src/services/desktop-assist-gate.ts +251 -0
- package/mcp-server/src/services/desktop-assist.ts +308 -0
- package/mcp-server/src/services/desktop-bench.ts +372 -0
- package/mcp-server/src/services/desktop-client.ts +76 -0
- package/mcp-server/src/services/desktop-judgments.ts +218 -0
- package/mcp-server/src/services/desktop-risk.ts +743 -0
- package/mcp-server/src/services/edit-stale.ts +299 -0
- package/mcp-server/src/services/embedding-service.ts +108 -0
- package/mcp-server/src/services/embedding-worker.ts +46 -0
- package/mcp-server/src/services/fb-regions.ts +221 -0
- package/mcp-server/src/services/file-checksums.ts +26 -9
- package/mcp-server/src/services/file-excerpt.ts +59 -0
- package/mcp-server/src/services/identity.ts +291 -0
- package/mcp-server/src/services/local-embeddings.ts +2 -77
- package/mcp-server/src/services/memory-client.ts +23 -0
- package/mcp-server/src/services/memory-judge-backfill.ts +462 -0
- package/mcp-server/src/services/memory-judgments.ts +901 -0
- package/mcp-server/src/services/memory-maintenance.ts +360 -0
- package/mcp-server/src/services/memory-retrieval.ts +349 -0
- package/mcp-server/src/services/memory-schema.ts +280 -0
- package/mcp-server/src/services/memory-sessions.ts +87 -0
- package/mcp-server/src/services/memory-triage.ts +182 -0
- package/mcp-server/src/services/memory-verification.ts +68 -0
- package/mcp-server/src/services/memory-writer.ts +125 -0
- package/mcp-server/src/services/neural-interface.ts +1217 -73
- package/mcp-server/src/services/profiles.ts +428 -0
- package/mcp-server/src/services/secret-gate.ts +52 -0
- package/mcp-server/src/services/server-instructions.ts +122 -0
- package/mcp-server/src/services/sqlite.ts +1456 -823
- package/mcp-server/src/services/typesafe-bench.ts +247 -0
- package/mcp-server/src/services/typesafe-config.ts +790 -0
- package/mcp-server/src/services/typesafe.ts +390 -0
- package/mcp-server/src/services/vector-cache.ts +255 -0
- package/mcp-server/src/services/whiteboard-geometry.ts +711 -0
- package/mcp-server/src/tools/agents.ts +539 -0
- package/mcp-server/src/tools/bluesky.ts +1186 -0
- package/mcp-server/src/tools/browser-advanced.ts +15 -6
- package/mcp-server/src/tools/browser-batch.ts +178 -0
- package/mcp-server/src/tools/browser-cheatsheet.ts +155 -0
- package/mcp-server/src/tools/browser-interact.ts +362 -255
- package/mcp-server/src/tools/browser-navigate.ts +164 -77
- package/mcp-server/src/tools/browser-observe.ts +2222 -1330
- package/mcp-server/src/tools/browser.ts +181 -107
- package/mcp-server/src/tools/card.ts +7 -5
- package/mcp-server/src/tools/choice.ts +103 -0
- package/mcp-server/src/tools/computer.ts +190 -0
- package/mcp-server/src/tools/discord.ts +10 -23
- package/mcp-server/src/tools/fb-groups.ts +387 -0
- package/mcp-server/src/tools/forget.ts +1 -1
- package/mcp-server/src/tools/git.ts +3 -1
- package/mcp-server/src/tools/gsc/experience.ts +440 -0
- package/mcp-server/src/tools/gsc/helpers.ts +288 -0
- package/mcp-server/src/tools/gsc/indexing.ts +495 -0
- package/mcp-server/src/tools/gsc/inspect.ts +391 -0
- package/mcp-server/src/tools/gsc/navigate.ts +236 -0
- package/mcp-server/src/tools/gsc/performance.ts +352 -0
- package/mcp-server/src/tools/gsc/settings.ts +564 -0
- package/mcp-server/src/tools/gsc.ts +95 -0
- package/mcp-server/src/tools/image.ts +3 -1
- package/mcp-server/src/tools/leonardo-browser-tools.ts +47 -37
- package/mcp-server/src/tools/leonardo.ts +7 -5
- package/mcp-server/src/tools/loop.ts +118 -25
- package/mcp-server/src/tools/memories.ts +65 -5
- package/mcp-server/src/tools/morelogin.ts +81 -0
- package/mcp-server/src/tools/profile.ts +122 -0
- package/mcp-server/src/tools/recall.ts +105 -261
- package/mcp-server/src/tools/reflect.ts +171 -145
- package/mcp-server/src/tools/remember.ts +223 -115
- package/mcp-server/src/tools/style-guide-tools.ts +250 -0
- package/mcp-server/src/tools/style-guide.ts +16 -0
- package/mcp-server/src/tools/sync.ts +66 -9
- package/mcp-server/src/tools/tictactoe.ts +3 -1
- package/mcp-server/src/tools/utils.ts +22 -0
- package/mcp-server/src/tools/whiteboard-format.ts +394 -0
- package/mcp-server/src/tools/whiteboard-tools.ts +191 -183
- package/mcp-server/src/tools/whiteboard.ts +7 -5
- package/mcp-server/src/tools/youtube/analytics.ts +106 -0
- package/mcp-server/src/tools/youtube/dedup.ts +57 -0
- package/mcp-server/src/tools/youtube/download.ts +41 -0
- package/mcp-server/src/tools/youtube/helpers.ts +183 -0
- package/mcp-server/src/tools/youtube/media.ts +109 -0
- package/mcp-server/src/tools/youtube/metadata.ts +112 -0
- package/mcp-server/src/tools/youtube/navigate.ts +81 -0
- package/mcp-server/src/tools/youtube/playlist.ts +134 -0
- package/mcp-server/src/tools/youtube/schedule.ts +158 -0
- package/mcp-server/src/tools/youtube/source.ts +101 -0
- package/mcp-server/src/tools/youtube/upload.ts +296 -0
- package/mcp-server/src/tools/youtube.ts +47 -0
- package/mcp-server/src/tui.ts +8 -9
- package/neural-interface/games/TicTacToe/1.svg +1 -0
- package/neural-interface/games/TicTacToe/2.svg +1 -0
- package/neural-interface/games/TicTacToe/3.svg +1 -0
- package/neural-interface/games/TicTacToe/4.svg +1 -0
- package/neural-interface/games/TicTacToe/5.svg +1 -0
- package/neural-interface/games/TicTacToe/6.svg +1 -0
- package/neural-interface/games/TicTacToe/7.svg +1 -0
- package/neural-interface/games/TicTacToe/8.svg +1 -0
- package/neural-interface/games/TicTacToe/9.svg +1 -0
- package/neural-interface/games/TicTacToe/Board.svg +1 -0
- package/neural-interface/games/TicTacToe/Circle.svg +1 -0
- package/neural-interface/games/TicTacToe/Cross.svg +1 -0
- package/neural-interface/i18n/en.json +7022 -0
- package/neural-interface/i18n/pt-BR.json +7026 -0
- package/neural-interface/lib/assistant-api.js +1005 -0
- package/neural-interface/lib/assistant-attachments.js +121 -0
- package/neural-interface/lib/assistant-brains/claude.js +207 -0
- package/neural-interface/lib/assistant-brains/codex-browser-policy-hook.mjs +43 -0
- package/neural-interface/lib/assistant-brains/codex-route-gate-hook.mjs +56 -0
- package/neural-interface/lib/assistant-brains/codex.js +213 -0
- package/neural-interface/lib/assistant-brains/opencode-browser-policy.js +47 -0
- package/neural-interface/lib/assistant-brains/opencode-route-gate.js +62 -0
- package/neural-interface/lib/assistant-brains/opencode.js +320 -0
- package/neural-interface/lib/assistant-budget.js +318 -0
- package/neural-interface/lib/assistant-catalog.js +731 -0
- package/neural-interface/lib/assistant-clarify.js +664 -0
- package/neural-interface/lib/assistant-config.js +232 -0
- package/neural-interface/lib/assistant-dispatch.js +2744 -0
- package/neural-interface/lib/assistant-envelope.js +582 -0
- package/neural-interface/lib/assistant-evidence.js +236 -0
- package/neural-interface/lib/assistant-hidden-models.js +272 -0
- package/neural-interface/lib/assistant-jev.js +117 -0
- package/neural-interface/lib/assistant-judgments.js +111 -0
- package/neural-interface/lib/assistant-media.js +335 -0
- package/neural-interface/lib/assistant-memory.js +398 -0
- package/neural-interface/lib/assistant-persona.js +444 -0
- package/neural-interface/lib/assistant-plan-permissions.js +448 -0
- package/neural-interface/lib/assistant-playbooks/design.md +87 -0
- package/neural-interface/lib/assistant-playbooks.js +72 -0
- package/neural-interface/lib/assistant-pricing.js +159 -0
- package/neural-interface/lib/assistant-route-gate-codex.js +163 -0
- package/neural-interface/lib/assistant-route-gate.js +212 -0
- package/neural-interface/lib/assistant-router.js +1324 -0
- package/neural-interface/lib/assistant-runtime.js +3337 -0
- package/neural-interface/lib/assistant-task-prompt.js +472 -0
- package/neural-interface/lib/assistant-usage.js +1688 -0
- package/neural-interface/lib/augmented-path.js +70 -0
- package/neural-interface/lib/automation-storage-migration.js +54 -0
- package/neural-interface/lib/backup-service.js +851 -0
- package/neural-interface/lib/backup-zip-reader.js +262 -0
- package/neural-interface/lib/browser-assist-api.js +41 -0
- package/neural-interface/lib/browser-console.js +176 -0
- package/neural-interface/lib/browser-execution.js +241 -0
- package/neural-interface/lib/browser-extraction.js +191 -0
- package/neural-interface/lib/browser-language.js +83 -0
- package/neural-interface/lib/browser-screenshot.js +399 -0
- package/neural-interface/lib/browser-semantic-context.js +639 -0
- package/neural-interface/lib/browser-snapshots.js +192 -0
- package/neural-interface/lib/browser-tab-recovery.js +119 -0
- package/neural-interface/lib/browser-targets.js +70 -0
- package/neural-interface/lib/browser-tool-policy.js +926 -0
- package/neural-interface/lib/claude-accounts.js +230 -0
- package/neural-interface/lib/claude-agent-bridge.js +3203 -0
- package/neural-interface/lib/claude-bypass-policy.js +99 -0
- package/neural-interface/lib/claude-executable.js +241 -0
- package/neural-interface/lib/claude-history.js +429 -0
- package/neural-interface/lib/claude-model-catalog.js +444 -0
- package/neural-interface/lib/claude-panel-requests.js +165 -0
- package/neural-interface/lib/claude-panel-session.js +272 -0
- package/neural-interface/lib/claude-permission-rules.js +153 -0
- package/neural-interface/lib/claude-session-cleanup.js +71 -0
- package/neural-interface/lib/claude-session-list.js +82 -0
- package/neural-interface/lib/claude-session-ops.js +140 -0
- package/neural-interface/lib/claude-settings-view.js +51 -0
- package/neural-interface/lib/claude-skin-connect.js +79 -0
- package/neural-interface/lib/claude-stall-policy.js +7 -0
- package/neural-interface/lib/claude-temporary.js +435 -0
- package/neural-interface/lib/cli-update-planner.js +312 -0
- package/neural-interface/lib/codex-capabilities.d.ts +41 -0
- package/neural-interface/lib/codex-capabilities.js +148 -0
- package/neural-interface/lib/codex-collaboration-mode.js +17 -0
- package/neural-interface/lib/codex-compat-rpc.js +241 -0
- package/neural-interface/lib/codex-mcp-approvals.js +56 -0
- package/neural-interface/lib/codex-mcp-apps.js +47 -0
- package/neural-interface/lib/codex-model-context.js +202 -0
- package/neural-interface/lib/codex-plan-store.js +71 -0
- package/neural-interface/lib/codex-request-journal.js +242 -0
- package/neural-interface/lib/codex-runtime-path.js +213 -0
- package/neural-interface/lib/codex-session-catalog.js +231 -0
- package/neural-interface/lib/codex-session-settings.js +46 -0
- package/neural-interface/lib/codex-sidepanel-permissions.js +110 -0
- package/neural-interface/lib/codex-thread-history.js +165 -0
- package/neural-interface/lib/codex-user-verification.js +34 -0
- package/neural-interface/lib/codex-writer-lifecycle.js +152 -0
- package/neural-interface/lib/confined-fs.js +256 -0
- package/neural-interface/lib/db.js +922 -0
- package/neural-interface/lib/design-md.js +9 -0
- package/neural-interface/lib/desktop/api.js +92 -0
- package/neural-interface/lib/desktop/audit.js +62 -0
- package/neural-interface/lib/desktop/build.js +423 -0
- package/neural-interface/lib/desktop/config.js +606 -0
- package/neural-interface/lib/desktop/coords.js +76 -0
- package/neural-interface/lib/desktop/fake-helper.js +1192 -0
- package/neural-interface/lib/desktop/frames.js +77 -0
- package/neural-interface/lib/desktop/grants.js +84 -0
- package/neural-interface/lib/desktop/guards.js +170 -0
- package/neural-interface/lib/desktop/helper/SynabunDesktop.swift +3056 -0
- package/neural-interface/lib/desktop/lease.js +51 -0
- package/neural-interface/lib/desktop/manager.js +545 -0
- package/neural-interface/lib/desktop/press-contexts.js +103 -0
- package/neural-interface/lib/desktop/protocol.js +495 -0
- package/neural-interface/lib/desktop/service.js +1202 -0
- package/neural-interface/lib/effort-levels.js +148 -0
- package/neural-interface/lib/event-loop-monitor.js +67 -0
- package/neural-interface/lib/exec-loop-command.js +209 -0
- package/neural-interface/lib/folder-picker.ps1 +77 -0
- package/neural-interface/lib/http-guards.js +287 -0
- package/neural-interface/lib/jsonl-lines.js +94 -0
- package/neural-interface/lib/loop-logger.js +127 -0
- package/neural-interface/lib/mcp-client-config.js +133 -0
- package/neural-interface/lib/mcp-installer.js +487 -0
- package/neural-interface/lib/mcp-profile-refresh-coordinator.js +78 -0
- package/neural-interface/lib/mcp-profile-servers.js +120 -0
- package/neural-interface/lib/memo-cache.js +84 -0
- package/neural-interface/lib/memory-api.js +758 -0
- package/neural-interface/lib/memory-map-api.js +1150 -0
- package/neural-interface/lib/memory-map-layout.js +872 -0
- package/neural-interface/lib/memory-map-worker.js +165 -0
- package/neural-interface/lib/memory-session-capture.js +81 -0
- package/neural-interface/lib/memory-timers.js +62 -0
- package/neural-interface/lib/morelogin.js +301 -0
- package/neural-interface/lib/native-binary-runtime.js +382 -0
- package/neural-interface/lib/native-loop-goal.js +33 -0
- package/neural-interface/lib/native-loop-providers.js +1002 -0
- package/neural-interface/lib/native-loop-runtime.js +747 -0
- package/neural-interface/lib/offline-page.js +54 -0
- package/neural-interface/lib/opencode-hidden-models.js +113 -0
- package/neural-interface/lib/opencode-history.js +253 -0
- package/neural-interface/lib/opencode-runtime-config.js +58 -0
- package/neural-interface/lib/opencode-v2-client.js +486 -0
- package/neural-interface/lib/opencode-v2-ws-requests.js +955 -0
- package/neural-interface/lib/path-confine.js +213 -0
- package/neural-interface/lib/plugin-installer.js +264 -0
- package/neural-interface/lib/project-storage.js +766 -0
- package/neural-interface/lib/pty-host/core.js +603 -0
- package/neural-interface/lib/pty-host/flow.js +43 -0
- package/neural-interface/lib/pty-host/host.js +47 -0
- package/neural-interface/lib/pty-host/manager.js +360 -0
- package/neural-interface/lib/pty-host/protocol.js +81 -0
- package/neural-interface/lib/pty-host/snapshot.js +133 -0
- package/neural-interface/lib/remote-policy.js +582 -0
- package/neural-interface/lib/rulesets/api.js +175 -0
- package/neural-interface/lib/rulesets/fs-safe.js +245 -0
- package/neural-interface/lib/rulesets/installer.js +1453 -0
- package/neural-interface/lib/rulesets/json-edit.js +186 -0
- package/neural-interface/lib/rulesets/lock.js +170 -0
- package/neural-interface/lib/rulesets/managed-text.js +314 -0
- package/neural-interface/lib/rulesets/normalise.js +24 -0
- package/neural-interface/lib/rulesets/render.js +84 -0
- package/neural-interface/lib/schedule-launch-intent.js +40 -0
- package/neural-interface/lib/session-cache-builder.js +524 -0
- package/neural-interface/lib/session-chunker.js +344 -0
- package/neural-interface/lib/session-indexer.js +327 -0
- package/neural-interface/lib/session-title-generator.js +232 -0
- package/neural-interface/lib/skill-prompt-bridge.js +11 -0
- package/neural-interface/lib/style-guide/api.js +375 -0
- package/neural-interface/lib/style-guide/color.js +352 -0
- package/neural-interface/lib/style-guide/export-css.js +99 -0
- package/neural-interface/lib/style-guide/export-dtcg.js +209 -0
- package/neural-interface/lib/style-guide/export-tailwind.js +102 -0
- package/neural-interface/lib/style-guide/fonts.json +227 -0
- package/neural-interface/lib/style-guide/import.js +1268 -0
- package/neural-interface/lib/style-guide/pointers.js +103 -0
- package/neural-interface/lib/style-guide/presets.js +232 -0
- package/neural-interface/lib/style-guide/render-design-md.js +319 -0
- package/neural-interface/lib/style-guide/schema.js +848 -0
- package/neural-interface/lib/style-guide/security.js +43 -0
- package/neural-interface/lib/style-guide/store.js +491 -0
- package/neural-interface/lib/style-guide/summary.js +106 -0
- package/neural-interface/lib/style-guide/tokens.js +170 -0
- package/neural-interface/lib/synabun-update-plan.js +324 -0
- package/neural-interface/lib/synabun-version.js +179 -0
- package/neural-interface/lib/system-restore.js +99 -0
- package/neural-interface/lib/terminal-env.js +86 -0
- package/neural-interface/lib/ui-blob-store.js +106 -0
- package/neural-interface/lib/vector-cache.js +2 -0
- package/neural-interface/lib/virtual-socket.js +60 -0
- package/neural-interface/lib/whatsapp/api.js +146 -0
- package/neural-interface/lib/whatsapp/auth-store.js +389 -0
- package/neural-interface/lib/whatsapp/baileys-adapter.js +457 -0
- package/neural-interface/lib/whatsapp/brain.js +128 -0
- package/neural-interface/lib/whatsapp/bridge.js +1499 -0
- package/neural-interface/lib/whatsapp/cards.js +519 -0
- package/neural-interface/lib/whatsapp/commands.js +92 -0
- package/neural-interface/lib/whatsapp/config.js +291 -0
- package/neural-interface/lib/whatsapp/connector/entry.mjs +25 -0
- package/neural-interface/lib/whatsapp/connector/manifest.json +6 -0
- package/neural-interface/lib/whatsapp/connector/npm-shrinkwrap.json +717 -0
- package/neural-interface/lib/whatsapp/connector/package.json +8 -0
- package/neural-interface/lib/whatsapp/fake-baileys.js +444 -0
- package/neural-interface/lib/whatsapp/format.js +552 -0
- package/neural-interface/lib/whatsapp/host-core.js +1225 -0
- package/neural-interface/lib/whatsapp/host.js +119 -0
- package/neural-interface/lib/whatsapp/identity.js +288 -0
- package/neural-interface/lib/whatsapp/inbound.js +149 -0
- package/neural-interface/lib/whatsapp/installer.js +562 -0
- package/neural-interface/lib/whatsapp/manager.js +638 -0
- package/neural-interface/lib/whatsapp/paths.js +81 -0
- package/neural-interface/lib/whatsapp/protocol.js +327 -0
- package/neural-interface/lib/whatsapp/qr-svg.js +57 -0
- package/neural-interface/lib/whatsapp/redact.js +130 -0
- package/neural-interface/lib/whatsapp/service.js +1305 -0
- package/neural-interface/lib/whatsapp/vendor/qrcodegen.js +848 -0
- package/neural-interface/package-lock.json +4431 -0
- package/neural-interface/package.json +58 -27
- package/neural-interface/public/apple-touch-icon.png +0 -0
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude.svg +1 -0
- package/neural-interface/public/favicon-16x16.png +0 -0
- package/neural-interface/public/favicon-32x32.png +0 -0
- package/neural-interface/public/icon-192.png +0 -0
- package/neural-interface/public/icon-512.png +0 -0
- package/neural-interface/public/index.html +32 -182
- package/neural-interface/public/index2d.html +2 -2
- package/neural-interface/public/install-app.html +31 -0
- package/neural-interface/public/logo-synabun.png +0 -0
- package/neural-interface/public/logoHD.png +0 -0
- package/neural-interface/public/manifest.json +2 -0
- package/neural-interface/public/mcp.svg +4 -0
- package/neural-interface/public/offline.html +670 -299
- package/neural-interface/public/onboarding.html +804 -161
- package/neural-interface/public/opencode-logo-dark-square.svg +1 -0
- package/neural-interface/public/shared/agent-runtime-options.js +596 -0
- package/neural-interface/public/shared/api.js +339 -28
- package/neural-interface/public/shared/assistant/asst-accounts.js +318 -0
- package/neural-interface/public/shared/assistant/asst-agents-dock.js +296 -0
- package/neural-interface/public/shared/assistant/asst-api.js +270 -0
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +744 -0
- package/neural-interface/public/shared/assistant/asst-budget.js +333 -0
- package/neural-interface/public/shared/assistant/asst-composer.js +656 -0
- package/neural-interface/public/shared/assistant/asst-computer.js +803 -0
- package/neural-interface/public/shared/assistant/asst-control.js +1018 -0
- package/neural-interface/public/shared/assistant/asst-hosts.js +262 -0
- package/neural-interface/public/shared/assistant/asst-mascot.js +313 -0
- package/neural-interface/public/shared/assistant/asst-menu.js +375 -0
- package/neural-interface/public/shared/assistant/asst-panel.js +1845 -0
- package/neural-interface/public/shared/assistant/asst-rack-styles.js +586 -0
- package/neural-interface/public/shared/assistant/asst-render.js +3305 -0
- package/neural-interface/public/shared/assistant/asst-route.js +1653 -0
- package/neural-interface/public/shared/assistant/asst-roving.js +75 -0
- package/neural-interface/public/shared/assistant/asst-sanitize.js +117 -0
- package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +488 -0
- package/neural-interface/public/shared/assistant/asst-sidepanel.js +675 -0
- package/neural-interface/public/shared/assistant/asst-slash.js +258 -0
- package/neural-interface/public/shared/assistant/asst-state.js +727 -0
- package/neural-interface/public/shared/assistant/asst-styles.js +1686 -0
- package/neural-interface/public/shared/assistant/asst-tool-kinds.js +1017 -0
- package/neural-interface/public/shared/assistant/asst-usage.js +444 -0
- package/neural-interface/public/shared/assistant/asst-ws.js +175 -0
- package/neural-interface/public/shared/automation-context.js +75 -0
- package/neural-interface/public/shared/blob-store.js +99 -0
- package/neural-interface/public/shared/cdx/cdx-capabilities.js +118 -0
- package/neural-interface/public/shared/cdx/cdx-cards.js +37 -0
- package/neural-interface/public/shared/cdx/cdx-commands.js +171 -0
- package/neural-interface/public/shared/cdx/cdx-context-menu.js +402 -0
- package/neural-interface/public/shared/cdx/cdx-context-model.js +220 -0
- package/neural-interface/public/shared/cdx/cdx-features.js +408 -0
- package/neural-interface/public/shared/cdx/cdx-icons.js +89 -0
- package/neural-interface/public/shared/cdx/cdx-mcp-app-host.js +414 -0
- package/neural-interface/public/shared/cdx/cdx-mcp-app-proxy.html +6 -0
- package/neural-interface/public/shared/cdx/cdx-mcp-app-proxy.js +56 -0
- package/neural-interface/public/shared/cdx/cdx-mcp-app.js +178 -0
- package/neural-interface/public/shared/cdx/cdx-model-catalog.js +120 -0
- package/neural-interface/public/shared/cdx/cdx-output-model.js +261 -0
- package/neural-interface/public/shared/cdx/cdx-output.js +382 -0
- package/neural-interface/public/shared/cdx/cdx-panel.js +1372 -0
- package/neural-interface/public/shared/cdx/cdx-plan.js +266 -0
- package/neural-interface/public/shared/cdx/cdx-protocol.js +682 -0
- package/neural-interface/public/shared/cdx/cdx-render.js +3381 -0
- package/neural-interface/public/shared/cdx/cdx-requests.js +1136 -0
- package/neural-interface/public/shared/cdx/cdx-styles.js +3251 -0
- package/neural-interface/public/shared/cdx/cdx-tabs.js +7992 -0
- package/neural-interface/public/shared/cdx/cdx-ws.js +363 -0
- package/neural-interface/public/shared/cli-status.js +169 -0
- package/neural-interface/public/shared/command-runner-model.js +30 -0
- package/neural-interface/public/shared/constants.js +22 -5
- package/neural-interface/public/shared/cp/cp-agents.js +186 -0
- package/neural-interface/public/shared/cp/cp-bash.js +252 -0
- package/neural-interface/public/shared/cp/cp-compose.js +159 -0
- package/neural-interface/public/shared/cp/cp-context-menu.js +309 -0
- package/neural-interface/public/shared/cp/cp-context-model.js +157 -0
- package/neural-interface/public/shared/cp/cp-context-window.js +80 -0
- package/neural-interface/public/shared/cp/cp-ctx.js +11 -0
- package/neural-interface/public/shared/cp/cp-diff.js +255 -0
- package/neural-interface/public/shared/cp/cp-event-rows.js +637 -0
- package/neural-interface/public/shared/cp/cp-events.js +846 -0
- package/neural-interface/public/shared/cp/cp-markdown.js +104 -0
- package/neural-interface/public/shared/cp/cp-permission-model.js +905 -0
- package/neural-interface/public/shared/cp/cp-permissions.js +626 -0
- package/neural-interface/public/shared/cp/cp-rehydrate.js +113 -0
- package/neural-interface/public/shared/cp/cp-restore.js +224 -0
- package/neural-interface/public/shared/cp/cp-session-model.js +338 -0
- package/neural-interface/public/shared/cp/cp-session-settings.js +254 -0
- package/neural-interface/public/shared/cp/cp-sessions.js +227 -0
- package/neural-interface/public/shared/cp/cp-statusline.js +153 -0
- package/neural-interface/public/shared/cp/cp-styles.js +349 -0
- package/neural-interface/public/shared/cp/cp-tasks-model.js +282 -0
- package/neural-interface/public/shared/cp/cp-tasks.js +107 -0
- package/neural-interface/public/shared/cp/cp-temporary-view.js +56 -0
- package/neural-interface/public/shared/cp/cp-temporary.js +231 -0
- package/neural-interface/public/shared/cp/cp-tool-cards.js +326 -0
- package/neural-interface/public/shared/cp/cp-tool-results.js +618 -0
- package/neural-interface/public/shared/cp/cp-usage-model.js +124 -0
- package/neural-interface/public/shared/graph-activity.js +68 -0
- package/neural-interface/public/shared/html-shell.js +116 -17
- package/neural-interface/public/shared/i18n.js +132 -34
- package/neural-interface/public/shared/node-positions-store.js +44 -0
- package/neural-interface/public/shared/ocp-hidden-models.js +116 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-approvals.js +355 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-autoaccept-button.js +35 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-automation-ownership.js +72 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-binding.js +280 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-caps.js +67 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-changes-logic.js +179 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-changes.js +164 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +725 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-compact-button.js +75 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-composer-logic.js +1204 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-confirm-logic.js +117 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-confirm.js +59 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-context-gauge.js +252 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-context-menu.js +478 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-context-model.js +179 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-events.js +233 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-icons.js +30 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-manager.js +1065 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-mention-hints.js +182 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-modelpicker.js +655 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +2636 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-plan.js +601 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-profile-inheritance.js +78 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +649 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-providers.js +35 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +357 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render-logic.js +189 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +1869 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-select.js +42 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send-logic.js +117 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +1714 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-session-actions.js +468 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-session-widgets.js +63 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +366 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-settings-link.js +19 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-slash-hints.js +343 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-state.js +746 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-status-logic.js +347 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-status.js +338 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +3543 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-subagents-logic.js +482 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-tools-logic.js +351 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-tools.js +168 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-variantpicker.js +315 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +545 -0
- package/neural-interface/public/shared/provider-icons.js +30 -0
- package/neural-interface/public/shared/pwa-install-page.js +34 -0
- package/neural-interface/public/shared/pwa-install.css +12 -0
- package/neural-interface/public/shared/pwa-install.js +28 -0
- package/neural-interface/public/shared/session-title.js +100 -0
- package/neural-interface/public/shared/settings/settings-ia.js +389 -0
- package/neural-interface/public/shared/settings/settings-kit.js +234 -0
- package/neural-interface/public/shared/settings/settings-rules.js +160 -0
- package/neural-interface/public/shared/settings/settings-search.js +463 -0
- package/neural-interface/public/shared/settings/settings-shell.js +570 -0
- package/neural-interface/public/shared/start-bridge.js +583 -0
- package/neural-interface/public/shared/state.js +5 -0
- package/neural-interface/public/shared/storage.js +50 -7
- package/neural-interface/public/shared/styleguide/sg-agents.js +42 -0
- package/neural-interface/public/shared/styleguide/sg-brand.js +4 -0
- package/neural-interface/public/shared/styleguide/sg-color-client.js +388 -0
- package/neural-interface/public/shared/styleguide/sg-colors.js +151 -0
- package/neural-interface/public/shared/styleguide/sg-components.js +35 -0
- package/neural-interface/public/shared/styleguide/sg-history.js +41 -0
- package/neural-interface/public/shared/styleguide/sg-icons-logo.js +38 -0
- package/neural-interface/public/shared/styleguide/sg-imagery.js +4 -0
- package/neural-interface/public/shared/styleguide/sg-import-export.js +48 -0
- package/neural-interface/public/shared/styleguide/sg-kit.js +284 -0
- package/neural-interface/public/shared/styleguide/sg-labels.js +318 -0
- package/neural-interface/public/shared/styleguide/sg-layout.js +4 -0
- package/neural-interface/public/shared/styleguide/sg-motion.js +5 -0
- package/neural-interface/public/shared/styleguide/sg-panel.js +237 -0
- package/neural-interface/public/shared/styleguide/sg-preview.js +138 -0
- package/neural-interface/public/shared/styleguide/sg-rules.js +9 -0
- package/neural-interface/public/shared/styleguide/sg-shape.js +24 -0
- package/neural-interface/public/shared/styleguide/sg-state.js +132 -0
- package/neural-interface/public/shared/styleguide/sg-typography.js +125 -0
- package/neural-interface/public/shared/styles.css +8614 -1477
- package/neural-interface/public/shared/synabun-mascot.js +1625 -0
- package/neural-interface/public/shared/synabun-ticker.js +376 -0
- package/neural-interface/public/shared/term-ready.js +307 -0
- package/neural-interface/public/shared/term-renderer.js +343 -0
- package/neural-interface/public/shared/ui-assistant-panel.js +10 -0
- package/neural-interface/public/shared/ui-assistant.js +77 -0
- package/neural-interface/public/shared/ui-automation-studio.js +5105 -3573
- package/neural-interface/public/shared/ui-claude-panel.js +11685 -6333
- package/neural-interface/public/shared/ui-codex-panel.js +12 -0
- package/neural-interface/public/shared/ui-command-runner.js +251 -468
- package/neural-interface/public/shared/ui-cost-widget.js +55 -34
- package/neural-interface/public/shared/ui-detail.js +77 -14
- package/neural-interface/public/shared/ui-explorer.js +360 -181
- package/neural-interface/public/shared/ui-file-explorer.js +485 -107
- package/neural-interface/public/shared/ui-help.js +2 -2
- package/neural-interface/public/shared/ui-image-gallery.js +45 -21
- package/neural-interface/public/shared/ui-judgments.js +1346 -0
- package/neural-interface/public/shared/ui-keybinds.js +3 -2
- package/neural-interface/public/shared/ui-link.js +105 -51
- package/neural-interface/public/shared/ui-loading.js +222 -11
- package/neural-interface/public/shared/ui-memory-maintenance.js +111 -0
- package/neural-interface/public/shared/ui-menubar.js +100 -31
- package/neural-interface/public/shared/ui-native-loop-router-state.js +94 -0
- package/neural-interface/public/shared/ui-native-loop-router.js +493 -0
- package/neural-interface/public/shared/ui-native-window-id.js +7 -0
- package/neural-interface/public/shared/ui-navbar.js +357 -222
- package/neural-interface/public/shared/ui-notifications.js +58 -12
- package/neural-interface/public/shared/ui-opencode-panel-v2.js +17 -0
- package/neural-interface/public/shared/ui-panels.js +15 -7
- package/neural-interface/public/shared/ui-resume.js +823 -653
- package/neural-interface/public/shared/ui-schedule-queue.js +172 -0
- package/neural-interface/public/shared/ui-schedules-studio.js +1882 -0
- package/neural-interface/public/shared/ui-scroll-follow.js +194 -0
- package/neural-interface/public/shared/ui-sessions.js +789 -34
- package/neural-interface/public/shared/ui-settings.js +5774 -2194
- package/neural-interface/public/shared/ui-sidepanel-layout.js +109 -0
- package/neural-interface/public/shared/ui-sidepanel-runtime.js +19 -0
- package/neural-interface/public/shared/ui-sidepanel-session-entry.js +40 -0
- package/neural-interface/public/shared/ui-sidepanel-sessions.js +221 -0
- package/neural-interface/public/shared/ui-sidepanel-state.js +131 -0
- package/neural-interface/public/shared/ui-sidepanel-tray.js +547 -0
- package/neural-interface/public/shared/ui-sidepanel-windows.js +387 -0
- package/neural-interface/public/shared/ui-skills.js +18 -32
- package/neural-interface/public/shared/ui-styleguide.js +35 -0
- package/neural-interface/public/shared/ui-sync.js +87 -0
- package/neural-interface/public/shared/ui-terminal.js +7863 -6385
- package/neural-interface/public/shared/ui-tool-updates.js +292 -0
- package/neural-interface/public/shared/ui-tooltip.js +156 -15
- package/neural-interface/public/shared/ui-tutorial-draw.js +119 -683
- package/neural-interface/public/shared/ui-tutorial-layout.js +135 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +59 -2209
- package/neural-interface/public/shared/ui-tutorial.css +102 -0
- package/neural-interface/public/shared/ui-tutorial.js +557 -1076
- package/neural-interface/public/shared/ui-update.js +420 -68
- package/neural-interface/public/shared/ui-whatsapp.js +941 -0
- package/neural-interface/public/shared/ui-whiteboard.js +1076 -233
- package/neural-interface/public/shared/ui-workspaces.js +2 -1
- package/neural-interface/public/shared/utils.js +58 -0
- package/neural-interface/public/shared/vterm-buffer.js +15 -5
- package/neural-interface/public/shared/whatsapp/wa-phone.js +65 -0
- package/neural-interface/public/shared/whatsapp/wa-view.js +411 -0
- package/neural-interface/public/sidepanel-session.html +18 -0
- package/neural-interface/public/sw.js +10 -2
- package/neural-interface/public/synabun.png +0 -0
- package/neural-interface/public/variant/2d/graph.js +1707 -1635
- package/neural-interface/public/variant/2d/lasso.js +2 -1
- package/neural-interface/public/variant/2d/layout.js +595 -594
- package/neural-interface/public/variant/2d/main.js +557 -521
- package/neural-interface/public/variant/2d/settings-gfx.js +18 -24
- package/neural-interface/public/variant/3d/main.js +517 -500
- package/neural-interface/public/variant/3d/map-controls.js +144 -0
- package/neural-interface/public/variant/3d/map-data.js +251 -0
- package/neural-interface/public/variant/3d/map-labels.js +241 -0
- package/neural-interface/public/variant/3d/map-pick.js +90 -0
- package/neural-interface/public/variant/3d/map-renderer.js +521 -0
- package/neural-interface/public/variant/3d/map-shaders.js +158 -0
- package/neural-interface/public/variant/3d/map-view.js +618 -0
- package/neural-interface/public/vendor/three/manifest.json +3 -0
- package/neural-interface/public/vendor/three/three-0.169.0.min.js +3844 -0
- package/neural-interface/public/vendor/xterm/manifest.json +3 -0
- package/neural-interface/public/vendor/xterm/xterm-6.0.0.css +285 -0
- package/neural-interface/public/vendor/xterm/xterm-6.0.0.min.js +192 -0
- package/neural-interface/scripts/build-vendor.mjs +64 -0
- package/neural-interface/scripts/codex-protocol-fixture.mjs +83 -0
- package/neural-interface/scripts/rebuild-pty.js +39 -22
- package/neural-interface/scripts/vendor-three-entry.js +24 -0
- package/neural-interface/scripts/vendor-xterm-entry.js +8 -0
- package/neural-interface/server.js +33983 -15000
- package/neural-interface/skins/default/skin.css +90 -0
- package/neural-interface/skins/default/skin.json +8 -0
- package/neural-interface/templates/rulesets/README.md +9 -0
- package/neural-interface/templates/rulesets/claude.md +3 -0
- package/neural-interface/templates/rulesets/codex.md +2 -0
- package/neural-interface/templates/rulesets/coexistence.md +6 -0
- package/neural-interface/templates/rulesets/core.md +26 -0
- package/neural-interface/templates/rulesets/cursor.md +2 -0
- package/neural-interface/templates/rulesets/gemini.md +2 -0
- package/neural-interface/templates/rulesets/legacy-hashes.json +227 -0
- package/neural-interface/templates/rulesets/manifest.json +12 -0
- package/neural-interface/templates/rulesets/opencode.md +2 -0
- package/package.json +131 -53
- package/postinstall.js +55 -26
- package/preuninstall.js +187 -0
- package/scripts/restore-data-home-from-backup.mjs +239 -0
- package/setup.js +872 -260
- package/skills/leonardo/SKILL.md +19 -10
- package/skills/leonardo/modules/image-prompter.md +18 -12
- package/skills/leonardo/modules/video-prompter.md +19 -13
- package/skills/synabun/SKILL.md +209 -185
- package/skills/synabun/icon.png +0 -0
- package/skills/synabun/modules/audit.md +380 -374
- package/skills/synabun/modules/changelog.md +15 -6
- package/skills/synabun/modules/idea.md +148 -144
- package/skills/synabun/modules/memorize.md +13 -4
- package/skills/synabun/modules/schedule.md +198 -0
- package/skills/synabun/synabunicon.png +0 -0
- package/updater.cmd +31 -0
- package/updater.mjs +382 -0
- package/updater.sh +29 -0
- package/hooks/claude-code/test-user-learning.mjs +0 -531
- package/neural-interface/public/claude-chat.css +0 -878
- package/neural-interface/public/claude-chat.html +0 -92
- package/neural-interface/public/claude-chat.js +0 -1168
- package/neural-interface/public/index-legacy.html +0 -14677
- package/neural-interface/public/index2d-legacy.html +0 -8193
- package/neural-interface/public/shared/html-term-renderer.js +0 -1022
- package/neural-interface/public/variant/3d/background.js +0 -884
- package/neural-interface/public/variant/3d/camera.js +0 -661
- package/neural-interface/public/variant/3d/gfx.js +0 -116
- package/neural-interface/public/variant/3d/graph.js +0 -2399
- package/neural-interface/public/variant/3d/settings-gfx.js +0 -228
- package/neural-interface/templates/CLAUDE-template.md +0 -211
|
@@ -0,0 +1,3337 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════
|
|
2
|
+
// SynaBun — Assistant runtime (sessions, brains, unified WebSocket, mailbox)
|
|
3
|
+
// ═══════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// Owns every assistant session: its record (persisted), its brain (Claude /
|
|
6
|
+
// Codex / OpenCode, created lazily), the attached UI sockets (zero or more; a
|
|
7
|
+
// bounded packet buffer bridges detached periods), the transcript log used for
|
|
8
|
+
// rehydration, per-turn auto-recall, dispatch mailbox delivery, and the memory
|
|
9
|
+
// obligation nudges. All server dependencies are injected.
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, resolve } from 'node:path';
|
|
14
|
+
import { createClaudeBrain } from './assistant-brains/claude.js';
|
|
15
|
+
import { createCodexBrain } from './assistant-brains/codex.js';
|
|
16
|
+
import { createOpenCodeBrain } from './assistant-brains/opencode.js';
|
|
17
|
+
import { buildAssistantPersona, formatMailbox, reconcileRouteItems } from './assistant-persona.js';
|
|
18
|
+
import { codexUsageCostUsd } from './assistant-budget.js';
|
|
19
|
+
import { CLAUDE_ESTIMATE_SOURCE, createClaudeMeter, createCodexMeter, createOpenCodeMeter, totalTokens, withTotal, zeroTokens } from './assistant-usage.js';
|
|
20
|
+
import { claudeTokensCostUsd } from './assistant-pricing.js';
|
|
21
|
+
import { normalizeRouteMode } from './assistant-config.js';
|
|
22
|
+
import { TASK_CLASS_META } from './assistant-router.js';
|
|
23
|
+
import { defaultIsHidden, enabledStandIn, findModel, providerAllHidden, selectSheetModels } from './assistant-catalog.js';
|
|
24
|
+
import { normalizeEffort } from './effort-levels.js';
|
|
25
|
+
import { claudeRouteAvailability, createRouteGate, mcpStatusRouteAvailability, ROUTE_GATE_LOOP_MESSAGE } from './assistant-route-gate.js';
|
|
26
|
+
import { claudeReadOnlyPermission, planDenyMessage, planToolDecision, readOnlyDenyMessage, readOnlyToolDecision } from './assistant-plan-permissions.js';
|
|
27
|
+
import { codexRouteGateBootstrap } from './assistant-route-gate-codex.js';
|
|
28
|
+
import { proposedPlanText } from './assistant-envelope.js';
|
|
29
|
+
import { clampBrainModes, clampRouteMode, defaultRemotePolicyRegistry, effectiveLevel, isComputerTool, readRegisteredProjects, remoteBrainLimited, remoteComputerDenial, remoteComputerUse, remoteToolDenial, runExceedsPolicy } from './remote-policy.js';
|
|
30
|
+
import { browserToolDenial } from './browser-tool-policy.js';
|
|
31
|
+
|
|
32
|
+
export const BRAIN_PROVIDERS = new Set(['claude-code', 'codex', 'opencode']);
|
|
33
|
+
// Every tab is called SynaBun; the session title (the history list) comes from
|
|
34
|
+
// the first prompt. Legacy "Assistant N" titles still count as unnamed.
|
|
35
|
+
export const DEFAULT_SESSION_TITLE = 'SynaBun';
|
|
36
|
+
export function isDefaultSessionTitle(title) { return !title || /^(?:SynaBun|Assistant(?: \d+)?)$/.test(String(title).trim()); }
|
|
37
|
+
const MAILBOX_REASONS = new Set(['turn_completed', 'needs_input', 'permission_request', 'failed', 'interrupted', 'stalled', 'budget_warning', 'budget_exceeded', 'budget_unpriced', 'queue_started', 'user_intervened', 'stopped']);
|
|
38
|
+
// A stopped run reaches the brain when the user stopped it or it ran out of money.
|
|
39
|
+
const MAILBOX_STOP_REASONS = new Set(['user', 'budget_cap', 'session_budget_cap', 'budget_unpriced']);
|
|
40
|
+
const usd = (value) => `$${(Number(value) || 0).toFixed(2)}`;
|
|
41
|
+
const TRANSCRIPT_EVENT_TYPES = new Set(['assistant', 'user', 'result', 'system']);
|
|
42
|
+
// Below the autonomous level a remote session's brain asks before any tool the
|
|
43
|
+
// read-only classifier would refuse. These stay unasked: questions and plans (they reach
|
|
44
|
+
// the user anyway), in-session bookkeeping, subagent launches (their own calls
|
|
45
|
+
// are asked), memory writes, and agent_dispatch (its route card is the approval).
|
|
46
|
+
const REMOTE_ASK_EXEMPT = new Set([
|
|
47
|
+
'AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode', 'TodoWrite', 'TodoRead', 'ToolSearch', 'Task', 'Agent',
|
|
48
|
+
'mcp__SynaBun__remember', 'mcp__SynaBun__reflect', 'mcp__SynaBun__category', 'mcp__SynaBun__agent_dispatch',
|
|
49
|
+
]);
|
|
50
|
+
// An OpenCode brain of a remote session reaches the web through these on its own
|
|
51
|
+
// (no SynaBun browser, no approval): the gate plugin refuses them.
|
|
52
|
+
const REMOTE_OPENCODE_WEB_TOOLS = new Set(['webfetch', 'websearch', 'codesearch']);
|
|
53
|
+
// Computer use from a remote session (see "computer use from a remote session"): what the
|
|
54
|
+
// brain is told when its computer tool does not run.
|
|
55
|
+
const COMPUTER_NO_TURN_NOTE = 'Computer use from WhatsApp runs only inside the task that asked for it; no such task is running.';
|
|
56
|
+
const COMPUTER_DECLINED_NOTE = 'The user did not allow computer use for this task. Do not retry it or work around it in this task: say what you could not do.';
|
|
57
|
+
const COMPUTER_CLOSED_NOTE = 'The request to control the Mac closed without an answer, so nothing was allowed. Do not retry it in this task.';
|
|
58
|
+
const COMPUTER_SUPERSEDED_NOTE = 'The user sent a newer message, so computer use for this task ended. Do not retry it or work around it: end your turn now; their message is next.';
|
|
59
|
+
// "Keep planning" without a note (asst-control.js buildClaude): no feedback to plan on.
|
|
60
|
+
const KEEP_PLANNING_DEFAULT = 'Keep planning — revise the plan.';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The plan a Codex / OpenCode plan turn proposed, from its `result` event, or
|
|
64
|
+
* '' when the turn asked or answered instead. Only an explicit proposal counts:
|
|
65
|
+
* Codex's plan item or <proposed_plan> block, and the <proposed_plan> block
|
|
66
|
+
* OpenCode's plan-mode instructions ask for (assistant-brains/opencode.js).
|
|
67
|
+
* Nothing is guessed from the reply's shape: a numbered clarifying question
|
|
68
|
+
* looks like a plan and a prose plan does not, and a false card invites
|
|
69
|
+
* approving what is not a plan, while a missing one only leaves plan mode on.
|
|
70
|
+
*/
|
|
71
|
+
export function planTurnText(result = {}) {
|
|
72
|
+
return String(result.plan || '').trim() || proposedPlanText(result.result);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A provider's usage-limit report (the Claude Agent SDK's `rate_limit_info`) as the one notice a
|
|
77
|
+
* session shows: `{ status: 'allowed_warning' | 'rejected', resetsAt: epoch ms | null, rateLimitType }`.
|
|
78
|
+
* `null`: the limit is no longer near, or its reset time has passed. `undefined`: the report names
|
|
79
|
+
* no status, so the notice stays as it is.
|
|
80
|
+
*/
|
|
81
|
+
export function limitNotice(info, nowMs = Date.now()) {
|
|
82
|
+
const status = info?.status;
|
|
83
|
+
if (status === 'allowed') return null;
|
|
84
|
+
if (status !== 'allowed_warning' && status !== 'rejected') return undefined;
|
|
85
|
+
const raw = Number(info.resetsAt);
|
|
86
|
+
const resetsAt = raw > 0 ? (raw < 1e12 ? raw * 1000 : raw) : null; // the SDK reports seconds
|
|
87
|
+
if (resetsAt !== null && resetsAt <= nowMs) return null;
|
|
88
|
+
return { status, resetsAt, rateLimitType: info.rateLimitType || null };
|
|
89
|
+
}
|
|
90
|
+
const limitKey = (limit) => (limit ? `${limit.status}|${limit.resetsAt ?? ''}` : '');
|
|
91
|
+
|
|
92
|
+
function iso(now = Date.now()) { return new Date(now).toISOString(); }
|
|
93
|
+
function readJson(path, fallback) { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; } }
|
|
94
|
+
function writeJsonAtomic(path, value) {
|
|
95
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
96
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
97
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
|
98
|
+
renameSync(tmp, path);
|
|
99
|
+
}
|
|
100
|
+
function clip(value, max = 200) { const text = String(value ?? '').replace(/\s+/g, ' ').trim(); return text.length > max ? `${text.slice(0, max - 1)}…` : text; }
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A brain effort checked against its model (effort-levels.js). "off" (or a
|
|
104
|
+
* model with no levels) stays "off" for Claude and Codex, which reset to the
|
|
105
|
+
* model's default on it; OpenCode has no such variant, so it gets null.
|
|
106
|
+
*/
|
|
107
|
+
export function checkBrainEffort(provider, model, effort, catalog) {
|
|
108
|
+
if (!effort) return null;
|
|
109
|
+
if (!catalog) return String(effort);
|
|
110
|
+
const next = normalizeEffort({ provider, model, effort, catalog }).effort;
|
|
111
|
+
return next || (provider === 'opencode' ? null : 'off');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `catalog` (a catalog value) checks the effort against the brain's model (effort-levels.js). */
|
|
115
|
+
export function normalizeBrain(input = {}, defaults = {}, { catalog = null } = {}) {
|
|
116
|
+
// Plan mode is a flag beside the approval mode ('plan' as a mode is the legacy form).
|
|
117
|
+
const legacyPlan = input?.permissionMode === 'plan';
|
|
118
|
+
const provider = BRAIN_PROVIDERS.has(input?.provider) ? input.provider : (BRAIN_PROVIDERS.has(defaults.provider) ? defaults.provider : 'claude-code');
|
|
119
|
+
const model = input?.model ? String(input.model) : (defaults.model || null);
|
|
120
|
+
const effort = input?.effort ? String(input.effort) : (defaults.effort || null);
|
|
121
|
+
return {
|
|
122
|
+
provider,
|
|
123
|
+
model,
|
|
124
|
+
effort: checkBrainEffort(provider, model, effort, catalog),
|
|
125
|
+
agent: input?.agent ? String(input.agent) : null,
|
|
126
|
+
accountId: input?.accountId ? String(input.accountId) : (provider === 'opencode' ? null : 'default'),
|
|
127
|
+
cwd: input?.cwd ? String(input.cwd) : (input?.project ? String(input.project) : (defaults.cwd || null)),
|
|
128
|
+
mcpProfile: input?.mcpProfile ? String(input.mcpProfile) : (provider === 'claude-code' ? 'full' : (defaults.mcpProfile || null)),
|
|
129
|
+
permissionMode: input?.permissionMode && !legacyPlan ? String(input.permissionMode) : 'default',
|
|
130
|
+
planMode: typeof input?.planMode === 'boolean' ? input.planMode : (legacyPlan || defaults?.planMode === true),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A brain's `mode_changed` → the record fields it changes (Claude leaving 'plan' = the plan was approved). */
|
|
135
|
+
export function brainModeUpdate(brain, mode, planMode, { fromBrain = false } = {}) {
|
|
136
|
+
const patch = {};
|
|
137
|
+
if (mode === 'plan') patch.planMode = true;
|
|
138
|
+
else if (mode) {
|
|
139
|
+
patch.permissionMode = String(mode);
|
|
140
|
+
if (fromBrain && brain?.provider === 'claude-code') patch.planMode = false;
|
|
141
|
+
}
|
|
142
|
+
if (typeof planMode === 'boolean') patch.planMode = planMode;
|
|
143
|
+
return patch;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Claude SDK hook maps merged per event (either side may be null). */
|
|
147
|
+
export function mergeHooks(a, b) {
|
|
148
|
+
if (!a) return b || null;
|
|
149
|
+
if (!b) return a;
|
|
150
|
+
const out = { ...a };
|
|
151
|
+
for (const [event, matchers] of Object.entries(b)) out[event] = [...(out[event] || []), ...matchers];
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function createAssistantRuntime({
|
|
156
|
+
dispatcher = null,
|
|
157
|
+
claudeAccounts = null,
|
|
158
|
+
ClaudeSession = null,
|
|
159
|
+
sdkVersion = null,
|
|
160
|
+
handleCodexSkinWebSocket = null,
|
|
161
|
+
ensureIsolatedServe = null,
|
|
162
|
+
stopIsolatedServe = null,
|
|
163
|
+
setupOpencodeSidepanelConfig = null,
|
|
164
|
+
broadcastSync = () => {},
|
|
165
|
+
dataDir,
|
|
166
|
+
memory = null,
|
|
167
|
+
buildCatalog = null,
|
|
168
|
+
detectProject = () => 'global',
|
|
169
|
+
PACKAGE_ROOT = process.cwd(),
|
|
170
|
+
config = {},
|
|
171
|
+
log = () => {},
|
|
172
|
+
now = Date.now,
|
|
173
|
+
brainFactories = null,
|
|
174
|
+
// Routing (assistant-router.js), the model catalog and the config store.
|
|
175
|
+
router = null,
|
|
176
|
+
catalog = null,
|
|
177
|
+
configStore = null,
|
|
178
|
+
// Computer use (lib/desktop/service.js).
|
|
179
|
+
desktop = null,
|
|
180
|
+
// Route gate: the loopback URL the OpenCode plugin and the Codex hook ask,
|
|
181
|
+
// the Codex binary (for the hook's trust probe) and the bootstrap builder.
|
|
182
|
+
gateUrl = null,
|
|
183
|
+
codexBin = () => 'codex',
|
|
184
|
+
codexGateBootstrap = codexRouteGateBootstrap,
|
|
185
|
+
// Codex list prices (assistant-budget.js createModelPricing). When given, a
|
|
186
|
+
// brain whose spend cannot be priced is refused (the caps fail closed).
|
|
187
|
+
pricing = null,
|
|
188
|
+
usage = null,
|
|
189
|
+
// The meters of assistant-usage.js, by provider (tests swap one in).
|
|
190
|
+
meterFactories = null,
|
|
191
|
+
codexHomeForAccount = null,
|
|
192
|
+
// Clarification (assistant-clarify.js): human prompts, the brain's own
|
|
193
|
+
// question cards and the clarify cards' answers reach it from here.
|
|
194
|
+
clarifier = null,
|
|
195
|
+
// Remote sessions (lib/remote-policy.js): the level registry shared with the
|
|
196
|
+
// dispatcher and the WhatsApp bridge, and the registered project paths a
|
|
197
|
+
// remote session's workers must run in.
|
|
198
|
+
remotePolicy = defaultRemotePolicyRegistry,
|
|
199
|
+
registeredProjects = () => readRegisteredProjects(),
|
|
200
|
+
// (capability) → boolean, the verifier of lib/remote-policy.js createPhoneAuthority(): is this the
|
|
201
|
+
// capability of the one WhatsApp bridge? Without a verifier nothing can grant computer use remotely.
|
|
202
|
+
phoneAuthority = null,
|
|
203
|
+
} = {}) {
|
|
204
|
+
if (!dataDir) throw new Error('createAssistantRuntime requires dataDir');
|
|
205
|
+
const settings = {
|
|
206
|
+
idleDisposeMs: Math.max(60_000, Number(config.idleDisposeMinutes || 120) * 60_000),
|
|
207
|
+
// A remote session on OpenCode waits this long for its gate plugin's hello before a turn (else refused).
|
|
208
|
+
remoteGateHelloMs: Math.max(0, Number(config.remoteGateHelloMs ?? 5000)),
|
|
209
|
+
// An OpenCode turn's end waits this long for the serve's stored messages (the usage meter's
|
|
210
|
+
// reconcile); past it the turn is settled as not reconciled and the next turn may start.
|
|
211
|
+
openCodeReconcileMs: Number(config.openCodeReconcileMs) > 0 ? Number(config.openCodeReconcileMs) : 10_000,
|
|
212
|
+
bufferCap: 500,
|
|
213
|
+
mailboxBatchMs: Number(config.mailboxBatchMs) || 2000,
|
|
214
|
+
retention: 200,
|
|
215
|
+
defaultBrain: config.defaultBrain || {},
|
|
216
|
+
personaExtra: config.persona?.extra || '',
|
|
217
|
+
maxBudgetUsd: Number(config.brains?.claude?.maxBudgetUsd) || 10,
|
|
218
|
+
// A brain's approval or question card waits this long, then it is declined
|
|
219
|
+
// (0 = wait forever). A pending card holds the turn and the mailbox.
|
|
220
|
+
approvalTimeoutMs: Math.max(0, Number(config.approvalTimeoutMinutes ?? 10) * 60_000) || 0,
|
|
221
|
+
questionTimeoutMs: Math.max(0, Number(config.questionTimeoutMinutes ?? 30) * 60_000) || 0,
|
|
222
|
+
};
|
|
223
|
+
const indexPath = resolve(dataDir, 'assistant-sessions.json');
|
|
224
|
+
const sessions = new Map(); // id → live
|
|
225
|
+
const listeners = new Set();
|
|
226
|
+
// Passive packet observers (the WhatsApp bridge): they see every packet a
|
|
227
|
+
// session sends, never count as delivery, and never hold a session open.
|
|
228
|
+
const packetObservers = new Set();
|
|
229
|
+
let unsubscribeDispatcher = null;
|
|
230
|
+
let unsubscribePolicy = null;
|
|
231
|
+
let unsubscribeUsage = null;
|
|
232
|
+
const usageTimers = new Map();
|
|
233
|
+
const usageSentAt = new Map();
|
|
234
|
+
const meters = { 'claude-code': createClaudeMeter, codex: createCodexMeter, opencode: createOpenCodeMeter, ...(meterFactories || {}) };
|
|
235
|
+
// Token metering is additive: a throw in a meter or the ledger is logged once
|
|
236
|
+
// per kind and never reaches the turn logic (running, dollars, the turn's end).
|
|
237
|
+
const usageErrors = new Set();
|
|
238
|
+
function usageError(kind, error) {
|
|
239
|
+
if (usageErrors.has(kind)) return;
|
|
240
|
+
usageErrors.add(kind);
|
|
241
|
+
log('assistant:usage-error', `${kind}: ${error?.message || String(error)}`);
|
|
242
|
+
}
|
|
243
|
+
function metered(kind, fn, fallback = null) {
|
|
244
|
+
try { return fn(); } catch (error) { usageError(kind, error); return fallback; }
|
|
245
|
+
}
|
|
246
|
+
// shutdown(): no scheduler work starts any more.
|
|
247
|
+
let shuttingDown = false;
|
|
248
|
+
|
|
249
|
+
// ── persistence ────────────────────────────────────────────────────────────
|
|
250
|
+
function transcriptPath(id) { return resolve(dataDir, 'assistant', id, 'transcript.jsonl'); }
|
|
251
|
+
function loadIndex() {
|
|
252
|
+
const rows = readJson(indexPath, []);
|
|
253
|
+
if (!Array.isArray(rows)) return;
|
|
254
|
+
for (const row of rows) {
|
|
255
|
+
if (!row?.id) continue;
|
|
256
|
+
sessions.set(row.id, makeLive({ ...row, status: row.status === 'ended' ? 'ended' : 'idle', windowIds: [] }));
|
|
257
|
+
// A remote session nobody registered again since the restart reads as read-only.
|
|
258
|
+
if (row.remote) {
|
|
259
|
+
try { remotePolicy?.markRemote?.(row.id, { channel: row.remote.channel || row.channel || 'whatsapp' }); } catch {}
|
|
260
|
+
try { remotePolicy?.setSessionBrain?.(row.id, row.brain?.provider || null); } catch {}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
let persistTimer = null;
|
|
265
|
+
function persist({ force = false } = {}) {
|
|
266
|
+
const write = () => {
|
|
267
|
+
const rows = [...sessions.values()].map((live) => publicRecord(live.record, { withLive: false, persisted: true }))
|
|
268
|
+
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
|
|
269
|
+
.slice(0, settings.retention);
|
|
270
|
+
try { writeJsonAtomic(indexPath, rows); } catch (error) { log('assistant:index-error', error?.message || String(error)); }
|
|
271
|
+
};
|
|
272
|
+
if (force) { if (persistTimer) { clearTimeout(persistTimer); persistTimer = null; } write(); return; }
|
|
273
|
+
if (persistTimer) return;
|
|
274
|
+
persistTimer = setTimeout(() => { persistTimer = null; write(); }, 300);
|
|
275
|
+
persistTimer.unref?.();
|
|
276
|
+
}
|
|
277
|
+
function appendTranscript(id, entry) {
|
|
278
|
+
try {
|
|
279
|
+
const path = transcriptPath(id);
|
|
280
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
281
|
+
appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
|
|
282
|
+
} catch {}
|
|
283
|
+
}
|
|
284
|
+
function readTranscript(id, limit = 400) {
|
|
285
|
+
const path = transcriptPath(id);
|
|
286
|
+
if (!existsSync(path)) return [];
|
|
287
|
+
try {
|
|
288
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
|
|
289
|
+
return lines.slice(-Math.max(1, limit)).map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
290
|
+
} catch { return []; }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── records ────────────────────────────────────────────────────────────────
|
|
294
|
+
function makeLive(record) {
|
|
295
|
+
return {
|
|
296
|
+
record, brain: null, brainProvider: null, brainMeter: null, brainMeterProvider: null, turnTaskId: null, turnTasks: [], sockets: new Set(), buffer: [], running: false, pendingControls: new Map(), controlTimers: new Map(),
|
|
297
|
+
mailbox: [], mailboxTimer: null, obligations: new Set(), rememberedThisTurn: false, disposeTimer: null, personaText: '',
|
|
298
|
+
lastPrompt: '', mailboxTurn: false, nudgedRuns: new Set(),
|
|
299
|
+
// The provider's usage limit as last reported (limitNotice), or null: one state, never a list.
|
|
300
|
+
limit: null,
|
|
301
|
+
// Human prompts so far: one request cycle each (assistant-clarify.js briefs).
|
|
302
|
+
promptCycle: 0,
|
|
303
|
+
// Claude brains: the turns sent, keyed by their final text, for the in-process recall hook.
|
|
304
|
+
turnMeta: new Map(),
|
|
305
|
+
desktopGrant: null,
|
|
306
|
+
// Route gate (assistant-route-gate.js): one per session while routing is wired.
|
|
307
|
+
// gatePluginAt: when the OpenCode serve loaded the gate plugin (until then, reactive).
|
|
308
|
+
// gateNudge: { reason, gen } while a reactive refusal waits for its turn to end.
|
|
309
|
+
gate: null, gateToken: null, gateNudge: null, gatePluginAt: null,
|
|
310
|
+
// Turns between runQuery's entry and its end (the prompt prefix awaits recall,
|
|
311
|
+
// the catalog and the brain before `running` is set): busy all along.
|
|
312
|
+
// `prompting`: the generation of the human prompt among them (one at a time).
|
|
313
|
+
turnPending: 0, prompting: null,
|
|
314
|
+
// Remote sessions: the last human prompt carried untrusted content (capped at ask
|
|
315
|
+
// until the next one); the desktop approved the plan of the running turn; the
|
|
316
|
+
// level the live brain was built for (bypass allowed only when autonomous).
|
|
317
|
+
turnUntrusted: false, planApprovedByDesktop: false, brainSkipAllowed: null,
|
|
318
|
+
// A Codex / OpenCode brain built read-only for a remote session (lib/remote-policy.js).
|
|
319
|
+
brainRemoteReadOnly: false,
|
|
320
|
+
// Computer use from a remote session (remoteComputerUse decides). computerTurn: the running
|
|
321
|
+
// turn's own state { approved, by, declined }, null between turns; computerAsk: its open
|
|
322
|
+
// "control your Mac for this?" request { requestId, followers }; computerGrantLive: the held
|
|
323
|
+
// desktop grant is active now; brainComputerMode: what the live brain was built for.
|
|
324
|
+
computerTurn: null, computerAsk: null, computerGrantLive: false, brainComputerMode: null,
|
|
325
|
+
// A route card that said the Mac will be controlled (assistant-router.js computerConsent):
|
|
326
|
+
// computerRoutes: routeId → { turn, epoch } recorded when the card was sent; computerRouteGrant:
|
|
327
|
+
// the owner's yes to such a card, waiting for the mailbox turn that executes that route
|
|
328
|
+
// ({ routeId, origin, epoch, at }); computerEpoch: bumped whenever remoteComputerUse's answer
|
|
329
|
+
// changes, so a card or a yes from before the change counts for nothing.
|
|
330
|
+
computerRoutes: new Map(), computerRouteGrant: null, computerEpoch: 0,
|
|
331
|
+
// Codex / OpenCode plan turns end with the plan: the runtime's own "Plan ready"
|
|
332
|
+
// card ({ requestId, plan }, the work scheduler's hold), the running turn that
|
|
333
|
+
// offers one, its plan, and the generation it started in.
|
|
334
|
+
planCard: null, planTurn: false, planText: '', planTurnGen: 0,
|
|
335
|
+
// Plan cards closed unanswered (request id → reason): an answer still on its way is refused.
|
|
336
|
+
closedPlanCards: new Map(),
|
|
337
|
+
// The work scheduler (see "work scheduler"): the cancellation generation, the
|
|
338
|
+
// queued work (kind → entry), the entry that holds the session now, the
|
|
339
|
+
// fallback retry, and the generation each route card was raised in.
|
|
340
|
+
// Closing (endSessionWork): the close under way (a promise), and the generation it started.
|
|
341
|
+
gen: 0, work: new Map(), workActive: null, workTimer: null, workPumpQueued: false, routeGens: new Map(),
|
|
342
|
+
closing: null, closedGen: 0,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
// ── remote sessions (lib/remote-policy.js) ─────────────────────────────────
|
|
346
|
+
/**
|
|
347
|
+
* The session's remote policy, or null. `brainProvider` is the session's own
|
|
348
|
+
* brain (`provider` for a brain about to be set): a Codex / OpenCode brain
|
|
349
|
+
* makes it read-only (effectiveLevel).
|
|
350
|
+
*/
|
|
351
|
+
function remotePolicyFor(liveOrRecord, provider = null) {
|
|
352
|
+
const record = liveOrRecord?.record || liveOrRecord || null;
|
|
353
|
+
const id = record?.id || null;
|
|
354
|
+
if (!id || !remotePolicy?.getSessionPolicy) return null;
|
|
355
|
+
let policy = null;
|
|
356
|
+
try { policy = remotePolicy.getSessionPolicy(id) || null; }
|
|
357
|
+
catch { return record?.remote ? { level: 'read-only', failClosed: true } : null; }
|
|
358
|
+
if (!policy) return null;
|
|
359
|
+
const brainProvider = provider || record?.brain?.provider || policy.brainProvider || null;
|
|
360
|
+
return brainProvider === policy.brainProvider ? policy : { ...policy, brainProvider };
|
|
361
|
+
}
|
|
362
|
+
/** The level the session's current turn runs at: null for a desktop session. */
|
|
363
|
+
function remoteLevel(live) {
|
|
364
|
+
const policy = remotePolicyFor(live);
|
|
365
|
+
return policy ? effectiveLevel(policy, { untrusted: live.turnUntrusted === true, now: now() }) : null;
|
|
366
|
+
}
|
|
367
|
+
function registeredProjectPaths() {
|
|
368
|
+
try { const rows = typeof registeredProjects === 'function' ? registeredProjects() : registeredProjects; return Array.isArray(rows) ? rows : []; } catch { return []; }
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* A mode write: `next` is what was asked for (kept on the remote record, so a
|
|
372
|
+
* level raised later gives it back); the record gets what the level allows.
|
|
373
|
+
*/
|
|
374
|
+
function clampModeWrite(live, next, { fromBrain = false } = {}) {
|
|
375
|
+
if (!remotePolicyFor(live)) return next;
|
|
376
|
+
const before = live.record.remote?.modes || null;
|
|
377
|
+
// A brain's own report changes the plan flag only: its approval mode is the clamped one it was given.
|
|
378
|
+
const wanted = fromBrain && before
|
|
379
|
+
? { permissionMode: before.permissionMode || 'default', planMode: next.planMode === true }
|
|
380
|
+
: { permissionMode: next.permissionMode || 'default', planMode: next.planMode === true };
|
|
381
|
+
live.record.remote = { ...(live.record.remote || { channel: live.record.channel || 'whatsapp' }), modes: wanted };
|
|
382
|
+
return clampedBrain(live, next);
|
|
383
|
+
}
|
|
384
|
+
function clampedBrain(live, brain) {
|
|
385
|
+
const policy = remotePolicyFor(live, brain?.provider || null);
|
|
386
|
+
if (!policy) return brain;
|
|
387
|
+
const wanted = live.record.remote?.modes || { permissionMode: brain.permissionMode, planMode: brain.planMode === true };
|
|
388
|
+
const modes = clampBrainModes(policy, wanted, { untrusted: live.turnUntrusted === true, now: now(), planApproved: live.planApprovedByDesktop === true });
|
|
389
|
+
return { ...brain, ...modes };
|
|
390
|
+
}
|
|
391
|
+
/** Re-applies the level to the record and the live brain; true when the modes changed. */
|
|
392
|
+
async function reclampLive(live) {
|
|
393
|
+
if (!remotePolicyFor(live)) return false;
|
|
394
|
+
const before = live.record.brain;
|
|
395
|
+
const next = clampedBrain(live, before);
|
|
396
|
+
if (next.permissionMode === before.permissionMode && (next.planMode === true) === (before.planMode === true)) return false;
|
|
397
|
+
live.record.brain = next;
|
|
398
|
+
touch(live);
|
|
399
|
+
try { await live.brain?.setPermissionMode?.(next.permissionMode, { planMode: next.planMode === true }); } catch {}
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
// ── computer use from a remote session ─────────────────────────────────────
|
|
403
|
+
// remoteComputerUse (lib/remote-policy.js) is the one place that decides: off,
|
|
404
|
+
// allowed (Autonomous, the owner's switch on) or allowed after one approval per
|
|
405
|
+
// turn (Ask). Everything here asks it. A remote Claude brain is built with a
|
|
406
|
+
// HELD desktop grant (its MCP headers are fixed at start); the grant is live
|
|
407
|
+
// only while a turn runs and that turn may use the computer: unasked, or after
|
|
408
|
+
// the owner's yes to the turn's one request. It is never persisted and ends
|
|
409
|
+
// with the turn; a pause, a level change, the switch going off, a Stop, a stop
|
|
410
|
+
// at the Mac, the brain going away and a shutdown all hold it again and stop
|
|
411
|
+
// the action in progress (the desktop's lease is released).
|
|
412
|
+
function desktopReady() { try { return !!desktop?.isReady?.(); } catch { return false; } }
|
|
413
|
+
/**
|
|
414
|
+
* Whether the running turn may use the computer unasked at Autonomous: only a
|
|
415
|
+
* turn started by a trusted message from the owner's phone (computerTurn.unasked,
|
|
416
|
+
* set from what started the turn, never from a session flag). Every other turn
|
|
417
|
+
* of a remote session (a mailbox turn, a continuation, one the CLI started,
|
|
418
|
+
* one typed on the desktop, one carrying a picture or third-party text) is
|
|
419
|
+
* "untrusted" for computer use: it asks once. No turn running: the base answer.
|
|
420
|
+
*/
|
|
421
|
+
function computerUntrusted(live) { return !!live?.computerTurn && live.computerTurn.unasked !== true; }
|
|
422
|
+
/** remoteComputerUse for the session now (`untrusted`: overrides the running turn's own); null for a desktop session. */
|
|
423
|
+
function computerDecision(liveOrRecord, { untrusted = null } = {}) {
|
|
424
|
+
const policy = remotePolicyFor(liveOrRecord);
|
|
425
|
+
if (!policy) return null;
|
|
426
|
+
const live = liveOrRecord?.record ? liveOrRecord : sessions.get(liveOrRecord?.id) || null;
|
|
427
|
+
return remoteComputerUse(policy, { untrusted: untrusted === null ? computerUntrusted(live) : untrusted === true, now: now(), desktop: { supported: computerSupported(), ready: desktopReady() } });
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* A remote session's computer tools may run right now: a turn runs, the owner
|
|
431
|
+
* did not say no in it, and it is allowed unasked or was approved.
|
|
432
|
+
*/
|
|
433
|
+
function computerRuns(live) {
|
|
434
|
+
const decision = computerDecision(live);
|
|
435
|
+
if (!decision || !live.computerTurn || live.computerTurn.declined === true) return false;
|
|
436
|
+
return decision.state === 'allowed' || (decision.state === 'ask' && live.computerTurn.approved === true);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* What one computer tool call of a remote session gets: { access: 'run' } |
|
|
440
|
+
* { access: 'ask' } (the turn's one request) | { access: 'deny', reason, message }.
|
|
441
|
+
*/
|
|
442
|
+
function computerAccess(live) {
|
|
443
|
+
const decision = computerDecision(live);
|
|
444
|
+
if (!decision) return null;
|
|
445
|
+
if (decision.state === 'off') return { access: 'deny', reason: decision.reason, message: remoteComputerDenial(decision.reason) };
|
|
446
|
+
if (!live.computerTurn) return { access: 'deny', reason: 'no_turn', message: COMPUTER_NO_TURN_NOTE };
|
|
447
|
+
// A no (or an unanswered request) holds for the rest of the turn, whatever the level becomes.
|
|
448
|
+
if (live.computerTurn.declined === true) return { access: 'deny', reason: 'declined', message: live.computerTurn.superseded === true ? COMPUTER_SUPERSEDED_NOTE : COMPUTER_DECLINED_NOTE };
|
|
449
|
+
if (decision.state === 'allowed' || live.computerTurn.approved === true) return { access: 'run', reason: decision.reason };
|
|
450
|
+
return { access: 'ask', reason: decision.reason };
|
|
451
|
+
}
|
|
452
|
+
/** The held grant goes live or dark. Going dark stops the session's action in progress and releases the desktop. */
|
|
453
|
+
function setComputerGrant(live, runs) {
|
|
454
|
+
const was = live.computerGrantLive === true;
|
|
455
|
+
live.computerGrantLive = runs === true;
|
|
456
|
+
if (live.desktopGrant && (was || runs)) {
|
|
457
|
+
const channel = live.record.remote?.channel || live.record.channel || 'whatsapp';
|
|
458
|
+
const approved = live.computerTurn?.approved === true;
|
|
459
|
+
// The audit says where the session is driven from and how this use was allowed.
|
|
460
|
+
const remote = runs ? { channel, approval: approved ? 'approved_turn' : 'unasked', approvedBy: approved ? live.computerTurn.by : null } : { channel };
|
|
461
|
+
try { desktop?.setGrantActive?.(live.desktopGrant, runs === true, { remote }); } catch (error) { log('assistant:desktop-grant-error', error?.message || String(error)); }
|
|
462
|
+
}
|
|
463
|
+
if (was && !runs) {
|
|
464
|
+
try { desktop?.onSessionToggle?.(live.record.id, false); } catch {}
|
|
465
|
+
log('assistant:remote-policy', `${live.record.id}: computer use ended; the desktop grant is held again`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function syncComputerGrant(live) {
|
|
469
|
+
if (remotePolicyFor(live)) setComputerGrant(live, computerRuns(live));
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* The open "control your Mac for this?" request closes without a grant: every
|
|
473
|
+
* computer tool call that waited on it is refused and the card closes.
|
|
474
|
+
*/
|
|
475
|
+
function closeComputerAsk(live, reason, message = COMPUTER_CLOSED_NOTE) {
|
|
476
|
+
const ask = live.computerAsk;
|
|
477
|
+
if (!ask) return false;
|
|
478
|
+
live.computerAsk = null;
|
|
479
|
+
const shown = live.pendingControls.delete(ask.requestId);
|
|
480
|
+
clearControlTimer(live, ask.requestId);
|
|
481
|
+
for (const rid of [ask.requestId, ...ask.followers]) { try { live.brain?.respondControl?.(rid, { behavior: 'deny', message }); } catch {} }
|
|
482
|
+
if (shown) sendToSockets(live, { type: 'control_cancelled', request_id: ask.requestId, reason });
|
|
483
|
+
if (live.record.status === 'awaiting' && !live.pendingControls.size) live.record.status = live.running ? 'running' : 'idle';
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* A turn begins: nothing of the last one carries over (no approval, no open
|
|
488
|
+
* request). `unasked`: it was started by a trusted message from the owner's
|
|
489
|
+
* phone (the only kind of turn Autonomous lets use the computer without asking).
|
|
490
|
+
* `id` names the turn to the desktop service: a call admitted in one turn never runs in another.
|
|
491
|
+
*/
|
|
492
|
+
function beginComputerTurn(live, { unasked = false } = {}) {
|
|
493
|
+
if (!remotePolicyFor(live)) return;
|
|
494
|
+
closeComputerAsk(live, 'turn_ended');
|
|
495
|
+
live.computerTurnSeq = (Number(live.computerTurnSeq) || 0) + 1;
|
|
496
|
+
live.computerTurn = { id: `${live.record.id}#${live.computerTurnSeq}`, approved: false, by: null, declined: false, unasked: unasked === true };
|
|
497
|
+
syncComputerGrant(live);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* A newer message from the owner's phone arrived (the bridge calls this before
|
|
501
|
+
* it buffers or queues it): an approval the running turn holds ends at once and
|
|
502
|
+
* the action in progress stops; a yes still waiting for its mailbox turn is void.
|
|
503
|
+
* The turn itself goes on without the computer. A turn that runs unasked
|
|
504
|
+
* (Autonomous, started by the owner's own message) holds no approval: it is not
|
|
505
|
+
* ended here (the owner stops it with /stop). → true when an approval ended.
|
|
506
|
+
*/
|
|
507
|
+
function supersedeComputerApproval(id) {
|
|
508
|
+
const live = sessions.get(String(id || ''));
|
|
509
|
+
if (!live || !remotePolicyFor(live)) return false;
|
|
510
|
+
dropRouteComputer(live);
|
|
511
|
+
const turn = live.computerTurn;
|
|
512
|
+
if (!turn || turn.approved !== true) return false;
|
|
513
|
+
turn.approved = false;
|
|
514
|
+
turn.by = null;
|
|
515
|
+
turn.declined = true;
|
|
516
|
+
turn.superseded = true;
|
|
517
|
+
setComputerGrant(live, computerRuns(live));
|
|
518
|
+
log('assistant:remote-policy', `${live.record.id}: a newer phone message ended this turn's computer approval`);
|
|
519
|
+
sendToSockets(live, { type: 'assistant:session', session: view(live), reason: 'computer' });
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
/** The turn ended, was stopped, or its brain went: its approval and the live grant end with it. */
|
|
523
|
+
function endComputerTurn(live, reason = 'turn_ended') {
|
|
524
|
+
if (!live.computerTurn && !live.computerAsk && !live.computerGrantLive) return;
|
|
525
|
+
const approved = live.computerTurn?.approved === true;
|
|
526
|
+
closeComputerAsk(live, reason);
|
|
527
|
+
live.computerTurn = null;
|
|
528
|
+
setComputerGrant(live, false);
|
|
529
|
+
// The panel's Computer switch said "on for this task": it reads the session again.
|
|
530
|
+
if (approved && sessions.get(live.record.id) === live) sendToSockets(live, { type: 'assistant:session', session: view(live), reason: 'computer' });
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* A Claude brain's permission request for a computer tool in a remote session
|
|
534
|
+
* (its hook answered 'ask'). It is never shown as a generic tool card: the
|
|
535
|
+
* first one of a turn becomes the turn's ONE request of its own kind (the
|
|
536
|
+
* phone and the desktop can answer it), the ones that arrive while it is open
|
|
537
|
+
* wait on the same answer, and one that needs no asking is answered at once.
|
|
538
|
+
* → true when the packet was dealt with here.
|
|
539
|
+
*/
|
|
540
|
+
function computerControlRequest(live, packet) {
|
|
541
|
+
const request = packet.request || {};
|
|
542
|
+
const tool = String(request.tool_name || request.toolName || '');
|
|
543
|
+
if (!isComputerTool(tool) || !remotePolicyFor(live)) return false;
|
|
544
|
+
const rid = String(packet.request_id);
|
|
545
|
+
const access = computerAccess(live);
|
|
546
|
+
const respond = (response) => { try { live.brain?.respondControl?.(rid, response); } catch {} };
|
|
547
|
+
if (access.access === 'run') { respond({ behavior: 'allow' }); return true; }
|
|
548
|
+
if (access.access === 'deny') { respond({ behavior: 'deny', message: access.message }); return true; }
|
|
549
|
+
if (live.computerAsk) { live.computerAsk.followers.push(rid); return true; }
|
|
550
|
+
const card = {
|
|
551
|
+
type: 'control_request', request_id: rid,
|
|
552
|
+
request: { subtype: 'computer_use', kind: 'computer_use', tool_name: 'computer_use', channel: live.record.remote?.channel || live.record.channel || 'whatsapp', level: computerDecision(live)?.level || null, reason: access.reason },
|
|
553
|
+
};
|
|
554
|
+
live.computerAsk = { requestId: rid, followers: [] };
|
|
555
|
+
live.pendingControls.set(rid, card);
|
|
556
|
+
live.record.status = 'awaiting';
|
|
557
|
+
armControlTimeout(live, card);
|
|
558
|
+
touch(live);
|
|
559
|
+
log('assistant:remote-policy', `${live.record.id}: asking to control the Mac for this turn (${rid})`);
|
|
560
|
+
sendToSockets(live, card);
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
/** The running turn may use the computer from here on (the owner's yes): the grant goes live and every host hears it. */
|
|
564
|
+
function approveComputerTurn(live, origin, how = 'request') {
|
|
565
|
+
const turn = live.computerTurn;
|
|
566
|
+
turn.approved = true;
|
|
567
|
+
turn.by = origin;
|
|
568
|
+
setComputerGrant(live, computerRuns(live));
|
|
569
|
+
log('assistant:remote-policy', `${live.record.id}: computer use approved for this turn (${origin}, ${how})`);
|
|
570
|
+
const status = { type: 'event', event: { type: 'system', subtype: 'status', text: `Computer use approved for this task (${origin === 'whatsapp' ? 'from WhatsApp' : 'on this computer'}). It ends with the task.` } };
|
|
571
|
+
sendToSockets(live, status);
|
|
572
|
+
appendTranscript(live.record.id, { at: iso(now()), packet: status });
|
|
573
|
+
sendToSockets(live, { type: 'assistant:session', session: view(live), reason: 'computer' });
|
|
574
|
+
}
|
|
575
|
+
/** Nothing of a route card's computer consent outlives a new prompt, a Stop, a stop at the Mac or a changed decision. */
|
|
576
|
+
function dropRouteComputer(live) {
|
|
577
|
+
live.computerRoutes.clear();
|
|
578
|
+
live.computerRouteGrant = null;
|
|
579
|
+
}
|
|
580
|
+
// The phone's authority is a capability, not a string. Only the WhatsApp bridge holds it (handed
|
|
581
|
+
// over once, at wiring time, by the composition root), so no other caller in this process can
|
|
582
|
+
// grant computer use, grant the Mac part of a route, or start a turn that counts as the owner's
|
|
583
|
+
// own message from the phone, whatever `origin` it passes.
|
|
584
|
+
function fromPhone(authority) {
|
|
585
|
+
try { return typeof phoneAuthority === 'function' && typeof authority === 'symbol' && phoneAuthority(authority) === true; } catch { return false; }
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* A route card that said the Mac will be controlled was approved on the option
|
|
589
|
+
* it said it for (the router's computerApproved sink). The yes is the computer
|
|
590
|
+
* approval of the turn that carries the route out, when: the card was bound
|
|
591
|
+
* when it was sent, the answer came from the owner's phone or the desktop,
|
|
592
|
+
* remoteComputerUse still says "ask" and has not changed since, and no newer
|
|
593
|
+
* prompt, Stop or stop at the Mac came in between. `viaMailbox`: the turn that
|
|
594
|
+
* raised the card is over, so the mailbox turn that executes the decided route
|
|
595
|
+
* gets it (claimRouteComputer); otherwise the turn that raised it, if it is
|
|
596
|
+
* still the one running. → true when the yes was taken.
|
|
597
|
+
*/
|
|
598
|
+
function routerComputerApproved(id, { routeId, origin = null, viaMailbox = false, authority = null } = {}) {
|
|
599
|
+
const live = sessions.get(String(id || ''));
|
|
600
|
+
const key = String(routeId || '');
|
|
601
|
+
const binding = live?.computerRoutes.get(key);
|
|
602
|
+
if (!live || !binding) return false;
|
|
603
|
+
live.computerRoutes.delete(key); // one answer per card
|
|
604
|
+
// Granted only by the bridge's match on a message from the owner's phone, which it proves with
|
|
605
|
+
// its capability (`origin` is audit text): an answer that arrived by socket or REST, or from any
|
|
606
|
+
// other caller in this process, approves the route alone (the phone is then asked about the Mac).
|
|
607
|
+
if (!fromPhone(authority)) return false;
|
|
608
|
+
if (binding.epoch !== live.computerEpoch || computerDecision(live)?.state !== 'ask') {
|
|
609
|
+
log('assistant:remote-policy', `${live.record.id}: route ${key} approved, but computer use changed since its card was sent: no computer approval`);
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
if (viaMailbox === true) {
|
|
613
|
+
live.computerRouteGrant = { routeId: key, origin, epoch: live.computerEpoch, at: now() };
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
const turn = live.computerTurn;
|
|
617
|
+
if (!turn || turn !== binding.turn || turn.declined === true) return false;
|
|
618
|
+
approveComputerTurn(live, origin, `route ${key}`);
|
|
619
|
+
// A computer call of this turn that was already waiting on its own request is answered by the same yes.
|
|
620
|
+
const ask = live.computerAsk;
|
|
621
|
+
if (ask) {
|
|
622
|
+
live.computerAsk = null;
|
|
623
|
+
if (live.pendingControls.delete(ask.requestId)) { clearControlTimer(live, ask.requestId); controlResolved(live, ask.requestId, origin); }
|
|
624
|
+
for (const rid of [ask.requestId, ...ask.followers]) { try { live.brain?.respondControl?.(rid, { behavior: 'allow' }); } catch {} }
|
|
625
|
+
if (live.record.status === 'awaiting' && !live.pendingControls.size) live.record.status = live.running ? 'running' : 'idle';
|
|
626
|
+
}
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* A mailbox turn that executes decided routes begins: if one of them is the
|
|
631
|
+
* route whose card carried the owner's computer approval, this turn has it.
|
|
632
|
+
* Used once; void after a changed decision or the approval timeout.
|
|
633
|
+
*/
|
|
634
|
+
function claimRouteComputer(live, routeIds = []) {
|
|
635
|
+
const grant = live.computerRouteGrant;
|
|
636
|
+
if (!grant || !routeIds.includes(grant.routeId)) return false;
|
|
637
|
+
live.computerRouteGrant = null;
|
|
638
|
+
const turn = live.computerTurn;
|
|
639
|
+
if (!turn || turn.declined === true || grant.epoch !== live.computerEpoch || computerDecision(live)?.state !== 'ask') return false;
|
|
640
|
+
if (settings.approvalTimeoutMs > 0 && now() - grant.at > settings.approvalTimeoutMs) return false;
|
|
641
|
+
approveComputerTurn(live, grant.origin, `route ${grant.routeId}, mailbox turn`);
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* The turn's request was answered (the caller already took it out of the
|
|
646
|
+
* pending cards, in the same synchronous step that found it). A yes from the
|
|
647
|
+
* owner's phone or the desktop turns computer use on for this turn only, and
|
|
648
|
+
* only while remoteComputerUse still says "ask" for it; anything else is a no
|
|
649
|
+
* for the rest of the turn. → true when computer use runs now.
|
|
650
|
+
*/
|
|
651
|
+
function settleComputerAsk(live, ask, approved, { origin = 'ui', message = null, authority = null } = {}) {
|
|
652
|
+
if (live.computerAsk === ask) live.computerAsk = null;
|
|
653
|
+
const decision = computerDecision(live);
|
|
654
|
+
const turn = live.computerTurn;
|
|
655
|
+
const trusted = fromPhone(authority);
|
|
656
|
+
// Granted only by a yes the bridge matched on a message from the owner's phone (its capability says so; `origin` is audit text), while the turn lives and the decision is not "off".
|
|
657
|
+
const granted = approved === true && trusted && !!turn && !!decision && decision.state !== 'off';
|
|
658
|
+
if (granted && decision.state === 'ask') { turn.approved = true; turn.by = origin; }
|
|
659
|
+
if (!granted && turn) { turn.declined = true; turn.approved = false; turn.by = null; }
|
|
660
|
+
const runs = granted && computerRuns(live);
|
|
661
|
+
if (runs && turn.approved === true) approveComputerTurn(live, origin);
|
|
662
|
+
else setComputerGrant(live, runs);
|
|
663
|
+
const denial = decision?.state === 'off' ? remoteComputerDenial(decision.reason) : (String(message || '').trim() || COMPUTER_DECLINED_NOTE);
|
|
664
|
+
for (const rid of [ask.requestId, ...ask.followers]) { try { live.brain?.respondControl?.(rid, runs ? { behavior: 'allow' } : { behavior: 'deny', message: denial }); } catch {} }
|
|
665
|
+
if (live.record.status === 'awaiting' && !live.pendingControls.size) live.record.status = live.running ? 'running' : 'idle';
|
|
666
|
+
return runs;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* A level was registered, changed or cleared: computer use follows what
|
|
670
|
+
* remoteComputerUse says now (a change of its answer ends the running turn's
|
|
671
|
+
* approval and stops the action in progress), the brain's modes follow the
|
|
672
|
+
* level, and the session's runs that go past the new level stop. A Claude brain
|
|
673
|
+
* built for another level restarts when idle (bypass is only allowed to a brain
|
|
674
|
+
* built at the autonomous level; its persona and its held grant follow the
|
|
675
|
+
* computer-use mode it was built for).
|
|
676
|
+
*/
|
|
677
|
+
function onRemotePolicyChanged(sessionId, previous = null) {
|
|
678
|
+
const live = sessions.get(String(sessionId || ''));
|
|
679
|
+
const policy = live ? remotePolicyFor(live) : null;
|
|
680
|
+
if (!live || !policy) return;
|
|
681
|
+
if (!live.record.remote) live.record.remote = { channel: policy.channel || 'whatsapp', since: iso(now()) };
|
|
682
|
+
const after = computerDecision(live);
|
|
683
|
+
const before = previous ? remoteComputerUse(previous, { untrusted: computerUntrusted(live), now: now(), desktop: { supported: computerSupported(), ready: desktopReady() } }) : null;
|
|
684
|
+
if (!before || before.state !== after.state || before.reason !== after.reason) {
|
|
685
|
+
// Did the running turn have the Mac (approved at Ask, or unasked at Autonomous)? Read before anything is closed.
|
|
686
|
+
const hadControl = live.computerGrantLive === true || live.computerTurn?.approved === true;
|
|
687
|
+
closeComputerAsk(live, 'policy');
|
|
688
|
+
if (live.computerTurn) { live.computerTurn.approved = false; live.computerTurn.by = null; }
|
|
689
|
+
// Computer use went off under a turn that had the Mac (the switch, a pause, an unlink, the bridge
|
|
690
|
+
// gone): that turn is done with it. When the policy allows it again (a resume, a rebuilt bridge),
|
|
691
|
+
// the turn neither gets it back nor asks again; the next message from the phone starts afresh.
|
|
692
|
+
if (after?.state === 'off' && hadControl && live.computerTurn) { live.computerTurn.declined = true; live.computerTurn.unasked = false; }
|
|
693
|
+
// A route card sent before the change no longer carries a computer approval, and a yes already given to one is void.
|
|
694
|
+
live.computerEpoch += 1;
|
|
695
|
+
dropRouteComputer(live);
|
|
696
|
+
}
|
|
697
|
+
syncComputerGrant(live);
|
|
698
|
+
reclampLive(live).catch(() => {});
|
|
699
|
+
stopRunsAboveLevel(live, policy);
|
|
700
|
+
// Otherwise the next turn restarts it (runQuery).
|
|
701
|
+
if (brainNeedsRestart(live) && !sessionBusy(live) && !live.brain?.hasPendingWork?.()) disposeBrain(live, 'policy').catch(() => {});
|
|
702
|
+
touch(live);
|
|
703
|
+
if (live.creating) return; // createSession announces it
|
|
704
|
+
broadcast('assistant:session-updated', live);
|
|
705
|
+
sendToSockets(live, { type: 'assistant:session', session: view(live), reason: 'remote' });
|
|
706
|
+
}
|
|
707
|
+
function stopRunsAboveLevel(live, policy) {
|
|
708
|
+
if (!dispatcher?.list || !dispatcher?.stop) return;
|
|
709
|
+
let runs = [];
|
|
710
|
+
try { runs = dispatcher.list({ assistantSessionId: live.record.id, activeOnly: true }) || []; } catch { runs = []; }
|
|
711
|
+
if (!runs.length) return;
|
|
712
|
+
const projects = registeredProjectPaths();
|
|
713
|
+
for (const run of runs) {
|
|
714
|
+
if (!runExceedsPolicy(policy, run, { registeredProjects: projects, now: now() })) continue;
|
|
715
|
+
log('assistant:remote-policy', `${live.record.id}: stopping ${run.runId} (above the ${effectiveLevel(policy, { now: now() })} level)`);
|
|
716
|
+
Promise.resolve(dispatcher.stop(run.runId, 'remote_policy')).catch(() => {});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/** Something runs: a turn, one on its way to the brain, the scheduler's work (a compact), the brain itself. */
|
|
720
|
+
function sessionBusy(live) {
|
|
721
|
+
return !!live && (live.running || (Number(live.turnPending) || 0) > 0 || !!activeWork(live) || !!live.brain?.isBusy?.());
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* A Claude brain built for another level: without the remote hook (built
|
|
725
|
+
* before the session became remote), or with bypass allowed at a level that
|
|
726
|
+
* no longer allows it (or the reverse).
|
|
727
|
+
*/
|
|
728
|
+
function brainNeedsRestart(live) {
|
|
729
|
+
if (!live?.brain) return false;
|
|
730
|
+
const policy = remotePolicyFor(live);
|
|
731
|
+
// A Codex / OpenCode brain built before the session became remote is not read-only yet.
|
|
732
|
+
if (live.brainProvider !== 'claude-code') return !!policy && live.brainRemoteReadOnly !== true;
|
|
733
|
+
if (!policy) return false;
|
|
734
|
+
if (live.brainSkipAllowed !== (effectiveLevel(policy, { now: now() }) === 'autonomous')) return true;
|
|
735
|
+
// Its persona and its held desktop grant were built for another computer-use mode.
|
|
736
|
+
return live.brainComputerMode !== (computerDecision(live, { untrusted: false })?.state || 'off');
|
|
737
|
+
}
|
|
738
|
+
function defaultRouteMode() {
|
|
739
|
+
try { return configStore?.routing?.().defaultMode || 'ask-unsure'; } catch { return 'ask-unsure'; }
|
|
740
|
+
}
|
|
741
|
+
function routingView(record) {
|
|
742
|
+
let askBelow = 0.75;
|
|
743
|
+
try { askBelow = configStore?.routing?.().askBelow ?? askBelow; } catch {}
|
|
744
|
+
const mode = normalizeRouteMode(record.routing?.mode) || null;
|
|
745
|
+
// A remote session below the autonomous level asks on a route card for every task.
|
|
746
|
+
const policy = remotePolicyFor(record);
|
|
747
|
+
const effectiveMode = policy ? clampRouteMode(policy, mode || defaultRouteMode(), { now: now() }) : (mode || defaultRouteMode());
|
|
748
|
+
return { mode, effectiveMode, defaultMode: defaultRouteMode(), askBelow };
|
|
749
|
+
}
|
|
750
|
+
function computerSupported() { try { return !!desktop?.isSupported?.(); } catch { return false; } }
|
|
751
|
+
/**
|
|
752
|
+
* Effective computer toggle: explicit per-session value, else the service default (ON after setup).
|
|
753
|
+
* A remote session has no toggle of its own: on means remoteComputerUse does not say "off"
|
|
754
|
+
* (whether a call runs right now is computerRuns: unasked, or after the turn's approval).
|
|
755
|
+
*/
|
|
756
|
+
function computerEffective(record) {
|
|
757
|
+
const remote = computerDecision(record);
|
|
758
|
+
if (remote) return remote.state !== 'off';
|
|
759
|
+
if (!computerSupported()) return false;
|
|
760
|
+
if (record.computerUse === true || record.computerUse === false) return record.computerUse;
|
|
761
|
+
try { return !!desktop.defaultSessionOn?.(); } catch { return false; }
|
|
762
|
+
}
|
|
763
|
+
function brainCapabilities(record) {
|
|
764
|
+
try { return catalog?.brainInfo?.(record.brain) || null; } catch { return null; }
|
|
765
|
+
}
|
|
766
|
+
/** The hidden id (Assistant → Models) that `model` names for `provider`, else null. */
|
|
767
|
+
/** An effort the model does not run becomes the nearest one it does; unknown models pass through. */
|
|
768
|
+
function brainEffort(provider, model, effort) {
|
|
769
|
+
try { return checkBrainEffort(provider, model, effort, catalog?.peek?.() || null); } catch { return effort || null; }
|
|
770
|
+
}
|
|
771
|
+
function hiddenBrainModel(provider, model) {
|
|
772
|
+
if (!provider || !model) return null;
|
|
773
|
+
try { return catalog?.hiddenId?.(provider, model) || null; } catch { return null; }
|
|
774
|
+
}
|
|
775
|
+
function modelDisabledError(model) {
|
|
776
|
+
const error = new Error(`${model} is disabled in the Assistant's Models list. Pick an enabled model, or enable it in ⋯ → Models….`);
|
|
777
|
+
error.code = 'MODEL_DISABLED';
|
|
778
|
+
error.status = 400;
|
|
779
|
+
return error;
|
|
780
|
+
}
|
|
781
|
+
function everyModelDisabledError(provider) {
|
|
782
|
+
const error = new Error(`Every ${provider} model is disabled in the Assistant's Models list. Pick another provider, or enable one in ⋯ → Models….`);
|
|
783
|
+
error.code = 'MODEL_DISABLED';
|
|
784
|
+
error.status = 400;
|
|
785
|
+
return error;
|
|
786
|
+
}
|
|
787
|
+
/** The catalog value; built on demand only when nothing is cached yet. */
|
|
788
|
+
async function catalogValue() {
|
|
789
|
+
const cached = catalog?.peek?.() || null;
|
|
790
|
+
if (cached) return cached;
|
|
791
|
+
try { return (await catalog?.full?.()) || null; } catch { return null; }
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* The brain a new session (or a provider switch) runs on, never a model the
|
|
795
|
+
* user disabled (Assistant → Models). A disabled model named by the caller is
|
|
796
|
+
* refused, or with `fallback` (the UI's start path) replaced by the same
|
|
797
|
+
* model at another context window, else the provider's enabled default, else
|
|
798
|
+
* the default brain provider's. A null model is pinned when the provider's
|
|
799
|
+
* own default is disabled (dispatch does the same). → { brain, fallback }:
|
|
800
|
+
* fallback = { reason: 'model'|'provider', from, fromProvider, to, provider, label }
|
|
801
|
+
* when the caller's model (or every model of its provider) was replaced.
|
|
802
|
+
*/
|
|
803
|
+
async function enabledBrain(brain, { fallback = false, named = true } = {}) {
|
|
804
|
+
const { provider, model } = brain;
|
|
805
|
+
const disabled = model ? hiddenBrainModel(provider, model) : null;
|
|
806
|
+
if (model && !disabled) return { brain, fallback: null };
|
|
807
|
+
if (disabled && named && !fallback) throw modelDisabledError(model);
|
|
808
|
+
const value = await catalogValue();
|
|
809
|
+
if (!value) {
|
|
810
|
+
if (disabled) throw modelDisabledError(model);
|
|
811
|
+
return { brain, fallback: null };
|
|
812
|
+
}
|
|
813
|
+
if (!disabled && !defaultIsHidden(value, provider) && !providerAllHidden(value, provider)) return { brain, fallback: null };
|
|
814
|
+
const note = (reason, row, to) => ({ reason, from: reason === 'model' ? model : provider, fromProvider: provider, to: row.id, provider: to, label: row.label || row.id });
|
|
815
|
+
const same = enabledStandIn(value, provider, model);
|
|
816
|
+
if (same) return { brain: normalizeBrain({ ...brain, model: same.id }, {}, { catalog: value }), fallback: disabled ? note('model', same, provider) : null };
|
|
817
|
+
// Every model of this provider is disabled (or an OpenCode model is, whose default is OpenCode's own).
|
|
818
|
+
if (!fallback) throw disabled ? modelDisabledError(model) : everyModelDisabledError(provider);
|
|
819
|
+
const other = [settings.defaultBrain.provider, 'claude-code'].find((p) => BRAIN_PROVIDERS.has(p) && p !== provider);
|
|
820
|
+
const row = other ? enabledStandIn(value, other) : null;
|
|
821
|
+
if (!row) throw disabled ? modelDisabledError(model) : everyModelDisabledError(provider);
|
|
822
|
+
const next = normalizeBrain({ provider: other, model: row.id, cwd: brain.cwd, mcpProfile: brain.mcpProfile, permissionMode: 'default' }, {}, { catalog: value });
|
|
823
|
+
return { brain: next, fallback: note(disabled ? 'model' : 'provider', row, other) };
|
|
824
|
+
}
|
|
825
|
+
function publicRecord(record, { withLive = true, live = null, persisted = false } = {}) {
|
|
826
|
+
const out = { ...record };
|
|
827
|
+
if (!persisted) delete out.usageState;
|
|
828
|
+
if (withLive && live) {
|
|
829
|
+
out.running = live.running;
|
|
830
|
+
out.attached = live.sockets.size;
|
|
831
|
+
out.brainActive = !!live.brain;
|
|
832
|
+
out.pendingControls = live.pendingControls.size;
|
|
833
|
+
out.obligations = [...live.obligations];
|
|
834
|
+
out.routing = routingView(record);
|
|
835
|
+
out.brainCapabilities = brainCapabilities(record);
|
|
836
|
+
out.pendingRoutes = router?.pendingCards ? router.pendingCards(record.id).length : 0;
|
|
837
|
+
const policy = remotePolicyFor(record);
|
|
838
|
+
const computer = policy ? computerDecision(live) : null;
|
|
839
|
+
out.computerUse = computerEffective(record);
|
|
840
|
+
// A remote session's computer use is not a per-conversation choice: no explicit value, and why it is what it is.
|
|
841
|
+
out.computerUseExplicit = policy ? null : (record.computerUse === true || record.computerUse === false ? record.computerUse : null);
|
|
842
|
+
out.computerRemote = computer ? { state: computer.state, reason: computer.reason, level: computer.level, approved: live.computerTurn?.approved === true } : null;
|
|
843
|
+
out.features = { routing: !!router, computer: computerSupported() };
|
|
844
|
+
out.budget = sessionMoney(record.id);
|
|
845
|
+
out.remoteLevel = policy ? effectiveLevel(policy, { untrusted: live.turnUntrusted === true, now: now() }) : null;
|
|
846
|
+
}
|
|
847
|
+
return out;
|
|
848
|
+
}
|
|
849
|
+
function view(live) { return publicRecord(live.record, { withLive: true, live }); }
|
|
850
|
+
function touch(live) { live.record.updatedAt = iso(now()); persist(); }
|
|
851
|
+
function requireLive(id) {
|
|
852
|
+
const live = sessions.get(id);
|
|
853
|
+
if (!live) { const error = new Error(`Unknown assistant session ${id}`); error.code = 'SESSION_NOT_FOUND'; error.status = 404; throw error; }
|
|
854
|
+
return live;
|
|
855
|
+
}
|
|
856
|
+
function broadcast(type, live, extra = {}) {
|
|
857
|
+
try { broadcastSync({ type, session: view(live), ...extra }); } catch {}
|
|
858
|
+
for (const listener of listeners) { try { listener({ type, session: view(live), ...extra }); } catch {} }
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ── sockets + forwarding ───────────────────────────────────────────────────
|
|
862
|
+
function sendToSockets(live, packet) {
|
|
863
|
+
const raw = JSON.stringify(packet);
|
|
864
|
+
let delivered = 0;
|
|
865
|
+
for (const ws of [...live.sockets]) {
|
|
866
|
+
if (ws.readyState !== 1) { live.sockets.delete(ws); continue; }
|
|
867
|
+
try { ws.send(raw); delivered += 1; } catch { live.sockets.delete(ws); }
|
|
868
|
+
}
|
|
869
|
+
if (!delivered) {
|
|
870
|
+
live.buffer.push(packet);
|
|
871
|
+
if (live.buffer.length > settings.bufferCap) live.buffer.splice(0, live.buffer.length - settings.bufferCap);
|
|
872
|
+
}
|
|
873
|
+
// Observers never count as delivery: the buffer, the replay and idle disposal stay the UI's.
|
|
874
|
+
if (packetObservers.size) {
|
|
875
|
+
const sessionId = live.record.id;
|
|
876
|
+
for (const observer of [...packetObservers]) { try { observer({ sessionId, packet }); } catch {} }
|
|
877
|
+
}
|
|
878
|
+
return delivered;
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* The task a turn's tokens belong to: the turn's own while one runs, else the
|
|
882
|
+
* latest human task (record.taskId, which only a human prompt moves).
|
|
883
|
+
*/
|
|
884
|
+
function currentTask(sessionId) {
|
|
885
|
+
const live = sessions.get(sessionId);
|
|
886
|
+
return live?.turnTaskId || live?.record.taskId || metered('task', () => usage?.currentTask?.(sessionId)?.id) || null;
|
|
887
|
+
}
|
|
888
|
+
/** The session's current task, made when it has none yet. Never throws. */
|
|
889
|
+
function ensureTaskId(sessionId) {
|
|
890
|
+
return metered('task', () => usage?.ensureTask?.(sessionId)?.id) || null;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* What the gauge, the usage endpoint and agent_usage show. `session` is the headline: every
|
|
894
|
+
* task of the session added up from the ledger file, so a follow-up prompt, a re-attach, a
|
|
895
|
+
* reload and a restart all show the same, growing total. `task` is one task's detail.
|
|
896
|
+
*/
|
|
897
|
+
function usageView(sessionId, taskId = 'current') {
|
|
898
|
+
if (!usage) return null;
|
|
899
|
+
const live = sessions.get(sessionId);
|
|
900
|
+
// 'current' is the latest human task, also while a mailbox turn works for an older one.
|
|
901
|
+
const view = usage.taskView(sessionId, taskId === 'current' ? live?.record.taskId || 'current' : taskId);
|
|
902
|
+
// Work in progress anywhere in the session makes its total provisional, whatever task is shown.
|
|
903
|
+
const markSessionLive = () => { if (view.session) { view.session.live = true; view.session.fidelity = 'live'; } };
|
|
904
|
+
if (live?.running) markSessionLive();
|
|
905
|
+
if (!view.task) return view;
|
|
906
|
+
const empty = () => withTotal(zeroTokens());
|
|
907
|
+
view.task.agents = view.task.agents.map((agent) => {
|
|
908
|
+
if (agent.key === 'brain') return { ...agent, title: 'Brain', state: live?.running && currentTask(sessionId) === view.task.id ? 'running' : 'idle' };
|
|
909
|
+
if (agent.key === 'judgments') return { ...agent, title: 'Judgments (Jev)', state: 'done' };
|
|
910
|
+
// peek: the few fields this needs, without the full view and the memory lookup get() does (a dispatcher without it: get).
|
|
911
|
+
let run = null;
|
|
912
|
+
try { run = typeof dispatcher?.peek === 'function' ? dispatcher.peek(agent.runId) : dispatcher?.get?.(agent.runId); } catch {}
|
|
913
|
+
// A run the dispatcher dropped has ended: its tokens stay, under a short name.
|
|
914
|
+
if (!run) return { ...agent, title: `Run ${String(agent.runId || agent.key).slice(0, 8)}`, state: 'done' };
|
|
915
|
+
const state = run.state || 'idle';
|
|
916
|
+
const displayState = state === 'completed' ? 'done' : state === 'starting' || state === 'awaiting_route' ? 'queued' : state === 'awaiting_permission' ? 'running' : state === 'interrupted' ? 'stopped' : state;
|
|
917
|
+
return { ...agent, title: run.title || agent.runId, state: ['queued', 'running', 'idle', 'done', 'failed', 'stopped'].includes(displayState) ? displayState : 'idle' };
|
|
918
|
+
});
|
|
919
|
+
if (live?.running && currentTask(sessionId) === view.task.id && !view.task.agents.some((agent) => agent.key === 'brain')) {
|
|
920
|
+
view.task.agents.unshift({ key: 'brain', scope: 'brain', runId: null, provider: live.record.brain?.provider || null, model: live.record.brain?.model || null, tokens: empty(), pending: empty(), subagents: { total: 0 }, fidelity: 'live', partialReason: null, title: 'Brain', state: 'running' });
|
|
921
|
+
}
|
|
922
|
+
let activeRuns = [];
|
|
923
|
+
try { activeRuns = dispatcher?.list?.({ assistantSessionId: sessionId, activeOnly: true }) || []; } catch {}
|
|
924
|
+
for (const run of Array.isArray(activeRuns) ? activeRuns : []) {
|
|
925
|
+
if (!run?.runId || view.task.agents.some((agent) => agent.key === run.runId)) continue;
|
|
926
|
+
// A warm run on a follow-up works for the task of that turn, which may be later than its own.
|
|
927
|
+
const runTaskId = run.turnTaskId || run.taskId || run.turns?.at(-1)?.taskId || currentTask(sessionId);
|
|
928
|
+
if (runTaskId !== view.task.id) continue;
|
|
929
|
+
const state = run.state === 'running' || run.state === 'awaiting_permission' ? 'running' : run.state === 'idle' ? 'idle' : 'queued';
|
|
930
|
+
view.task.agents.push({ key: run.runId, scope: 'run', runId: run.runId, provider: run.provider || null, model: run.model || null, tokens: empty(), pending: empty(), subagents: { total: 0 }, fidelity: 'exact', partialReason: null, title: run.title || run.runId, state });
|
|
931
|
+
}
|
|
932
|
+
if (view.task.agents.some((agent) => agent.state === 'running')) { view.task.live = true; view.task.fidelity = 'live'; }
|
|
933
|
+
if (view.task.live || (Array.isArray(activeRuns) && activeRuns.some((run) => run?.state === 'running' || run?.state === 'awaiting_permission'))) markSessionLive();
|
|
934
|
+
return view;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* A usage packet is a view, not an event: it goes to the open sockets only.
|
|
938
|
+
* Never buffered or replayed (attach sends a fresh view), never shown to the
|
|
939
|
+
* packet observers, and not built at all while no socket is attached.
|
|
940
|
+
*/
|
|
941
|
+
function sendUsage(live) {
|
|
942
|
+
if (!usage || !live || !sessions.has(live.record.id)) return;
|
|
943
|
+
metered('packet', () => {
|
|
944
|
+
const open = [...live.sockets].filter((ws) => ws.readyState === 1);
|
|
945
|
+
if (!open.length) return;
|
|
946
|
+
const raw = JSON.stringify({ type: 'assistant:usage', ...usageView(live.record.id) });
|
|
947
|
+
for (const ws of open) { try { ws.send(raw); } catch {} }
|
|
948
|
+
usageSentAt.set(live.record.id, Date.now());
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* The provider's usage limit is a view too: one state per session, sent to the open sockets
|
|
953
|
+
* when it changes and to a socket when it attaches. Never buffered, replayed, journaled or
|
|
954
|
+
* shown to the packet observers, so a provider that repeats its limit on every response
|
|
955
|
+
* (the Claude SDK's `rate_limit_event`) yields one packet, and the next one only on a change.
|
|
956
|
+
*/
|
|
957
|
+
function sendLimit(live, sockets = live.sockets) {
|
|
958
|
+
// A notice whose reset time has passed is gone, whoever asks.
|
|
959
|
+
if (live.limit?.resetsAt && live.limit.resetsAt <= now()) live.limit = null;
|
|
960
|
+
const raw = JSON.stringify({ type: 'assistant:limit', sessionId: live.record.id, limit: live.limit });
|
|
961
|
+
for (const ws of sockets) { if (ws.readyState === 1) { try { ws.send(raw); } catch {} } }
|
|
962
|
+
}
|
|
963
|
+
/** `next`: a limitNotice() value. True when the session's notice changed (and the sockets heard). */
|
|
964
|
+
function setLimit(live, next) {
|
|
965
|
+
if (next === undefined || limitKey(next) === limitKey(live.limit)) return false;
|
|
966
|
+
live.limit = next ? { ...next, provider: live.brainProvider || live.record.brain?.provider || null, at: iso(now()) } : null;
|
|
967
|
+
sendLimit(live);
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
function scheduleUsage(sessionId) {
|
|
971
|
+
if (!usage || !sessions.has(sessionId)) return;
|
|
972
|
+
if (usageTimers.has(sessionId)) return;
|
|
973
|
+
const wait = Math.max(0, 250 - (Date.now() - (usageSentAt.get(sessionId) || 0)));
|
|
974
|
+
const timer = setTimeout(() => { usageTimers.delete(sessionId); sendUsage(sessions.get(sessionId)); }, wait);
|
|
975
|
+
timer.unref?.();
|
|
976
|
+
usageTimers.set(sessionId, timer);
|
|
977
|
+
}
|
|
978
|
+
/** The meter's state onto the session record. `force`: rows were just settled, so the record is written at once. */
|
|
979
|
+
function saveMeter(live, { force = false } = {}) {
|
|
980
|
+
if (!live.brainMeter || !live.brainMeterProvider) return;
|
|
981
|
+
const state = metered('meter-state', () => live.brainMeter.state());
|
|
982
|
+
if (!state) return;
|
|
983
|
+
live.record.usageState ||= {};
|
|
984
|
+
const key = live.brainMeterProvider === 'claude-code' ? 'claude' : live.brainMeterProvider;
|
|
985
|
+
live.record.usageState[key] = state;
|
|
986
|
+
// A kill between the ledger append and the debounced write would count those rows again.
|
|
987
|
+
persist({ force });
|
|
988
|
+
}
|
|
989
|
+
function startMeter(live, provider) {
|
|
990
|
+
if (!usage) return;
|
|
991
|
+
live.brainMeter = null;
|
|
992
|
+
live.turnTasks.length = 0;
|
|
993
|
+
metered('meter-start', () => {
|
|
994
|
+
const state = live.record.providerSessionId ? live.record.usageState?.[provider === 'claude-code' ? 'claude' : provider] : null;
|
|
995
|
+
if (provider === 'claude-code') {
|
|
996
|
+
const meter = meters['claude-code']({ state });
|
|
997
|
+
if (!state && live.record.providerSessionId && live.record.turns) meter.expectHistory();
|
|
998
|
+
live.brainMeter = meter;
|
|
999
|
+
} else if (provider === 'opencode') {
|
|
1000
|
+
const meter = meters.opencode({ state });
|
|
1001
|
+
meter.setRoot(live.record.providerSessionId || null);
|
|
1002
|
+
live.brainMeter = meter;
|
|
1003
|
+
} // Codex needs the thread id first.
|
|
1004
|
+
});
|
|
1005
|
+
live.brainMeterProvider = provider;
|
|
1006
|
+
saveMeter(live);
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* The brain resumed into another provider session. Claude's counters there
|
|
1010
|
+
* are not the ones this meter differenced: it starts over and treats the
|
|
1011
|
+
* first result as one with history (partial, never a huge false delta).
|
|
1012
|
+
* OpenCode keeps its per-session totals and takes the new session as its root.
|
|
1013
|
+
*/
|
|
1014
|
+
function resetMeterIdentity(live, providerSessionId) {
|
|
1015
|
+
metered('resume', () => {
|
|
1016
|
+
if (live.brainMeterProvider === 'claude-code') { live.brainMeter?.reset(); live.brainMeter?.expectHistory(); }
|
|
1017
|
+
else if (live.brainMeterProvider === 'opencode') live.brainMeter?.setRoot(providerSessionId);
|
|
1018
|
+
// No brain yet: the saved Claude baseline must not be restored for the new session.
|
|
1019
|
+
if (!live.brainMeter && live.record.usageState) delete live.record.usageState.claude;
|
|
1020
|
+
usage?.clearPending?.(live.record.id, 'brain');
|
|
1021
|
+
});
|
|
1022
|
+
saveMeter(live);
|
|
1023
|
+
}
|
|
1024
|
+
/** The Codex brain's new rollout usage, settled. Never throws. */
|
|
1025
|
+
function pollCodex(live, { force = false } = {}) {
|
|
1026
|
+
if (!usage || live.brainProvider !== 'codex') return;
|
|
1027
|
+
metered('codex-poll', () => {
|
|
1028
|
+
const threadId = live.brain?.identity?.()?.providerThreadId || live.record.providerThreadId;
|
|
1029
|
+
if (!threadId) return;
|
|
1030
|
+
if (!live.brainMeter || live.brainMeterProvider !== 'codex' || live.codexMeterThreadId !== threadId) {
|
|
1031
|
+
const account = live.record.brain?.accountId || 'default';
|
|
1032
|
+
let home = null;
|
|
1033
|
+
try { home = codexHomeForAccount?.(account) || null; } catch {}
|
|
1034
|
+
if (!home) {
|
|
1035
|
+
const registry = readJson(resolve(dataDir, 'codex-accounts.json'), {});
|
|
1036
|
+
home = registry.accounts?.find((row) => row.id === account)?.home || null;
|
|
1037
|
+
}
|
|
1038
|
+
const state = live.record.usageState?.codexThreadId === threadId ? live.record.usageState?.codex : null;
|
|
1039
|
+
const sinceMs = !state && live.record.turns > 1 ? live.turnStartedMs || now() : Date.parse(live.record.createdAt) || 0;
|
|
1040
|
+
// A response whose prompt is over its model's long-context size is priced whole at the long rates.
|
|
1041
|
+
const longPromptTokens = (model) => { try { return pricing?.codexPrice?.(model || live.record.brain?.model)?.long?.size || 0; } catch { return 0; } };
|
|
1042
|
+
live.brainMeter = meters.codex({ codexHome: home || undefined, threadId, sinceMs, state, longPromptTokens });
|
|
1043
|
+
live.brainMeterProvider = 'codex';
|
|
1044
|
+
live.codexMeterThreadId = threadId;
|
|
1045
|
+
live.record.usageState ||= {};
|
|
1046
|
+
live.record.usageState.codexThreadId = threadId;
|
|
1047
|
+
}
|
|
1048
|
+
if (!force && Date.now() - (live.codexPolledAt || 0) < 2000) return;
|
|
1049
|
+
live.codexPolledAt = Date.now();
|
|
1050
|
+
const rows = live.brainMeter.poll();
|
|
1051
|
+
const taskId = rows.length ? currentTask(live.record.id) || ensureTaskId(live.record.id) : null;
|
|
1052
|
+
// The long-context tier follows the brain's configured id ("<id>[extended]"); a rollout names the plain model.
|
|
1053
|
+
const info = brainCapabilities(live.record) || {};
|
|
1054
|
+
const extended = /\[extended\]$/.test(String(live.record.brain?.model || info.id || ''));
|
|
1055
|
+
for (const row of rows) {
|
|
1056
|
+
const model = row.model || live.record.brain?.model || null;
|
|
1057
|
+
const price = (() => { try { return pricing?.codexPrice?.(model) || codexBrainPrice(live); } catch { return codexBrainPrice(live); } })();
|
|
1058
|
+
// row.usage is the provider's own keys (input includes cached): what the list price is per.
|
|
1059
|
+
// row.long: these responses' prompts were over the long-context size (priced whole at the long rates).
|
|
1060
|
+
const costUsd = price ? codexUsageCostUsd(row.usage, price, { contextWindow: extended ? info.contextWindow || null : null, long: row.long ?? null }) : null;
|
|
1061
|
+
usage.settle({ sessionId: live.record.id, taskId, scope: 'brain', runId: null, turn: live.record.turns, provider: 'codex', model, part: row.part, tokens: row.tokens, costUsd, costBasis: costUsd === null ? 'unpriced' : 'estimated', fidelity: row.fidelity, reason: row.reason, source: row.source });
|
|
1062
|
+
}
|
|
1063
|
+
saveMeter(live, { force: rows.length > 0 });
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
function openCodeEvent(live, type, event) {
|
|
1067
|
+
if (!usage || live.brainMeterProvider !== 'opencode' || !live.brainMeter) return;
|
|
1068
|
+
metered('opencode-event', () => {
|
|
1069
|
+
if (!live.brainMeter.onEvent(type, event)) return;
|
|
1070
|
+
usage.setPending(live.record.id, 'brain', { taskId: currentTask(live.record.id), scope: 'brain', provider: 'opencode', model: live.record.brain?.model, tokens: live.brainMeter.live().tokens });
|
|
1071
|
+
saveMeter(live);
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
/** An OpenCode turn's tokens, settled once its stored messages were read back. Never rejects. */
|
|
1075
|
+
async function finishOpenCode(live, meter, taskId, turn) {
|
|
1076
|
+
let reconciled = false;
|
|
1077
|
+
// A serve that never answers must not hold the next turn: past the limit the turn is settled as
|
|
1078
|
+
// not reconciled. What a late answer still adds to the meter is above the mark taken below, so
|
|
1079
|
+
// the next finish books it once.
|
|
1080
|
+
let timer = null;
|
|
1081
|
+
const expired = new Promise((done) => { timer = setTimeout(() => done(false), settings.openCodeReconcileMs); timer.unref?.(); });
|
|
1082
|
+
try { reconciled = await Promise.race([expired, Promise.resolve().then(() => live.brain?.reconcileUsage?.(meter)).then((ok) => ok === true)]); } catch {}
|
|
1083
|
+
clearTimeout(timer);
|
|
1084
|
+
metered('opencode-finish', () => {
|
|
1085
|
+
const delta = meter.markTurn();
|
|
1086
|
+
const parts = ['main', 'subagents'].filter((part) => totalTokens(delta[part]) > 0);
|
|
1087
|
+
if (!parts.length && delta.costUsd > 0) parts.push('main');
|
|
1088
|
+
// The turn's cost is one number: it rides on the first row, null on the other.
|
|
1089
|
+
parts.forEach((part, index) => {
|
|
1090
|
+
usage.settle({ sessionId: live.record.id, taskId, scope: 'brain', runId: null, turn, provider: 'opencode', model: live.record.brain?.model, part, tokens: delta[part], costUsd: index === 0 ? delta.costUsd : null, costBasis: 'reported', fidelity: reconciled ? 'exact' : 'partial', reason: reconciled ? null : 'opencode-not-reconciled', source: 'opencode-messages' });
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
metered('opencode-finish', () => usage.clearPending(live.record.id, 'brain'));
|
|
1094
|
+
if (live.brainMeter === meter) saveMeter(live, { force: true });
|
|
1095
|
+
scheduleUsage(live.record.id);
|
|
1096
|
+
}
|
|
1097
|
+
/** One finish at a time: a second turn end waits for the first, and so does the next turn (runTurn). */
|
|
1098
|
+
function queueOpenCodeFinish(live, taskId, turn) {
|
|
1099
|
+
const meter = live.brainMeter;
|
|
1100
|
+
if (!usage || !meter || live.brainMeterProvider !== 'opencode') return;
|
|
1101
|
+
const chain = (live.openCodeFinalizing || Promise.resolve())
|
|
1102
|
+
.then(() => finishOpenCode(live, meter, taskId, turn))
|
|
1103
|
+
.catch((error) => usageError('opencode-finish', error))
|
|
1104
|
+
.then(() => { if (live.openCodeFinalizing === chain) live.openCodeFinalizing = null; });
|
|
1105
|
+
live.openCodeFinalizing = chain;
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* One Claude row into the ledger. The CLI reports what a counted call cost. An estimate row (a
|
|
1109
|
+
* call cut off by an interrupt, which the CLI never counts) has no reported cost: it is priced
|
|
1110
|
+
* at list price and joins the brain's spend, so the caps see it too.
|
|
1111
|
+
*/
|
|
1112
|
+
function settleClaudeRow(live, taskId, row, settled = null) {
|
|
1113
|
+
const id = live.record.id;
|
|
1114
|
+
const estimate = row.source === CLAUDE_ESTIMATE_SOURCE;
|
|
1115
|
+
const costUsd = estimate ? claudeTokensCostUsd(row.model, row.tokens) : row.costUsd;
|
|
1116
|
+
usage.settle({
|
|
1117
|
+
sessionId: id, taskId, scope: 'brain', runId: null, turn: live.record.turns, provider: 'claude-code', model: row.model, part: row.part, tokens: row.tokens,
|
|
1118
|
+
costUsd, costBasis: estimate ? (costUsd === null ? 'unpriced' : 'estimated') : 'reported',
|
|
1119
|
+
fidelity: row.fidelity || settled?.fidelity, reason: row.reason ?? settled?.reason ?? null, source: row.source || settled?.source,
|
|
1120
|
+
});
|
|
1121
|
+
if (estimate && costUsd > 0) {
|
|
1122
|
+
live.record.costUsd = Number(((Number(live.record.costUsd) || 0) + costUsd).toFixed(4));
|
|
1123
|
+
try { dispatcher?.checkSession?.(id); } catch {}
|
|
1124
|
+
sendCost(live);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/** The Claude brain's own SDK messages: a result settles the task at the front of the FIFO, the rest is pending. */
|
|
1128
|
+
function claudeUsageEvent(live, event) {
|
|
1129
|
+
const id = live.record.id;
|
|
1130
|
+
metered('claude-meter', () => {
|
|
1131
|
+
const { settled, pendingChanged } = live.brainMeter.onEvent(event);
|
|
1132
|
+
const taskId = live.turnTasks[0] || currentTask(id) || ensureTaskId(id);
|
|
1133
|
+
if (settled) for (const row of settled.rows) settleClaudeRow(live, taskId, row, settled);
|
|
1134
|
+
if (pendingChanged) usage.setPending(id, 'brain', { taskId, scope: 'brain', provider: 'claude-code', model: live.record.brain?.model, tokens: live.brainMeter.pending().tokens });
|
|
1135
|
+
if (settled) saveMeter(live, { force: true });
|
|
1136
|
+
});
|
|
1137
|
+
// That turn has its result: the next queued prompt's task is in flight now.
|
|
1138
|
+
if (event.type === 'result') live.turnTasks.shift();
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* The Claude brain's turn ended without a result that counts what the stream showed (an
|
|
1142
|
+
* interrupt, a dead process, the brain going away): those calls were spent, so the live
|
|
1143
|
+
* estimate is booked instead of dropped. A result that still arrives takes them off its delta.
|
|
1144
|
+
*/
|
|
1145
|
+
function settleClaudeEstimate(live, reason) {
|
|
1146
|
+
if (!usage || live.brainMeterProvider !== 'claude-code' || !live.brainMeter) return;
|
|
1147
|
+
const id = live.record.id;
|
|
1148
|
+
metered('claude-meter', () => {
|
|
1149
|
+
const rows = typeof live.brainMeter.settlePending === 'function' ? live.brainMeter.settlePending({ reason }) : (live.brainMeter.clearPending?.(), []);
|
|
1150
|
+
const taskId = rows.length ? live.turnTasks[0] || currentTask(id) || ensureTaskId(id) : null;
|
|
1151
|
+
for (const row of rows) settleClaudeRow(live, taskId, row);
|
|
1152
|
+
usage.clearPending?.(id, 'brain');
|
|
1153
|
+
if (rows.length) saveMeter(live, { force: true });
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
function onBrainPacket(live, packet) {
|
|
1157
|
+
if (!packet || typeof packet !== 'object') return;
|
|
1158
|
+
const id = live.record.id;
|
|
1159
|
+
if (packet.type === 'event') {
|
|
1160
|
+
const event = packet.event || {};
|
|
1161
|
+
// The usage limit comes with every response: the session keeps its state and says so when
|
|
1162
|
+
// it changes (assistant:limit). The event itself goes to no socket, buffer or observer.
|
|
1163
|
+
if (event.type === 'rate_limit_event') { setLimit(live, limitNotice(event.rate_limit_info, now())); return; }
|
|
1164
|
+
if (usage && live.brainProvider === 'claude-code' && live.brainMeterProvider === 'claude-code' && live.brainMeter && ['assistant', 'stream_event', 'result'].includes(event.type) && !event.brain) claudeUsageEvent(live, event);
|
|
1165
|
+
if (event.type === 'assistant' && live.gate && gateIsReactive(live)) reactiveGate(live, event);
|
|
1166
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
1167
|
+
if (event.session_id && live.brainProvider === 'claude-code') live.record.providerSessionId = event.session_id;
|
|
1168
|
+
if (live.brainProvider === 'claude-code') applyRouteAvailability(live, claudeRouteAvailability(event));
|
|
1169
|
+
touch(live);
|
|
1170
|
+
}
|
|
1171
|
+
// OpenCode (after start and before each turn) and Codex (MCP startup) report their servers.
|
|
1172
|
+
if (event.type === 'system' && event.subtype === 'mcp_status' && live.brainProvider !== 'claude-code') applyRouteAvailability(live, mcpStatusRouteAvailability(event.servers));
|
|
1173
|
+
if (event.type === 'result') {
|
|
1174
|
+
const cost = Number(event.total_cost_usd);
|
|
1175
|
+
if (Number.isFinite(cost) && cost > 0) {
|
|
1176
|
+
// Brains report a running total; a smaller value means the provider
|
|
1177
|
+
// session restarted (resume, brain switch), so it counts in full.
|
|
1178
|
+
const last = Number(live.record.lastReportedCost) || 0;
|
|
1179
|
+
const delta = cost >= last ? cost - last : cost;
|
|
1180
|
+
live.record.costUsd = Number(((Number(live.record.costUsd) || 0) + delta).toFixed(4));
|
|
1181
|
+
live.record.lastReportedCost = cost;
|
|
1182
|
+
live.record.costBasis = event.cost_basis || 'reported';
|
|
1183
|
+
// The brain's spend counts toward the session caps: warn, or stop the session's runs.
|
|
1184
|
+
try { dispatcher?.checkSession?.(id); } catch {}
|
|
1185
|
+
sendCost(live);
|
|
1186
|
+
}
|
|
1187
|
+
// The Claude CLI's own cap stopped the brain: its process cannot run another turn.
|
|
1188
|
+
if (event.subtype === 'error_max_budget_usd' && live.brainProvider === 'claude-code') live.brainCapHit = true;
|
|
1189
|
+
if (live.planTurn) live.planText = planTurnText(event);
|
|
1190
|
+
touch(live);
|
|
1191
|
+
}
|
|
1192
|
+
if (TRANSCRIPT_EVENT_TYPES.has(event.type) && !(event.type === 'system' && event.subtype !== 'init')) appendTranscript(id, { at: iso(now()), packet });
|
|
1193
|
+
}
|
|
1194
|
+
// A remote session's computer tool asking for permission: the turn's one request, never a generic card.
|
|
1195
|
+
if (packet.type === 'control_request' && computerControlRequest(live, packet)) return;
|
|
1196
|
+
if (packet.type === 'control_cancelled' && live.computerAsk) {
|
|
1197
|
+
const rid = String(packet.request_id);
|
|
1198
|
+
const ask = live.computerAsk;
|
|
1199
|
+
// A call that waited on the open request went away: it was never a card of its own.
|
|
1200
|
+
if (ask.followers.includes(rid)) { ask.followers = ask.followers.filter((id) => id !== rid); return; }
|
|
1201
|
+
// The brain withdrew the request itself (its turn was interrupted): nothing was approved.
|
|
1202
|
+
if (ask.requestId === rid) {
|
|
1203
|
+
live.computerAsk = null;
|
|
1204
|
+
for (const id of ask.followers) { try { live.brain?.respondControl?.(id, { behavior: 'deny', message: COMPUTER_CLOSED_NOTE }); } catch {} }
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (packet.type === 'control_request') {
|
|
1208
|
+
live.pendingControls.set(String(packet.request_id), packet);
|
|
1209
|
+
live.record.status = 'awaiting';
|
|
1210
|
+
armControlTimeout(live, packet);
|
|
1211
|
+
// A question the brain asks with its own tool joins the request's brief.
|
|
1212
|
+
try { clarifier?.onNativeQuestion?.(id, { requestId: String(packet.request_id), request: packet.request || {}, cycle: live.promptCycle, prompt: live.lastPrompt }); } catch {}
|
|
1213
|
+
}
|
|
1214
|
+
if (packet.type === 'control_cancelled') {
|
|
1215
|
+
live.pendingControls.delete(String(packet.request_id));
|
|
1216
|
+
clearControlTimer(live, String(packet.request_id));
|
|
1217
|
+
try { clarifier?.onNativeSettled?.(id, String(packet.request_id), 'cancelled'); } catch {}
|
|
1218
|
+
}
|
|
1219
|
+
const modeEvent = packet.type === 'mode_changed' ? packet : (packet.type === 'event' && packet.event?.type === 'mode_changed' ? packet.event : null);
|
|
1220
|
+
if (modeEvent && (modeEvent.mode || typeof modeEvent.planMode === 'boolean')) {
|
|
1221
|
+
const wasPlanning = isPlanningLive(live);
|
|
1222
|
+
const before = live.record.brain;
|
|
1223
|
+
const reported = { ...live.record.brain, ...brainModeUpdate(live.record.brain, modeEvent.mode, modeEvent.planMode, { fromBrain: true }) };
|
|
1224
|
+
// A remote session's level holds here too: a brain that left what it allows is put back.
|
|
1225
|
+
live.record.brain = clampModeWrite(live, reported, { fromBrain: true });
|
|
1226
|
+
if (live.record.brain.permissionMode !== reported.permissionMode || (live.record.brain.planMode === true) !== (reported.planMode === true)) {
|
|
1227
|
+
Promise.resolve(live.brain?.setPermissionMode?.(live.record.brain.permissionMode, { planMode: live.record.brain.planMode === true })).catch(() => {});
|
|
1228
|
+
}
|
|
1229
|
+
// The plan left mid-turn without passing the approval (answerControl clears
|
|
1230
|
+
// it there): the plan turn skipped routing, so the work it continues into
|
|
1231
|
+
// is routed first, like any other.
|
|
1232
|
+
if (wasPlanning && !isPlanningLive(live)) live.gate?.startTurn({ carry: true });
|
|
1233
|
+
touch(live);
|
|
1234
|
+
// Every other open host follows (this socket already has the packet).
|
|
1235
|
+
if (before.permissionMode !== live.record.brain.permissionMode || (before.planMode === true) !== (live.record.brain.planMode === true)) broadcast('assistant:session-updated', live);
|
|
1236
|
+
}
|
|
1237
|
+
// The CLI began a turn on its own (a background agent finished, a wakeup fired).
|
|
1238
|
+
if (packet.type === 'turn_started' && !live.running) {
|
|
1239
|
+
// A turn the CLI started itself is a new turn for the route gate too.
|
|
1240
|
+
live.gate?.startTurn();
|
|
1241
|
+
live.running = true;
|
|
1242
|
+
beginComputerTurn(live);
|
|
1243
|
+
if (usage) {
|
|
1244
|
+
// Its tokens go to the latest human task; its own result takes it off the FIFO again.
|
|
1245
|
+
live.turnTaskId ||= live.record.taskId || ensureTaskId(id);
|
|
1246
|
+
if (live.brainProvider === 'claude-code' && live.turnTaskId && !live.turnTasks.length) live.turnTasks.push(live.turnTaskId);
|
|
1247
|
+
scheduleUsage(id);
|
|
1248
|
+
}
|
|
1249
|
+
live.record.status = 'running';
|
|
1250
|
+
live.record.lastActiveAt = iso(now());
|
|
1251
|
+
if (live.disposeTimer) { clearTimeout(live.disposeTimer); live.disposeTimer = null; }
|
|
1252
|
+
touch(live);
|
|
1253
|
+
}
|
|
1254
|
+
let plan = '';
|
|
1255
|
+
let planGen = 0;
|
|
1256
|
+
if (packet.type === 'done' || packet.type === 'aborted' || packet.type === 'error') {
|
|
1257
|
+
if (packet.type !== 'error' || !live.brain?.isBusy?.()) {
|
|
1258
|
+
// The turn's tokens (none of this throws): Codex is polled once, here.
|
|
1259
|
+
if (live.brainProvider === 'codex') pollCodex(live, { force: true });
|
|
1260
|
+
if (live.brainProvider === 'opencode') queueOpenCodeFinish(live, currentTask(id), live.record.turns);
|
|
1261
|
+
// Claude: a turn that ended normally has its result (a background sub-agent still streaming is
|
|
1262
|
+
// settled by the next one). An aborted or dead turn may never get one: its estimate is booked.
|
|
1263
|
+
if (live.brainProvider === 'claude-code') {
|
|
1264
|
+
if (packet.type === 'done') metered('claude-meter', () => { if (live.brainMeter?.clearPending?.()) usage?.clearPending?.(id, 'brain'); });
|
|
1265
|
+
else settleClaudeEstimate(live, 'interrupted');
|
|
1266
|
+
}
|
|
1267
|
+
live.running = false;
|
|
1268
|
+
// A remote session's computer use ends with its turn (the approval and the live grant); a
|
|
1269
|
+
// prompt the CLI still holds starts its own turn, unapproved.
|
|
1270
|
+
endComputerTurn(live, packet.type === 'aborted' ? 'stopped' : 'turn_ended');
|
|
1271
|
+
if (live.brainProvider === 'claude-code' && live.brain?.isBusy?.()) beginComputerTurn(live);
|
|
1272
|
+
// A Claude prompt sent mid-turn is still queued in the CLI: its task is the turn in flight now.
|
|
1273
|
+
if (live.brainProvider !== 'claude-code' || !live.brain?.isBusy?.()) live.turnTasks.length = 0;
|
|
1274
|
+
live.turnTaskId = live.turnTasks[0] || null;
|
|
1275
|
+
live.record.status = 'idle';
|
|
1276
|
+
// A plan the desktop approved ran in this turn; the next one is read-only again.
|
|
1277
|
+
live.planApprovedByDesktop = false;
|
|
1278
|
+
// A Codex / OpenCode plan turn that ended with its plan: the card follows the `done`.
|
|
1279
|
+
if (packet.type === 'done' && !(Number(packet.code) > 0) && live.planTurn && isPlanningLive(live)) { plan = live.planText; planGen = live.planTurnGen; }
|
|
1280
|
+
live.planTurn = false;
|
|
1281
|
+
live.planText = '';
|
|
1282
|
+
// The brain's cards close with its turn. The runtime's plan card, open while
|
|
1283
|
+
// a compact, a Stop or an idle error ends one, stays answerable and timed.
|
|
1284
|
+
const keep = live.planCard?.requestId || null;
|
|
1285
|
+
settleNativeQuestions(live, 'turn_ended', keep);
|
|
1286
|
+
for (const rid of [...live.pendingControls.keys()]) if (rid !== keep) live.pendingControls.delete(rid);
|
|
1287
|
+
clearControlTimers(live, keep);
|
|
1288
|
+
if (keep) live.record.status = 'awaiting';
|
|
1289
|
+
touch(live);
|
|
1290
|
+
scheduleUsage(id);
|
|
1291
|
+
onTurnFinished(live);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
sendToSockets(live, packet);
|
|
1295
|
+
if (plan) offerPlanCard(live, plan, planGen);
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Codex and OpenCode end a plan turn with the plan instead of asking for
|
|
1299
|
+
* approval the way Claude's ExitPlanMode does: the runtime asks, with the same
|
|
1300
|
+
* "Plan ready" card (answerControl → answerPlanCard). It is the work
|
|
1301
|
+
* scheduler's hold (the mailbox and continuations wait, like behind a pending
|
|
1302
|
+
* Claude card), is declined after the question timeout, and a new prompt
|
|
1303
|
+
* replaces it. `gen`: the plan turn's generation. A newer prompt, a Stop or
|
|
1304
|
+
* the session closing since then means its plan is not the one to approve:
|
|
1305
|
+
* no card.
|
|
1306
|
+
*/
|
|
1307
|
+
function offerPlanCard(live, plan, gen) {
|
|
1308
|
+
if (gen !== live.gen || live.record.status === 'ended') { log('assistant:plan', `${live.record.id}: no plan card (a newer prompt or a Stop came after the plan turn)`); return; }
|
|
1309
|
+
const requestId = `plan-${randomUUID()}`;
|
|
1310
|
+
const packet = { type: 'control_request', request_id: requestId, request: { subtype: 'can_use_tool', kind: 'plan', tool_name: 'ExitPlanMode', input: { plan } } };
|
|
1311
|
+
holdWork(live, { requestId, plan });
|
|
1312
|
+
live.pendingControls.set(requestId, packet);
|
|
1313
|
+
live.record.status = 'awaiting';
|
|
1314
|
+
armControlTimeout(live, packet);
|
|
1315
|
+
touch(live);
|
|
1316
|
+
sendToSockets(live, packet);
|
|
1317
|
+
}
|
|
1318
|
+
/** A plan card closed unanswered: an answer still on its way is refused (answerControl). */
|
|
1319
|
+
function rememberClosedPlanCard(live, requestId, reason) {
|
|
1320
|
+
live.closedPlanCards.set(requestId, reason);
|
|
1321
|
+
while (live.closedPlanCards.size > 20) live.closedPlanCards.delete(live.closedPlanCards.keys().next().value);
|
|
1322
|
+
}
|
|
1323
|
+
/** The runtime's plan card closes unanswered (a new prompt, a Stop, the brain gone); plan mode stays on. */
|
|
1324
|
+
function closePlanCard(live, reason) {
|
|
1325
|
+
const card = live.planCard;
|
|
1326
|
+
if (!card || !releaseHold(live, card.requestId)) return;
|
|
1327
|
+
live.pendingControls.delete(card.requestId);
|
|
1328
|
+
clearControlTimer(live, card.requestId);
|
|
1329
|
+
rememberClosedPlanCard(live, card.requestId, reason);
|
|
1330
|
+
if (live.record.status === 'awaiting' && !live.pendingControls.size) live.record.status = live.running ? 'running' : 'idle';
|
|
1331
|
+
sendToSockets(live, { type: 'control_cancelled', request_id: card.requestId, reason });
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* The user approved the plan: plan mode ends here, on the approval itself —
|
|
1335
|
+
* the record (persisted, so a reload and History show it off), every open host
|
|
1336
|
+
* (broadcast), the plan gate and policy, and the dispatch refusal — not when
|
|
1337
|
+
* the brain reports it later. "Auto-accept edits" on the card sets the approval
|
|
1338
|
+
* mode the work runs in. A remote session's level can keep plan mode on
|
|
1339
|
+
* (read-only). Leaving plan mid-turn resets the route gate: the approved work
|
|
1340
|
+
* is routed first, like any other.
|
|
1341
|
+
*/
|
|
1342
|
+
function approvePlan(live, decision = null) {
|
|
1343
|
+
const wasPlanning = isPlanningLive(live);
|
|
1344
|
+
const mode = decision === 'acceptEdits' || decision === 'auto' ? (live.record.brain.provider === 'claude-code' ? 'acceptEdits' : 'auto') : null;
|
|
1345
|
+
if (!wasPlanning && !mode) return false;
|
|
1346
|
+
// A plain approval changes the plan flag only (a remote session keeps the approval mode it asked for).
|
|
1347
|
+
live.record.brain = clampModeWrite(live, { ...live.record.brain, ...(mode ? { permissionMode: mode } : {}), planMode: false }, { fromBrain: !mode });
|
|
1348
|
+
touch(live);
|
|
1349
|
+
if (isPlanningLive(live)) return false;
|
|
1350
|
+
if (wasPlanning) live.gate?.startTurn({ carry: true });
|
|
1351
|
+
log('assistant:plan', `${live.record.id}: plan approved; plan mode off (${live.record.brain.permissionMode})`);
|
|
1352
|
+
const status = { type: 'event', event: { type: 'system', subtype: 'status', text: 'Plan approved: plan mode is off.' } };
|
|
1353
|
+
sendToSockets(live, status);
|
|
1354
|
+
appendTranscript(live.record.id, { at: iso(now()), packet: status });
|
|
1355
|
+
sendToSockets(live, { type: 'assistant:session', session: view(live), reason: 'plan_approved' });
|
|
1356
|
+
broadcast('assistant:session-updated', live);
|
|
1357
|
+
return true;
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* The runtime's own plan card answered (its hold released). Approved: plan
|
|
1361
|
+
* mode off, then the implementation turn is queued as the scheduler's 'plan'
|
|
1362
|
+
* work: the next turn. "Keep planning" with a note: the brain revises the plan
|
|
1363
|
+
* in a 'plan' turn too (plan mode stays on). Without one nothing runs.
|
|
1364
|
+
*/
|
|
1365
|
+
function answerPlanCard(live, card, response = {}, { superseded = false } = {}) {
|
|
1366
|
+
releaseHold(live, card.requestId);
|
|
1367
|
+
if (live.record.status === 'awaiting' && !live.pendingControls.size) live.record.status = live.running ? 'running' : 'idle';
|
|
1368
|
+
if (response.behavior === 'allow') {
|
|
1369
|
+
approvePlan(live, response.planDecision || null);
|
|
1370
|
+
if (!isPlanningLive(live)) queueWork(live, { kind: 'plan', options: { text: `[SynaBun] The user approved your plan and plan mode is off. Carry it out now, from where the conversation left off.${router ? ' Route the work first, like any task.' : ''}`, permissionMode: live.record.brain.permissionMode } });
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
const note = String(response.message || '').trim();
|
|
1374
|
+
// `superseded`: the card closed because the user wrote something else (a remote channel); their message is the feedback.
|
|
1375
|
+
if (note && note !== KEEP_PLANNING_DEFAULT && superseded !== true) {
|
|
1376
|
+
queueWork(live, { kind: 'plan', revise: true, options: { text: `[SynaBun] The user did not approve the plan yet. Revise it with their feedback (plan mode stays on):\n\n${note}`, offerPlan: true } });
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
const status = { type: 'event', event: { type: 'system', subtype: 'status', text: 'Plan mode stays on: tell the assistant what to change.' } };
|
|
1380
|
+
sendToSockets(live, status);
|
|
1381
|
+
appendTranscript(live.record.id, { at: iso(now()), packet: status });
|
|
1382
|
+
// The panel went "working" when it answered: nothing runs.
|
|
1383
|
+
if (!sessionBusy(live)) sendToSockets(live, { type: 'reattach_result', ok: true, sessionId: live.record.id, running: false });
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Approval and question cards are bounded: unanswered after the timeout, the
|
|
1387
|
+
* brain gets a decline (Codex continues with "declined"; a Claude tool is
|
|
1388
|
+
* denied) and the card closes as cancelled. The 2026-09-27 Codex escalation
|
|
1389
|
+
* card held its turn — and every worker result behind it — for 34 minutes.
|
|
1390
|
+
*/
|
|
1391
|
+
function controlTimeoutMs(request = {}) {
|
|
1392
|
+
const tool = String(request.tool_name || request.toolName || '');
|
|
1393
|
+
const question = request.kind === 'question' || request.kind === 'elicitation' || tool === 'AskUserQuestion' || tool === 'ExitPlanMode';
|
|
1394
|
+
return question ? settings.questionTimeoutMs : settings.approvalTimeoutMs;
|
|
1395
|
+
}
|
|
1396
|
+
function armControlTimeout(live, packet) {
|
|
1397
|
+
const id = String(packet.request_id);
|
|
1398
|
+
clearControlTimer(live, id);
|
|
1399
|
+
const ms = controlTimeoutMs(packet.request || {});
|
|
1400
|
+
if (!(ms > 0)) return;
|
|
1401
|
+
const timer = setTimeout(() => expireControl(live, id, packet, ms), ms);
|
|
1402
|
+
timer.unref?.();
|
|
1403
|
+
live.controlTimers.set(id, timer);
|
|
1404
|
+
}
|
|
1405
|
+
function clearControlTimer(live, id) {
|
|
1406
|
+
const timer = live.controlTimers.get(id);
|
|
1407
|
+
if (timer) { clearTimeout(timer); live.controlTimers.delete(id); }
|
|
1408
|
+
}
|
|
1409
|
+
/** `keep`: a request id whose timer stays (the open plan card). */
|
|
1410
|
+
function clearControlTimers(live, keep = null) {
|
|
1411
|
+
for (const [id, timer] of live.controlTimers) {
|
|
1412
|
+
if (id === keep) continue;
|
|
1413
|
+
clearTimeout(timer);
|
|
1414
|
+
live.controlTimers.delete(id);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
/** The brain's open question cards closed unanswered (turn over, brain gone): their brief rounds close too. */
|
|
1418
|
+
function settleNativeQuestions(live, reason, keep = null) {
|
|
1419
|
+
if (!clarifier?.onNativeSettled) return;
|
|
1420
|
+
for (const requestId of live.pendingControls.keys()) {
|
|
1421
|
+
if (requestId === keep) continue;
|
|
1422
|
+
try { clarifier.onNativeSettled(live.record.id, requestId, reason); } catch {}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
function expireControl(live, id, packet, ms) {
|
|
1426
|
+
live.controlTimers.delete(id);
|
|
1427
|
+
// Answered, cancelled, or a new request reusing the id (Codex restarts ids at 0).
|
|
1428
|
+
if (live.pendingControls.get(id) !== packet) return;
|
|
1429
|
+
live.pendingControls.delete(id);
|
|
1430
|
+
try { clarifier?.onNativeSettled?.(live.record.id, id, 'timeout'); } catch {}
|
|
1431
|
+
const minutes = Math.max(1, Math.round(ms / 60_000));
|
|
1432
|
+
const waited = `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
1433
|
+
log('assistant:control-timeout', `${live.record.id}: ${id} (${packet.request?.tool_name || packet.request?.method || 'request'}) declined after ${waited}`);
|
|
1434
|
+
try {
|
|
1435
|
+
const ask = live.computerAsk?.requestId === id ? live.computerAsk : null;
|
|
1436
|
+
// The turn's "control your Mac?" request: unanswered is a no for the rest of the turn.
|
|
1437
|
+
if (ask) settleComputerAsk(live, ask, false, { origin: 'timeout', message: `No decision within ${waited}, so computer use was not allowed for this task. Do not retry it; tell the user what you could not do.` });
|
|
1438
|
+
// The runtime's own plan card has no brain request behind it: plan mode just stays on.
|
|
1439
|
+
else if (releaseHold(live, id)) rememberClosedPlanCard(live, id, 'timeout');
|
|
1440
|
+
else live.brain?.respondControl?.(id, { behavior: 'deny', message: `No decision within ${waited}, so SynaBun declined this request. Continue without it, or tell the user what still needs their approval.` });
|
|
1441
|
+
} catch (error) { log('assistant:control-timeout-error', error?.message || String(error)); }
|
|
1442
|
+
sendToSockets(live, { type: 'control_cancelled', request_id: id, reason: 'timeout' });
|
|
1443
|
+
const status = { type: 'event', event: { type: 'system', subtype: 'status', text: `No decision within ${waited}: the request was declined.` } };
|
|
1444
|
+
sendToSockets(live, status);
|
|
1445
|
+
appendTranscript(live.record.id, { at: iso(now()), packet: status });
|
|
1446
|
+
if (!live.pendingControls.size && live.record.status === 'awaiting') live.record.status = live.running ? 'running' : 'idle';
|
|
1447
|
+
touch(live);
|
|
1448
|
+
}
|
|
1449
|
+
function onTurnFinished(live) {
|
|
1450
|
+
if (live.mailboxTurn) { live.mailboxTurn = false; }
|
|
1451
|
+
// Non-Claude brains have no Stop hook: nudge once per run when obligations remain.
|
|
1452
|
+
if (live.brainProvider !== 'claude-code' && live.obligations.size && !live.rememberedThisTurn) {
|
|
1453
|
+
for (const runId of [...live.obligations]) {
|
|
1454
|
+
// Stored since the run's last event (the worker, or this brain's own remember, which a
|
|
1455
|
+
// Codex/OpenCode brain cannot report): no reminder. dispatcher.get re-reads the run's memory.
|
|
1456
|
+
let stored = false;
|
|
1457
|
+
try { stored = !!dispatcher?.get?.(runId)?.memoryStored; } catch { stored = false; }
|
|
1458
|
+
if (stored) { live.obligations.delete(runId); continue; }
|
|
1459
|
+
if (live.nudgedRuns.has(runId)) continue;
|
|
1460
|
+
live.nudgedRuns.add(runId);
|
|
1461
|
+
enqueueMailbox(live, { kind: 'memory_due', run: dispatcher?.get?.(runId) || { runId }, text: 'No memory was stored for this completed run. Call remember (category + project + related_files + source_ref = run id) before summarizing.' });
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
live.rememberedThisTurn = false;
|
|
1465
|
+
// Reactive route gate (Codex without its hook, OpenCode without its plugin):
|
|
1466
|
+
// the interrupted turn is followed by one system turn that tells the brain to
|
|
1467
|
+
// route first (or, for a browser-policy refusal, why the call was stopped), in
|
|
1468
|
+
// the generation the refusal happened in.
|
|
1469
|
+
if (live.gateNudge) {
|
|
1470
|
+
const nudge = live.gateNudge;
|
|
1471
|
+
live.gateNudge = null;
|
|
1472
|
+
const marker = nudge.browser ? '[SynaBun Browser]' : '[SynaBun Router]';
|
|
1473
|
+
queueWork(live, { kind: 'nudge', gen: nudge.gen, options: { text: `${marker} Your last tool call was stopped before it finished. ${nudge.reason}`, gateCarry: true } });
|
|
1474
|
+
}
|
|
1475
|
+
// What comes next is the scheduler's (a plan card this turn offers holds it first).
|
|
1476
|
+
pumpSoon(live);
|
|
1477
|
+
scheduleDispose(live);
|
|
1478
|
+
}
|
|
1479
|
+
function scheduleDispose(live) {
|
|
1480
|
+
if (live.disposeTimer) clearTimeout(live.disposeTimer);
|
|
1481
|
+
if (live.sockets.size || live.running) return;
|
|
1482
|
+
live.disposeTimer = setTimeout(() => {
|
|
1483
|
+
live.disposeTimer = null;
|
|
1484
|
+
// Pending background work re-arms this timer through the `done` of the turn
|
|
1485
|
+
// the CLI starts when that work reports back.
|
|
1486
|
+
if (live.sockets.size || sessionBusy(live) || live.brain?.hasPendingWork?.()) return;
|
|
1487
|
+
disposeBrain(live, 'idle').catch(() => {});
|
|
1488
|
+
}, settings.idleDisposeMs);
|
|
1489
|
+
live.disposeTimer.unref?.();
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// ── work scheduler ─────────────────────────────────────────────────────────
|
|
1493
|
+
// One queue per session for the work the runtime starts on its own. Each kind
|
|
1494
|
+
// has one slot (a newer entry replaces a queued one). Work runs one at a time,
|
|
1495
|
+
// only while the session is free, in this order: the turn an answered plan
|
|
1496
|
+
// card started ('plan': the approved work, or a revision), /compact, the route
|
|
1497
|
+
// gate's nudge, a "do it here" continuation, then the mailbox. The open plan
|
|
1498
|
+
// card is the one hold: the last three wait for its answer. A compact holds
|
|
1499
|
+
// everything until it settles.
|
|
1500
|
+
//
|
|
1501
|
+
// Cancellation is by generation. A user Stop, closing the session and every
|
|
1502
|
+
// human prompt start a new one (newGeneration). Each entry, and each route
|
|
1503
|
+
// card's continuation still on its way from the router, carries the
|
|
1504
|
+
// generation it was created in and is dropped once that is stale: when it
|
|
1505
|
+
// would start, after every await on its way to the brain (runTurn), and when a
|
|
1506
|
+
// late callback arrives (routerContinue). The mailbox belongs to no turn: it is
|
|
1507
|
+
// never stale, and a Stop lets it go on.
|
|
1508
|
+
const WORK_ORDER = ['plan', 'compact', 'nudge', 'continuation'];
|
|
1509
|
+
const HELD_BY_PLAN_CARD = new Set(['nudge', 'continuation']);
|
|
1510
|
+
|
|
1511
|
+
function workCurrent(live, entry) {
|
|
1512
|
+
return !!entry && (entry.gen === null || entry.gen === live.gen) && sessions.get(live.record.id) === live && live.record.status !== 'ended';
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* The entry that holds the session now. One a Stop or the session closing
|
|
1516
|
+
* made stale holds nothing: a turn on its way drops itself at its next check
|
|
1517
|
+
* (its runQuery still counts as busy until then), and a compact call that
|
|
1518
|
+
* never returns (a disposed OpenCode serve) cannot block the session for good.
|
|
1519
|
+
*/
|
|
1520
|
+
function activeWork(live) {
|
|
1521
|
+
return live.workActive && workCurrent(live, live.workActive) ? live.workActive : null;
|
|
1522
|
+
}
|
|
1523
|
+
/** `entry`: { kind, gen?, options? } (`gen` defaults to the current generation). */
|
|
1524
|
+
function queueWork(live, entry) {
|
|
1525
|
+
live.work.set(entry.kind, { ...entry, gen: entry.gen === undefined ? live.gen : entry.gen });
|
|
1526
|
+
pumpSoon(live);
|
|
1527
|
+
}
|
|
1528
|
+
function dropWork(live, ...kinds) { for (const kind of kinds) live.work.delete(kind); }
|
|
1529
|
+
/**
|
|
1530
|
+
* A human prompt ('superseded'), a user Stop ('stopped') or the session
|
|
1531
|
+
* closing: the queued work goes, a turn still on its way to the brain is
|
|
1532
|
+
* dropped at its next check, a callback for the old generation is refused,
|
|
1533
|
+
* and the plan card closes (an answer still on its way gets PLAN_CARD_CLOSED).
|
|
1534
|
+
*/
|
|
1535
|
+
function newGeneration(live, reason) {
|
|
1536
|
+
live.gen += 1;
|
|
1537
|
+
live.work.clear();
|
|
1538
|
+
closePlanCard(live, reason);
|
|
1539
|
+
// A newer prompt, a Stop or the session closing: a route card's computer consent does not outlive it.
|
|
1540
|
+
dropRouteComputer(live);
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* The work a human prompt must not overtake: the plan's turn (the next turn
|
|
1544
|
+
* once the card is answered) and a /compact, queued or running. → the
|
|
1545
|
+
* refusal's message, else null.
|
|
1546
|
+
*/
|
|
1547
|
+
function workReserved(live) {
|
|
1548
|
+
const find = (kind) => live.work.get(kind) || (activeWork(live)?.kind === kind ? live.workActive : null);
|
|
1549
|
+
const plan = find('plan');
|
|
1550
|
+
if (plan) return plan.revise ? 'The plan revision runs first; send this after it.' : 'The approved plan runs first; send this after it.';
|
|
1551
|
+
if (find('compact')) return 'The assistant is compacting its context; send this when it is done.';
|
|
1552
|
+
return null;
|
|
1553
|
+
}
|
|
1554
|
+
/** The open plan card holds the queue; each way it closes releases it, once (the release that wins pumps). */
|
|
1555
|
+
function holdWork(live, card) { live.planCard = card; }
|
|
1556
|
+
function releaseHold(live, requestId) {
|
|
1557
|
+
if (!live.planCard || live.planCard.requestId !== requestId) return false;
|
|
1558
|
+
live.planCard = null;
|
|
1559
|
+
pumpSoon(live);
|
|
1560
|
+
return true;
|
|
1561
|
+
}
|
|
1562
|
+
/** Pumps once the current synchronous work is done (a turn's `done` reaches the sockets, its plan card is offered). */
|
|
1563
|
+
function pumpSoon(live) {
|
|
1564
|
+
if (live.workPumpQueued) return;
|
|
1565
|
+
live.workPumpQueued = true;
|
|
1566
|
+
queueMicrotask(() => { live.workPumpQueued = false; pumpWork(live); });
|
|
1567
|
+
}
|
|
1568
|
+
/** The first entry allowed to start (stale ones are dropped on the way), or null. */
|
|
1569
|
+
function nextWork(live) {
|
|
1570
|
+
for (const kind of WORK_ORDER) {
|
|
1571
|
+
const entry = live.work.get(kind);
|
|
1572
|
+
if (!entry) continue;
|
|
1573
|
+
if (!workCurrent(live, entry)) { live.work.delete(kind); continue; }
|
|
1574
|
+
if (live.planCard && HELD_BY_PLAN_CARD.has(kind)) continue;
|
|
1575
|
+
return entry;
|
|
1576
|
+
}
|
|
1577
|
+
return null;
|
|
1578
|
+
}
|
|
1579
|
+
function pumpWork(live) {
|
|
1580
|
+
if (live.workTimer) { clearTimeout(live.workTimer); live.workTimer = null; }
|
|
1581
|
+
if (shuttingDown || live.closing || sessions.get(live.record.id) !== live || live.record.status === 'ended' || activeWork(live)) return;
|
|
1582
|
+
const entry = nextWork(live);
|
|
1583
|
+
// The mailbox waits for its batch window and the plan card.
|
|
1584
|
+
if (!entry && !(live.mailbox.length && !live.mailboxTimer && !live.planCard)) return;
|
|
1585
|
+
if (sessionBusy(live)) {
|
|
1586
|
+
// The running turn's end pumps again; this covers a brain that frees up without one.
|
|
1587
|
+
live.workTimer = setTimeout(() => { live.workTimer = null; pumpWork(live); }, settings.mailboxBatchMs);
|
|
1588
|
+
live.workTimer.unref?.();
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
if (entry) startWork(live, entry);
|
|
1592
|
+
else if (mailboxAllowed(live)) startWork(live, { kind: 'mailbox', gen: null });
|
|
1593
|
+
}
|
|
1594
|
+
/** Runs `entry` as the session's work until it reached the brain (a turn) or settled (a compact). */
|
|
1595
|
+
async function startWork(live, entry) {
|
|
1596
|
+
live.work.delete(entry.kind);
|
|
1597
|
+
live.workActive = entry;
|
|
1598
|
+
let result = null;
|
|
1599
|
+
try {
|
|
1600
|
+
if (entry.kind === 'compact') await runCompact(live, entry);
|
|
1601
|
+
else if (entry.kind === 'mailbox') result = await runMailboxTurn(live);
|
|
1602
|
+
else result = await runQuery(live, { ...entry.options, system: true, work: entry });
|
|
1603
|
+
} catch (error) { log(`assistant:${entry.kind}-error`, error?.message || String(error)); }
|
|
1604
|
+
if (live.workActive === entry) live.workActive = null;
|
|
1605
|
+
// A human prompt reached the brain first: this goes after its turn.
|
|
1606
|
+
if (result?.code === 'ASSISTANT_BUSY' && entry.kind !== 'mailbox' && workCurrent(live, entry) && !live.work.has(entry.kind)) live.work.set(entry.kind, entry);
|
|
1607
|
+
// Dropped on its way (a Stop, the session closing): a panel still "working" on it hears that nothing runs.
|
|
1608
|
+
if (result?.code === 'TURN_CANCELLED' && !sessionBusy(live) && sessions.get(live.record.id) === live) sendToSockets(live, { type: 'reattach_result', ok: true, sessionId: live.record.id, running: false });
|
|
1609
|
+
pumpSoon(live);
|
|
1610
|
+
return result;
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* /compact: session work, so nothing starts until it settles. OpenCode's call
|
|
1614
|
+
* returns when the compaction is over; Claude and Codex compact in a turn of
|
|
1615
|
+
* their own, and the brain reports busy until its `done`.
|
|
1616
|
+
*/
|
|
1617
|
+
async function runCompact(live, entry) {
|
|
1618
|
+
const brain = live.brain;
|
|
1619
|
+
// No brain, nothing to compact: the panel that asked stops "compacting…".
|
|
1620
|
+
if (!brain?.compact) { entry.reply?.({ type: 'reattach_result', ok: true, sessionId: live.record.id, running: !!live.running || (Number(live.turnPending) || 0) > 0 }); return; }
|
|
1621
|
+
try { await brain.compact(); }
|
|
1622
|
+
catch (error) {
|
|
1623
|
+
const packet = { type: 'error', message: error?.message || String(error) };
|
|
1624
|
+
if (entry.reply) entry.reply(packet); else sendToSockets(live, packet);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// ── brains ─────────────────────────────────────────────────────────────────
|
|
1629
|
+
function dispatchSpend(sessionId) {
|
|
1630
|
+
try { return dispatcher ? Number(dispatcher.totals({ assistantSessionId: sessionId }).costUsd) || 0 : 0; } catch { return 0; }
|
|
1631
|
+
}
|
|
1632
|
+
/** The brain's own spend in a session (the dispatcher adds it to the session total). */
|
|
1633
|
+
function brainSpend(sessionId) { return Number(sessions.get(sessionId)?.record.costUsd) || 0; }
|
|
1634
|
+
/** Brain cap (USD per session): the Budget tab's value, read live. */
|
|
1635
|
+
function brainCapUsd() {
|
|
1636
|
+
try { const value = Number(configStore?.budget?.().brainUsd); if (value > 0) return value; } catch {}
|
|
1637
|
+
return settings.maxBudgetUsd;
|
|
1638
|
+
}
|
|
1639
|
+
function sessionMoney(sessionId) {
|
|
1640
|
+
try { return dispatcher?.sessionBudget?.(sessionId) || null; } catch { return null; }
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* How the brain's spend is known: 'reported' (Claude; OpenCode with a list
|
|
1644
|
+
* price), 'estimated' (Codex tokens at list price), 'free', or null (no
|
|
1645
|
+
* price: the dollar caps could not hold it).
|
|
1646
|
+
*/
|
|
1647
|
+
function brainCostBasis(live) {
|
|
1648
|
+
const brain = live.record.brain || {};
|
|
1649
|
+
if (brain.provider === 'claude-code') return 'reported';
|
|
1650
|
+
const info = brainCapabilities(live.record) || {};
|
|
1651
|
+
if (brain.provider === 'codex') {
|
|
1652
|
+
if (!pricing?.codexPrice) return null;
|
|
1653
|
+
try { return pricing.codexPrice(brain.model || info.id || null) ? 'estimated' : null; } catch { return null; }
|
|
1654
|
+
}
|
|
1655
|
+
if (info.price?.basis === 'free') return 'free';
|
|
1656
|
+
return info.price ? 'reported' : null;
|
|
1657
|
+
}
|
|
1658
|
+
function codexBrainPrice(live) {
|
|
1659
|
+
const brain = live.record.brain || {};
|
|
1660
|
+
try { return pricing?.codexPrice?.(brain.model || brainCapabilities(live.record)?.id || null) || null; } catch { return null; }
|
|
1661
|
+
}
|
|
1662
|
+
function sendCost(live) {
|
|
1663
|
+
const budget = sessionMoney(live.record.id);
|
|
1664
|
+
const basis = brainCostBasis(live);
|
|
1665
|
+
sendToSockets(live, { type: 'assistant:cost', sessionUsd: live.record.costUsd, dispatchUsd: dispatchSpend(live.record.id), budget, brainCapUsd: brainCapUsd(), brainMetered: basis !== null, brainCostBasis: basis });
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Why the brain may not start a turn: the session's hard cap (brain + agents)
|
|
1669
|
+
* or the brain cap, checked before every turn. A turn already running
|
|
1670
|
+
* finishes; the Claude CLI also gets the remaining brain budget as its own cap.
|
|
1671
|
+
*/
|
|
1672
|
+
function brainBudgetBlock(live) {
|
|
1673
|
+
// Fail closed: a brain whose spend has no price could spend past every cap.
|
|
1674
|
+
if (pricing && brainCostBasis(live) === null) {
|
|
1675
|
+
const model = live.record.brain?.model || brainCapabilities(live.record)?.id || 'its default model';
|
|
1676
|
+
return { code: 'BRAIN_UNPRICED', message: `No price is known for the ${live.record.brain?.provider || ''} brain model ${model}, so the budget caps cannot hold its spend. Pick a priced or free model for the brain.` };
|
|
1677
|
+
}
|
|
1678
|
+
const money = sessionMoney(live.record.id);
|
|
1679
|
+
if (money && money.totalUsd >= money.hardUsd) {
|
|
1680
|
+
return { code: 'SESSION_BUDGET_EXCEEDED', message: `This session spent ${usd(money.totalUsd)} (brain ${usd(money.brainUsd)} + agents ${usd(money.dispatchUsd)}), at its hard cap of ${usd(money.hardUsd)}. Raise the cap in ⋯ → Budget… to continue.` };
|
|
1681
|
+
}
|
|
1682
|
+
const cap = brainCapUsd();
|
|
1683
|
+
const spent = Number(live.record.costUsd) || 0;
|
|
1684
|
+
if (spent >= cap) return { code: 'BRAIN_BUDGET_EXCEEDED', message: `The brain spent ${usd(spent)} in this session, at its cap of ${usd(cap)}. Raise the brain cap in ⋯ → Budget… to continue.` };
|
|
1685
|
+
return null;
|
|
1686
|
+
}
|
|
1687
|
+
/** The Budget tab saved new caps: held mailbox items may go now, and every open panel refreshes its chip. */
|
|
1688
|
+
function onBudgetChanged() {
|
|
1689
|
+
for (const live of sessions.values()) {
|
|
1690
|
+
if (live.record.status === 'ended') continue;
|
|
1691
|
+
try { dispatcher?.checkSession?.(live.record.id); } catch {}
|
|
1692
|
+
sendCost(live);
|
|
1693
|
+
if (live.mailbox.length && !brainBudgetBlock(live)) { live.budgetHoldNotified = false; scheduleMailbox(live, 0); }
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
async function personaFor(live) {
|
|
1697
|
+
const brain = live.record.brain;
|
|
1698
|
+
let catalogValue = {};
|
|
1699
|
+
try { catalogValue = typeof buildCatalog === 'function' ? (await buildCatalog({ compact: true })) || {} : {}; } catch {}
|
|
1700
|
+
let routingCfg = { preferences: {}, askBelow: 0.75 };
|
|
1701
|
+
try { routingCfg = configStore?.routing?.() || routingCfg; } catch {}
|
|
1702
|
+
let sheet = null;
|
|
1703
|
+
try { if (catalogValue?.models) sheet = selectSheetModels(catalogValue, { brain, preferences: routingCfg.preferences || {}, max: 8 }); } catch {}
|
|
1704
|
+
let setupState = null;
|
|
1705
|
+
try { setupState = desktop?.setupState?.() || null; } catch {}
|
|
1706
|
+
return buildAssistantPersona({
|
|
1707
|
+
assistantSessionId: live.record.id,
|
|
1708
|
+
brain,
|
|
1709
|
+
project: live.record.project || 'global',
|
|
1710
|
+
toolPrefix: brain.provider === 'claude-code' ? 'mcp__SynaBun__' : 'SynaBun_',
|
|
1711
|
+
catalog: { models: catalogValue.models || {}, accounts: catalogValue.accounts || {}, projects: catalogValue.projects || [], profiles: catalogValue.profiles || catalogValue.mcpProfiles?.presets || [] },
|
|
1712
|
+
limits: dispatcher?.refreshLimits?.() || dispatcher?.limits || {},
|
|
1713
|
+
defaults: settings.defaultBrain,
|
|
1714
|
+
extra: settings.personaExtra,
|
|
1715
|
+
hasAskUserQuestion: brain.provider === 'claude-code',
|
|
1716
|
+
routing: router ? { mode: routingView(live.record).effectiveMode, askBelow: routingCfg.askBelow, preferences: routingCfg.preferences || {} } : null,
|
|
1717
|
+
sheet,
|
|
1718
|
+
opencodeTotal: catalogValue.opencodeTotal ?? null,
|
|
1719
|
+
hiddenTotal: Object.values(catalogValue.hiddenRows || {}).reduce((sum, rows) => sum + (Array.isArray(rows) ? rows.length : 0), 0),
|
|
1720
|
+
brainInfo: brainCapabilities(live.record),
|
|
1721
|
+
// A remote session: what remoteComputerUse says for it (a turn with untrusted content is asked either way).
|
|
1722
|
+
computer: { available: computerSupported(), enabled: computerEffective(live.record), setupState, remote: computerDecision(live, { untrusted: false }) },
|
|
1723
|
+
taskClasses: TASK_CLASS_META,
|
|
1724
|
+
clarify: !!clarifier,
|
|
1725
|
+
channel: live.record.channel || live.record.remote?.channel || null,
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* The session's brain, starting it if needed → the brain, or null when this
|
|
1730
|
+
* start went stale (a close came after it began or is under way, or the
|
|
1731
|
+
* runtime shuts down): the caller drops its turn (runTurn). Checked when it
|
|
1732
|
+
* begins and after every await: a stale start assigns nothing, and a start
|
|
1733
|
+
* never replaces a brain another start of the session assigned meanwhile.
|
|
1734
|
+
*/
|
|
1735
|
+
async function ensureBrain(live) {
|
|
1736
|
+
if (live.brain) return live.brain;
|
|
1737
|
+
const startGen = live.gen;
|
|
1738
|
+
const stale = () => shuttingDown || !!live.closing || live.closedGen > startGen;
|
|
1739
|
+
if (stale()) return null;
|
|
1740
|
+
// A remote session's brain starts at the modes its level allows.
|
|
1741
|
+
const remote = remotePolicyFor(live);
|
|
1742
|
+
if (remote) live.record.brain = clampedBrain(live, live.record.brain);
|
|
1743
|
+
const brain = live.record.brain;
|
|
1744
|
+
const sink = { send: (packet) => onBrainPacket(live, packet) };
|
|
1745
|
+
const persona = await personaFor(live);
|
|
1746
|
+
if (stale()) return null;
|
|
1747
|
+
if (live.brain) return live.brain;
|
|
1748
|
+
live.personaText = persona;
|
|
1749
|
+
// Computer use: one unforgeable grant per brain (the MCP `computer` group is
|
|
1750
|
+
// advertised only to callers presenting it). Revoked when the brain goes.
|
|
1751
|
+
// A remote session's Claude brain gets one only when remoteComputerUse does
|
|
1752
|
+
// not say "off" for it, and it is minted HELD: it resolves to nothing until
|
|
1753
|
+
// a turn may use the computer (syncComputerGrant). Codex / OpenCode: never.
|
|
1754
|
+
const computerMode = remote ? (brain.provider === 'claude-code' ? computerDecision(live, { untrusted: false })?.state || 'off' : 'off') : null;
|
|
1755
|
+
if ((!remote || computerMode !== 'off') && !live.desktopGrant && computerSupported() && desktop?.mintGrant) {
|
|
1756
|
+
try {
|
|
1757
|
+
const info = brainCapabilities(live.record);
|
|
1758
|
+
live.desktopGrant = desktop.mintGrant({
|
|
1759
|
+
kind: 'brain', assistantSessionId: live.record.id, runId: null, provider: brain.provider, model: brain.model || null, vision: info?.vision ?? null,
|
|
1760
|
+
...(remote ? { held: true, remote: { channel: live.record.remote?.channel || live.record.channel || 'whatsapp' } } : {}),
|
|
1761
|
+
});
|
|
1762
|
+
live.computerGrantLive = false;
|
|
1763
|
+
} catch (error) { log('assistant:desktop-grant-error', error?.message || String(error)); }
|
|
1764
|
+
}
|
|
1765
|
+
const deps = {
|
|
1766
|
+
ClaudeSession, sdkVersion, PACKAGE_ROOT, handleCodexSkinWebSocket, ensureIsolatedServe, stopIsolatedServe, setupOpencodeSidepanelConfig,
|
|
1767
|
+
claudeAccountEnv: (accountId) => (claudeAccounts?.envFor?.(accountId) || {}),
|
|
1768
|
+
// The Claude CLI caps this process at what the session's brain budget has left.
|
|
1769
|
+
maxBudgetUsd: Math.max(0.1, Math.round((brainCapUsd() - (Number(live.record.costUsd) || 0)) * 100) / 100),
|
|
1770
|
+
// A Codex brain reports tokens: priced at the list price of its model (estimated).
|
|
1771
|
+
priceCodexUsage: (usage) => {
|
|
1772
|
+
const price = codexBrainPrice(live);
|
|
1773
|
+
if (!price) return null;
|
|
1774
|
+
// An "[extended]" window is priced at the long-context rate (never under-counted).
|
|
1775
|
+
const info = brainCapabilities(live.record) || {};
|
|
1776
|
+
const extended = /\[extended\]$/.test(String(live.record.brain?.model || info.id || ''));
|
|
1777
|
+
return codexUsageCostUsd(usage, price, { contextWindow: extended ? info.contextWindow || null : null });
|
|
1778
|
+
},
|
|
1779
|
+
onCodexThread: (threadId) => { if (threadId) live.record.providerThreadId = threadId; if (live.turnTaskId) pollCodex(live, { force: true }); },
|
|
1780
|
+
onCodexUsage: () => { if (live.turnTaskId) pollCodex(live); },
|
|
1781
|
+
onOpenCodeEvent: (type, event) => openCodeEvent(live, type, event),
|
|
1782
|
+
// Also a resume into another session: what the meter counts as the main loop follows it.
|
|
1783
|
+
onOpenCodeRoot: (sessionId) => { live.record.providerSessionId = sessionId; metered('meter-state', () => live.brainMeter?.setRoot?.(sessionId)); saveMeter(live); },
|
|
1784
|
+
desktopGrant: live.desktopGrant || null,
|
|
1785
|
+
// A remote session's level (lib/remote-policy.js): the Claude brain allows
|
|
1786
|
+
// bypass only at autonomous. null for a desktop session.
|
|
1787
|
+
remoteLevel: remote ? effectiveLevel(remote, { now: now() }) : null,
|
|
1788
|
+
// Plan mode stays read-only at a remote session's read-only level (read at every call).
|
|
1789
|
+
planReadOnly: () => planReadOnly(live),
|
|
1790
|
+
// A Codex / OpenCode brain of a remote session is read-only for good: the
|
|
1791
|
+
// read-only sandbox (Codex), the plan agent (OpenCode), no auto-accept.
|
|
1792
|
+
remoteReadOnly: !!remote && remoteBrainLimited(brain.provider),
|
|
1793
|
+
};
|
|
1794
|
+
const gate = ensureGate(live);
|
|
1795
|
+
// A remote session's hook / plugin refuses a call it could not check (fail closed).
|
|
1796
|
+
if (gate && brain.provider === 'opencode' && gateUrl) deps.routeGate = { url: gateUrl, session: live.record.id, token: live.gateToken, ...(remote ? { remote: true } : {}) };
|
|
1797
|
+
if (gate && brain.provider === 'codex') {
|
|
1798
|
+
deps.routeGate = gateUrl
|
|
1799
|
+
? () => codexGateBootstrap({ url: gateUrl, session: live.record.id, token: live.gateToken, codexBin: typeof codexBin === 'function' ? codexBin() : codexBin, ...(remote ? { remote: true } : {}) })
|
|
1800
|
+
: async () => null;
|
|
1801
|
+
}
|
|
1802
|
+
const factories = brainFactories || { 'claude-code': createClaudeBrain, codex: createCodexBrain, opencode: createOpenCodeBrain };
|
|
1803
|
+
const factory = factories[brain.provider];
|
|
1804
|
+
if (!factory) throw new Error(`Unsupported brain provider ${brain.provider}`);
|
|
1805
|
+
const memoryHooks = brain.provider === 'claude-code' && memory?.claudeHooks ? memory.claudeHooks({
|
|
1806
|
+
session: live.record.id, project: live.record.project || 'global',
|
|
1807
|
+
getGeneration: () => live.record.memoryGeneration || 1,
|
|
1808
|
+
bumpGeneration: () => { live.record.memoryGeneration = (live.record.memoryGeneration || 1) + 1; touch(live); },
|
|
1809
|
+
markRemembered: () => { live.rememberedThisTurn = true; live.obligations.clear(); },
|
|
1810
|
+
hasPendingObligations: () => live.obligations.size > 0 && !live.rememberedThisTurn,
|
|
1811
|
+
onRecall: (recalled) => { if (recalled?.results?.length || recalled?.alreadyPresent) sendToSockets(live, { type: 'event', event: { type: 'synabun.memories', memories: recalled.results || [], alreadyPresent: !!recalled.alreadyPresent } }); },
|
|
1812
|
+
describeTurn: (text) => describeTurn(live, text),
|
|
1813
|
+
}) : null;
|
|
1814
|
+
// Claude: memory, the route gate, the browser policy (every session: plan mode and subagents too), a remote session's hook.
|
|
1815
|
+
const claude = brain.provider === 'claude-code';
|
|
1816
|
+
const hooks = mergeHooks(mergeHooks(mergeHooks(memoryHooks, claude && gate ? gateHooks(live) : null), claude ? browserHooks(live) : null), claude && (remote || live.record.remote) ? remoteHooks(live) : null);
|
|
1817
|
+
live.brainProvider = brain.provider;
|
|
1818
|
+
startMeter(live, brain.provider);
|
|
1819
|
+
const created = factory({ session: live.record, sink, deps, persona, hooks });
|
|
1820
|
+
live.brain = created;
|
|
1821
|
+
live.brainSkipAllowed = remote ? deps.remoteLevel === 'autonomous' : null;
|
|
1822
|
+
live.brainComputerMode = computerMode;
|
|
1823
|
+
live.brainRemoteReadOnly = deps.remoteReadOnly === true;
|
|
1824
|
+
live.record.status = 'idle';
|
|
1825
|
+
touch(live);
|
|
1826
|
+
try { await created.start?.(); } catch (error) {
|
|
1827
|
+
log('assistant:brain-start-error', error?.message || String(error));
|
|
1828
|
+
// A brain a close already took (disposed while it started) is not the session's any more.
|
|
1829
|
+
if (live.brain === created) {
|
|
1830
|
+
sendToSockets(live, { type: 'error', message: `Brain failed to start: ${error?.message || error}` });
|
|
1831
|
+
await disposeBrain(live, 'start_failed').catch(() => {});
|
|
1832
|
+
}
|
|
1833
|
+
throw error;
|
|
1834
|
+
}
|
|
1835
|
+
// Stale now: the close (or the shutdown) disposes it as the session's brain (disposeBrain), once.
|
|
1836
|
+
return stale() ? null : created;
|
|
1837
|
+
}
|
|
1838
|
+
async function disposeBrain(live, reason = 'closed') {
|
|
1839
|
+
const brain = live.brain;
|
|
1840
|
+
if (!brain) return;
|
|
1841
|
+
while (live.openCodeFinalizing) await live.openCodeFinalizing;
|
|
1842
|
+
try {
|
|
1843
|
+
const identity = brain.identity?.() || {};
|
|
1844
|
+
if (identity.providerSessionId) live.record.providerSessionId = identity.providerSessionId;
|
|
1845
|
+
if (identity.providerThreadId) live.record.providerThreadId = identity.providerThreadId;
|
|
1846
|
+
} catch {}
|
|
1847
|
+
live.brain = null;
|
|
1848
|
+
if (live.brainProvider === 'codex') pollCodex(live, { force: true });
|
|
1849
|
+
// No further turn will book them: a Claude brain's provisional tokens are booked as an estimate,
|
|
1850
|
+
// any other brain's go (Codex was just polled, OpenCode settled at its turn's end).
|
|
1851
|
+
if (live.brainMeterProvider === 'claude-code') settleClaudeEstimate(live, 'no-result');
|
|
1852
|
+
metered('dispose', () => { live.brainMeter?.clearPending?.(); usage?.clearPending?.(live.record.id, 'brain'); });
|
|
1853
|
+
saveMeter(live);
|
|
1854
|
+
live.brainMeter = null;
|
|
1855
|
+
live.brainMeterProvider = null;
|
|
1856
|
+
live.turnTasks.length = 0;
|
|
1857
|
+
live.turnTaskId = null;
|
|
1858
|
+
live.running = false;
|
|
1859
|
+
closePlanCard(live, 'brain_disposed');
|
|
1860
|
+
live.planTurn = false;
|
|
1861
|
+
// A remote session's computer use ends with its brain: the turn's approval, its open request, the live grant.
|
|
1862
|
+
endComputerTurn(live, reason === 'closed' ? 'session_closed' : 'brain_disposed');
|
|
1863
|
+
settleNativeQuestions(live, 'brain_disposed');
|
|
1864
|
+
live.pendingControls.clear();
|
|
1865
|
+
clearControlTimers(live);
|
|
1866
|
+
try { await brain.dispose?.(); } catch (error) { log('assistant:brain-dispose-error', error?.message || String(error)); }
|
|
1867
|
+
// The next brain reports its own routing reach, and its own OpenCode serve says hello again.
|
|
1868
|
+
live.gatePluginAt = null;
|
|
1869
|
+
live.gate?.setUnavailable(null);
|
|
1870
|
+
if (live.desktopGrant) {
|
|
1871
|
+
try { desktop?.revokeFor?.({ token: live.desktopGrant }); } catch {}
|
|
1872
|
+
try { desktop?.releaseOwner?.({ assistantSessionId: live.record.id }); } catch {}
|
|
1873
|
+
live.desktopGrant = null;
|
|
1874
|
+
}
|
|
1875
|
+
live.computerGrantLive = false;
|
|
1876
|
+
live.brainComputerMode = null;
|
|
1877
|
+
// A "do it here" continuation goes with its brain; the plan's turn does not (it starts the next brain).
|
|
1878
|
+
dropWork(live, 'continuation');
|
|
1879
|
+
try { router?.cancelForSession?.(live.record.id, reason === 'closed' ? 'session_closed' : 'brain_disposed', { origins: ['agent_route', 'vision_guard'] }); } catch {}
|
|
1880
|
+
// Clarify cards outlive a brain (the answers reach the next one as a mailbox turn), not the session.
|
|
1881
|
+
if (reason === 'closed') { try { clarifier?.cancelForSession?.(live.record.id, 'session_closed'); } catch {} }
|
|
1882
|
+
live.record.status = reason === 'closed' ? 'ended' : 'idle';
|
|
1883
|
+
touch(live);
|
|
1884
|
+
sendToSockets(live, { type: 'assistant:session', session: view(live), reason });
|
|
1885
|
+
// Work that waited for the old brain (a busy one) can start the next.
|
|
1886
|
+
pumpSoon(live);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// ── turns ──────────────────────────────────────────────────────────────────
|
|
1890
|
+
/** A vision model on the brain's own provider for a turn carrying images the brain cannot see. */
|
|
1891
|
+
function visionGuard(live) {
|
|
1892
|
+
const info = brainCapabilities(live.record);
|
|
1893
|
+
if (!info || info.vision !== false) return null;
|
|
1894
|
+
const provider = live.record.brain.provider;
|
|
1895
|
+
let pref = null;
|
|
1896
|
+
try { pref = configStore?.routing?.().preferences?.vision || null; } catch {}
|
|
1897
|
+
if (pref?.provider === provider && pref.model && !hiddenBrainModel(provider, pref.model)) return { model: pref.model, effort: pref.effort || null, label: pref.label || pref.model };
|
|
1898
|
+
const value = catalog?.peek?.();
|
|
1899
|
+
const rows = (value?.models?.[provider] || []).filter((row) => row.vision === true && row.status !== 'unavailable');
|
|
1900
|
+
rows.sort((a, b) => (a.price?.output ?? Number.POSITIVE_INFINITY) - (b.price?.output ?? Number.POSITIVE_INFINITY));
|
|
1901
|
+
return rows[0] ? { model: rows[0].id, effort: null, label: rows[0].label || rows[0].id } : null;
|
|
1902
|
+
}
|
|
1903
|
+
function emitRouteEvent(live, phase, route) {
|
|
1904
|
+
const packet = { type: 'event', event: { type: 'synabun.route', phase, route } };
|
|
1905
|
+
sendToSockets(live, packet);
|
|
1906
|
+
if (phase !== 'card') appendTranscript(live.record.id, { at: iso(now()), packet });
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
/**
|
|
1910
|
+
* The turn a Claude brain's UserPromptSubmit hook is looking at: the exact
|
|
1911
|
+
* text runQuery sent, else — when exactly one turn is pending — the one whose
|
|
1912
|
+
* prompt the text contains. Used once; null for turns the CLI starts itself
|
|
1913
|
+
* (the hook then classifies the text on its own).
|
|
1914
|
+
*/
|
|
1915
|
+
function describeTurn(live, text) {
|
|
1916
|
+
const key = String(text ?? '').trim();
|
|
1917
|
+
const pending = live.turnMeta;
|
|
1918
|
+
if (!key || !pending?.size) return null;
|
|
1919
|
+
if (pending.has(key)) { const meta = pending.get(key); pending.delete(key); return meta; }
|
|
1920
|
+
if (pending.size === 1) {
|
|
1921
|
+
const [onlyKey, meta] = pending.entries().next().value;
|
|
1922
|
+
if (meta?.prompt && key.includes(String(meta.prompt).trim())) { pending.delete(onlyKey); return meta; }
|
|
1923
|
+
}
|
|
1924
|
+
return null;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* A turn refused before it reached the brain → { ok:false, code, message }.
|
|
1929
|
+
* A prompt a socket sent (`request`: { id, reply }) is answered to that socket
|
|
1930
|
+
* alone, with its request id: other hosts never saw the prompt, and the panel
|
|
1931
|
+
* puts the text back in its composer. Otherwise every socket hears it, except
|
|
1932
|
+
* ASSISTANT_BUSY: its caller (submit, the scheduler) has the result and
|
|
1933
|
+
* retries, so no host hears an error for a message it did not send.
|
|
1934
|
+
*/
|
|
1935
|
+
function refuse(live, code, message, { packet = { type: 'error', code, message }, request = null } = {}) {
|
|
1936
|
+
if (request?.reply) request.reply(request.id ? { ...packet, request_id: request.id } : packet);
|
|
1937
|
+
else if (code !== 'ASSISTANT_BUSY') sendToSockets(live, packet);
|
|
1938
|
+
return { ok: false, code, message };
|
|
1939
|
+
}
|
|
1940
|
+
const BUSY_MESSAGE = 'The assistant is still working; wait for the turn to finish or abort it.';
|
|
1941
|
+
/**
|
|
1942
|
+
* A turn dropped on its way to the brain (its generation went stale, or its
|
|
1943
|
+
* session closed). A prompt a socket sent is answered to that socket, with
|
|
1944
|
+
* its request id: the panel puts the text back.
|
|
1945
|
+
*/
|
|
1946
|
+
function dropTurn(live, request = null) {
|
|
1947
|
+
log('assistant:work', `${live.record.id}: a turn was cancelled before it reached the brain`);
|
|
1948
|
+
const message = 'The turn was cancelled before it started.';
|
|
1949
|
+
if (request?.reply) request.reply({ type: 'error', code: 'TURN_CANCELLED', message: 'The session was closed before this message reached the assistant.', ...(request.id ? { request_id: request.id } : {}) });
|
|
1950
|
+
return { ok: false, code: 'TURN_CANCELLED', message };
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* One turn → { ok:true } or { ok:false, code, message }. Busy from entry to
|
|
1954
|
+
* end (turnPending): the prompt prefix awaits before `running` is set, and
|
|
1955
|
+
* the scheduler must not start a turn meanwhile.
|
|
1956
|
+
*/
|
|
1957
|
+
async function runQuery(live, options = {}) {
|
|
1958
|
+
if (!options.system) {
|
|
1959
|
+
// A human prompt is refused before it supersedes anything: while the session
|
|
1960
|
+
// closes, before the approved plan's turn or a compact, behind another prompt
|
|
1961
|
+
// still on its way to the brain (prompts reach it in the order they came),
|
|
1962
|
+
// and while a Codex / OpenCode brain is mid-turn.
|
|
1963
|
+
if (live.closing) return refuse(live, 'SESSION_ENDED', 'This assistant session is closing.', { request: options.request });
|
|
1964
|
+
const reserved = workReserved(live)
|
|
1965
|
+
|| (live.prompting !== null ? 'Another message is still on its way to the assistant; send this after it.' : null)
|
|
1966
|
+
|| (live.running && live.brainProvider !== 'claude-code' ? BUSY_MESSAGE : null);
|
|
1967
|
+
if (reserved) return refuse(live, 'ASSISTANT_BUSY', reserved, { request: options.request });
|
|
1968
|
+
// A new generation, before anything awaits (recall, the catalog, the brain):
|
|
1969
|
+
// the plan card it replaces closes now, so an approval still on its way
|
|
1970
|
+
// cannot approve the plan this prompt supersedes (answerControl refuses
|
|
1971
|
+
// it), and the old turn's queued work (a "do it here" continuation) goes.
|
|
1972
|
+
newGeneration(live, 'superseded');
|
|
1973
|
+
live.prompting = live.gen;
|
|
1974
|
+
}
|
|
1975
|
+
// The generation the turn entered in: a close after it drops the turn (runTurn),
|
|
1976
|
+
// and its count (endSessionWork zeroed it).
|
|
1977
|
+
const turnGen = live.gen;
|
|
1978
|
+
live.turnPending = (Number(live.turnPending) || 0) + 1;
|
|
1979
|
+
try { return await runTurn(live, { ...options, turnGen }); }
|
|
1980
|
+
finally {
|
|
1981
|
+
if (live.closedGen <= turnGen) live.turnPending = Math.max(0, (Number(live.turnPending) || 1) - 1);
|
|
1982
|
+
if (!options.system && live.prompting === turnGen) live.prompting = null;
|
|
1983
|
+
pumpSoon(live);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
// `authority`: the WhatsApp bridge's capability; it alone makes a prompt count as the owner's own
|
|
1987
|
+
// plain message from the phone (fromPhone). `origin` below is audit text and decides nothing.
|
|
1988
|
+
// `origin`: who wrote a human prompt ('ui', 'whatsapp', 'api'); `untrusted`: its
|
|
1989
|
+
// text carries content the owner did not write (a remote session caps it at ask).
|
|
1990
|
+
// `offerPlan`: a system turn that revises a plan still ends with a plan card.
|
|
1991
|
+
// `work`: the scheduler entry this system turn is; `request`: { id, reply } of a
|
|
1992
|
+
// prompt a socket sent (refuse); `turnGen`: the generation runQuery entered in.
|
|
1993
|
+
async function runTurn(live, { text, images = [], cwd = null, model = null, effort = null, permissionMode = null, planMode = null, system = false, taskId = null, turnModel = null, turnEffort = null, gateOpen = false, gateCarry = false, origin = null, authority = null, untrusted = false, offerPlan = false, work = null, request = null, turnGen = live.gen, computerRoutes = null } = {}) {
|
|
1994
|
+
const prompt = String(text || '').trim();
|
|
1995
|
+
if (!prompt && !(Array.isArray(images) && images.length)) return refuse(live, 'EMPTY_PROMPT', 'No prompt provided', { packet: { type: 'error', message: 'No prompt provided' }, request });
|
|
1996
|
+
// The scheduler's turn goes once a Stop, the session closing or a newer
|
|
1997
|
+
// prompt made its generation stale. Any turn goes once its session closed
|
|
1998
|
+
// after it entered (a prompt still being set up and the mailbox too: a Stop
|
|
1999
|
+
// lets those run) or ended.
|
|
2000
|
+
// Checked after every await, the last time right before the brain gets it.
|
|
2001
|
+
const cancelled = () => live.closedGen > turnGen || (work ? !workCurrent(live, work) : sessions.get(live.record.id) !== live || live.record.status === 'ended');
|
|
2002
|
+
// The brain never switches onto, or runs a turn on, a model the user hid.
|
|
2003
|
+
const refused = [model && model !== live.record.brain.model ? model : null, turnModel].find((m) => m && hiddenBrainModel(live.record.brain.provider, m));
|
|
2004
|
+
if (refused) {
|
|
2005
|
+
const message = turnModel === refused
|
|
2006
|
+
? `This turn did not run: ${refused} is disabled in the Assistant's Models list. Pick an enabled model, or enable it in ⋯ → Models….`
|
|
2007
|
+
: modelDisabledError(refused).message;
|
|
2008
|
+
return refuse(live, 'MODEL_DISABLED', message, { request });
|
|
2009
|
+
}
|
|
2010
|
+
// A cold catalog must not read as "no price": build it before judging the brain's price.
|
|
2011
|
+
if (pricing && live.record.brain?.provider !== 'claude-code' && catalog?.full && !catalog.peek?.()) {
|
|
2012
|
+
try { await catalog.full(); } catch {}
|
|
2013
|
+
if (cancelled()) return dropTurn(live, request);
|
|
2014
|
+
}
|
|
2015
|
+
const budgetBlock = brainBudgetBlock(live);
|
|
2016
|
+
if (budgetBlock) return refuse(live, budgetBlock.code, budgetBlock.message, { request });
|
|
2017
|
+
// A remote session: a human prompt sets the trust of this turn and the ones it leads to.
|
|
2018
|
+
const remote = remotePolicyFor(live);
|
|
2019
|
+
if (!system) live.turnUntrusted = untrusted === true;
|
|
2020
|
+
// A Claude brain its CLI cap stopped restarts with the budget it has now.
|
|
2021
|
+
if (live.brainCapHit && live.brain && !live.running) {
|
|
2022
|
+
live.brainCapHit = false;
|
|
2023
|
+
await disposeBrain(live, 'budget');
|
|
2024
|
+
if (cancelled()) return dropTurn(live, request);
|
|
2025
|
+
}
|
|
2026
|
+
// A Claude brain built for another remote level restarts (resuming its conversation).
|
|
2027
|
+
if (live.brain && !live.running && brainNeedsRestart(live)) {
|
|
2028
|
+
await disposeBrain(live, 'policy');
|
|
2029
|
+
if (cancelled()) return dropTurn(live, request);
|
|
2030
|
+
}
|
|
2031
|
+
const brain = await ensureBrain(live);
|
|
2032
|
+
if (!brain || cancelled()) return dropTurn(live, request);
|
|
2033
|
+
// The last OpenCode turn's tokens are still being read back: no new turn mid-reconcile.
|
|
2034
|
+
if (live.openCodeFinalizing) {
|
|
2035
|
+
while (live.openCodeFinalizing) await live.openCodeFinalizing;
|
|
2036
|
+
if (cancelled()) return dropTurn(live, request);
|
|
2037
|
+
}
|
|
2038
|
+
// A remote session on OpenCode runs only once its gate plugin is loaded: the
|
|
2039
|
+
// plugin is what refuses web fetch / search and the denylist before a call runs.
|
|
2040
|
+
if (remote && live.brainProvider === 'opencode') {
|
|
2041
|
+
const ready = await remoteGateReady(live);
|
|
2042
|
+
if (cancelled()) return dropTurn(live, request);
|
|
2043
|
+
if (!ready) {
|
|
2044
|
+
log('assistant:remote-policy', `${live.record.id}: the OpenCode gate plugin did not load; turn refused`);
|
|
2045
|
+
return refuse(live, 'REMOTE_GATE_MISSING', 'SynaBun could not load its safety plugin into OpenCode, so this WhatsApp conversation does not run on it. Switch the WhatsApp brain to Claude, or restart SynaBun and try again.', { request });
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
if (live.running && live.brainProvider !== 'claude-code') {
|
|
2049
|
+
return refuse(live, 'ASSISTANT_BUSY', BUSY_MESSAGE, { packet: { type: 'error', message: BUSY_MESSAGE, code: 'ASSISTANT_BUSY' }, request });
|
|
2050
|
+
}
|
|
2051
|
+
let finalText = prompt;
|
|
2052
|
+
if (!system) {
|
|
2053
|
+
live.lastPrompt = prompt;
|
|
2054
|
+
// A prompt from elsewhere (WhatsApp, the API) is news to the panel: it goes live too.
|
|
2055
|
+
const promptPacket = { type: 'event', event: { type: 'synabun.user_prompt', text: prompt, images: images.length, ...(origin && origin !== 'ui' ? { origin: String(origin) } : {}) } };
|
|
2056
|
+
appendTranscript(live.record.id, { at: iso(now()), packet: promptPacket });
|
|
2057
|
+
if (origin && origin !== 'ui') sendToSockets(live, promptPacket);
|
|
2058
|
+
if (live.brainProvider !== 'claude-code' && memory?.recallForPrompt) {
|
|
2059
|
+
let recalled = null;
|
|
2060
|
+
try { recalled = await memory.recallForPrompt({ prompt, project: live.record.project, session: live.record.id, generation: live.record.memoryGeneration || 1 }); } catch {}
|
|
2061
|
+
// Closed while it recalled: the session that reopened hears nothing of this prompt, and its record keeps what came since.
|
|
2062
|
+
if (cancelled()) return dropTurn(live, request);
|
|
2063
|
+
try {
|
|
2064
|
+
if (recalled?.results?.length || recalled?.alreadyPresent) sendToSockets(live, { type: 'event', event: { type: 'synabun.memories', memories: recalled.results || [], alreadyPresent: !!recalled.alreadyPresent } });
|
|
2065
|
+
if (recalled?.block) finalText = `${recalled.block}\n\n${prompt}`;
|
|
2066
|
+
} catch {}
|
|
2067
|
+
}
|
|
2068
|
+
if (model || effort || cwd || permissionMode || typeof planMode === 'boolean') {
|
|
2069
|
+
const next = { ...live.record.brain };
|
|
2070
|
+
if (model) next.model = model;
|
|
2071
|
+
if (effort) next.effort = effort;
|
|
2072
|
+
if (model || effort) next.effort = brainEffort(next.provider, next.model, next.effort);
|
|
2073
|
+
if (cwd) { next.cwd = cwd; live.record.project = detectProject(cwd) || 'global'; }
|
|
2074
|
+
if (permissionMode) Object.assign(next, brainModeUpdate(next, permissionMode));
|
|
2075
|
+
if (typeof planMode === 'boolean') next.planMode = planMode;
|
|
2076
|
+
live.record.brain = permissionMode || typeof planMode === 'boolean' ? clampModeWrite(live, next) : next;
|
|
2077
|
+
touch(live);
|
|
2078
|
+
}
|
|
2079
|
+
// Route mode / remembered routes changed since the brain last saw them.
|
|
2080
|
+
if (router?.stamp) {
|
|
2081
|
+
try {
|
|
2082
|
+
const stamp = router.stamp(live.record.id);
|
|
2083
|
+
if (stamp?.changed) {
|
|
2084
|
+
// After the recalled-memories block (it stays first), before the prompt.
|
|
2085
|
+
finalText = finalText.endsWith(prompt) ? `${finalText.slice(0, finalText.length - prompt.length)}${stamp.text}\n\n${prompt}` : `${stamp.text}\n\n${finalText}`;
|
|
2086
|
+
stamp.commit();
|
|
2087
|
+
}
|
|
2088
|
+
} catch {}
|
|
2089
|
+
}
|
|
2090
|
+
// A new request cycle. Open clarify questions make this message the
|
|
2091
|
+
// user's reply (assistant-clarify.js); otherwise the last brief retires.
|
|
2092
|
+
live.promptCycle += 1;
|
|
2093
|
+
let clarified = null;
|
|
2094
|
+
try { clarified = clarifier?.onUserPrompt?.(live.record.id, { text: prompt, cycle: live.promptCycle }) || null; } catch { clarified = null; }
|
|
2095
|
+
if (clarified?.note) {
|
|
2096
|
+
finalText = finalText.endsWith(prompt) ? `${finalText.slice(0, finalText.length - prompt.length)}${clarified.note}\n\n${prompt}` : `${clarified.note}\n\n${finalText}`;
|
|
2097
|
+
}
|
|
2098
|
+
// A human prompt is a new task; the reply to a clarify question stays in the one that asked.
|
|
2099
|
+
if (usage) taskId = clarified?.note
|
|
2100
|
+
? (live.record.taskId || ensureTaskId(live.record.id))
|
|
2101
|
+
: metered('task', () => usage.beginTask(live.record.id, { title: prompt.split('\n')[0].slice(0, 80), at: now() })?.id);
|
|
2102
|
+
// An image the brain's model cannot see: run this turn on a vision model
|
|
2103
|
+
// of the same provider (the conversation stays in the same session).
|
|
2104
|
+
if (Array.isArray(images) && images.length && !turnModel) {
|
|
2105
|
+
const guard = visionGuard(live);
|
|
2106
|
+
if (guard) {
|
|
2107
|
+
turnModel = guard.model;
|
|
2108
|
+
turnEffort = guard.effort;
|
|
2109
|
+
emitRouteEvent(live, 'auto', {
|
|
2110
|
+
routeId: `route-vision-${now()}`, runIds: [], taskClass: 'chat', taskClassLabel: 'Screenshots & images', summary: 'Image attached',
|
|
2111
|
+
confidence: null, reason: 'The current model cannot see images, so this turn runs on a model that can.',
|
|
2112
|
+
target: { kind: 'direct', provider: live.record.brain.provider, model: guard.model, effort: guard.effort, label: guard.label, vision: true },
|
|
2113
|
+
alternatives: [], decidedBy: 'rule', remembered: false, optionId: null, corrections: [],
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
// A brain already on a model the user hid since keeps running, with a warning (once per model).
|
|
2119
|
+
const ownModel = live.record.brain.model || null;
|
|
2120
|
+
if (!turnModel && ownModel && live.hiddenModelWarned !== ownModel && hiddenBrainModel(live.record.brain.provider, ownModel)) {
|
|
2121
|
+
live.hiddenModelWarned = ownModel;
|
|
2122
|
+
sendToSockets(live, { type: 'event', event: { type: 'system', subtype: 'status', text: `This Assistant runs on ${ownModel}, which is disabled in the Models list. It keeps running; pick another model to switch.` } });
|
|
2123
|
+
}
|
|
2124
|
+
// The last check: from here on the turn is the brain's.
|
|
2125
|
+
if (cancelled()) return dropTurn(live, request);
|
|
2126
|
+
if (usage) {
|
|
2127
|
+
taskId ||= live.record.taskId || ensureTaskId(live.record.id);
|
|
2128
|
+
// record.taskId is the latest human task: a system turn that carries its own names only its turn.
|
|
2129
|
+
if (!system || !live.record.taskId) live.record.taskId = taskId;
|
|
2130
|
+
// The Claude CLI queues a prompt sent mid-turn: the turn in flight keeps its task (the FIFO's front).
|
|
2131
|
+
if (live.brainProvider === 'claude-code' && taskId) { live.turnTasks.push(taskId); live.turnTaskId = live.turnTasks[0]; }
|
|
2132
|
+
else live.turnTaskId = taskId;
|
|
2133
|
+
scheduleUsage(live.record.id);
|
|
2134
|
+
}
|
|
2135
|
+
// A remote session runs every turn at what its level allows now (an expired
|
|
2136
|
+
// autonomous window, an untrusted prompt): the modes go to the brain with the turn.
|
|
2137
|
+
if (remote) live.record.brain = clampedBrain(live, live.record.brain);
|
|
2138
|
+
// A Codex / OpenCode plan turn ends with the plan, not an approval request: the runtime asks.
|
|
2139
|
+
// (runQuery already closed the plan card this turn replaces.)
|
|
2140
|
+
live.planTurn = live.brainProvider !== 'claude-code' && live.record.brain.planMode === true && !remote && (!system || offerPlan === true);
|
|
2141
|
+
live.planTurnGen = live.gen;
|
|
2142
|
+
live.planText = '';
|
|
2143
|
+
// A remote session: this turn's computer use starts from nothing (no approval carries over).
|
|
2144
|
+
beginComputerTurn(live, { unasked: !system && fromPhone(authority) && untrusted !== true && !(Array.isArray(images) && images.length) });
|
|
2145
|
+
// …except the owner's yes to the route card this mailbox turn executes, when that card said the Mac will be controlled.
|
|
2146
|
+
if (system && Array.isArray(computerRoutes) && computerRoutes.length) claimRouteComputer(live, computerRoutes);
|
|
2147
|
+
live.running = true;
|
|
2148
|
+
live.record.status = 'running';
|
|
2149
|
+
live.record.lastActiveAt = iso(now());
|
|
2150
|
+
live.turnStartedMs = now();
|
|
2151
|
+
live.record.turns = (Number(live.record.turns) || 0) + 1;
|
|
2152
|
+
if (isDefaultSessionTitle(live.record.title)) {
|
|
2153
|
+
if (!system) { live.record.title = clip(prompt.split('\n')[0], 60); broadcast('assistant:session-updated', live); }
|
|
2154
|
+
}
|
|
2155
|
+
touch(live);
|
|
2156
|
+
// Every turn names its model: the brain was built from an older snapshot of
|
|
2157
|
+
// the record, and routed/continuation turns override it for one turn only.
|
|
2158
|
+
const effectiveModel = turnModel || live.record.brain.model || null;
|
|
2159
|
+
const effectiveEffort = brainEffort(live.record.brain.provider, effectiveModel, turnEffort || live.record.brain.effort || null);
|
|
2160
|
+
// The Claude brain recalls in its UserPromptSubmit hook, which sees only the
|
|
2161
|
+
// final text: record who wrote it (mailbox / router = system) and the user's
|
|
2162
|
+
// own words, so a router-stamped first turn recalls on the prompt itself.
|
|
2163
|
+
if (live.brainProvider === 'claude-code' && finalText.trim()) {
|
|
2164
|
+
live.turnMeta.delete(finalText.trim());
|
|
2165
|
+
live.turnMeta.set(finalText.trim(), { system: !!system, prompt: system ? null : prompt });
|
|
2166
|
+
while (live.turnMeta.size > 8) live.turnMeta.delete(live.turnMeta.keys().next().value);
|
|
2167
|
+
}
|
|
2168
|
+
// Every turn starts unrouted; a continuation or a route_decided "here" turn starts open.
|
|
2169
|
+
// Plan mode never routes: the brain explores read-only (the plan policy) and
|
|
2170
|
+
// the approved plan is routed on the next turn.
|
|
2171
|
+
ensureGate(live)?.startTurn({ open: gateOpen || live.record.brain?.planMode === true, carry: gateCarry });
|
|
2172
|
+
try {
|
|
2173
|
+
await brain.sendUserTurn({ text: finalText, images, cwd, model: effectiveModel, effort: effectiveEffort, permissionMode: permissionMode || remote ? live.record.brain.permissionMode : null, planMode: live.record.brain.planMode === true });
|
|
2174
|
+
} catch (error) {
|
|
2175
|
+
live.running = false;
|
|
2176
|
+
endComputerTurn(live, 'turn_failed');
|
|
2177
|
+
// The prompt never reached the brain: its task leaves the FIFO, a turn still in flight keeps its own.
|
|
2178
|
+
const queued = live.turnTasks.lastIndexOf(taskId);
|
|
2179
|
+
if (queued >= 0) live.turnTasks.splice(queued, 1);
|
|
2180
|
+
live.turnTaskId = live.turnTasks[0] || null;
|
|
2181
|
+
scheduleUsage(live.record.id);
|
|
2182
|
+
live.planTurn = false;
|
|
2183
|
+
live.record.status = 'idle';
|
|
2184
|
+
const message = error?.message || String(error);
|
|
2185
|
+
sendToSockets(live, { type: 'error', message });
|
|
2186
|
+
sendToSockets(live, { type: 'done', code: 1 });
|
|
2187
|
+
return { ok: false, code: 'TURN_FAILED', message };
|
|
2188
|
+
}
|
|
2189
|
+
return { ok: true };
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
/** The OpenCode serve of a remote session loaded the gate plugin (its hello), waiting up to remoteGateHelloMs. */
|
|
2193
|
+
async function remoteGateReady(live) {
|
|
2194
|
+
if (!live.gate || !gateUrl) return false;
|
|
2195
|
+
// Wall-clock time: an injected clock may stand still.
|
|
2196
|
+
const deadline = Date.now() + settings.remoteGateHelloMs;
|
|
2197
|
+
while (live.gatePluginAt == null) {
|
|
2198
|
+
if (Date.now() >= deadline) return false;
|
|
2199
|
+
await new Promise((done) => { const timer = setTimeout(done, 25); timer.unref?.(); });
|
|
2200
|
+
}
|
|
2201
|
+
return true;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
// ── mailbox ────────────────────────────────────────────────────────────────
|
|
2205
|
+
function enqueueMailbox(live, item) {
|
|
2206
|
+
live.mailbox.push({ ...item, at: iso(now()) });
|
|
2207
|
+
if (live.mailbox.length > 40) live.mailbox.splice(0, live.mailbox.length - 40);
|
|
2208
|
+
scheduleMailbox(live);
|
|
2209
|
+
}
|
|
2210
|
+
/** Items that arrive together go in one turn: the scheduler delivers them once this window closes. */
|
|
2211
|
+
function scheduleMailbox(live, delay = settings.mailboxBatchMs) {
|
|
2212
|
+
if (!live.mailbox.length || live.mailboxTimer) return;
|
|
2213
|
+
live.mailboxTimer = setTimeout(() => { live.mailboxTimer = null; pumpWork(live); }, delay);
|
|
2214
|
+
live.mailboxTimer.unref?.();
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* A result / needs_input item for a turn the brain already read with
|
|
2218
|
+
* agent_wait, agent_read or agent_status (the dispatcher's delivery mark).
|
|
2219
|
+
* Every other kind (memory_due, permission, route_*, stalled, budget…) is
|
|
2220
|
+
* always delivered.
|
|
2221
|
+
*/
|
|
2222
|
+
function alreadyRead(item) {
|
|
2223
|
+
if (!item || (item.kind !== 'result' && item.kind !== 'needs_input')) return false;
|
|
2224
|
+
const turn = Number(item.turn);
|
|
2225
|
+
if (!Number.isFinite(turn) || turn <= 0 || !item.run?.runId) return false;
|
|
2226
|
+
let mark = null;
|
|
2227
|
+
try { mark = dispatcher?.deliveryState?.(item.run.runId) || null; } catch { mark = null; }
|
|
2228
|
+
return !!mark && Number(mark.turn) >= turn;
|
|
2229
|
+
}
|
|
2230
|
+
/** Out of money: the events wait (the Budget tab re-schedules them after a raise), said once. */
|
|
2231
|
+
function mailboxAllowed(live) {
|
|
2232
|
+
const budgetBlock = brainBudgetBlock(live);
|
|
2233
|
+
if (!budgetBlock) return true;
|
|
2234
|
+
if (!live.budgetHoldNotified) {
|
|
2235
|
+
live.budgetHoldNotified = true;
|
|
2236
|
+
sendToSockets(live, { type: 'error', code: budgetBlock.code, message: `${budgetBlock.message} ${live.mailbox.length} agent event${live.mailbox.length === 1 ? '' : 's'} will reach the brain after that.` });
|
|
2237
|
+
}
|
|
2238
|
+
return false;
|
|
2239
|
+
}
|
|
2240
|
+
/** The mailbox now, if the scheduler would start it (no batch window: it is asked for). */
|
|
2241
|
+
function deliverMailbox(live) {
|
|
2242
|
+
if (shuttingDown || live.closing || !live.mailbox.length || sessions.get(live.record.id) !== live || live.record.status === 'ended') return Promise.resolve(null);
|
|
2243
|
+
if (sessionBusy(live) || live.planCard || nextWork(live) || !mailboxAllowed(live)) { pumpSoon(live); return Promise.resolve(null); }
|
|
2244
|
+
return startWork(live, { kind: 'mailbox', gen: null });
|
|
2245
|
+
}
|
|
2246
|
+
/** The mailbox's turn (the scheduler's 'mailbox' work). */
|
|
2247
|
+
async function runMailboxTurn(live) {
|
|
2248
|
+
// Read while this batch waited (inside the 2 s window, or while the brain
|
|
2249
|
+
// was busy with the turn that waited for it): not news any more.
|
|
2250
|
+
let unread = live.mailbox.splice(0, live.mailbox.length).filter((item) => !alreadyRead(item));
|
|
2251
|
+
if (!unread.length) { scheduleDispose(live); return null; }
|
|
2252
|
+
// A decided route whose card carried the owner's computer approval is carried out in a turn of
|
|
2253
|
+
// its own: nothing else in the mailbox rides on that approval. The rest waits for the next turn.
|
|
2254
|
+
const marked = live.computerRouteGrant?.routeId || null;
|
|
2255
|
+
const own = marked ? unread.filter((item) => item?.kind === 'route_decided' && String(item.route?.routeId || '') === marked) : [];
|
|
2256
|
+
if (own.length && own.length < unread.length) {
|
|
2257
|
+
live.mailbox.unshift(...unread.filter((item) => !own.includes(item)));
|
|
2258
|
+
unread = own;
|
|
2259
|
+
}
|
|
2260
|
+
// A route decided with no run attached whose run exists by now is not asked to be dispatched again.
|
|
2261
|
+
let sessionRuns = [];
|
|
2262
|
+
try { sessionRuns = unread.some((item) => item?.kind === 'route_decided') ? (dispatcher?.list?.({ assistantSessionId: live.record.id, limit: 500 }) || []) : []; } catch { sessionRuns = []; }
|
|
2263
|
+
const items = reconcileRouteItems(unread, sessionRuns);
|
|
2264
|
+
const text = formatMailbox(items);
|
|
2265
|
+
sendToSockets(live, { type: 'event', event: { type: 'synabun.mailbox', items } });
|
|
2266
|
+
appendTranscript(live.record.id, { at: iso(now()), packet: { type: 'event', event: { type: 'synabun.mailbox', items } } });
|
|
2267
|
+
live.mailboxTurn = true;
|
|
2268
|
+
// The user picked "here" on a card: that turn does the task, so it starts open.
|
|
2269
|
+
const decidedHere = items.some((item) => item.kind === 'route_decided' && item.route?.target?.kind === 'direct' && !(item.runIds || []).length);
|
|
2270
|
+
const firstRun = items.find((item) => item.run);
|
|
2271
|
+
const taskId = firstRun?.run?.turns?.find((turn) => turn.n === firstRun.turn)?.taskId || firstRun?.run?.taskId || live.record.taskId || null;
|
|
2272
|
+
// The decided "here" routes this turn carries out (one of them may hold the owner's computer approval).
|
|
2273
|
+
const computerRoutes = items.filter((item) => item.kind === 'route_decided' && item.route?.target?.kind === 'direct' && !(item.runIds || []).length).map((item) => String(item.route?.routeId || '')).filter(Boolean);
|
|
2274
|
+
const result = await runQuery(live, { text, system: true, taskId, gateOpen: decidedHere, computerRoutes });
|
|
2275
|
+
// A human prompt reached the brain first: the items go after its turn.
|
|
2276
|
+
if (result?.code === 'ASSISTANT_BUSY') { live.mailbox.unshift(...items); live.mailboxTurn = false; }
|
|
2277
|
+
return result;
|
|
2278
|
+
}
|
|
2279
|
+
function onDispatchEvent(payload) {
|
|
2280
|
+
const run = payload?.run;
|
|
2281
|
+
if (!run?.assistantSessionId) return;
|
|
2282
|
+
const live = sessions.get(run.assistantSessionId);
|
|
2283
|
+
if (!live) return;
|
|
2284
|
+
if (!live.record.dispatchRunIds?.includes(run.runId)) { live.record.dispatchRunIds = [...(live.record.dispatchRunIds || []), run.runId].slice(-100); touch(live); }
|
|
2285
|
+
sendToSockets(live, { type: 'assistant:dispatch', reason: payload.reason, run });
|
|
2286
|
+
scheduleUsage(live.record.id);
|
|
2287
|
+
if (payload.reason === 'turn_completed' && (run.outcome === 'done' || run.outcome === 'blocked') && !run.memoryStored) live.obligations.add(run.runId);
|
|
2288
|
+
if (payload.reason === 'memory_stored' || run.memoryStored) live.obligations.delete(run.runId);
|
|
2289
|
+
if (payload.reason === 'completed' || payload.reason === 'failed' || payload.reason === 'stopped' || payload.reason === 'interrupted') {
|
|
2290
|
+
sendToSockets(live, { type: 'event', event: { type: 'synabun.dispatch_result', run, result: run.lastResult || null } });
|
|
2291
|
+
}
|
|
2292
|
+
if (!MAILBOX_REASONS.has(payload.reason)) return;
|
|
2293
|
+
if (payload.reason === 'stopped' && !MAILBOX_STOP_REASONS.has(run.completionReason)) return;
|
|
2294
|
+
// needs_input already carries the question; its turn_completed twin would
|
|
2295
|
+
// hand the brain the same event twice.
|
|
2296
|
+
if (payload.reason === 'turn_completed' && run.lastResult?.status === 'needs_input') return;
|
|
2297
|
+
const kind = payload.reason === 'turn_completed' ? 'result' : payload.reason;
|
|
2298
|
+
const carriesResult = kind === 'result' || kind === 'needs_input';
|
|
2299
|
+
const item = {
|
|
2300
|
+
kind, run, summary: run.lastResult?.summary ? clip(run.lastResult.summary, 400) : null, question: run.lastResult?.question || null,
|
|
2301
|
+
files: run.lastResult?.files || [], follow_ups: run.lastResult?.follow_ups || [], request: payload.request || null, error: run.error || null,
|
|
2302
|
+
text: payload.text || null,
|
|
2303
|
+
turn: Number.isFinite(Number(payload.turn)) ? Number(payload.turn) : null,
|
|
2304
|
+
// Jev's reading of the result: a "done" its own output does not show, and a status read without a ## Result block.
|
|
2305
|
+
unverified: carriesResult ? (run.lastResult?.unverifiedClaim?.evidence || null) : null,
|
|
2306
|
+
resultSource: carriesResult ? (run.lastResult?.source || null) : null,
|
|
2307
|
+
// Image / video creation: the files this turn generated ({ kind, path, url, mime, bytes }).
|
|
2308
|
+
media: carriesResult && Array.isArray(run.lastResult?.media) ? run.lastResult.media : [],
|
|
2309
|
+
};
|
|
2310
|
+
// Already read by the brain (agent_wait in the same turn that dispatched it): no second turn.
|
|
2311
|
+
if (alreadyRead(item)) return;
|
|
2312
|
+
enqueueMailbox(live, item);
|
|
2313
|
+
}
|
|
2314
|
+
function onRunRemoved(payload) {
|
|
2315
|
+
const live = payload?.assistantSessionId ? sessions.get(payload.assistantSessionId) : null;
|
|
2316
|
+
if (!live) return;
|
|
2317
|
+
const removed = new Set((payload.runIds || []).map(String));
|
|
2318
|
+
if (!removed.size) return;
|
|
2319
|
+
for (const runId of removed) {
|
|
2320
|
+
live.obligations.delete(runId);
|
|
2321
|
+
live.nudgedRuns.delete(runId);
|
|
2322
|
+
}
|
|
2323
|
+
// A result can be waiting in the mailbox while the user clears its run.
|
|
2324
|
+
// Do not let that stale result (or its memory reminder) start another turn.
|
|
2325
|
+
live.mailbox = live.mailbox.filter((item) => !removed.has(String(item?.run?.runId || item?.runId || '')));
|
|
2326
|
+
if (!live.mailbox.length && live.mailboxTimer) {
|
|
2327
|
+
clearTimeout(live.mailboxTimer);
|
|
2328
|
+
live.mailboxTimer = null;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
function onPermissionBroadcast(message) {
|
|
2332
|
+
const live = message?.assistantSessionId ? sessions.get(message.assistantSessionId) : null;
|
|
2333
|
+
if (!live) return;
|
|
2334
|
+
if (message.type === 'assistant:permission-request') {
|
|
2335
|
+
sendToSockets(live, { type: 'event', event: { type: 'synabun.dispatch_control_request', runId: message.runId, request_id: message.request?.requestId, provider: dispatcher?.get?.(message.runId)?.provider || null, request: message.request } });
|
|
2336
|
+
} else if (message.type === 'assistant:permission-resolved') {
|
|
2337
|
+
sendToSockets(live, { type: 'event', event: { type: 'synabun.dispatch_control_resolved', runId: message.runId, request_id: message.requestId, resolvedBy: message.resolvedBy, behavior: message.behavior } });
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// ── public API ─────────────────────────────────────────────────────────────
|
|
2342
|
+
function listSessions() {
|
|
2343
|
+
return [...sessions.values()].map((live) => view(live)).sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
|
|
2344
|
+
}
|
|
2345
|
+
function getSession(id, { transcript = true, limit = 400 } = {}) {
|
|
2346
|
+
const live = sessions.get(id);
|
|
2347
|
+
if (!live) return null;
|
|
2348
|
+
return { ...view(live), transcript: transcript ? readTranscript(id, limit) : undefined, persona: live.personaText || undefined };
|
|
2349
|
+
}
|
|
2350
|
+
/**
|
|
2351
|
+
* `fallback`: the UI's start path — a disabled model starts on its enabled stand-in (reported back) instead of refusing.
|
|
2352
|
+
* `channel`: where the conversation happens ('whatsapp'); the persona adapts to it.
|
|
2353
|
+
* `internal.remote` ({ channel, level, strictWorkerApprovals, autonomousUntil, computerUse }): an in-process
|
|
2354
|
+
* caller (the WhatsApp bridge) starts a remote session. REST callers pass only the body, so
|
|
2355
|
+
* they cannot. The record is flagged before it is persisted: after a restart without a new
|
|
2356
|
+
* registration the session reads as read-only.
|
|
2357
|
+
*/
|
|
2358
|
+
async function createSession({ brain = {}, label = null, cwd = null, windowId = null, fallback = false, channel = null } = {}, internal = {}) {
|
|
2359
|
+
const id = `assistant-${randomUUID()}`;
|
|
2360
|
+
const requested = normalizeBrain({ ...brain, cwd: brain?.cwd || cwd || settings.defaultBrain.cwd || PACKAGE_ROOT }, settings.defaultBrain, { catalog: catalog?.peek?.() || null });
|
|
2361
|
+
const enabled = await enabledBrain(requested, { fallback: fallback === true, named: !!brain?.model });
|
|
2362
|
+
const normalized = enabled.brain;
|
|
2363
|
+
const record = {
|
|
2364
|
+
id, brain: normalized, title: label ? clip(label, 60) : DEFAULT_SESSION_TITLE, project: detectProject(normalized.cwd) || 'global',
|
|
2365
|
+
createdAt: iso(now()), updatedAt: iso(now()), lastActiveAt: null, status: 'idle', providerSessionId: null, providerThreadId: null,
|
|
2366
|
+
costUsd: 0, lastReportedCost: 0, dispatchRunIds: [], memoryGeneration: 1, windowId: windowId || null, turns: 0, taskId: null, usageState: {},
|
|
2367
|
+
};
|
|
2368
|
+
if (channel) record.channel = String(channel).trim().toLowerCase().slice(0, 40) || null;
|
|
2369
|
+
const live = makeLive(record);
|
|
2370
|
+
sessions.set(id, live);
|
|
2371
|
+
const remote = internal && typeof internal === 'object' && internal.remote && typeof internal.remote === 'object' ? internal.remote : null;
|
|
2372
|
+
if (remote) {
|
|
2373
|
+
const remoteChannel = String(remote.channel || record.channel || 'whatsapp');
|
|
2374
|
+
record.remote = { channel: remoteChannel, since: iso(now()), modes: { permissionMode: normalized.permissionMode || 'default', planMode: normalized.planMode === true } };
|
|
2375
|
+
record.computerUse = false;
|
|
2376
|
+
live.creating = true;
|
|
2377
|
+
// Every reader of the registry (dispatcher, bridge, API) sees the brain: Codex / OpenCode read as read-only.
|
|
2378
|
+
try { remotePolicy.setSessionBrain?.(id, normalized.provider); } catch {}
|
|
2379
|
+
try {
|
|
2380
|
+
remotePolicy.registerSessionPolicy(id, { level: remote.level, channel: remoteChannel, strictWorkerApprovals: remote.strictWorkerApprovals === true, autonomousUntil: remote.autonomousUntil ?? null, paused: remote.paused === true, computerUse: remote.computerUse === true });
|
|
2381
|
+
} catch (error) {
|
|
2382
|
+
// Never a remote session without a level: flagged, it reads as read-only.
|
|
2383
|
+
log('assistant:remote-policy', `${id}: registration failed (${error?.message || error}); read-only`);
|
|
2384
|
+
try { remotePolicy.markRemote?.(id, { channel: remoteChannel }); } catch {}
|
|
2385
|
+
}
|
|
2386
|
+
live.creating = false;
|
|
2387
|
+
record.brain = clampedBrain(live, record.brain);
|
|
2388
|
+
}
|
|
2389
|
+
persist({ force: true });
|
|
2390
|
+
broadcast('assistant:session-created', live);
|
|
2391
|
+
return enabled.fallback ? { ...view(live), fallback: enabled.fallback } : view(live);
|
|
2392
|
+
}
|
|
2393
|
+
function applyRouteMode(live, mode) {
|
|
2394
|
+
const normalized = mode === null || mode === undefined || mode === '' ? null : normalizeRouteMode(mode);
|
|
2395
|
+
if (mode && !normalized) { const error = new Error(`Unknown route mode ${mode}`); error.code = 'ROUTE_MODE_INVALID'; error.status = 400; throw error; }
|
|
2396
|
+
live.record.routing = { ...(live.record.routing || {}), mode: normalized };
|
|
2397
|
+
}
|
|
2398
|
+
function applyComputerUse(live, enabled) {
|
|
2399
|
+
// A remote session's computer use is not a per-conversation choice: the owner's switch and
|
|
2400
|
+
// the level decide (remoteComputerUse). A PATCH, the socket and updateSession change nothing.
|
|
2401
|
+
if (remotePolicyFor(live)) return;
|
|
2402
|
+
const value = enabled === null || enabled === undefined ? null : !!enabled;
|
|
2403
|
+
live.record.computerUse = value;
|
|
2404
|
+
try { desktop?.onSessionToggle?.(live.record.id, computerEffective(live.record)); } catch {}
|
|
2405
|
+
}
|
|
2406
|
+
async function updateSession(id, patch = {}) {
|
|
2407
|
+
const live = sessions.get(id);
|
|
2408
|
+
if (!live) return null;
|
|
2409
|
+
if (patch.title) live.record.title = clip(patch.title, 60);
|
|
2410
|
+
if (patch.routing && typeof patch.routing === 'object' && 'mode' in patch.routing) applyRouteMode(live, patch.routing.mode);
|
|
2411
|
+
if ('computerUse' in patch) applyComputerUse(live, patch.computerUse);
|
|
2412
|
+
if (patch.brain && typeof patch.brain === 'object') {
|
|
2413
|
+
// A provider switch that names no model starts from the new provider's
|
|
2414
|
+
// default, never from the old provider's model id.
|
|
2415
|
+
const switching = BRAIN_PROVIDERS.has(patch.brain.provider) && patch.brain.provider !== live.record.brain.provider;
|
|
2416
|
+
const base = switching && !patch.brain.model ? { ...live.record.brain, model: null } : live.record.brain;
|
|
2417
|
+
let next = normalizeBrain({ ...base, ...patch.brain, model: patch.brain.model || base.model }, base, { catalog: catalog?.peek?.() || null });
|
|
2418
|
+
const modelChanged = next.model !== live.record.brain.model || next.provider !== live.record.brain.provider;
|
|
2419
|
+
if (modelChanged) next = (await enabledBrain(next)).brain;
|
|
2420
|
+
// A remote session keeps what was asked for and runs what its level allows.
|
|
2421
|
+
if ('permissionMode' in patch.brain || 'planMode' in patch.brain) next = clampModeWrite(live, next);
|
|
2422
|
+
else next = clampedBrain(live, next);
|
|
2423
|
+
const providerChanged = next.provider !== live.record.brain.provider || next.accountId !== live.record.brain.accountId || next.cwd !== live.record.brain.cwd || next.mcpProfile !== live.record.brain.mcpProfile;
|
|
2424
|
+
// A usage limit is the account's: another provider or account starts without the notice.
|
|
2425
|
+
if (next.provider !== live.record.brain.provider || next.accountId !== live.record.brain.accountId) setLimit(live, null);
|
|
2426
|
+
live.record.brain = next;
|
|
2427
|
+
live.record.project = detectProject(next.cwd) || live.record.project || 'global';
|
|
2428
|
+
if (providerChanged) {
|
|
2429
|
+
await disposeBrain(live, 'switched');
|
|
2430
|
+
live.record.providerSessionId = null;
|
|
2431
|
+
live.record.providerThreadId = null;
|
|
2432
|
+
if (live.record.usageState) delete live.record.usageState[next.provider === 'claude-code' ? 'claude' : next.provider];
|
|
2433
|
+
if (live.record.usageState) delete live.record.usageState.codexThreadId;
|
|
2434
|
+
live.record.status = 'idle';
|
|
2435
|
+
// A remote session's level follows its brain (a switch to Codex / OpenCode is read-only at once).
|
|
2436
|
+
try { remotePolicy?.setSessionBrain?.(live.record.id, next.provider); } catch {}
|
|
2437
|
+
} else if (live.brain) {
|
|
2438
|
+
if (next.permissionMode !== (patch.previousPermissionMode || null) || typeof patch.brain.planMode === 'boolean') { try { await live.brain.setPermissionMode?.(next.permissionMode, { planMode: next.planMode === true }); } catch {} }
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
touch(live);
|
|
2442
|
+
broadcast('assistant:session-updated', live);
|
|
2443
|
+
sendToSockets(live, { type: 'assistant:session', session: view(live), reason: 'updated' });
|
|
2444
|
+
return view(live);
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* Nothing pending outlives a session, and closing starts before anything
|
|
2448
|
+
* awaits (one close at a time). A new generation drops the queued work, every
|
|
2449
|
+
* turn still on its way to the brain (a prompt being set up and the mailbox
|
|
2450
|
+
* too: runTurn) with its busy count, and the plan card. Until the brain is gone
|
|
2451
|
+
* (`closing`, → the call that ends it) nothing starts and no prompt is taken,
|
|
2452
|
+
* and a socket that attaches waits (handleWebSocket): the session reopens clean.
|
|
2453
|
+
*/
|
|
2454
|
+
async function endSessionWork(live) {
|
|
2455
|
+
while (live.closing) await live.closing;
|
|
2456
|
+
let done = null;
|
|
2457
|
+
live.closing = new Promise((resolve) => { done = resolve; });
|
|
2458
|
+
newGeneration(live, 'brain_disposed');
|
|
2459
|
+
live.closedGen = live.gen;
|
|
2460
|
+
live.turnPending = 0;
|
|
2461
|
+
live.prompting = null;
|
|
2462
|
+
live.workActive = null;
|
|
2463
|
+
if (live.workTimer) { clearTimeout(live.workTimer); live.workTimer = null; }
|
|
2464
|
+
live.gateNudge = null;
|
|
2465
|
+
return () => { live.closing = null; done(); };
|
|
2466
|
+
}
|
|
2467
|
+
async function closeSession(id) {
|
|
2468
|
+
const live = requireLive(id);
|
|
2469
|
+
const closed = await endSessionWork(live);
|
|
2470
|
+
try {
|
|
2471
|
+
await disposeBrain(live, 'closed');
|
|
2472
|
+
live.record.status = 'ended';
|
|
2473
|
+
touch(live);
|
|
2474
|
+
persist({ force: true });
|
|
2475
|
+
broadcast('assistant:session-ended', live);
|
|
2476
|
+
} finally { closed(); }
|
|
2477
|
+
return view(live);
|
|
2478
|
+
}
|
|
2479
|
+
async function destroySession(id) {
|
|
2480
|
+
const live = sessions.get(id);
|
|
2481
|
+
if (!live) return false;
|
|
2482
|
+
const closed = await endSessionWork(live);
|
|
2483
|
+
try {
|
|
2484
|
+
await disposeBrain(live, 'closed');
|
|
2485
|
+
for (const ws of live.sockets) { try { ws.close(1000, 'session destroyed'); } catch {} }
|
|
2486
|
+
sessions.delete(id);
|
|
2487
|
+
const usageTimer = usageTimers.get(id);
|
|
2488
|
+
if (usageTimer) clearTimeout(usageTimer);
|
|
2489
|
+
usageTimers.delete(id);
|
|
2490
|
+
usageSentAt.delete(id);
|
|
2491
|
+
metered('drop', () => usage?.dropSession?.(id));
|
|
2492
|
+
try { remotePolicy?.forgetSession?.(id); } catch {}
|
|
2493
|
+
try { rmSync(resolve(dataDir, 'assistant', id), { recursive: true, force: true }); } catch {}
|
|
2494
|
+
persist({ force: true });
|
|
2495
|
+
broadcast('assistant:session-ended', live, { destroyed: true });
|
|
2496
|
+
} finally { closed(); }
|
|
2497
|
+
return true;
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* MCP terminal pin → assistant session id. Claude brains pin the session id
|
|
2501
|
+
* itself; the OpenCode brain pins `assistant-oc-<24>` (its isolated serve).
|
|
2502
|
+
*/
|
|
2503
|
+
function resolveTerminal(pin) {
|
|
2504
|
+
if (!pin) return null;
|
|
2505
|
+
if (sessions.has(pin)) return pin;
|
|
2506
|
+
for (const [id, live] of sessions) {
|
|
2507
|
+
if (live.brain?.termId === pin) return id;
|
|
2508
|
+
if (/^assistant-oc-/.test(pin) && `assistant-oc-${String(id).replace(/^assistant-/, '').slice(0, 24)}` === pin) return id;
|
|
2509
|
+
}
|
|
2510
|
+
return null;
|
|
2511
|
+
}
|
|
2512
|
+
function accountInUse(provider, accountId) {
|
|
2513
|
+
for (const live of sessions.values()) {
|
|
2514
|
+
if (live.brain && live.record.brain?.provider === provider && (live.record.brain?.accountId || 'default') === accountId) return true;
|
|
2515
|
+
}
|
|
2516
|
+
return false;
|
|
2517
|
+
}
|
|
2518
|
+
function ownsIsolatedServe(termId) {
|
|
2519
|
+
for (const live of sessions.values()) if (live.brain?.termId === termId) return true;
|
|
2520
|
+
return false;
|
|
2521
|
+
}
|
|
2522
|
+
function noteDispatch(sessionId, runId) {
|
|
2523
|
+
const live = sessions.get(sessionId);
|
|
2524
|
+
if (!live || live.record.dispatchRunIds?.includes(runId)) return;
|
|
2525
|
+
live.record.dispatchRunIds = [...(live.record.dispatchRunIds || []), runId].slice(-100);
|
|
2526
|
+
touch(live);
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
// ── programmatic surface (the WhatsApp bridge; the socket uses the same paths) ──
|
|
2530
|
+
/** Every packet any session sends, as { sessionId, packet }. Returns the unsubscribe. */
|
|
2531
|
+
function observe(listener) {
|
|
2532
|
+
if (typeof listener !== 'function') throw new Error('observe requires a function');
|
|
2533
|
+
packetObservers.add(listener);
|
|
2534
|
+
return () => packetObservers.delete(listener);
|
|
2535
|
+
}
|
|
2536
|
+
/** Something runs, or the approved plan's turn or a compact is waiting to (a prompt would be refused). */
|
|
2537
|
+
function isBusy(id) { const live = sessions.get(String(id || '')); return sessionBusy(live) || (!!live && !!workReserved(live)); }
|
|
2538
|
+
/** The session's remote policy (lib/remote-policy.js), or null. */
|
|
2539
|
+
function sessionPolicy(id) { const live = sessions.get(String(id || '')); return live ? remotePolicyFor(live) : null; }
|
|
2540
|
+
/** Where the session's conversation happens: 'whatsapp' (WhatsApp Link), or null for the panel. */
|
|
2541
|
+
function sessionChannel(id) { const live = sessions.get(String(id || '')); return live ? (live.record.channel || live.record.remote?.channel || null) : null; }
|
|
2542
|
+
/**
|
|
2543
|
+
* A human prompt from outside the panel → { ok:true } or { ok:false, code, message }.
|
|
2544
|
+
* `requireIdle` refuses while anything runs (ASSISTANT_BUSY) instead of queueing
|
|
2545
|
+
* behind it; `untrusted` marks text the owner did not write (capped at ask).
|
|
2546
|
+
*/
|
|
2547
|
+
async function submit(id, { text = '', images = [], origin = 'api', requireIdle = true, untrusted = false, authority = null } = {}) {
|
|
2548
|
+
const live = sessions.get(String(id || ''));
|
|
2549
|
+
if (!live) return { ok: false, code: 'SESSION_NOT_FOUND', message: `Unknown assistant session ${id}` };
|
|
2550
|
+
if (live.record.status === 'ended' || live.closing) return { ok: false, code: 'SESSION_ENDED', message: 'This assistant session was closed.' };
|
|
2551
|
+
if (requireIdle && isBusy(live.record.id)) return { ok: false, code: 'ASSISTANT_BUSY', message: 'The assistant is still working; wait for the turn to finish or stop it.' };
|
|
2552
|
+
const pictures = (Array.isArray(images) ? images : []).filter((img) => img && img.base64).map((img) => ({ base64: String(img.base64), mediaType: String(img.mediaType || 'image/jpeg') }));
|
|
2553
|
+
try {
|
|
2554
|
+
return await runQuery(live, { text, images: pictures, origin: String(origin || 'api'), untrusted: untrusted === true, authority });
|
|
2555
|
+
} catch (error) {
|
|
2556
|
+
return { ok: false, code: error?.code || 'BRAIN_START_FAILED', message: error?.message || String(error) };
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
/**
|
|
2560
|
+
* Stop the running turn (the panel's Stop, WhatsApp's /stop): everything
|
|
2561
|
+
* pending for the session goes too. A new generation drops the queued work
|
|
2562
|
+
* (the approved plan's turn, a revision, a continuation, the nudge, a queued
|
|
2563
|
+
* compact) and a turn still on its way to the brain (a slow brain start
|
|
2564
|
+
* included), and closes the plan card (late answers are refused), which
|
|
2565
|
+
* releases its hold: the mailbox goes on. The in-turn route cards close.
|
|
2566
|
+
*/
|
|
2567
|
+
async function stopTurn(id, { origin = 'api' } = {}) {
|
|
2568
|
+
const live = sessions.get(String(id || ''));
|
|
2569
|
+
if (!live) return { ok: false, code: 'SESSION_NOT_FOUND', message: `Unknown assistant session ${id}` };
|
|
2570
|
+
newGeneration(live, 'stopped');
|
|
2571
|
+
// A remote session: computer use stops with the turn, before the brain is even asked to (its
|
|
2572
|
+
// approval, its open request, the live grant; an action in progress is aborted).
|
|
2573
|
+
endComputerTurn(live, 'stopped');
|
|
2574
|
+
try { router?.cancelForSession?.(live.record.id, 'aborted', { origins: ['agent_route', 'vision_guard'] }); } catch {}
|
|
2575
|
+
if (origin !== 'ui') log('assistant:stop', `${live.record.id}: turn stopped from ${origin}`);
|
|
2576
|
+
try { await live.brain?.abort?.(); } catch (error) { return { ok: false, code: 'STOP_FAILED', message: error?.message || String(error) }; }
|
|
2577
|
+
return { ok: true };
|
|
2578
|
+
}
|
|
2579
|
+
function controlResolved(live, requestId, origin) {
|
|
2580
|
+
sendToSockets(live, { type: 'control_resolved', request_id: requestId, origin: String(origin || 'ui') });
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* Answer an open card of this session: a route card (the router), a clarify
|
|
2584
|
+
* card (the clarifier) or the brain's own request, which must be pending — an
|
|
2585
|
+
* unknown id never reaches the brain, and a card of another session is unknown
|
|
2586
|
+
* here. → { ok, kind, code?, message? }; on success every socket hears
|
|
2587
|
+
* control_resolved with the origin.
|
|
2588
|
+
*
|
|
2589
|
+
* `superseded` (an option, with a denial): the card closes because the user
|
|
2590
|
+
* wrote something else instead of answering it (the WhatsApp bridge). Nothing
|
|
2591
|
+
* is approved, and it is not the user's own "no": a route is cancelled without
|
|
2592
|
+
* a "declined" event, a clarify round without "go with your assumptions". Only
|
|
2593
|
+
* an in-process caller can pass it: a `superseded` key inside a response (a
|
|
2594
|
+
* socket, the REST API, a brain) is dropped and never honoured.
|
|
2595
|
+
*/
|
|
2596
|
+
async function answerControl(id, requestId, response = {}, { origin = 'ui', superseded: movedOn = false, authority = null } = {}) {
|
|
2597
|
+
const live = sessions.get(String(id || ''));
|
|
2598
|
+
if (!live) return { ok: false, code: 'SESSION_NOT_FOUND', message: `Unknown assistant session ${id}` };
|
|
2599
|
+
const rid = String(requestId ?? '');
|
|
2600
|
+
if (!rid) return { ok: false, code: 'REQUEST_ID_REQUIRED', message: 'request_id is required' };
|
|
2601
|
+
const { superseded: _dropped, ...inner } = response && typeof response === 'object' ? response : {};
|
|
2602
|
+
const superseded = movedOn === true && (inner.decline === true || inner.behavior === 'deny');
|
|
2603
|
+
const sid = live.record.id;
|
|
2604
|
+
// Route cards are answered by the router and never reach the brain.
|
|
2605
|
+
if (router?.owns?.(rid, sid)) {
|
|
2606
|
+
if (superseded && typeof router.cancel === 'function') {
|
|
2607
|
+
controlResolved(live, rid, origin);
|
|
2608
|
+
router.cancel(rid, 'superseded', { sessionId: sid });
|
|
2609
|
+
return { ok: true, kind: 'route', superseded: true };
|
|
2610
|
+
}
|
|
2611
|
+
let decided = null;
|
|
2612
|
+
try { decided = await router.answer(rid, inner, { origin, sessionId: sid, authority }); }
|
|
2613
|
+
catch (error) { return { ok: false, kind: 'route', code: error?.code || 'ROUTE_INVALID', message: error?.message || String(error) }; }
|
|
2614
|
+
// A "do it here" pick whose turn is gone (routerContinue refused it): the
|
|
2615
|
+
// router closed the card as expired on every host, and nothing was approved.
|
|
2616
|
+
if (decided?.status === 'expired') {
|
|
2617
|
+
return { ok: false, kind: 'route', code: 'ROUTE_EXPIRED', reason: decided.reason || 'expired', message: 'That route card expired: a newer message, a Stop or closing the session came after its turn, so nothing was approved.' };
|
|
2618
|
+
}
|
|
2619
|
+
controlResolved(live, rid, origin);
|
|
2620
|
+
return { ok: true, kind: 'route' };
|
|
2621
|
+
}
|
|
2622
|
+
// Clarify cards are answered by the clarifier; the brain hears it in its tool result or its mailbox.
|
|
2623
|
+
if (clarifier?.owns?.(rid, sid)) {
|
|
2624
|
+
if (superseded && typeof clarifier.cancel === 'function') {
|
|
2625
|
+
controlResolved(live, rid, origin);
|
|
2626
|
+
clarifier.cancel(rid, 'superseded', { sessionId: sid });
|
|
2627
|
+
return { ok: true, kind: 'clarify', superseded: true };
|
|
2628
|
+
}
|
|
2629
|
+
try { clarifier.answer(rid, inner, { origin, sessionId: sid }); }
|
|
2630
|
+
catch (error) { return { ok: false, kind: 'clarify', code: error?.code || 'CLARIFY_INVALID', message: error?.message || String(error) }; }
|
|
2631
|
+
controlResolved(live, rid, origin);
|
|
2632
|
+
return { ok: true, kind: 'clarify' };
|
|
2633
|
+
}
|
|
2634
|
+
// Computer use of a WhatsApp session is granted on the owner's phone only (the bridge's match,
|
|
2635
|
+
// proved by its capability): an "allow" from anyone else, whatever origin it names, is refused
|
|
2636
|
+
// here, before the request is touched. It stays open.
|
|
2637
|
+
if (live.computerAsk?.requestId === rid && !fromPhone(authority) && inner.behavior === 'allow') {
|
|
2638
|
+
return { ok: false, kind: 'computer', code: 'COMPUTER_PHONE_ONLY', message: 'Controlling the Mac from a WhatsApp conversation is approved on the owner\'s phone only. It can be denied here.' };
|
|
2639
|
+
}
|
|
2640
|
+
const pending = live.pendingControls.get(rid);
|
|
2641
|
+
if (!pending) {
|
|
2642
|
+
// A plan card a newer turn replaced (or that expired): its plan is not the one to approve.
|
|
2643
|
+
const closed = live.closedPlanCards.get(rid);
|
|
2644
|
+
if (closed) {
|
|
2645
|
+
const message = closed === 'superseded'
|
|
2646
|
+
? 'That plan card expired: a newer message replaced it, so nothing was approved.'
|
|
2647
|
+
: closed === 'stopped' ? 'That plan card was closed by Stop, so nothing was approved.' : 'That plan card expired, so nothing was approved.';
|
|
2648
|
+
return { ok: false, kind: 'plan', code: 'PLAN_CARD_CLOSED', reason: closed, message };
|
|
2649
|
+
}
|
|
2650
|
+
return { ok: false, kind: 'brain', code: 'CONTROL_UNKNOWN', message: `No open request ${rid} in this session.` };
|
|
2651
|
+
}
|
|
2652
|
+
live.pendingControls.delete(rid);
|
|
2653
|
+
clearControlTimer(live, rid);
|
|
2654
|
+
// A remote turn's "control your Mac for this?" request: the runtime's own, claimed and settled
|
|
2655
|
+
// in this same synchronous step (the brain's waiting calls get the answer from here).
|
|
2656
|
+
const ask = live.computerAsk?.requestId === rid ? live.computerAsk : null;
|
|
2657
|
+
if (ask) {
|
|
2658
|
+
// Closed because the owner wrote something else (`movedOn`): never a grant, whatever the response says.
|
|
2659
|
+
const granted = settleComputerAsk(live, ask, inner.behavior === 'allow' && movedOn !== true, { origin, message: inner.message, authority });
|
|
2660
|
+
controlResolved(live, rid, origin);
|
|
2661
|
+
return { ok: true, kind: 'computer', granted };
|
|
2662
|
+
}
|
|
2663
|
+
// The runtime's own plan card (a Codex / OpenCode plan turn that ended) never reaches the brain.
|
|
2664
|
+
const card = live.planCard?.requestId === rid ? live.planCard : null;
|
|
2665
|
+
if (card) {
|
|
2666
|
+
answerPlanCard(live, card, inner, { superseded });
|
|
2667
|
+
controlResolved(live, rid, origin);
|
|
2668
|
+
return { ok: true, kind: 'plan' };
|
|
2669
|
+
}
|
|
2670
|
+
// An answer to the brain's own question card joins the request's brief.
|
|
2671
|
+
try { clarifier?.onNativeAnswer?.(live.record.id, { requestId: rid, response: inner }); } catch {}
|
|
2672
|
+
// A plan approved on the desktop may run even in a read-only remote session (this turn only).
|
|
2673
|
+
const tool = String(pending.request?.tool_name || pending.request?.toolName || '');
|
|
2674
|
+
if (origin === 'ui' && tool === 'ExitPlanMode' && inner.behavior === 'allow') live.planApprovedByDesktop = true;
|
|
2675
|
+
// Plan mode ends before the brain hears the approval (Claude continues in the same turn).
|
|
2676
|
+
if (tool === 'ExitPlanMode' && inner.behavior === 'allow') approvePlan(live, inner.planDecision || null);
|
|
2677
|
+
live.brain?.respondControl?.(rid, inner);
|
|
2678
|
+
controlResolved(live, rid, origin);
|
|
2679
|
+
return { ok: true, kind: 'brain' };
|
|
2680
|
+
}
|
|
2681
|
+
/** Answer a worker's permission request of this session; the run records `origin` as who resolved it. */
|
|
2682
|
+
function answerDispatchControl(id, runId, requestId, response = {}, { origin = 'ui' } = {}) {
|
|
2683
|
+
if (!dispatcher?.respondPermission) return { ok: false, code: 'DISPATCH_UNAVAILABLE', message: 'Dispatch is not available' };
|
|
2684
|
+
const live = sessions.get(String(id || ''));
|
|
2685
|
+
if (!live) return { ok: false, code: 'SESSION_NOT_FOUND', message: `Unknown assistant session ${id}` };
|
|
2686
|
+
let run = null;
|
|
2687
|
+
try { run = dispatcher.get?.(runId) || null; } catch { run = null; }
|
|
2688
|
+
if (run?.assistantSessionId && String(run.assistantSessionId) !== live.record.id) {
|
|
2689
|
+
return { ok: false, code: 'RUN_NOT_IN_SESSION', message: `Run ${runId} belongs to another assistant session` };
|
|
2690
|
+
}
|
|
2691
|
+
try {
|
|
2692
|
+
dispatcher.respondPermission(runId, requestId, response || {}, { origin: String(origin || 'ui') });
|
|
2693
|
+
return { ok: true };
|
|
2694
|
+
} catch (error) {
|
|
2695
|
+
return { ok: false, code: error?.code || 'PERMISSION_FAILED', message: error?.message || String(error) };
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
/** The open cards of a session, as the panel replays them: the brain's own, then route and clarify cards. */
|
|
2699
|
+
function pendingControls(id) {
|
|
2700
|
+
const live = sessions.get(String(id || ''));
|
|
2701
|
+
if (!live) return [];
|
|
2702
|
+
const out = [...live.pendingControls.values()];
|
|
2703
|
+
try { out.push(...(router?.pendingCards?.(live.record.id) || [])); } catch {}
|
|
2704
|
+
try { out.push(...(clarifier?.pendingCards?.(live.record.id) || [])); } catch {}
|
|
2705
|
+
return out;
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
// ── WebSocket handling ─────────────────────────────────────────────────────
|
|
2709
|
+
function sessionIdFromUrl(url) {
|
|
2710
|
+
const pathname = typeof url === 'string' ? url : url?.pathname || '';
|
|
2711
|
+
const match = /\/ws\/assistant\/([^/?#]+)/.exec(pathname);
|
|
2712
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
2713
|
+
}
|
|
2714
|
+
async function handleWebSocket(ws, url) {
|
|
2715
|
+
const id = sessionIdFromUrl(url);
|
|
2716
|
+
const live = id ? sessions.get(id) : null;
|
|
2717
|
+
if (!live) {
|
|
2718
|
+
try { ws.send(JSON.stringify({ type: 'error', message: 'Unknown assistant session', code: 'SESSION_NOT_FOUND' })); ws.close(4404, 'unknown session'); } catch {}
|
|
2719
|
+
return;
|
|
2720
|
+
}
|
|
2721
|
+
const receive = (msg) => handleClientMessage(live, ws, msg).catch((error) => {
|
|
2722
|
+
log('assistant:ws-error', error?.message || String(error));
|
|
2723
|
+
// A prompt that failed to start carries its request id back: the panel restores its text.
|
|
2724
|
+
const requestId = msg?.type === 'query' && msg.request_id ? { request_id: String(msg.request_id) } : {};
|
|
2725
|
+
try { ws.send(JSON.stringify({ type: 'error', code: error?.code || undefined, message: error?.message || String(error), ...requestId })); } catch {}
|
|
2726
|
+
});
|
|
2727
|
+
// A close still disposing the brain: this socket attaches once it is over (the
|
|
2728
|
+
// session reopens clean, nothing of the old brain's turns left), and what it
|
|
2729
|
+
// sends meanwhile is handled after that, in order.
|
|
2730
|
+
let held = live.closing ? [] : null;
|
|
2731
|
+
ws.on('message', (raw) => {
|
|
2732
|
+
let msg;
|
|
2733
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
2734
|
+
if (held) held.push(msg); else receive(msg);
|
|
2735
|
+
});
|
|
2736
|
+
ws.on('close', () => {
|
|
2737
|
+
live.sockets.delete(ws);
|
|
2738
|
+
scheduleDispose(live);
|
|
2739
|
+
});
|
|
2740
|
+
if (held) {
|
|
2741
|
+
while (live.closing) await live.closing;
|
|
2742
|
+
if (sessions.get(id) !== live) {
|
|
2743
|
+
try { ws.send(JSON.stringify({ type: 'error', message: 'Unknown assistant session', code: 'SESSION_NOT_FOUND' })); ws.close(4404, 'unknown session'); } catch {}
|
|
2744
|
+
return;
|
|
2745
|
+
}
|
|
2746
|
+
if (ws.readyState !== 1) return; // gone while it waited
|
|
2747
|
+
}
|
|
2748
|
+
if (live.disposeTimer) { clearTimeout(live.disposeTimer); live.disposeTimer = null; }
|
|
2749
|
+
live.sockets.add(ws);
|
|
2750
|
+
live.record.status = live.record.status === 'ended' ? 'idle' : live.record.status;
|
|
2751
|
+
const engine = live.brain ? { type: 'engine', engine: live.brainProvider === 'claude-code' ? 'sdk' : live.brainProvider, brain: live.brainProvider, sdkVersion } : { type: 'engine', engine: 'assistant', brain: live.record.brain.provider, sdkVersion };
|
|
2752
|
+
try { ws.send(JSON.stringify(engine)); } catch {}
|
|
2753
|
+
try { ws.send(JSON.stringify({ type: 'assistant:session', session: view(live), reason: 'attached' })); } catch {}
|
|
2754
|
+
if (usage) try { ws.send(JSON.stringify({ type: 'assistant:usage', ...usageView(live.record.id) })); } catch {}
|
|
2755
|
+
// The limit notice as it is now (null clears one this panel still shows from before).
|
|
2756
|
+
sendLimit(live, [ws]);
|
|
2757
|
+
const replay = live.buffer.splice(0, live.buffer.length);
|
|
2758
|
+
try { ws.send(JSON.stringify({ type: 'reattach_result', ok: true, sessionId: live.record.id, running: live.running, replayed: replay.length })); } catch {}
|
|
2759
|
+
for (const packet of replay) { try { ws.send(JSON.stringify(packet)); } catch {} }
|
|
2760
|
+
for (const pending of live.pendingControls.values()) { try { ws.send(JSON.stringify(pending)); } catch {} }
|
|
2761
|
+
// Route and clarify cards live in the router / clarifier, not the brain: replay the open ones.
|
|
2762
|
+
for (const card of router?.pendingCards?.(live.record.id) || []) { try { ws.send(JSON.stringify(card)); } catch {} }
|
|
2763
|
+
for (const card of clarifier?.pendingCards?.(live.record.id) || []) { try { ws.send(JSON.stringify(card)); } catch {} }
|
|
2764
|
+
const waiting = held || [];
|
|
2765
|
+
held = null;
|
|
2766
|
+
for (const msg of waiting) receive(msg);
|
|
2767
|
+
}
|
|
2768
|
+
async function handleClientMessage(live, ws, msg) {
|
|
2769
|
+
const reply = (packet) => { try { ws.send(JSON.stringify(packet)); } catch {} };
|
|
2770
|
+
switch (msg.type) {
|
|
2771
|
+
case 'query': {
|
|
2772
|
+
// `request_id`: the panel's id for this prompt. A refusal reaches this socket
|
|
2773
|
+
// alone and carries it back, so this panel (only) puts the text back.
|
|
2774
|
+
const request = { id: msg.request_id ? String(msg.request_id) : null, reply };
|
|
2775
|
+
const result = await runQuery(live, { text: msg.prompt, images: msg.images || [], cwd: msg.cwd || null, model: msg.model || null, effort: msg.effort || null, permissionMode: msg.permissionMode || null, planMode: typeof msg.planMode === 'boolean' ? msg.planMode : null, origin: 'ui', request });
|
|
2776
|
+
// Refused before it ran: the panel, "working" since it sent, shows what runs now.
|
|
2777
|
+
if (result && !result.ok && result.code !== 'TURN_FAILED') reply({ type: 'reattach_result', ok: true, sessionId: live.record.id, running: isBusy(live.record.id) });
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
case 'control_response': {
|
|
2781
|
+
const requestId = String(msg.request_id || msg.response?.request_id || '');
|
|
2782
|
+
const inner = msg.response?.response || msg.response || {};
|
|
2783
|
+
if (!requestId) return;
|
|
2784
|
+
const answered = await answerControl(live.record.id, requestId, inner, { origin: 'ui' });
|
|
2785
|
+
// A rejected route or clarify answer keeps its card open with the message; an
|
|
2786
|
+
// unknown brain request is dropped, as the brain itself would. A route card
|
|
2787
|
+
// that expired meanwhile already reads Expired: this host hears why.
|
|
2788
|
+
if (!answered.ok && answered.code === 'ROUTE_EXPIRED') {
|
|
2789
|
+
try { ws.send(JSON.stringify({ type: 'event', event: { type: 'system', subtype: 'status', text: answered.message } })); } catch {}
|
|
2790
|
+
} else if (!answered.ok && (answered.kind === 'route' || answered.kind === 'clarify')) {
|
|
2791
|
+
try { ws.send(JSON.stringify({ type: 'error', code: answered.code, request_id: requestId, message: answered.message })); } catch {}
|
|
2792
|
+
}
|
|
2793
|
+
// An "allow" for computer use that only the phone may give: the card stays open and says so.
|
|
2794
|
+
if (!answered.ok && answered.code === 'COMPUTER_PHONE_ONLY') {
|
|
2795
|
+
try { ws.send(JSON.stringify({ type: 'event', event: { type: 'system', subtype: 'status', text: answered.message } })); } catch {}
|
|
2796
|
+
}
|
|
2797
|
+
// A refused answer to a closed plan card: that card shows it expired, and the
|
|
2798
|
+
// panel, which went "working" on the answer, shows what really runs.
|
|
2799
|
+
if (!answered.ok && answered.kind === 'plan') {
|
|
2800
|
+
for (const packet of [
|
|
2801
|
+
{ type: 'control_cancelled', request_id: requestId, reason: answered.reason },
|
|
2802
|
+
{ type: 'event', event: { type: 'system', subtype: 'status', text: answered.message } },
|
|
2803
|
+
{ type: 'reattach_result', ok: true, sessionId: live.record.id, running: sessionBusy(live) },
|
|
2804
|
+
]) { try { ws.send(JSON.stringify(packet)); } catch {} }
|
|
2805
|
+
}
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
case 'abort':
|
|
2809
|
+
await stopTurn(live.record.id, { origin: 'ui' });
|
|
2810
|
+
return;
|
|
2811
|
+
case 'set_route_mode':
|
|
2812
|
+
applyRouteMode(live, msg.mode);
|
|
2813
|
+
touch(live);
|
|
2814
|
+
broadcast('assistant:session-updated', live);
|
|
2815
|
+
sendToSockets(live, { type: 'assistant:session', session: view(live), reason: 'routing' });
|
|
2816
|
+
return;
|
|
2817
|
+
case 'set_computer_use':
|
|
2818
|
+
applyComputerUse(live, msg.enabled);
|
|
2819
|
+
touch(live);
|
|
2820
|
+
broadcast('assistant:session-updated', live);
|
|
2821
|
+
sendToSockets(live, { type: 'assistant:session', session: view(live), reason: 'computer' });
|
|
2822
|
+
return;
|
|
2823
|
+
case 'desktop_stop':
|
|
2824
|
+
try { await desktop?.stop?.({ scope: 'all', reason: 'ui', assistantSessionId: live.record.id }); } catch (error) { log('assistant:desktop-stop-error', error?.message || String(error)); }
|
|
2825
|
+
return;
|
|
2826
|
+
case 'set_permission_mode':
|
|
2827
|
+
if (msg.mode || typeof msg.planMode === 'boolean') {
|
|
2828
|
+
const asked = { ...live.record.brain, ...(msg.mode ? brainModeUpdate(live.record.brain, msg.mode) : {}) };
|
|
2829
|
+
if (typeof msg.planMode === 'boolean') asked.planMode = msg.planMode;
|
|
2830
|
+
const next = clampModeWrite(live, asked);
|
|
2831
|
+
live.record.brain = next;
|
|
2832
|
+
touch(live);
|
|
2833
|
+
await live.brain?.setPermissionMode?.(next.permissionMode, { planMode: next.planMode === true });
|
|
2834
|
+
// The panel shows what the level allows, not what it asked for.
|
|
2835
|
+
if (next !== asked) sendToSockets(live, { type: 'assistant:session', session: view(live), reason: 'remote' });
|
|
2836
|
+
}
|
|
2837
|
+
return;
|
|
2838
|
+
case 'compact':
|
|
2839
|
+
live.record.memoryGeneration = (live.record.memoryGeneration || 1) + 1;
|
|
2840
|
+
touch(live);
|
|
2841
|
+
// Session work: it starts once a running turn ends, and nothing else starts until it settles.
|
|
2842
|
+
queueWork(live, { kind: 'compact', reply });
|
|
2843
|
+
return;
|
|
2844
|
+
case 'clear':
|
|
2845
|
+
live.record.memoryGeneration = (live.record.memoryGeneration || 1) + 1;
|
|
2846
|
+
touch(live);
|
|
2847
|
+
return;
|
|
2848
|
+
case 'heartbeat':
|
|
2849
|
+
live.record.lastActiveAt = iso(now());
|
|
2850
|
+
if (msg.windowId) live.record.windowId = msg.windowId;
|
|
2851
|
+
return;
|
|
2852
|
+
case 'reattach':
|
|
2853
|
+
try { ws.send(JSON.stringify({ type: 'reattach_result', ok: true, sessionId: live.record.id, running: live.running })); } catch {}
|
|
2854
|
+
// The panel draws route and clarify cards only from control_request packets (deduped by request_id).
|
|
2855
|
+
for (const card of router?.pendingCards?.(live.record.id) || []) { try { ws.send(JSON.stringify(card)); } catch {} }
|
|
2856
|
+
for (const card of clarifier?.pendingCards?.(live.record.id) || []) { try { ws.send(JSON.stringify(card)); } catch {} }
|
|
2857
|
+
return;
|
|
2858
|
+
case 'switch_brain':
|
|
2859
|
+
await updateSession(live.record.id, { brain: { provider: msg.brain || msg.provider, model: msg.model, effort: msg.effort, accountId: msg.accountId, mcpProfile: msg.mcpProfile, cwd: msg.cwd, agent: msg.agent, permissionMode: msg.permissionMode, planMode: typeof msg.planMode === 'boolean' ? msg.planMode : undefined } });
|
|
2860
|
+
return;
|
|
2861
|
+
case 'list_sessions': {
|
|
2862
|
+
const rows = listSessions();
|
|
2863
|
+
try { ws.send(JSON.stringify({ type: 'assistant:sessions', sessions: rows })); } catch {}
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
case 'resume':
|
|
2867
|
+
if (msg.providerSessionId) {
|
|
2868
|
+
const moved = msg.providerSessionId !== live.record.providerSessionId;
|
|
2869
|
+
live.record.providerSessionId = msg.providerSessionId;
|
|
2870
|
+
// Another provider session: the meter's baseline was the old one's.
|
|
2871
|
+
if (moved) resetMeterIdentity(live, msg.providerSessionId);
|
|
2872
|
+
touch(live);
|
|
2873
|
+
await live.brain?.resume?.(msg.providerSessionId);
|
|
2874
|
+
}
|
|
2875
|
+
return;
|
|
2876
|
+
case 'mcp_status':
|
|
2877
|
+
await live.brain?.mcpStatus?.();
|
|
2878
|
+
return;
|
|
2879
|
+
case 'dispatch_control_response': {
|
|
2880
|
+
if (!dispatcher) return;
|
|
2881
|
+
const answered = answerDispatchControl(live.record.id, msg.runId, msg.request_id, msg.response || {}, { origin: 'user' });
|
|
2882
|
+
if (!answered.ok) { try { ws.send(JSON.stringify({ type: 'error', message: answered.message })); } catch {} }
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
default:
|
|
2886
|
+
return;
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
// ── router sinks (wired lazily in server.js; see assistant-router.js) ──────
|
|
2891
|
+
function routerSession(id) {
|
|
2892
|
+
const live = sessions.get(id);
|
|
2893
|
+
if (!live) return null;
|
|
2894
|
+
// A remote session below autonomous: one route card per task is the task's approval.
|
|
2895
|
+
const policy = remotePolicyFor(live);
|
|
2896
|
+
const routingMode = policy ? clampRouteMode(policy, live.record.routing?.mode || null, { untrusted: live.turnUntrusted === true, now: now() }) : (live.record.routing?.mode || null);
|
|
2897
|
+
// remote: a decided route authorizes one dispatch only (assistant-router.js).
|
|
2898
|
+
// computer: what remoteComputerUse says for the running turn ("ask": a computer route card done here also asks for the Mac).
|
|
2899
|
+
const computer = policy ? computerDecision(live) : null;
|
|
2900
|
+
return { brain: live.record.brain, routingMode, running: live.running, remote: !!policy, computer: computer ? { state: computer.state, reason: computer.reason } : null };
|
|
2901
|
+
}
|
|
2902
|
+
/**
|
|
2903
|
+
* The panel's route chip counts pending routes from the session view, so a card
|
|
2904
|
+
* whose packet never reached the panel (a half-open socket) is still one click
|
|
2905
|
+
* away: the chip offers it and a `reattach` replays pending cards. Deferred a
|
|
2906
|
+
* tick so a decided/expired card is already closed in the router's count.
|
|
2907
|
+
*/
|
|
2908
|
+
function publishPendingRoutes(live) {
|
|
2909
|
+
queueMicrotask(() => { if (sessions.get(live.record.id) === live) sendToSockets(live, { type: 'assistant:session', session: view(live), reason: 'routes' }); });
|
|
2910
|
+
}
|
|
2911
|
+
function routerSend(id, packet) {
|
|
2912
|
+
const live = sessions.get(id);
|
|
2913
|
+
const isCard = packet?.type === 'control_request' && (packet.request?.subtype === 'route' || packet.request?.kind === 'route');
|
|
2914
|
+
if (!live || !packet) { if (isCard) log('assistant:route-card', `${packet.request_id} → session ${id} not live`); return; }
|
|
2915
|
+
const delivered = sendToSockets(live, packet);
|
|
2916
|
+
if (isCard) {
|
|
2917
|
+
// The generation a "do it here" pick on this card continues in (routerContinue).
|
|
2918
|
+
const routeId = String(packet.request?.routeId || packet.request_id || '');
|
|
2919
|
+
if (routeId) { live.routeGens.set(routeId, live.gen); while (live.routeGens.size > 50) live.routeGens.delete(live.routeGens.keys().next().value); }
|
|
2920
|
+
// The card says the Mac will be controlled: bound here, once, to the turn that raised it and to
|
|
2921
|
+
// what remoteComputerUse says now. A card sent again, or one the decision no longer backs, binds nothing.
|
|
2922
|
+
if (routeId && packet.request?.computer?.optionId && !live.computerRoutes.has(routeId) && live.computerTurn && computerDecision(live)?.state === 'ask') {
|
|
2923
|
+
live.computerRoutes.set(routeId, { turn: live.computerTurn, epoch: live.computerEpoch });
|
|
2924
|
+
while (live.computerRoutes.size > 20) live.computerRoutes.delete(live.computerRoutes.keys().next().value);
|
|
2925
|
+
}
|
|
2926
|
+
// A trace for the next "the card never showed" report: how many sockets took it.
|
|
2927
|
+
log('assistant:route-card', `${packet.request_id} → ${delivered} socket(s)${delivered ? '' : ' (buffered for the next attach)'}`);
|
|
2928
|
+
publishPendingRoutes(live);
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
function routerEvent(id, phase, route) {
|
|
2932
|
+
const live = sessions.get(id);
|
|
2933
|
+
if (!live) return;
|
|
2934
|
+
emitRouteEvent(live, phase, route);
|
|
2935
|
+
if (phase !== 'card' && phase !== 'pending' && phase !== 'auto') publishPendingRoutes(live);
|
|
2936
|
+
}
|
|
2937
|
+
function routerMailbox(id, item) { const live = sessions.get(id); if (live && item) enqueueMailbox(live, item); }
|
|
2938
|
+
/** agent_route answered (or failed) for this session: the route gate follows it (`meta.taskClass`: a chat route holds). */
|
|
2939
|
+
function routerRouted(id, result, meta = {}) { const live = sessions.get(id); if (live) ensureGate(live)?.onRouteResult(result, meta); }
|
|
2940
|
+
function routerRouteFailed(id, error) {
|
|
2941
|
+
const live = sessions.get(id);
|
|
2942
|
+
if (!live) return;
|
|
2943
|
+
log('assistant:route-gate', `agent_route failed for ${id} (${error?.code || error?.message || error}); the gate stays open this turn`);
|
|
2944
|
+
ensureGate(live)?.onRouteError(error);
|
|
2945
|
+
}
|
|
2946
|
+
/**
|
|
2947
|
+
* A "do it here with <model>" pick: the scheduler's continuation, in the
|
|
2948
|
+
* generation of the turn the route came from (its card's, else the current
|
|
2949
|
+
* one). The router awaits the catalog before it calls this, so a pick that
|
|
2950
|
+
* arrives after a newer prompt, a Stop or the session closing is refused
|
|
2951
|
+
* (false): the router then approves nothing and the card expires.
|
|
2952
|
+
*/
|
|
2953
|
+
function routerContinue(id, { target = null, route = null } = {}) {
|
|
2954
|
+
const live = sessions.get(id);
|
|
2955
|
+
if (!live || !target) return false;
|
|
2956
|
+
const gen = live.routeGens.get(String(route?.routeId || '')) ?? live.gen;
|
|
2957
|
+
if (gen !== live.gen || live.record.status === 'ended' || live.closing) {
|
|
2958
|
+
log('assistant:work', `${id}: a late "do it here" pick (${route?.routeId || 'route'}) was dropped: a newer prompt or a Stop came after its turn`);
|
|
2959
|
+
return false;
|
|
2960
|
+
}
|
|
2961
|
+
const row = target.model ? findModel(catalog?.peek?.() || null, target.provider, target.model)?.row : null;
|
|
2962
|
+
const label = row?.label || target.model || 'the chosen model';
|
|
2963
|
+
const summary = route?.summary || '';
|
|
2964
|
+
const text = `[SynaBun Router] The user chose to run ${summary ? `"${summary}"` : 'this task'} here on ${label}${target.effort ? ` (effort ${target.effort})` : ''}. Continue that task now on this model, from where the conversation left off.`;
|
|
2965
|
+
queueWork(live, { kind: 'continuation', gen, options: { text, turnModel: target.model || null, turnEffort: target.effort || null, gateOpen: true } });
|
|
2966
|
+
return true;
|
|
2967
|
+
}
|
|
2968
|
+
function routerSinks() {
|
|
2969
|
+
return {
|
|
2970
|
+
sendCard: (sessionId, packet) => routerSend(sessionId, packet),
|
|
2971
|
+
cancelCard: (sessionId, requestId, reason) => routerSend(sessionId, { type: 'control_cancelled', request_id: requestId, reason }),
|
|
2972
|
+
routeEvent: (sessionId, phase, route) => routerEvent(sessionId, phase, route),
|
|
2973
|
+
mailbox: (sessionId, item) => routerMailbox(sessionId, item),
|
|
2974
|
+
continueDirect: (sessionId, payload) => routerContinue(sessionId, payload),
|
|
2975
|
+
routed: (sessionId, result, meta) => routerRouted(sessionId, result, meta),
|
|
2976
|
+
routeFailed: (sessionId, error) => routerRouteFailed(sessionId, error),
|
|
2977
|
+
computerApproved: (sessionId, payload) => routerComputerApproved(sessionId, payload),
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
// ── clarifier sinks (wired lazily in server.js; see assistant-clarify.js) ──
|
|
2982
|
+
/** The user's own words of this request: the last human prompt (the transcript after a restart). */
|
|
2983
|
+
function lastUserPrompt(live) {
|
|
2984
|
+
if (live.lastPrompt) return live.lastPrompt;
|
|
2985
|
+
const entries = readTranscript(live.record.id, 200);
|
|
2986
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
2987
|
+
const event = entries[i]?.packet?.event;
|
|
2988
|
+
if (event?.type === 'synabun.user_prompt' && event.text) return String(event.text);
|
|
2989
|
+
}
|
|
2990
|
+
return '';
|
|
2991
|
+
}
|
|
2992
|
+
function clarifySession(id) {
|
|
2993
|
+
const live = sessions.get(id);
|
|
2994
|
+
if (!live) return null;
|
|
2995
|
+
return { lastPrompt: lastUserPrompt(live), cycle: live.promptCycle || 0 };
|
|
2996
|
+
}
|
|
2997
|
+
function clarifyEvent(id, phase, brief) {
|
|
2998
|
+
const live = sessions.get(id);
|
|
2999
|
+
if (!live) return;
|
|
3000
|
+
const packet = { type: 'event', event: { type: 'synabun.clarify', phase, brief } };
|
|
3001
|
+
sendToSockets(live, packet);
|
|
3002
|
+
// The card itself replays from the clarifier while it is open; its outcome is history.
|
|
3003
|
+
if (phase !== 'card') appendTranscript(live.record.id, { at: iso(now()), packet });
|
|
3004
|
+
}
|
|
3005
|
+
/** agent_clarify is waiting for the user: the route gate holds the rest of the turn. */
|
|
3006
|
+
function clarifyGate(id, result) {
|
|
3007
|
+
const live = sessions.get(id);
|
|
3008
|
+
if (live) ensureGate(live)?.onClarify?.(result);
|
|
3009
|
+
}
|
|
3010
|
+
function clarifySinks() {
|
|
3011
|
+
return {
|
|
3012
|
+
sendCard: (sessionId, packet) => routerSend(sessionId, packet),
|
|
3013
|
+
cancelCard: (sessionId, requestId, reason) => routerSend(sessionId, { type: 'control_cancelled', request_id: requestId, reason }),
|
|
3014
|
+
event: (sessionId, phase, brief) => clarifyEvent(sessionId, phase, brief),
|
|
3015
|
+
mailbox: (sessionId, item) => routerMailbox(sessionId, item),
|
|
3016
|
+
gate: (sessionId, result) => clarifyGate(sessionId, result),
|
|
3017
|
+
};
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
// ── route gate (assistant-route-gate.js) ───────────────────────────────────
|
|
3021
|
+
function ensureGate(live) {
|
|
3022
|
+
if (!router) return null;
|
|
3023
|
+
if (!live.gate) {
|
|
3024
|
+
live.gate = createRouteGate({ sessionId: live.record.id, routeTool: () => (live.record.brain?.provider === 'claude-code' ? 'mcp__SynaBun__agent_route' : 'SynaBun_agent_route') });
|
|
3025
|
+
live.gateToken = randomUUID();
|
|
3026
|
+
}
|
|
3027
|
+
return live.gate;
|
|
3028
|
+
}
|
|
3029
|
+
/** Stop a turn that keeps calling tools without routing (the loop guard). */
|
|
3030
|
+
function stopForGateLoop(live) {
|
|
3031
|
+
dropWork(live, 'continuation', 'nudge');
|
|
3032
|
+
live.gateNudge = null;
|
|
3033
|
+
const text = `${ROUTE_GATE_LOOP_MESSAGE} ${live.gate?.snapshot?.().refusals || ''} tool calls were refused this turn; route the task (or rephrase it) and try again.`.replace(/\s+/g, ' ').trim();
|
|
3034
|
+
log('assistant:route-gate', `${live.record.id}: loop guard stopped the turn`);
|
|
3035
|
+
sendToSockets(live, { type: 'error', code: 'ROUTE_GATE_LOOP', message: text });
|
|
3036
|
+
const status = { type: 'event', event: { type: 'system', subtype: 'status', text } };
|
|
3037
|
+
sendToSockets(live, status);
|
|
3038
|
+
appendTranscript(live.record.id, { at: iso(now()), packet: status });
|
|
3039
|
+
setTimeout(() => { Promise.resolve(live.brain?.abort?.()).catch(() => {}); }, 0).unref?.();
|
|
3040
|
+
}
|
|
3041
|
+
function decide(live, tool) {
|
|
3042
|
+
// Plan mode: the plan policy (planGate) decides, never the route gate.
|
|
3043
|
+
if (isPlanningLive(live)) return { allow: true, state: 'plan', refusals: 0 };
|
|
3044
|
+
const decision = live.gate.check(tool);
|
|
3045
|
+
if (decision.abort) stopForGateLoop(live);
|
|
3046
|
+
return decision;
|
|
3047
|
+
}
|
|
3048
|
+
function isPlanningLive(live) { return live?.record?.brain?.planMode === true; }
|
|
3049
|
+
function isPlanning(id) { return isPlanningLive(sessions.get(String(id || ''))); }
|
|
3050
|
+
/**
|
|
3051
|
+
* Plan mode stays read-only (the read-only classifier) for a remote session at
|
|
3052
|
+
* the read-only level, the level that holds it in plan mode (clampBrainModes);
|
|
3053
|
+
* everywhere else it refuses code changes only.
|
|
3054
|
+
*/
|
|
3055
|
+
function planReadOnly(live) { return !!live && remoteLevel(live) === 'read-only'; }
|
|
3056
|
+
function isPlanReadOnly(id) { const live = sessions.get(String(id || '')); return isPlanningLive(live) && planReadOnly(live); }
|
|
3057
|
+
/**
|
|
3058
|
+
* Plan mode's policy for a tool call a host's pre-tool hook reports (every
|
|
3059
|
+
* session of the brain, subagents included): { allow:false, reason } or null.
|
|
3060
|
+
*/
|
|
3061
|
+
function planGate(live, host, tool, input) {
|
|
3062
|
+
if (!isPlanningLive(live)) return null;
|
|
3063
|
+
const readOnly = planReadOnly(live);
|
|
3064
|
+
const decision = readOnly ? readOnlyToolDecision(tool, input || {}, { host }) : planToolDecision(tool, input || {}, { host });
|
|
3065
|
+
if (decision !== 'deny') return null;
|
|
3066
|
+
log('assistant:plan', `${live.record.id}: declined ${tool} (${readOnly ? 'read-only' : 'no code changes'})`);
|
|
3067
|
+
return { allow: false, plan: true, reason: readOnly ? readOnlyDenyMessage(tool) : planDenyMessage(tool, { host }) };
|
|
3068
|
+
}
|
|
3069
|
+
/**
|
|
3070
|
+
* Checks after the tool started: a Codex brain whose hook is not trusted, and
|
|
3071
|
+
* an OpenCode brain whose serve has not loaded the gate plugin (no hello yet).
|
|
3072
|
+
*/
|
|
3073
|
+
function gateIsReactive(live) {
|
|
3074
|
+
return live.brain?.gateMode === 'reactive' || (live.brainProvider === 'opencode' && !live.gatePluginAt);
|
|
3075
|
+
}
|
|
3076
|
+
/**
|
|
3077
|
+
* A brain that cannot reach agent_route (its SynaBun MCP server is down, or
|
|
3078
|
+
* connected without the assistant role) is not gated: it could never route,
|
|
3079
|
+
* so every turn would end in the loop guard. null = cannot tell yet (no change).
|
|
3080
|
+
* Said once per change, in the transcript.
|
|
3081
|
+
*/
|
|
3082
|
+
function applyRouteAvailability(live, verdict) {
|
|
3083
|
+
const gate = ensureGate(live);
|
|
3084
|
+
if (!gate || !verdict) return;
|
|
3085
|
+
const reason = verdict.available ? null : (verdict.reason || 'routing is unavailable');
|
|
3086
|
+
const before = gate.snapshot().unavailable || null;
|
|
3087
|
+
if (before === reason) return;
|
|
3088
|
+
gate.setUnavailable(reason);
|
|
3089
|
+
const text = reason
|
|
3090
|
+
? `Route gate off for this brain: ${reason}. It can use every tool without routing until that is fixed.`
|
|
3091
|
+
: 'Route gate back on: this brain can route again.';
|
|
3092
|
+
log('assistant:route-gate', `${live.record.id}: ${text}`);
|
|
3093
|
+
const packet = { type: 'event', event: { type: 'system', subtype: 'status', text } };
|
|
3094
|
+
sendToSockets(live, packet);
|
|
3095
|
+
appendTranscript(live.record.id, { at: iso(now()), packet });
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* The gate's answer for one tool call, for hosts outside this process (the
|
|
3099
|
+
* OpenCode plugin, the Codex hook). `token` is the per-session secret they
|
|
3100
|
+
* were given; `providerSessionId` names the OpenCode session making the call
|
|
3101
|
+
* (a subagent's own session is not gated: launching it was).
|
|
3102
|
+
*/
|
|
3103
|
+
function gateCheck({ session, token, tool, input = {}, inputComplete = false, providerSessionId = null, agent = null, hello = false, host = null } = {}) {
|
|
3104
|
+
const live = session ? sessions.get(String(session)) : null;
|
|
3105
|
+
if (!live?.gate || !live.gateToken || String(token || '') !== live.gateToken) {
|
|
3106
|
+
const error = new Error('Unknown route gate'); error.code = 'ROUTE_GATE_UNKNOWN'; error.status = 404; throw error;
|
|
3107
|
+
}
|
|
3108
|
+
// The OpenCode plugin says hello when its serve loads it (any check it makes
|
|
3109
|
+
// proves the same): from then on the plugin gates before each tool runs,
|
|
3110
|
+
// not the reactive fallback.
|
|
3111
|
+
if (hello) { live.gatePluginAt = now(); return { ok: true, hello: true }; }
|
|
3112
|
+
if (host === 'opencode' && !live.gatePluginAt) live.gatePluginAt = now();
|
|
3113
|
+
const planned = planGate(live, host, tool, input);
|
|
3114
|
+
if (planned) return planned;
|
|
3115
|
+
// A remote session never runs these, subagents included (nor a call the host sent without its arguments).
|
|
3116
|
+
const remoteDenied = remoteGate(live, tool, input, { argsComplete: inputComplete === true, host });
|
|
3117
|
+
if (remoteDenied) return remoteDenied;
|
|
3118
|
+
// The browser policy: every session, plan mode and subagents included.
|
|
3119
|
+
const browserDenied = browserGate(live, tool, input, { host, argsComplete: inputComplete === true });
|
|
3120
|
+
if (browserDenied) return browserDenied;
|
|
3121
|
+
if (agent) return { allow: true, subagent: true };
|
|
3122
|
+
const own = live.brain?.identity?.()?.providerSessionId || live.record.providerSessionId || null;
|
|
3123
|
+
if (providerSessionId && own && String(providerSessionId) !== String(own)) return { allow: true, subagent: true };
|
|
3124
|
+
const decision = decide(live, tool);
|
|
3125
|
+
return decision.allow ? { allow: true } : { allow: false, reason: decision.reason, refusals: decision.refusals, stopped: !!decision.abort };
|
|
3126
|
+
}
|
|
3127
|
+
/**
|
|
3128
|
+
* A remote session's hard refusal for one tool call (every level), else null.
|
|
3129
|
+
* `argsComplete: false`: the host did not pass the call's arguments. An
|
|
3130
|
+
* OpenCode brain's own web tools are refused (the read-only level's network rule).
|
|
3131
|
+
*/
|
|
3132
|
+
function remoteGate(live, tool, input = {}, { argsComplete = true, host = null, computer = undefined } = {}) {
|
|
3133
|
+
if (!remotePolicyFor(live)) return null;
|
|
3134
|
+
let reason = null;
|
|
3135
|
+
if (host === 'opencode' && REMOTE_OPENCODE_WEB_TOOLS.has(String(tool || '').toLowerCase())) {
|
|
3136
|
+
reason = `${tool}: a WhatsApp conversation on OpenCode is read-only and does not reach the web on its own; use SynaBun's browser tools, or switch the WhatsApp brain to Claude.`;
|
|
3137
|
+
} else if (isComputerTool(tool)) {
|
|
3138
|
+
// `computer`: what computerAccess said for this call (the Claude hook passes it). Any other
|
|
3139
|
+
// caller (the Codex hook, the OpenCode plugin) asks here: those brains never get past "off".
|
|
3140
|
+
const access = computer === undefined ? computerAccess(live) : computer;
|
|
3141
|
+
if (!access || access.access === 'deny') reason = access?.message || remoteComputerDenial(access?.reason || null);
|
|
3142
|
+
// Allowed to run, or to ask: the rest of the checks (its arguments) still apply.
|
|
3143
|
+
else reason = remoteToolDenial(tool, input, { argsComplete, computerAllowed: true });
|
|
3144
|
+
} else {
|
|
3145
|
+
reason = remoteToolDenial(tool, input, { argsComplete });
|
|
3146
|
+
}
|
|
3147
|
+
if (!reason) return null;
|
|
3148
|
+
log('assistant:remote-policy', `${live.record.id}: refused ${tool} (${reason})`);
|
|
3149
|
+
return { allow: false, remote: true, reason: `${reason} Tell the user to do this in SynaBun on their computer if they need it.` };
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* The browser policy (lib/browser-tool-policy.js) for one brain tool call:
|
|
3153
|
+
* { allow:false, browser:true, reason } or null. Every provider and session,
|
|
3154
|
+
* plan mode and subagents included; scripts resolve against the session's
|
|
3155
|
+
* project cwd. `argsComplete: false` (the host sent only the command /
|
|
3156
|
+
* action of an oversized call): checked on what arrived.
|
|
3157
|
+
*/
|
|
3158
|
+
function browserGate(live, tool, input = {}, { host = null, argsComplete = true } = {}) {
|
|
3159
|
+
const kind = host || (live.brainProvider === 'claude-code' ? 'claude' : live.brainProvider);
|
|
3160
|
+
const reason = browserToolDenial(tool, input && typeof input === 'object' ? input : {}, { host: kind, cwd: live.record.brain?.cwd || PACKAGE_ROOT });
|
|
3161
|
+
if (!reason) return null;
|
|
3162
|
+
log('assistant:browser-policy', `${live.record.id}: refused ${tool}${argsComplete ? '' : ' (partial arguments)'}`);
|
|
3163
|
+
return { allow: false, browser: true, reason };
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* Claude brain: the browser policy as an in-process PreToolUse hook. Unlike
|
|
3167
|
+
* gateHooks it runs for subagents (agent_id) and in plan mode (decide()
|
|
3168
|
+
* steps aside while planning; this does not).
|
|
3169
|
+
*/
|
|
3170
|
+
function browserHooks(live) {
|
|
3171
|
+
return {
|
|
3172
|
+
PreToolUse: [{
|
|
3173
|
+
hooks: [async (input) => {
|
|
3174
|
+
const toolInput = input?.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
|
|
3175
|
+
const denied = browserGate(live, String(input?.tool_name || ''), toolInput, { host: 'claude' });
|
|
3176
|
+
return denied ? { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: denied.reason } } : {};
|
|
3177
|
+
}],
|
|
3178
|
+
}],
|
|
3179
|
+
};
|
|
3180
|
+
}
|
|
3181
|
+
/**
|
|
3182
|
+
* Claude brain of a remote session: an in-process PreToolUse hook that runs
|
|
3183
|
+
* for subagents too (never skipped on agent_id). Every level refuses what
|
|
3184
|
+
* remoteToolDenial names. Below autonomous, a tool the read-only classifier
|
|
3185
|
+
* would refuse is 'ask': the SDK then asks canUseTool even when a user allow
|
|
3186
|
+
* rule (or allowedTools) matches, so the card reaches the phone or the desktop.
|
|
3187
|
+
*/
|
|
3188
|
+
function remoteHooks(live) {
|
|
3189
|
+
return {
|
|
3190
|
+
PreToolUse: [{
|
|
3191
|
+
hooks: [async (input) => {
|
|
3192
|
+
const tool = String(input?.tool_name || '');
|
|
3193
|
+
const argsComplete = !!input?.tool_input && typeof input.tool_input === 'object';
|
|
3194
|
+
const toolInput = argsComplete ? input.tool_input : {};
|
|
3195
|
+
if (!remotePolicyFor(live)) return {};
|
|
3196
|
+
const computer = isComputerTool(tool) ? computerAccess(live) : undefined;
|
|
3197
|
+
const denied = remoteGate(live, tool, toolInput, { argsComplete, computer });
|
|
3198
|
+
if (denied) return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: denied.reason } };
|
|
3199
|
+
// A computer tool: it runs (Autonomous with the owner's switch on, or a turn the owner
|
|
3200
|
+
// approved), or the SDK asks canUseTool, which onBrainPacket turns into the turn's one
|
|
3201
|
+
// "control your Mac for this?" request (computerControlRequest).
|
|
3202
|
+
if (computer) {
|
|
3203
|
+
return computer.access === 'run'
|
|
3204
|
+
? { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Computer use is on for this WhatsApp turn.' } }
|
|
3205
|
+
: { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: 'The user is asked once per task before the Mac is controlled from WhatsApp.' } };
|
|
3206
|
+
}
|
|
3207
|
+
if (remoteLevel(live) === 'autonomous' || REMOTE_ASK_EXEMPT.has(tool)) return {};
|
|
3208
|
+
if (claudeReadOnlyPermission(tool, toolInput) !== 'deny') return {};
|
|
3209
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: `This session is driven from ${live.record.remote?.channel === 'whatsapp' || !live.record.remote?.channel ? 'WhatsApp' : live.record.remote.channel}: the user approves ${tool} first.` } };
|
|
3210
|
+
}],
|
|
3211
|
+
}],
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
/**
|
|
3215
|
+
* Claude brain: an in-process PreToolUse hook (subagent calls pass; launching
|
|
3216
|
+
* them was gated), and after an approved ExitPlanMode a note that the work it
|
|
3217
|
+
* continues into is routed first (answerControl reset the gate).
|
|
3218
|
+
*/
|
|
3219
|
+
function gateHooks(live) {
|
|
3220
|
+
return {
|
|
3221
|
+
PreToolUse: [{
|
|
3222
|
+
hooks: [async (input) => {
|
|
3223
|
+
if (!live.gate || input?.agent_id) return {};
|
|
3224
|
+
const decision = decide(live, input?.tool_name);
|
|
3225
|
+
if (decision.allow) return {};
|
|
3226
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: decision.reason } };
|
|
3227
|
+
}],
|
|
3228
|
+
}],
|
|
3229
|
+
PostToolUse: [{
|
|
3230
|
+
matcher: 'ExitPlanMode',
|
|
3231
|
+
hooks: [async (input) => {
|
|
3232
|
+
if (!live.gate || input?.agent_id || isPlanningLive(live) || live.gate.isOpen()) return {};
|
|
3233
|
+
return { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'SynaBun: the plan is approved and plan mode is off. Route the approved work first (agent_route), then carry it out here or dispatch it.' } };
|
|
3234
|
+
}],
|
|
3235
|
+
}],
|
|
3236
|
+
};
|
|
3237
|
+
}
|
|
3238
|
+
/**
|
|
3239
|
+
* Reactive fallback (a Codex brain whose app-server could not run the hook, an
|
|
3240
|
+
* OpenCode brain whose serve never loaded the plugin): a tool the gate or the
|
|
3241
|
+
* browser policy refuses has already started, so the turn is interrupted and
|
|
3242
|
+
* one system turn says why. A route refusal counts toward the loop guard.
|
|
3243
|
+
*/
|
|
3244
|
+
function reactiveGate(live, event) {
|
|
3245
|
+
const blocks = Array.isArray(event.message?.content) ? event.message.content : [];
|
|
3246
|
+
for (const block of blocks) {
|
|
3247
|
+
if (block?.type !== 'tool_use' || live.gateNudge) continue;
|
|
3248
|
+
const browser = browserGate(live, block.name, block.input || {});
|
|
3249
|
+
const decision = browser || decide(live, block.name);
|
|
3250
|
+
if (decision.allow || decision.abort) continue;
|
|
3251
|
+
live.gateNudge = { reason: decision.reason, gen: live.gen, browser: !!browser };
|
|
3252
|
+
log(browser ? 'assistant:browser-policy' : 'assistant:route-gate', `${live.record.id}: interrupting ${block.name} (reactive)`);
|
|
3253
|
+
Promise.resolve(live.brain?.abort?.()).catch(() => {});
|
|
3254
|
+
return;
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
// ── desktop service hooks (lib/desktop/service.js) ─────────────────────────
|
|
3259
|
+
/** The desktop gate's question: may this session act right now. A remote session: only inside a turn that may (computerRuns). */
|
|
3260
|
+
function getComputerUse(id) {
|
|
3261
|
+
const live = sessions.get(id);
|
|
3262
|
+
if (!live) return false;
|
|
3263
|
+
return remotePolicyFor(live) ? computerRuns(live) : computerEffective(live.record);
|
|
3264
|
+
}
|
|
3265
|
+
/** The turn a remote session's computer use belongs to right now (null: none, or a desktop session): the desktop service binds every admitted call to it. */
|
|
3266
|
+
function getComputerTurn(id) {
|
|
3267
|
+
const live = sessions.get(id);
|
|
3268
|
+
return live && remotePolicyFor(live) ? (live.computerTurn?.id ?? null) : null;
|
|
3269
|
+
}
|
|
3270
|
+
/** The desktop stopped this session (Esc, the corner, a Stop button; `reason` says which): its turn is interrupted. */
|
|
3271
|
+
async function abortTurn(id, { reason = null } = {}) {
|
|
3272
|
+
const live = sessions.get(id);
|
|
3273
|
+
if (!live) return false;
|
|
3274
|
+
dropWork(live, 'continuation');
|
|
3275
|
+
if (remotePolicyFor(live)) {
|
|
3276
|
+
// Driven from a phone: its computer use ends here, and the phone hears it (the bridge says it in one line).
|
|
3277
|
+
endComputerTurn(live, 'desktop_stop');
|
|
3278
|
+
dropRouteComputer(live);
|
|
3279
|
+
const status = { type: 'event', event: { type: 'system', subtype: 'status', text: 'Computer control was stopped on this Mac.' } };
|
|
3280
|
+
sendToSockets(live, status);
|
|
3281
|
+
appendTranscript(live.record.id, { at: iso(now()), packet: status });
|
|
3282
|
+
sendToSockets(live, { type: 'assistant:computer', phase: 'stopped', by: 'mac', reason: reason ? String(reason).slice(0, 40) : null });
|
|
3283
|
+
}
|
|
3284
|
+
try { await live.brain?.abort?.(); } catch {}
|
|
3285
|
+
return true;
|
|
3286
|
+
}
|
|
3287
|
+
function notifySession(id, packet) { const live = sessions.get(id); if (live && packet) sendToSockets(live, packet); }
|
|
3288
|
+
|
|
3289
|
+
function subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }
|
|
3290
|
+
async function shutdown() {
|
|
3291
|
+
if (unsubscribeDispatcher) { try { unsubscribeDispatcher(); } catch {} unsubscribeDispatcher = null; }
|
|
3292
|
+
if (unsubscribePolicy) { try { unsubscribePolicy(); } catch {} unsubscribePolicy = null; }
|
|
3293
|
+
if (unsubscribeUsage) { try { unsubscribeUsage(); } catch {} unsubscribeUsage = null; }
|
|
3294
|
+
for (const timer of usageTimers.values()) clearTimeout(timer);
|
|
3295
|
+
usageTimers.clear();
|
|
3296
|
+
packetObservers.clear();
|
|
3297
|
+
shuttingDown = true;
|
|
3298
|
+
for (const live of sessions.values()) {
|
|
3299
|
+
if (live.mailboxTimer) clearTimeout(live.mailboxTimer);
|
|
3300
|
+
if (live.disposeTimer) clearTimeout(live.disposeTimer);
|
|
3301
|
+
if (live.workTimer) clearTimeout(live.workTimer);
|
|
3302
|
+
await disposeBrain(live, 'shutdown').catch(() => {});
|
|
3303
|
+
live.record.status = 'idle';
|
|
3304
|
+
}
|
|
3305
|
+
persist({ force: true });
|
|
3306
|
+
// Rows whose append failed get one more try before the process goes.
|
|
3307
|
+
metered('flush', () => usage?.flush?.());
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
loadIndex();
|
|
3311
|
+
if (usage?.subscribe) unsubscribeUsage = metered('subscribe', () => usage.subscribe((sessionId) => scheduleUsage(sessionId)));
|
|
3312
|
+
if (remotePolicy?.subscribe) {
|
|
3313
|
+
unsubscribePolicy = remotePolicy.subscribe(({ sessionId, previous }) => { try { onRemotePolicyChanged(sessionId, previous); } catch (error) { log('assistant:remote-policy', error?.message || String(error)); } });
|
|
3314
|
+
}
|
|
3315
|
+
if (dispatcher?.subscribe) {
|
|
3316
|
+
unsubscribeDispatcher = dispatcher.subscribe((payload) => {
|
|
3317
|
+
if (payload?.type === 'assistant:dispatch') onDispatchEvent(payload);
|
|
3318
|
+
else if (payload?.type === 'assistant:run-removed') onRunRemoved(payload);
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3321
|
+
const originalBroadcast = broadcastSync;
|
|
3322
|
+
// Permission broadcasts come from the dispatcher through broadcastSync; observe them here too.
|
|
3323
|
+
broadcastSync = (message) => { try { originalBroadcast(message); } catch {} if (message?.type === 'assistant:permission-request' || message?.type === 'assistant:permission-resolved') onPermissionBroadcast(message); };
|
|
3324
|
+
|
|
3325
|
+
return {
|
|
3326
|
+
handleWebSocket, listSessions, getSession, createSession, updateSession, closeSession, destroySession,
|
|
3327
|
+
resolveTerminal, accountInUse, ownsIsolatedServe, noteDispatch, subscribe, shutdown,
|
|
3328
|
+
observeBroadcast: onPermissionBroadcast,
|
|
3329
|
+
routerSession, routerSend, routerEvent, routerMailbox, routerContinue, routerSinks, routerRouted, routerRouteFailed, routerComputerApproved, gateCheck, isPlanning, isPlanReadOnly,
|
|
3330
|
+
clarifySession, clarifyEvent, clarifyGate, clarifySinks,
|
|
3331
|
+
getComputerUse, getComputerTurn, supersedeComputerApproval, abortTurn, notifySession,
|
|
3332
|
+
brainSpend, brainCapUsd, onBudgetChanged, currentTask, usageView,
|
|
3333
|
+
// Programmatic surface (the WhatsApp bridge) and remote sessions.
|
|
3334
|
+
observe, submit, stopTurn, answerControl, answerDispatchControl, pendingControls, isBusy, sessionPolicy, sessionChannel,
|
|
3335
|
+
_internals: { sessions, ensureBrain, disposeBrain, runQuery, enqueueMailbox, deliverMailbox, settings, ensureGate, onBrainPacket, onTurnFinished, remoteHooks, packetObservers },
|
|
3336
|
+
};
|
|
3337
|
+
}
|