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,3203 @@
|
|
|
1
|
+
import { claudeStallSeconds } from './claude-stall-policy.js';
|
|
2
|
+
import { detectProject as memoryProject } from '../../mcp-server/dist/config.js';
|
|
3
|
+
// ── Claude Agent SDK bridge for the Claude sidepanel (claude-skin) ──
|
|
4
|
+
//
|
|
5
|
+
// Replaces the per-turn `claude --print` spawn architecture with one long-lived
|
|
6
|
+
// interactive SDK session per tab (streaming-input mode). What this buys:
|
|
7
|
+
// - Real pre-execution permission prompts via canUseTool (no kill/respawn)
|
|
8
|
+
// - AskUserQuestion answered in-place via updatedInput (process stays alive)
|
|
9
|
+
// - ExitPlanMode → genuine plan approval; same turn continues on approve
|
|
10
|
+
// - Live token streaming (includePartialMessages → stream_event deltas)
|
|
11
|
+
// - Subagent visibility (parent_tool_use_id on every message)
|
|
12
|
+
// - Native interrupt, permission modes, rewindFiles, supportedCommands
|
|
13
|
+
//
|
|
14
|
+
// Wire contract: the frontend receives `{type:'event', event:{…stream-json…}}`,
|
|
15
|
+
// `control_request`, `done`, `aborted`, `reattach_result`, `engine`,
|
|
16
|
+
// `mode_changed`, `rewind_result`, `control_cancelled`, `turn_started` (the CLI
|
|
17
|
+
// began a turn on its own — a background agent finished, a scheduled wakeup
|
|
18
|
+
// fired). Synthetic subagent events are `subagent` start / background / stop.
|
|
19
|
+
// Inbound: `query`, `control_response`, `compact`, `abort`, `set_permission_mode`,
|
|
20
|
+
// `rewind`, `mcp_status`, `dispose` (the tab was closed — end the session now).
|
|
21
|
+
//
|
|
22
|
+
// Ownership model: the session object owns the Query, the input queue, the
|
|
23
|
+
// event buffer, pending permission resolvers, cost baseline and watchdog. The
|
|
24
|
+
// WebSocket merely binds/unbinds to it — an "orphan" is just a session with no
|
|
25
|
+
// WS bound, so page-refresh reattach is the same object.
|
|
26
|
+
|
|
27
|
+
import { query, startup } from '@anthropic-ai/claude-agent-sdk';
|
|
28
|
+
import { CLAUDE_EFFORT_LEVELS } from './claude-model-catalog.js';
|
|
29
|
+
import { CLAUDE_NOT_INSTALLED, resolveClaudeSdkExecutable } from './claude-executable.js';
|
|
30
|
+
import { diagnoseLaunchFailure, isNativeBinaryLaunchFailure } from './native-binary-runtime.js';
|
|
31
|
+
import { normalizePanelSession, startSignature, applyPanelSessionOptions, liveSettingsPatch, normalizeMcpServers, mergeTabMcpServers } from './claude-panel-session.js';
|
|
32
|
+
import { SESSION_REQUESTS, runSessionRequest, slimRewind, slimMcpStatus } from './claude-panel-requests.js';
|
|
33
|
+
import { applyTemporaryOptions, plansDirsOf, planFileOfToolCall, removeTemporaryPlanFiles } from './claude-temporary.js';
|
|
34
|
+
import { rootBypassBlock } from './claude-bypass-policy.js';
|
|
35
|
+
import { randomUUID } from 'node:crypto';
|
|
36
|
+
import { dirname, join } from 'node:path';
|
|
37
|
+
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
|
38
|
+
import { createRequire } from 'node:module';
|
|
39
|
+
|
|
40
|
+
const _require = createRequire(import.meta.url);
|
|
41
|
+
export const SDK_VERSION = (() => {
|
|
42
|
+
try { return _require('@anthropic-ai/claude-agent-sdk/package.json').version; }
|
|
43
|
+
catch { /* package.json not in exports map — walk up from the entry file */ }
|
|
44
|
+
try {
|
|
45
|
+
let dir = dirname(_require.resolve('@anthropic-ai/claude-agent-sdk'));
|
|
46
|
+
for (let i = 0; i < 5; i++) {
|
|
47
|
+
const pj = `${dir}/package.json`;
|
|
48
|
+
if (existsSync(pj)) {
|
|
49
|
+
const v = JSON.parse(readFileSync(pj, 'utf-8'));
|
|
50
|
+
if (v.name === '@anthropic-ai/claude-agent-sdk') return v.version;
|
|
51
|
+
}
|
|
52
|
+
dir = dirname(dir);
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
return 'unknown';
|
|
56
|
+
})();
|
|
57
|
+
|
|
58
|
+
// Bookkeeping tools that must never hit the user with a permission card.
|
|
59
|
+
// Read-only tools (Read/Glob/Grep/…) are allowed by the SDK's own default-mode
|
|
60
|
+
// evaluation before canUseTool is consulted, so they need no entry here.
|
|
61
|
+
const AUTO_ALLOW = new Set(['TodoWrite', 'TodoRead', 'Task', 'Agent', 'ToolSearch', 'AskUserQuestion', 'ExitPlanMode']);
|
|
62
|
+
// (AskUserQuestion/ExitPlanMode are listed because they take dedicated paths below,
|
|
63
|
+
// never the generic prompt path.)
|
|
64
|
+
|
|
65
|
+
const ORPHAN_GRACE_MS = 30 * 60 * 1000; // survive sleep / refresh / tab throttling
|
|
66
|
+
const IDLE_REAP_MS = 15 * 60 * 1000; // end idle Queries to cap resident CLI processes
|
|
67
|
+
const ORPHAN_BUFFER_CAP = 2000; // entries; stream_event never buffered while detached
|
|
68
|
+
const DELTA_FLUSH_MS = 40; // coalescing window for text/thinking deltas
|
|
69
|
+
const WS_BACKPRESSURE_BYTES = 1024 * 1024;
|
|
70
|
+
|
|
71
|
+
// Stall-recovery herd control: when stalls are correlated (an MCP server or
|
|
72
|
+
// the API went down), every session's watchdog fires in the same window and
|
|
73
|
+
// each retry spawns a fresh CLI subprocess. Cap concurrent recreates; user-
|
|
74
|
+
// initiated prompts are never limited.
|
|
75
|
+
const MAX_CONCURRENT_RECREATES = 2;
|
|
76
|
+
let _recreatesInFlight = 0;
|
|
77
|
+
const _recreateWaiters = [];
|
|
78
|
+
function _acquireRecreateSlot() {
|
|
79
|
+
if (_recreatesInFlight < MAX_CONCURRENT_RECREATES) { _recreatesInFlight++; return Promise.resolve(); }
|
|
80
|
+
return new Promise(r => _recreateWaiters.push(r));
|
|
81
|
+
}
|
|
82
|
+
function _releaseRecreateSlot() {
|
|
83
|
+
const next = _recreateWaiters.shift();
|
|
84
|
+
if (next) next();
|
|
85
|
+
else _recreatesInFlight = Math.max(0, _recreatesInFlight - 1);
|
|
86
|
+
}
|
|
87
|
+
const STALL_WARN_SEC = 30;
|
|
88
|
+
const BOOT_TIMEOUT_SEC = 120;
|
|
89
|
+
const MAX_RETRIES = 2;
|
|
90
|
+
|
|
91
|
+
// What the sidepanel may rely on from this bridge, sent in the engine hello. The
|
|
92
|
+
// panel is served statically and reloads at any time, while this module only
|
|
93
|
+
// changes when the server restarts: a panel feature that needs bridge support
|
|
94
|
+
// shows only when its name is listed here.
|
|
95
|
+
export const PANEL_CAPABILITIES = Object.freeze([
|
|
96
|
+
'capabilities',
|
|
97
|
+
'session_info', // system/session_info after init: account, output styles, models, agents
|
|
98
|
+
'reload_skills', // inbound `reload_skills` → `reload_result` + a fresh commands_list
|
|
99
|
+
'permission_context', // control_request carries the CLI's title, reason, path, MCP server, flags
|
|
100
|
+
'permission_modes_v2', // dontAsk and auto are accepted; `modeFromSettings` leaves the mode to the user's settings
|
|
101
|
+
'deny_interrupt', // a deny response may carry `interrupt: true` (deny and stop the turn)
|
|
102
|
+
'ask_annotations', // AskUserQuestion answers keep their `annotations`
|
|
103
|
+
'tool_policy', // query `toolPolicy`: 'full' | 'read-only' | 'no-web'
|
|
104
|
+
'elicitation', // MCP elicitation as control_request subtype `elicitation` (for clients that declare the feature)
|
|
105
|
+
'session_settings', // query `session`: fallback model, limits, directories, plugins, strict MCP, instructions, sandbox, thinking, fast mode, output style, agent
|
|
106
|
+
'reload_plugins', // inbound `reload_plugins` → `reload_result` + fresh commands_list / mcp_status / plugins_list
|
|
107
|
+
'message_flags', // session.hookEvents / promptSuggestions / subagentText / agentSummaries → the matching SDK options
|
|
108
|
+
'task_control', // inbound `stop_task` / `background_tasks` → `task_control_result`; system/session_crons events
|
|
109
|
+
'origin_human', // a query marked `typed: true` is sent with origin {kind:'human'}
|
|
110
|
+
'session_requests', // inbound `session_request {id, what, args}` → `session_response`: context_usage, usage, mcp_status, mcp_reconnect, mcp_toggle, rewind_preview
|
|
111
|
+
'rewind_stats', // `rewind_result` carries filesChanged / insertions / deletions
|
|
112
|
+
'session_title', // query `title` names a new session where the CLI reads it
|
|
113
|
+
'conversation_rewind', // inbound `rewind_conversation {messageUuid, userMessageUuid?}` → `rewind_conversation_result`
|
|
114
|
+
'cache_cost', // system/cache_cost when resuming re-caches an expired context, and (source 'model_switch') after a model change
|
|
115
|
+
'warm_start', // inbound `warm` (a query without a prompt): start the CLI process before the first message
|
|
116
|
+
'permission_rules', // session_request `permission_rules` / `forget_session_rules`; reattach_result carries `grantedRules`
|
|
117
|
+
'mcp_dynamic', // session_request `mcp_set_servers {servers}` (a tab's remote servers, live) and `mcp_permission_mode {serverName, mode}`
|
|
118
|
+
'session_state', // the CLI's session_state_changed events reach the panel (running / idle / requires_action)
|
|
119
|
+
'session_settings_v2', // query `session` also reads: allowedTools, disallowedTools, tools, planModeInstructions, mcpServers, synabunAlwaysLoad, agents, skills, overlay, debug
|
|
120
|
+
'bypass_mode', // Bypass is the SDK's bypassPermissions: a tab's process can take it (query `permissionMode`, `set_permission_mode`, the plan card's `planDecision`); `mode_changed` carries `reason` when it was refused or undone
|
|
121
|
+
'mode_statements', // the page numbers its statements of the tab's mode (`modeSeq`) and every mode report (init, status, mode_changed, reattach_result) returns the number applied; `set_permission_mode {fromSettings}` and a plan answer's `planExit` go back to the mode the user's settings name
|
|
122
|
+
'temporary_chat', // query / warm / `config` `temporary: true`: a conversation that leaves nothing behind (lib/claude-temporary.js); `temporary_ended` when its process is gone
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
const WARM_TTL_MS = 3 * 60 * 1000; // an unused warm process is closed after this
|
|
126
|
+
|
|
127
|
+
const ACCOUNT_UNAVAILABLE = 'The Claude account this tab uses is no longer set up in SynaBun. Pick another one with /account, or add it again in Settings.';
|
|
128
|
+
// A panel session nothing configured yet was asked to start (see ensureQuery).
|
|
129
|
+
const CONFIG_REQUIRED = 'This tab\'s session was not started: its account and restrictions did not come with the request. Reload the page, or send a message first.';
|
|
130
|
+
// Panel sessions, a temporary chat (lib/claude-temporary.js): what it is told when it cannot go on, or cannot do something.
|
|
131
|
+
const TEMPORARY_ENDED = 'This temporary chat is over: its session is gone and nothing of it was kept. Start a new chat to go on.';
|
|
132
|
+
const TEMPORARY_REFUSED = 'A conversation that has started cannot be made temporary. Start a new chat and choose Temporary before its first message.';
|
|
133
|
+
const TEMPORARY_NO_REWIND = 'A temporary chat keeps no file checkpoints and no transcript, so there is nothing to rewind to.';
|
|
134
|
+
const TEMPORARY_NO_RESTART = 'In a temporary chat the session cannot be restarted: its conversation would be lost.';
|
|
135
|
+
|
|
136
|
+
// Permission modes. The two newer ones are offered to panel sessions only;
|
|
137
|
+
// every other caller keeps the four it had.
|
|
138
|
+
const CLASSIC_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
|
|
139
|
+
const PANEL_PERMISSION_MODES = [...CLASSIC_PERMISSION_MODES, 'dontAsk', 'auto'];
|
|
140
|
+
|
|
141
|
+
// Panel sessions and Bypass. A tab's process is launched able to take
|
|
142
|
+
// bypassPermissions (the SDK's allowDangerouslySkipPermissions), and only what
|
|
143
|
+
// the user chose puts it there: the mode a query or a starting control states
|
|
144
|
+
// (the mode control, a restored tab), `set_permission_mode`, the plan card's
|
|
145
|
+
// own choice, or the user's settings when the tab never picked a mode
|
|
146
|
+
// (permissions.defaultMode, which the CLI reads itself). Whether the user's
|
|
147
|
+
// settings or a policy turn Bypass off is the CLI's decision: it starts in
|
|
148
|
+
// another mode, or refuses the switch, and the tab is told.
|
|
149
|
+
const BYPASS = 'bypassPermissions';
|
|
150
|
+
const BYPASS_NOT_TAKEN = 'Claude Code did not start this session in Bypass: your Claude Code settings or a policy turn it off.';
|
|
151
|
+
// A Bypass the session reports that is not its mode. The sentence says whose it
|
|
152
|
+
// was: nobody's the user knows of, their own earlier pick or settings (the
|
|
153
|
+
// session was in Bypass before, by their choice), or the mode their settings
|
|
154
|
+
// started this process in before they picked another one.
|
|
155
|
+
const BYPASS_FOREIGN = 'Something other than you switched this session to Bypass. It was switched back.';
|
|
156
|
+
const BYPASS_LEFT = 'This session reported Bypass again after it had left it. It was switched back.';
|
|
157
|
+
const BYPASS_FROM_SETTINGS = 'Your Claude Code settings started this session in Bypass. It was switched to the mode this tab is in.';
|
|
158
|
+
const SETTINGS_MODE_UNKNOWN = 'This session did not start in the mode your Claude Code settings name, so it cannot go back to it: it runs in Default until it restarts.';
|
|
159
|
+
const bypassRefusalText = (why) => {
|
|
160
|
+
const text = String(why || '');
|
|
161
|
+
if (/disabled by settings/i.test(text)) return 'Bypass is turned off by your Claude Code settings or a policy (permissions.disableBypassPermissionsMode).';
|
|
162
|
+
if (/not launched/i.test(text)) return 'Bypass is not available in this session: its process was started without it.';
|
|
163
|
+
return `Claude Code refused Bypass${text ? `: ${text.slice(0, 200)}` : '.'}`;
|
|
164
|
+
};
|
|
165
|
+
// Why a process cannot be launched able to take Bypass ('' when it can). Claude
|
|
166
|
+
// Code exits at start when asked for it as root outside a sandbox, so there the
|
|
167
|
+
// option is left out and the mode is refused here instead
|
|
168
|
+
// (lib/claude-bypass-policy.js; `deps.bypassBlock` stands in for it in tests).
|
|
169
|
+
function panelBypassBlock() {
|
|
170
|
+
if (typeof deps?.bypassBlock === 'function') { try { return String(deps.bypassBlock() || ''); } catch { return ''; } }
|
|
171
|
+
return rootBypassBlock();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Tool policies a panel tab can pick: tools removed from the session.
|
|
175
|
+
const TOOL_POLICY_DISALLOW = {
|
|
176
|
+
'read-only': ['Edit', 'Write', 'NotebookEdit', 'Bash'],
|
|
177
|
+
'no-web': ['WebFetch', 'WebSearch'],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// The list for this server: capabilities that depend on what the host wired in.
|
|
181
|
+
export function panelCapabilities() {
|
|
182
|
+
const caps = [...PANEL_CAPABILITIES];
|
|
183
|
+
if (typeof deps?.claudeAccountEnv === 'function') caps.push('accounts'); // query `accountId`: run the tab under another Claude account
|
|
184
|
+
if (deps?.sessionOps === true) caps.push('session_ops'); // the host serves /api/claude-code/sessions/:id/{title,tag,fork,subagents} and DELETE
|
|
185
|
+
if (deps?.permissionRulesEdit === true) caps.push('permission_rules_edit'); // the host serves DELETE /api/claude-code/permission-rules
|
|
186
|
+
if (deps?.settingsView === true) caps.push('settings_view'); // the host serves GET /api/claude-code/settings/resolved
|
|
187
|
+
if (deps?.accountSettings === true) caps.push('account_settings'); // those settings routes take `account`: a named-account tab sees its own files
|
|
188
|
+
if (deps?.bypassPolicy === true) caps.push('bypass_policy'); // the host serves GET /api/claude-code/bypass-policy (is Bypass turned off, and by what)
|
|
189
|
+
return caps;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Per-session switch for everything the sidepanel adds on top of the shared
|
|
193
|
+
// session class. Only createClaudeBridge() passes it: the Assistant brain and
|
|
194
|
+
// every other ClaudeSession caller keep the options, events and defaults they
|
|
195
|
+
// had before.
|
|
196
|
+
const PANEL_SESSION_OPTS = Object.freeze({ panel: true });
|
|
197
|
+
// Panel sessions: the messages, besides a query and a warm start, that can start
|
|
198
|
+
// a process. Only these apply the `config` they carry (see handleMessage). Every
|
|
199
|
+
// other message looks at the session or needs a live one: what it carries is
|
|
200
|
+
// not applied, so a request that only looks (permission rules, usage, MCP
|
|
201
|
+
// status) binds no account, takes no lock and leaves a cold session cold.
|
|
202
|
+
const STARTING_CONTROLS = new Set(['compact', 'rewind', 'rewind_conversation']);
|
|
203
|
+
const controlCanStart = (msg) => STARTING_CONTROLS.has(msg?.type) || (msg?.type === 'session_request' && msg.what === 'rewind_preview');
|
|
204
|
+
// Panel sessions: the messages that wait in line while the session is between
|
|
205
|
+
// two processes (see _newTurn): the ones that can start a process, the one
|
|
206
|
+
// request that ends it, and the two whose effect depends on what came before
|
|
207
|
+
// them (a stop, a permission-mode switch: handled ahead of a prompt that is
|
|
208
|
+
// still waiting, the stop would stop nothing and the prompt would undo the
|
|
209
|
+
// switch). Everything else (a permission answer, a request that only looks) is
|
|
210
|
+
// handled when it arrives, as it always was.
|
|
211
|
+
const takesTurn = (msg) => msg?.type === 'query' || msg?.type === 'warm' || controlCanStart(msg)
|
|
212
|
+
|| (msg?.type === 'session_request' && msg.what === 'forget_session_rules')
|
|
213
|
+
|| msg?.type === 'abort' || msg?.type === 'set_permission_mode';
|
|
214
|
+
|
|
215
|
+
let deps = null; // injected once from server.js — see configureClaudeBridge()
|
|
216
|
+
export function configureClaudeBridge(d) { deps = d; }
|
|
217
|
+
|
|
218
|
+
// windowId:sessionId → session (only while detached, for reattach)
|
|
219
|
+
const _detached = new Map();
|
|
220
|
+
// every live session, for shutdown + sweeps
|
|
221
|
+
const _allSessions = new Set();
|
|
222
|
+
|
|
223
|
+
function _orphanKey(wid, sid) { return sid ? `${wid}:${sid}` : wid; }
|
|
224
|
+
|
|
225
|
+
function log(...args) { console.log('[claude-sdk]', ...args); }
|
|
226
|
+
|
|
227
|
+
// Panel sessions, a temporary chat (lib/claude-temporary.js): the plan files
|
|
228
|
+
// Claude Code wrote for one that still have to be removed. path → { plansDirs,
|
|
229
|
+
// since, ended, last }. One is removed once the pump of the process that wrote
|
|
230
|
+
// it has settled (`ended`), and stays owed after that removal, whether it
|
|
231
|
+
// removed the file or found none: the pump settles when the SDK's own cleanup
|
|
232
|
+
// stops waiting for the process, which can be before the process is gone, and
|
|
233
|
+
// a write that was slow puts the file back. The path is forgotten by the sweep
|
|
234
|
+
// that runs once the process can no longer be alive (`last`), when that sweep
|
|
235
|
+
// did not fail. A removal that fails keeps the path: every later sweep tries it
|
|
236
|
+
// again, and so does the shutdown, which waits for no process and forgets what
|
|
237
|
+
// it removed. Empty unless a temporary chat wrote a plan.
|
|
238
|
+
const _temporaryPlansOwed = new Map();
|
|
239
|
+
|
|
240
|
+
// How long the SDK lets a session's process live once it ends it
|
|
241
|
+
// (ProcessTransport.close() in sdk.mjs, SDK 0.3.288): two seconds to exit by
|
|
242
|
+
// itself, then SIGTERM, then five more before SIGKILL. Its cleanup, which the
|
|
243
|
+
// pump waits for, gives up after the first two.
|
|
244
|
+
const SDK_PROCESS_EXIT_MS = 2000 + 5000;
|
|
245
|
+
// The last sweep of a temporary chat's plan files, counted from the moment its
|
|
246
|
+
// pump settled (the SDK ended the process before that): a second later than
|
|
247
|
+
// the process can live.
|
|
248
|
+
const TEMPORARY_PLANS_LAST_SWEEP_MS = SDK_PROCESS_EXIT_MS + 1000;
|
|
249
|
+
|
|
250
|
+
function _removeOwedTemporaryPlans({ all = false } = {}) {
|
|
251
|
+
if (!_temporaryPlansOwed.size) return;
|
|
252
|
+
let removed = 0;
|
|
253
|
+
let failed = 0;
|
|
254
|
+
for (const [path, owed] of [..._temporaryPlansOwed]) {
|
|
255
|
+
if (!all && !owed.ended) continue;
|
|
256
|
+
const result = removeTemporaryPlanFiles([path], owed);
|
|
257
|
+
removed += result.removed.length;
|
|
258
|
+
if (result.failed.length) { failed++; continue; }
|
|
259
|
+
if (all || owed.last) _temporaryPlansOwed.delete(path);
|
|
260
|
+
}
|
|
261
|
+
if (removed) log(`temporary chat: removed ${removed} plan file${removed === 1 ? '' : 's'} it wrote`);
|
|
262
|
+
if (failed) log(`temporary chat: ${failed} plan file${failed === 1 ? '' : 's'} could not be removed and will be tried again`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function cleanEnv() {
|
|
266
|
+
const env = Object.fromEntries(
|
|
267
|
+
Object.entries(process.env).filter(([k]) =>
|
|
268
|
+
k !== 'CLAUDECODE' &&
|
|
269
|
+
!k.startsWith('VSCODE_') &&
|
|
270
|
+
k !== 'TERM_PROGRAM' &&
|
|
271
|
+
k !== 'TERM_PROGRAM_VERSION'
|
|
272
|
+
)
|
|
273
|
+
);
|
|
274
|
+
env.ENABLE_TOOL_SEARCH = 'true';
|
|
275
|
+
return env;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function validateWorkDir(cwd) {
|
|
279
|
+
if (!cwd) return null;
|
|
280
|
+
if (process.platform !== 'win32' && /^[A-Za-z]:[\\\/]/.test(cwd)) return null;
|
|
281
|
+
if (process.platform === 'win32' && cwd.startsWith('/')) return null;
|
|
282
|
+
if (!existsSync(cwd)) return null;
|
|
283
|
+
return cwd;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Push-based AsyncIterable feeding the SDK's streaming-input mode.
|
|
287
|
+
function createInputQueue() {
|
|
288
|
+
const buf = [];
|
|
289
|
+
let wake = null;
|
|
290
|
+
let closed = false;
|
|
291
|
+
return {
|
|
292
|
+
push(m) { if (closed) return false; buf.push(m); if (wake) { const w = wake; wake = null; w(); } return true; },
|
|
293
|
+
close() { closed = true; if (wake) { const w = wake; wake = null; w(); } },
|
|
294
|
+
get closed() { return closed; },
|
|
295
|
+
async *[Symbol.asyncIterator]() {
|
|
296
|
+
for (;;) {
|
|
297
|
+
while (buf.length) yield buf.shift();
|
|
298
|
+
if (closed) return;
|
|
299
|
+
await new Promise(r => { wake = r; });
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Optional per-session overrides (used by the assistant brain; the sidepanel
|
|
306
|
+
// passes none): { ownerKey, systemPromptAppend, settingSources, hooks,
|
|
307
|
+
// mcpHeaders, extraMcpServers, maxBudgetUsd, env, disallowedTools,
|
|
308
|
+
// allowedTools, permissionMode, model, effort, cwd, windowId, sessionId,
|
|
309
|
+
// planPermission, planHook, planDenyMessage, planExitMode, planModeInstructions }.
|
|
310
|
+
function mergeHookMatchers(base = {}, extra = {}) {
|
|
311
|
+
const merged = { ...base };
|
|
312
|
+
for (const [event, matchers] of Object.entries(extra || {})) {
|
|
313
|
+
if (!Array.isArray(matchers) || !matchers.length) continue;
|
|
314
|
+
merged[event] = [...(merged[event] || []), ...matchers];
|
|
315
|
+
}
|
|
316
|
+
return merged;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export class ClaudeSession {
|
|
320
|
+
constructor(ws, opts = {}) {
|
|
321
|
+
this.ws = ws;
|
|
322
|
+
this.opts = opts && typeof opts === 'object' ? opts : {};
|
|
323
|
+
this.panel = this.opts.panel === true; // a sidepanel tab (see PANEL_SESSION_OPTS)
|
|
324
|
+
this.windowId = this.opts.windowId || null;
|
|
325
|
+
this.sessionId = this.opts.sessionId || null; // claude session id (from init/result events)
|
|
326
|
+
this.model = this.opts.model || null;
|
|
327
|
+
this.cwd = this.opts.cwd || null;
|
|
328
|
+
this.effort = this.opts.effort || null;
|
|
329
|
+
this.permissionMode = this.opts.permissionMode || 'default';
|
|
330
|
+
|
|
331
|
+
this.q = null; // live Query or null
|
|
332
|
+
this.input = null; // input queue feeding the Query
|
|
333
|
+
this.abortController = null;
|
|
334
|
+
this.pumpPromise = null;
|
|
335
|
+
|
|
336
|
+
this.inTurn = false;
|
|
337
|
+
this.pendingTurns = 0; // user messages pushed minus results received
|
|
338
|
+
this.swallowResults = 0; // results to swallow (produced by interrupt()) — no done, no accounting
|
|
339
|
+
this.swallowDeadline = 0; // staleness guard: swallow entries expire after 15s
|
|
340
|
+
this.bootComplete = false;
|
|
341
|
+
this.lastEventTime = Date.now();
|
|
342
|
+
this.lastActivity = Date.now();
|
|
343
|
+
this.lastPrompt = null;
|
|
344
|
+
this.stallRetries = 0;
|
|
345
|
+
|
|
346
|
+
// The Claude Code executable the current Query was started with (see
|
|
347
|
+
// ensureQuery): what a launch failure is diagnosed against.
|
|
348
|
+
this._claudeExecutable = null;
|
|
349
|
+
|
|
350
|
+
this.pendingPerms = new Map(); // requestId → { toolName, resolve, input, kind, wire }
|
|
351
|
+
this.alwaysAllowed = new Set();
|
|
352
|
+
|
|
353
|
+
// Work the CLI comes back to without a new prompt, after the turn that started
|
|
354
|
+
// it has ended: background tasks (agents, shells, monitors — the CLI's
|
|
355
|
+
// background_tasks_changed level signal) and session crons (ScheduleWakeup,
|
|
356
|
+
// CronCreate, /loop — reported to the Stop hook). Both live inside one CLI
|
|
357
|
+
// process, so ending the Query kills them; _onProcessGone() resets them.
|
|
358
|
+
this._bgTasks = new Map(); // task_id → { task_id, task_type, description, ambient }
|
|
359
|
+
this._sessionCrons = [];
|
|
360
|
+
// Panel sessions: the permission updates granted from cards ("Always"), as
|
|
361
|
+
// the /permissions view lists them. Session-scoped ones live in the CLI
|
|
362
|
+
// process and are dropped with it; the others are in the settings files.
|
|
363
|
+
this._grantedRules = [];
|
|
364
|
+
this._bgAgentIds = new Set(); // Task/Agent tool_use ids that went to the background
|
|
365
|
+
this._queryGen = 0; // per Query, so a replaced one's hook callbacks are ignored
|
|
366
|
+
|
|
367
|
+
// Turn-accounting cross-checks (see _handleResult and _checkStall).
|
|
368
|
+
this._lastPushAt = 0; // last user message pushed into the Query
|
|
369
|
+
this._lastResultAt = 0; // last result the CLI delivered
|
|
370
|
+
this._resultOwesTurn = false; // that result said another queued turn follows
|
|
371
|
+
this._outputSinceResult = false; // main-thread output since that result
|
|
372
|
+
|
|
373
|
+
this.costBySession = new Map(); // sessionId → last cumulative total_cost_usd baseline
|
|
374
|
+
|
|
375
|
+
this.orphanBuffer = null; // non-null while detached
|
|
376
|
+
this.orphanResynced = false;
|
|
377
|
+
this.orphanKillTimer = null;
|
|
378
|
+
this.destroyed = false;
|
|
379
|
+
|
|
380
|
+
// Stable SynaBun browser-routing identity for this chat session. Sent as
|
|
381
|
+
// X-Synabun-Terminal on the SynaBun MCP entry so the Neural Interface
|
|
382
|
+
// routes this session's tab-less browser calls to its OWN tab — and keeps
|
|
383
|
+
// the same tab across ensureQuery() re-creations (stall recovery), which
|
|
384
|
+
// would otherwise mint a fresh Mcp-Session-Id and acquire a new tab.
|
|
385
|
+
this.synabunOwnerKey = this.opts.ownerKey || `sidepanel-${randomUUID()}`;
|
|
386
|
+
|
|
387
|
+
// Delta coalescing state: blockKey → { event, text, timer }
|
|
388
|
+
this._deltaBuf = new Map();
|
|
389
|
+
|
|
390
|
+
// Random start offset de-phases the 15s watchdogs of sessions created in
|
|
391
|
+
// the same tick (page reload restores N tabs at once) so their stall
|
|
392
|
+
// checks — and any resulting recoveries — don't align.
|
|
393
|
+
this._watchdogStartTimer = setTimeout(() => {
|
|
394
|
+
this._watchdogStartTimer = null;
|
|
395
|
+
if (this.destroyed) return;
|
|
396
|
+
this._watchdog = setInterval(() => this._checkStall(), 15_000);
|
|
397
|
+
this._watchdog.unref?.();
|
|
398
|
+
}, Math.floor(Math.random() * 15_000));
|
|
399
|
+
this._watchdogStartTimer.unref?.();
|
|
400
|
+
// Self-heartbeat the session lock while a live Query exists — covers the
|
|
401
|
+
// detached window where client heartbeats stop but the CLI still writes JSONL.
|
|
402
|
+
this._lockBeat = setInterval(() => {
|
|
403
|
+
if (this.q && this.sessionId && this.windowId && !this.temporary) {
|
|
404
|
+
try { deps.heartbeatLock(this.sessionId, this.windowId); } catch {}
|
|
405
|
+
}
|
|
406
|
+
}, 30_000);
|
|
407
|
+
this._lockBeat.unref?.();
|
|
408
|
+
this._idleReaper = setInterval(() => this._maybeReapIdle(), 60_000);
|
|
409
|
+
this._idleReaper.unref?.();
|
|
410
|
+
|
|
411
|
+
_allSessions.add(this);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── wire helpers ──────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
send(data) {
|
|
417
|
+
if (this.destroyed) return;
|
|
418
|
+
if (this.orphanBuffer) {
|
|
419
|
+
// High-volume deltas are not buffered while detached — full assistant
|
|
420
|
+
// messages still arrive and carry the final content.
|
|
421
|
+
if (data?.type === 'event' && data.event?.type === 'stream_event') return;
|
|
422
|
+
this.orphanBuffer.push(data);
|
|
423
|
+
if (this.orphanBuffer.length > ORPHAN_BUFFER_CAP) {
|
|
424
|
+
this.orphanBuffer.shift();
|
|
425
|
+
this.orphanResynced = true;
|
|
426
|
+
}
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (this._replaying) {
|
|
430
|
+
// Mid-replay: queue behind the buffered events to preserve ordering
|
|
431
|
+
// (same delta-drop policy as detachment — the window is a few ticks).
|
|
432
|
+
if (data?.type === 'event' && data.event?.type === 'stream_event') return;
|
|
433
|
+
this._replayQueue?.push(data);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (this.ws && this.ws.readyState === 1) {
|
|
437
|
+
try { this.ws.send(JSON.stringify(data)); } catch {}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
sendEvent(event) { this.send({ type: 'event', event }); }
|
|
442
|
+
|
|
443
|
+
sendDone(code) {
|
|
444
|
+
this.send({ type: 'done', code });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── query lifecycle ──────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
// `warm: true` (panel sessions, see the `warm` message) starts the CLI process
|
|
450
|
+
// without a prompt: the first real ensureQuery() adopts it if the session's
|
|
451
|
+
// configuration is still the one it was started with.
|
|
452
|
+
ensureQuery({ warm = false } = {}) {
|
|
453
|
+
if (this.q) return this.q;
|
|
454
|
+
// Panel sessions under a named Claude account: every process start checks
|
|
455
|
+
// the account again (a message, a recreation after a stall, a lost-session
|
|
456
|
+
// recovery, a rewind, a warm start). One that is gone refuses the start:
|
|
457
|
+
// the process would otherwise run under the ambient default identity while
|
|
458
|
+
// the tab says another. The environment validated here is the one used.
|
|
459
|
+
this._startRefused = null;
|
|
460
|
+
// Panel sessions: a process starts only for a session that was given its
|
|
461
|
+
// tab's configuration, by a query or by the starting control that reached
|
|
462
|
+
// it last (_establishFromControl). After a server restart, or when an idle tab's
|
|
463
|
+
// socket was replaced, the session object holds nothing but the id: started
|
|
464
|
+
// as it is, it would run under the default identity with every tool and
|
|
465
|
+
// default permissions while the tab says otherwise. Every start comes
|
|
466
|
+
// through here, so this is the one place that refuses.
|
|
467
|
+
if (this.panel && !this._established) {
|
|
468
|
+
this._startRefused = this._coldRefusal || { code: 'config_required', message: CONFIG_REQUIRED };
|
|
469
|
+
log(`session create refused: ${this._startRefused.code} (resume=${this.sessionId || 'none'})`);
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
// Panel sessions, a temporary chat whose process is gone: the conversation
|
|
473
|
+
// lived in that process and nowhere else. A new one would answer without
|
|
474
|
+
// it, so none is started (every start comes through here).
|
|
475
|
+
if (this.temporary && this._tempOver) {
|
|
476
|
+
this._closeWarm();
|
|
477
|
+
this._startRefused = { code: 'temporary_ended', message: TEMPORARY_ENDED };
|
|
478
|
+
log('session create refused: the temporary chat has ended');
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
let accountEnv = null;
|
|
482
|
+
if (this.panel && this.accountId) {
|
|
483
|
+
accountEnv = this._accountEnvOf(this.accountId);
|
|
484
|
+
if (!accountEnv) {
|
|
485
|
+
this._closeWarm();
|
|
486
|
+
this._startRefused = { code: 'account_unavailable', message: ACCOUNT_UNAVAILABLE };
|
|
487
|
+
log(`session create refused: the account "${this.accountId}" is no longer set up`);
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// Which Claude Code runs this session: the user's own installation, always.
|
|
492
|
+
// SynaBun carries none (lib/external-tools.js at the package root) and the
|
|
493
|
+
// SDK is never left to look for one. The host answers (deps.claudeExecutable,
|
|
494
|
+
// lib/claude-executable.js): an explicit override from cli-config.json
|
|
495
|
+
// ("claude-skin": { "sdkExecutable": "…" }), else the installed Claude Code.
|
|
496
|
+
// The model picker lists that same installation's models, so an alias means
|
|
497
|
+
// in the session what it meant in the picker.
|
|
498
|
+
//
|
|
499
|
+
// Nothing installed is an ordinary state: the start is refused with the
|
|
500
|
+
// sentence the panel shows its install help on, and nothing was started.
|
|
501
|
+
// The SDK itself is never called without an executable; only a host that
|
|
502
|
+
// brings its own query factory and no resolver (a test) starts without one.
|
|
503
|
+
let executable = null;
|
|
504
|
+
if (typeof deps.claudeExecutable === 'function') {
|
|
505
|
+
try { executable = deps.claudeExecutable() || null; } catch { executable = null; }
|
|
506
|
+
} else if (deps.sdkExecutable) {
|
|
507
|
+
executable = resolveClaudeSdkExecutable({ sdkExecutable: deps.sdkExecutable });
|
|
508
|
+
}
|
|
509
|
+
const ownFactory = typeof (warm ? deps.startupFactory : deps.queryFactory) === 'function';
|
|
510
|
+
if (!executable?.path && (typeof deps.claudeExecutable === 'function' || !ownFactory)) {
|
|
511
|
+
this._closeWarm();
|
|
512
|
+
this._startRefused = { code: 'claude_not_installed', message: executable?.reason || CLAUDE_NOT_INSTALLED };
|
|
513
|
+
log(`session create refused: no Claude Code to run (${executable?.reason || 'not installed'})`);
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
if (this._warm) {
|
|
517
|
+
const w = this._warm;
|
|
518
|
+
this._warm = null;
|
|
519
|
+
clearTimeout(w.timer);
|
|
520
|
+
if (!warm && w.ready && !w.closed && w.sig === this._warmSignature()) {
|
|
521
|
+
this.input = w.input;
|
|
522
|
+
this.abortController = w.abortController;
|
|
523
|
+
this.bootComplete = false;
|
|
524
|
+
this.lastEventTime = Date.now();
|
|
525
|
+
this._lastResultAt = 0;
|
|
526
|
+
this._resultOwesTurn = false;
|
|
527
|
+
this._outputSinceResult = false;
|
|
528
|
+
this._deltaBuf.clear();
|
|
529
|
+
log(`session create: adopted the warm process (resume=${this.sessionId || 'none'})`);
|
|
530
|
+
this._startModeSeen = false;
|
|
531
|
+
this.q = w.handle.query(this.input);
|
|
532
|
+
if (this.panel) this._noteProcessStarted();
|
|
533
|
+
this.pumpPromise = this._pump(this.q);
|
|
534
|
+
return this.q;
|
|
535
|
+
}
|
|
536
|
+
this._closeWarm(w);
|
|
537
|
+
}
|
|
538
|
+
// A new CLI process starts with no background work of its own; anything the
|
|
539
|
+
// previous one still reported died with it.
|
|
540
|
+
this._onProcessGone();
|
|
541
|
+
const gen = ++this._queryGen;
|
|
542
|
+
this.input = createInputQueue();
|
|
543
|
+
this.abortController = new AbortController();
|
|
544
|
+
this.bootComplete = false;
|
|
545
|
+
this.lastEventTime = Date.now();
|
|
546
|
+
this._lastResultAt = 0;
|
|
547
|
+
this._resultOwesTurn = false;
|
|
548
|
+
this._outputSinceResult = false;
|
|
549
|
+
this._deltaBuf.clear();
|
|
550
|
+
|
|
551
|
+
const workDir = validateWorkDir(this.cwd) || deps.PACKAGE_ROOT;
|
|
552
|
+
const sessionOpts = this.opts || {};
|
|
553
|
+
const options = {
|
|
554
|
+
cwd: workDir,
|
|
555
|
+
permissionMode: this.permissionMode || 'default',
|
|
556
|
+
// Opt-in (the assistant brain): lets plan mode or a later switch reach bypassPermissions.
|
|
557
|
+
...(sessionOpts.allowDangerouslySkipPermissions === true ? { allowDangerouslySkipPermissions: true } : {}),
|
|
558
|
+
includePartialMessages: deps.includePartialMessages !== false,
|
|
559
|
+
enableFileCheckpointing: true, // required for Query.rewindFiles()
|
|
560
|
+
// CRITICAL: without these two the SDK loads neither the Claude Code system
|
|
561
|
+
// prompt nor any ~/.claude settings/hooks/CLAUDE.md/MCP servers.
|
|
562
|
+
systemPrompt: sessionOpts.systemPromptAppend
|
|
563
|
+
? { type: 'preset', preset: 'claude_code', append: String(sessionOpts.systemPromptAppend) }
|
|
564
|
+
: { type: 'preset', preset: 'claude_code' },
|
|
565
|
+
settingSources: Array.isArray(sessionOpts.settingSources) ? sessionOpts.settingSources : ['user', 'project', 'local'],
|
|
566
|
+
canUseTool: (name, input, opts) => this._onCanUseTool(name, input, opts || {}),
|
|
567
|
+
hooks: mergeHookMatchers({
|
|
568
|
+
PreCompact: [{
|
|
569
|
+
hooks: [async () => {
|
|
570
|
+
this.sendEvent({ type: 'system', subtype: 'compact_started', message: 'Auto-compacting context…' });
|
|
571
|
+
return {};
|
|
572
|
+
}],
|
|
573
|
+
}],
|
|
574
|
+
// Stop fires at the end of every main-thread turn, and its input lists the
|
|
575
|
+
// session crons (ScheduleWakeup, CronCreate, /loop) that will wake this
|
|
576
|
+
// session later. The idle reaper must not end a session waiting on one.
|
|
577
|
+
Stop: [{
|
|
578
|
+
hooks: [async (input) => {
|
|
579
|
+
if (gen === this._queryGen) {
|
|
580
|
+
const before = JSON.stringify(this._sessionCrons);
|
|
581
|
+
this._sessionCrons = Array.isArray(input?.session_crons) ? input.session_crons : [];
|
|
582
|
+
// Panel sessions: the scheduled wakeups of this session, when they change.
|
|
583
|
+
if (this.panel && JSON.stringify(this._sessionCrons) !== before) this._announceCrons();
|
|
584
|
+
}
|
|
585
|
+
return {};
|
|
586
|
+
}],
|
|
587
|
+
}],
|
|
588
|
+
// Opt-in (the assistant brain): plan mode refuses before permission rules
|
|
589
|
+
// run, so a user allow rule (Edit(*), mcp__fs__*) cannot turn a code
|
|
590
|
+
// change back on. Subagents too.
|
|
591
|
+
...(typeof sessionOpts.planHook === 'function' ? {
|
|
592
|
+
PreToolUse: [{
|
|
593
|
+
hooks: [async (input) => {
|
|
594
|
+
if (this.permissionMode !== 'plan' || sessionOpts.planHook(input?.tool_name, input?.tool_input || {}) !== 'deny') return {};
|
|
595
|
+
this.send({ type: 'stderr', text: `Plan mode: declined ${input?.tool_name}` });
|
|
596
|
+
const reason = sessionOpts.planDenyMessage?.(input?.tool_name) || `Plan mode: ${input?.tool_name} was not run.`;
|
|
597
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } };
|
|
598
|
+
}],
|
|
599
|
+
}],
|
|
600
|
+
} : {}),
|
|
601
|
+
}, sessionOpts.hooks),
|
|
602
|
+
// `typesafe:'off'` on a query (the live skin test) keeps this CLI's hooks
|
|
603
|
+
// from paying for judgments: they read the kill switch from their env.
|
|
604
|
+
// (Panel sessions: the CLI reports running / idle / requires_action only when
|
|
605
|
+
// asked to; `idle` is its own word that a turn is over. See _noteSessionState.)
|
|
606
|
+
env: { ...cleanEnv(), ...(this.typesafeOff ? { SYNABUN_TYPESAFE: 'off' } : {}), ...(sessionOpts.env || {}), ...(accountEnv || {}), ...(this.panel ? { CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: '1' } : {}) },
|
|
607
|
+
abortController: this.abortController,
|
|
608
|
+
stderr: (text) => {
|
|
609
|
+
const t = String(text || '').trim();
|
|
610
|
+
if (t) this.send({ type: 'stderr', text: t });
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
if (Array.isArray(sessionOpts.disallowedTools) && sessionOpts.disallowedTools.length) options.disallowedTools = [...sessionOpts.disallowedTools];
|
|
614
|
+
if (this.panel) {
|
|
615
|
+
// The process is launched able to take Bypass, so the tab's mode control
|
|
616
|
+
// can switch to it and back on the live session (see BYPASS above). Where
|
|
617
|
+
// it cannot be (root), a tab that asks for Bypass runs in Default.
|
|
618
|
+
const bypassBlock = panelBypassBlock();
|
|
619
|
+
this._bypassLaunched = !bypassBlock;
|
|
620
|
+
if (this._bypassLaunched) options.allowDangerouslySkipPermissions = true;
|
|
621
|
+
else if (options.permissionMode === BYPASS) {
|
|
622
|
+
options.permissionMode = this.permissionMode = 'default';
|
|
623
|
+
this.sendEvent(this._numbered({ type: 'mode_changed', mode: 'default', reason: bypassBlock }));
|
|
624
|
+
}
|
|
625
|
+
// A tab that never picked a mode starts in the one the user's settings
|
|
626
|
+
// name (permissions.defaultMode): omitted, the CLI decides.
|
|
627
|
+
if (this._modeFromSettings) delete options.permissionMode;
|
|
628
|
+
this._startModeSeen = false; // the first init of this process says which mode it started in
|
|
629
|
+
this._startAskedBypass = options.permissionMode === BYPASS;
|
|
630
|
+
this._startedFromSettings = !('permissionMode' in options); // the CLI chose the starting mode (permissions.defaultMode)
|
|
631
|
+
this._launchMode = this._startedFromSettings ? '' : options.permissionMode; // what the process is in until it says otherwise (see _noteProcessStarted)
|
|
632
|
+
if (this._startAskedBypass) this._bypassHeld = true;
|
|
633
|
+
const removed = TOOL_POLICY_DISALLOW[this.toolPolicy];
|
|
634
|
+
if (removed) options.disallowedTools = [...new Set([...(options.disallowedTools || []), ...removed])];
|
|
635
|
+
// MCP servers asking the user for input or a sign-in. Only for a client
|
|
636
|
+
// that said it can show the card: left unset, the CLI declines for us.
|
|
637
|
+
if (this.clientFeatures?.has('elicitation')) {
|
|
638
|
+
options.onElicitation = (request, opts) => this._promptElicitation(request || {}, opts?.signal);
|
|
639
|
+
}
|
|
640
|
+
// The panel has a stop control per background task, so an interrupt of the
|
|
641
|
+
// turn leaves background agents and shells running.
|
|
642
|
+
if (this.clientFeatures?.has('task_stop')) options.perTaskStopAffordance = true;
|
|
643
|
+
// Resuming a session whose prompt cache has expired pays to write it
|
|
644
|
+
// again: the CLI says how much at SessionStart.
|
|
645
|
+
options.hooks = mergeHookMatchers(options.hooks, {
|
|
646
|
+
SessionStart: [{
|
|
647
|
+
hooks: [async (input) => {
|
|
648
|
+
if (gen === this._queryGen && input?.prompt_cache_likely_expired === true && Number(input?.estimated_cache_write_usd) > 0) {
|
|
649
|
+
this.sendEvent({
|
|
650
|
+
type: 'system', subtype: 'cache_cost', session_id: this.sessionId,
|
|
651
|
+
source: String(input.source || ''),
|
|
652
|
+
seconds_since_last_response: Number(input.seconds_since_last_response) || 0,
|
|
653
|
+
estimated_cache_write_usd: Number(input.estimated_cache_write_usd),
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
return {};
|
|
657
|
+
}],
|
|
658
|
+
}],
|
|
659
|
+
// Changing the model mid-conversation sends the whole context to the new
|
|
660
|
+
// model's cache with the next reply: the CLI says what that costs once
|
|
661
|
+
// the switch is done (a headless set_model fires this with source 'sdk').
|
|
662
|
+
PostModelSwitch: [{
|
|
663
|
+
hooks: [async (input) => {
|
|
664
|
+
if (gen === this._queryGen && input?.source !== 'resume' && Number(input?.estimated_cache_write_usd) > 0) {
|
|
665
|
+
this.sendEvent({
|
|
666
|
+
type: 'system', subtype: 'cache_cost', session_id: this.sessionId,
|
|
667
|
+
source: 'model_switch',
|
|
668
|
+
from_model: String(input.from_model || ''),
|
|
669
|
+
to_model: String(input.to_model || ''),
|
|
670
|
+
context_tokens: Number(input.context_tokens) || 0,
|
|
671
|
+
prompt_cache_warm: input.prompt_cache_warm === true,
|
|
672
|
+
estimated_cache_write_usd: Number(input.estimated_cache_write_usd),
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
return {};
|
|
676
|
+
}],
|
|
677
|
+
}],
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
if (Array.isArray(sessionOpts.allowedTools) && sessionOpts.allowedTools.length) options.allowedTools = [...sessionOpts.allowedTools];
|
|
681
|
+
// The plan workflow the CLI puts inside its own plan-mode reminder (it keeps its preamble and the ExitPlanMode footer).
|
|
682
|
+
if (typeof sessionOpts.planModeInstructions === 'string' && sessionOpts.planModeInstructions.trim()) options.planModeInstructions = sessionOpts.planModeInstructions;
|
|
683
|
+
if (Number(sessionOpts.maxBudgetUsd) > 0) options.maxBudgetUsd = Number(sessionOpts.maxBudgetUsd);
|
|
684
|
+
// Per-chat-session SynaBun identity: programmatic mcpServers shadows the
|
|
685
|
+
// same-named user-scope entry from settingSources, so this session's
|
|
686
|
+
// browser calls carry a stable X-Synabun-Terminal and get their own tab
|
|
687
|
+
// (instead of colliding with other sidepanel chats / CLI windows on the
|
|
688
|
+
// shared HTTP transport identity).
|
|
689
|
+
if (deps.mcpUrl) {
|
|
690
|
+
options.mcpServers = {
|
|
691
|
+
SynaBun: {
|
|
692
|
+
type: 'http',
|
|
693
|
+
url: deps.mcpUrl,
|
|
694
|
+
headers: {
|
|
695
|
+
'X-Synabun-Terminal': this.synabunOwnerKey,
|
|
696
|
+
'X-Synabun-Project': memoryProject(workDir),
|
|
697
|
+
// (A temporary chat is never named to the memory server by a conversation id.)
|
|
698
|
+
'X-Synabun-Memory-Session': (this.temporary ? null : this.sessionId) || this.synabunOwnerKey,
|
|
699
|
+
...(sessionOpts.mcpHeaders || {}),
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
if (sessionOpts.extraMcpServers && typeof sessionOpts.extraMcpServers === 'object') {
|
|
705
|
+
options.mcpServers = { ...(options.mcpServers || {}), ...sessionOpts.extraMcpServers };
|
|
706
|
+
}
|
|
707
|
+
// (A temporary chat has no transcript to resume from.)
|
|
708
|
+
if (this.sessionId && !this.temporary) options.resume = this.sessionId;
|
|
709
|
+
if (this.panel) {
|
|
710
|
+
// A new session takes the name the user gave the tab; a conversation
|
|
711
|
+
// rewind resumes at the chosen entry, once.
|
|
712
|
+
if (!this.sessionId && this._title) options.title = this._title;
|
|
713
|
+
if (this.sessionId && this._resumeAt) { options.resumeSessionAt = this._resumeAt; this._resumeAt = null; }
|
|
714
|
+
}
|
|
715
|
+
if (this.model) options.model = this.model;
|
|
716
|
+
if (this.effort && CLAUDE_EFFORT_LEVELS.includes(this.effort)) {
|
|
717
|
+
// Panel sessions use the typed option; every other caller keeps the flag it had.
|
|
718
|
+
if (this.panel) options.effort = this.effort;
|
|
719
|
+
else options.extraArgs = { effort: this.effort };
|
|
720
|
+
}
|
|
721
|
+
// Sibling-project access, same guard as legacy --add-dir
|
|
722
|
+
const parentDir = dirname(workDir);
|
|
723
|
+
if (parentDir && parentDir !== workDir && dirname(parentDir) !== parentDir) {
|
|
724
|
+
options.additionalDirectories = [parentDir];
|
|
725
|
+
}
|
|
726
|
+
// The tab's own session settings (fallback model, limits, directories,
|
|
727
|
+
// plugins, instructions, sandbox, thinking, fast mode, output style, agent).
|
|
728
|
+
// The host's own MCP entries, before a tab adds its servers: setMcpServers
|
|
729
|
+
// replaces the whole dynamic set, so these go back in with every change.
|
|
730
|
+
this._hostMcpServers = { ...(options.mcpServers || {}) };
|
|
731
|
+
if (this.panel && this.panelSession) {
|
|
732
|
+
// (A temporary chat writes no debug log: the log holds what the session said.)
|
|
733
|
+
const wantsDebug = this.temporary && this.panelSession.start?.debug;
|
|
734
|
+
const cfg = wantsDebug ? { ...this.panelSession, start: { ...this.panelSession.start, debug: false } } : this.panelSession;
|
|
735
|
+
const notes = applyPanelSessionOptions(options, cfg, { model: this.model, validateDir: validateWorkDir, debugFile: this.temporary ? '' : this._debugFilePath() });
|
|
736
|
+
if (wantsDebug) notes.push('A temporary chat writes no debug log.');
|
|
737
|
+
for (const note of notes) this.sendEvent({ type: 'system', subtype: 'runtime_notice', level: 'warn', message: note });
|
|
738
|
+
if (options.debugFile) this.sendEvent({ type: 'system', subtype: 'runtime_notice', level: 'info', message: `Debug log of this session: ${options.debugFile}` });
|
|
739
|
+
this._liveApplied = { ...this.panelSession.live };
|
|
740
|
+
}
|
|
741
|
+
// Panel sessions, a temporary chat: nothing saved, memory readable. Applied
|
|
742
|
+
// last, over everything above (lib/claude-temporary.js).
|
|
743
|
+
if (this.panel && this.temporary) {
|
|
744
|
+
// (Which of the session's MCP connections is SynaBun's is asked of the live process when a tool of one is called.)
|
|
745
|
+
applyTemporaryOptions(options, { mcpUrl: deps.mcpUrl || '', mcpStatus: () => this.q?.mcpServerStatus?.() });
|
|
746
|
+
this._tempPlansDirs = plansDirsOf(options.env, { cwd: workDir, settings: options.settings });
|
|
747
|
+
if (!this._tempSince) this._tempSince = Date.now();
|
|
748
|
+
}
|
|
749
|
+
// The executable resolved at the top of this call. The SDK uses
|
|
750
|
+
// pathToClaudeCodeExecutable verbatim with zero validation, so it was vetted
|
|
751
|
+
// there — notably a bare command name, which would ENOENT under the SDK's
|
|
752
|
+
// spawn(shell:false).
|
|
753
|
+
let runtimeLabel = 'unset';
|
|
754
|
+
this._claudeExecutable = null;
|
|
755
|
+
if (executable?.ignored) {
|
|
756
|
+
log('ignoring sdkExecutable override:', executable.ignored);
|
|
757
|
+
this.send({ type: 'stderr', text: `Ignoring sdkExecutable override — ${executable.ignored}` });
|
|
758
|
+
}
|
|
759
|
+
if (executable?.path) {
|
|
760
|
+
options.pathToClaudeCodeExecutable = executable.path;
|
|
761
|
+
this._claudeExecutable = executable.path;
|
|
762
|
+
runtimeLabel = executable.source === 'override' ? 'override' : executable.path;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (warm) {
|
|
766
|
+
// Start the process now; hand it the input queue when the first prompt comes.
|
|
767
|
+
// (`bypass`: started in Bypass, as the tab's stated mode or as the mode this session saw the user's
|
|
768
|
+
// settings name. A process kept ready is a process of this session's all the same: see _mayBypass.)
|
|
769
|
+
const w = { sig: this._warmSignature(), input: this.input, abortController: this.abortController, handle: null, ready: false, closed: false, timer: null, bypass: this._startAskedBypass === true || (this._startedFromSettings === true && this.permissionMode === BYPASS) };
|
|
770
|
+
this._warm = w;
|
|
771
|
+
log(`warm start: resume=${this.sessionId || 'none'} model=${this.model || 'default'} cwd=${workDir} cli=${runtimeLabel}`);
|
|
772
|
+
const startupFactory = typeof deps.startupFactory === 'function' ? deps.startupFactory : startup;
|
|
773
|
+
Promise.resolve().then(() => startupFactory({ options })).then((handle) => {
|
|
774
|
+
if (w.closed) { try { handle.close(); } catch {} return; }
|
|
775
|
+
w.handle = handle;
|
|
776
|
+
w.ready = true;
|
|
777
|
+
}).catch((err) => {
|
|
778
|
+
log('warm start failed:', err?.message || err);
|
|
779
|
+
if (this._warm === w) this._warm = null;
|
|
780
|
+
w.closed = true;
|
|
781
|
+
});
|
|
782
|
+
w.timer = setTimeout(() => { if (this._warm === w) { this._warm = null; this._closeWarm(w); } }, WARM_TTL_MS);
|
|
783
|
+
w.timer.unref?.();
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
log(`session create: resume=${this.sessionId || 'none'} model=${this.model || 'default'} effort=${this.effort || 'default'} mode=${this.permissionMode} cwd=${workDir} sdk=${SDK_VERSION} cli=${runtimeLabel}${sessionOpts.ownerKey ? ` owner=${sessionOpts.ownerKey}` : ''}`);
|
|
788
|
+
const queryFactory = typeof deps.queryFactory === 'function' ? deps.queryFactory : query;
|
|
789
|
+
this.q = queryFactory({ prompt: this.input, options });
|
|
790
|
+
if (this.panel) this._noteProcessStarted();
|
|
791
|
+
this.pumpPromise = this._pump(this.q);
|
|
792
|
+
return this.q;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Everything a process is started with: a warm one is only adopted when
|
|
796
|
+
// none of it changed between the first keystroke and the first message.
|
|
797
|
+
_warmSignature() {
|
|
798
|
+
return JSON.stringify([
|
|
799
|
+
this.cwd || '', this.model || '', this.effort || '', this.permissionMode || '', !!this._modeFromSettings,
|
|
800
|
+
this.toolPolicy || '', startSignature(this.panelSession), this.panelSession?.live || null,
|
|
801
|
+
this.sessionId || '', this.accountId || '', this._title || '', [...(this.clientFeatures || [])].sort(),
|
|
802
|
+
!!this.typesafeOff, this._resumeAt || '', !!this.temporary,
|
|
803
|
+
]);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
_closeWarm(w = this._warm) {
|
|
807
|
+
if (!w) return;
|
|
808
|
+
if (this._warm === w) this._warm = null;
|
|
809
|
+
clearTimeout(w.timer);
|
|
810
|
+
w.closed = true;
|
|
811
|
+
try { w.handle?.close(); } catch {}
|
|
812
|
+
try { w.abortController?.abort(); } catch {}
|
|
813
|
+
// (A process that was started in Bypass ended: the page is told when that is news, as for any other. See _mayBypass.)
|
|
814
|
+
if (w.bypass) this._factsSettled();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Panel sessions: the tab stated its mode (or its configuration) again while a
|
|
818
|
+
// process is kept ready. One that was started with something else is ended
|
|
819
|
+
// now, not at the next message: it would not be adopted anyway
|
|
820
|
+
// (_warmSignature), so the turn starts on a process launched in the stated
|
|
821
|
+
// mode, and nothing of this session's stays behind in a mode the tab left.
|
|
822
|
+
_warmStillFits() {
|
|
823
|
+
const w = this._warm;
|
|
824
|
+
if (w && w.sig !== this._warmSignature()) this._closeWarm(w);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async _pump(q) {
|
|
828
|
+
try {
|
|
829
|
+
for await (const m of q) {
|
|
830
|
+
if (this.destroyed) break;
|
|
831
|
+
// A replaced query (stall recreate, config change) must stop feeding the
|
|
832
|
+
// client — its late results would corrupt the new query's turn accounting.
|
|
833
|
+
if (this.q !== q) break;
|
|
834
|
+
this.lastEventTime = Date.now();
|
|
835
|
+
try { this._translate(m); } catch (err) { log('translate error:', err.message); }
|
|
836
|
+
}
|
|
837
|
+
// Generator ended (input closed / process exit). If a turn was in flight
|
|
838
|
+
// and this wasn't a deliberate teardown, surface it.
|
|
839
|
+
if (!this.destroyed && this.q === q && this.inTurn) {
|
|
840
|
+
log('query ended mid-turn');
|
|
841
|
+
this._finishTurnWithError('Claude session ended unexpectedly.');
|
|
842
|
+
}
|
|
843
|
+
} catch (err) {
|
|
844
|
+
if (this.destroyed || this.q !== q) return;
|
|
845
|
+
const msg = err?.message || String(err);
|
|
846
|
+
log('pump error:', msg);
|
|
847
|
+
if (/No conversation found/i.test(msg)) { this._recoverLostSession(); return; }
|
|
848
|
+
// The SDK cannot tell a permission problem from a libc mismatch: it buckets
|
|
849
|
+
// every spawn error together and always blames musl/glibc. Diagnose it
|
|
850
|
+
// properly, repair what we can, and fall back to the user's CLI.
|
|
851
|
+
if (isNativeBinaryLaunchFailure(err)) { this._recoverNativeRuntime(err); return; }
|
|
852
|
+
this._finishTurnWithError(msg);
|
|
853
|
+
} finally {
|
|
854
|
+
// Still current means the process went away on its own (exit, crash) rather
|
|
855
|
+
// than through _endQuery, which already cleaned up.
|
|
856
|
+
if (this.q === q) {
|
|
857
|
+
this.q = null;
|
|
858
|
+
this._onProcessGone();
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// The CLI process behind this session is gone — ended, replaced or crashed. Its
|
|
864
|
+
// background tasks and crons died with it: tell the panel, so it stops showing
|
|
865
|
+
// agents that will never report back. Idempotent.
|
|
866
|
+
_onProcessGone() {
|
|
867
|
+
// Panel sessions, a temporary chat: its conversation lived in the process
|
|
868
|
+
// that just ended and nowhere else. It is over, and the tab is told once.
|
|
869
|
+
if (this.temporary && this._tempLive && !this._tempOver) {
|
|
870
|
+
this._tempOver = true;
|
|
871
|
+
this._removeTemporaryPlans();
|
|
872
|
+
this.send({ type: 'temporary_ended' });
|
|
873
|
+
}
|
|
874
|
+
// Session-scoped permission rules lived in the process that just ended.
|
|
875
|
+
this._grantedRules = this._grantedRules.filter(u => u.destination && u.destination !== 'session');
|
|
876
|
+
const hadTasks = this._bgTasks.size > 0;
|
|
877
|
+
this._bgTasks.clear();
|
|
878
|
+
const hadCrons = this._sessionCrons.length > 0;
|
|
879
|
+
this._sessionCrons = [];
|
|
880
|
+
if (this.panel && hadCrons) this._announceCrons();
|
|
881
|
+
// (Panel sessions: with the process gone nothing is in Bypass any more, and the page is told when that is news.)
|
|
882
|
+
if (this.panel) this._factsSettled();
|
|
883
|
+
for (const id of this._bgAgentIds) {
|
|
884
|
+
this.sendEvent({
|
|
885
|
+
type: 'subagent', subtype: 'stop', tool_use_id: id,
|
|
886
|
+
is_error: true, status: 'stopped', session_id: this.sessionId,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
this._bgAgentIds.clear();
|
|
890
|
+
if (hadTasks) {
|
|
891
|
+
this.sendEvent({ type: 'system', subtype: 'background_tasks_changed', tasks: [], session_id: this.sessionId });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// The environment of a named Claude account (its own config directory), or
|
|
896
|
+
// null when that account cannot be used: an id that is not a plain name, a
|
|
897
|
+
// profile removed in Settings (it resolves to no config directory), a registry
|
|
898
|
+
// that throws. The one lookup behind every check: there is no fallback to an
|
|
899
|
+
// empty environment, which would be the ambient default identity.
|
|
900
|
+
_accountEnvOf(account) {
|
|
901
|
+
if (typeof account !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(account) || typeof deps.claudeAccountEnv !== 'function') return null;
|
|
902
|
+
let env = null;
|
|
903
|
+
try { env = deps.claudeAccountEnv(account); } catch { env = null; }
|
|
904
|
+
if (!env || typeof env.CLAUDE_CONFIG_DIR !== 'string' || !env.CLAUDE_CONFIG_DIR) return null;
|
|
905
|
+
return env;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// A start ensureQuery() refused (see there): tell the tab why and close the
|
|
909
|
+
// turn it is waiting on. False when the last start was not refused.
|
|
910
|
+
_announceRefusedStart() {
|
|
911
|
+
const refused = this._startRefused;
|
|
912
|
+
if (!refused) return false;
|
|
913
|
+
this._denyAllPending('Session error');
|
|
914
|
+
this.inTurn = false;
|
|
915
|
+
this.pendingTurns = 0;
|
|
916
|
+
this.send({ type: 'error', code: refused.code, message: refused.message });
|
|
917
|
+
this.sendDone(-1);
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// The account a panel query names, checked before the query touches anything.
|
|
922
|
+
// Returns { account } ('' is the default account) or { code, message } when the
|
|
923
|
+
// query must be refused: running it anyway would use the ambient default
|
|
924
|
+
// identity while the tab says another one.
|
|
925
|
+
_resolveQueryAccount(accountId, sessionId) {
|
|
926
|
+
if (accountId === undefined) return { account: this.accountId || '' };
|
|
927
|
+
const account = accountId === 'default' || accountId === '' || accountId === null ? '' : accountId;
|
|
928
|
+
if (account && !this._accountEnvOf(account)) return { code: 'account_unavailable', message: ACCOUNT_UNAVAILABLE };
|
|
929
|
+
// A conversation belongs to the account it started under: its transcript
|
|
930
|
+
// lives in that account's config directory and cannot be resumed from another.
|
|
931
|
+
// "Started under" is known once a message ran here (_accountBound). A session
|
|
932
|
+
// object made for a cold resume (the server restarted, the page reattached
|
|
933
|
+
// to nothing) only has the id: its first message says which account owns it.
|
|
934
|
+
if (this._accountBound && this.sessionId && sessionId && sessionId === this.sessionId && account !== (this.accountId || '')) {
|
|
935
|
+
return { code: 'account_change_refused', message: 'This conversation belongs to the account it started under. Start a new chat to use another account.' };
|
|
936
|
+
}
|
|
937
|
+
return { account };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Panel sessions: a control that can start a process reached a session no
|
|
941
|
+
// process has started for yet, and brings the tab's configuration (`config`:
|
|
942
|
+
// the fields of a query, without a prompt). It is applied by the code a query
|
|
943
|
+
// runs (_handleQuery: the account is validated, the lock taken, the tool
|
|
944
|
+
// policy, permission mode and session settings set), and nothing is started
|
|
945
|
+
// here: the message's own handling starts the process if it needs one.
|
|
946
|
+
// Until a process has started, the latest configuration is the one that
|
|
947
|
+
// counts: the tab may have changed (another account, other restrictions)
|
|
948
|
+
// since an earlier control or a warm start brought one. So the earlier one is
|
|
949
|
+
// dropped first, and a configuration that is refused leaves the session cold,
|
|
950
|
+
// with the reason for the reply, never startable under what it replaced. A
|
|
951
|
+
// control that brings none keeps what the session has.
|
|
952
|
+
_establishFromControl(msg) {
|
|
953
|
+
this._coldRefusal = null;
|
|
954
|
+
const config = msg?.config;
|
|
955
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return;
|
|
956
|
+
this._established = false;
|
|
957
|
+
this._handleQuery({ ...config, type: 'warm' }, { warmOnly: true, establishOnly: true });
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Panel sessions: a starting control whose configuration names another
|
|
961
|
+
// conversation than the one this session has. New chat and the session menu
|
|
962
|
+
// keep the tab's socket and tell the bridge nothing, so the tab can be on a
|
|
963
|
+
// conversation the session's process was not started for. Judged exactly as
|
|
964
|
+
// a prompt is (`sessionChanged` / `newChat` in _handleQuery): another session
|
|
965
|
+
// id, or none where the session has one. A control without a configuration
|
|
966
|
+
// says nothing about where the tab is, and changes nothing.
|
|
967
|
+
_controlLeftConversation(msg) {
|
|
968
|
+
const config = msg?.config;
|
|
969
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return false;
|
|
970
|
+
const sessionId = typeof config.sessionId === 'string' ? config.sessionId : '';
|
|
971
|
+
return sessionId ? (!!this.sessionId && sessionId !== this.sessionId) : !!this.sessionId;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Panel sessions: a process was started for this tab (not a warm one, which
|
|
975
|
+
// is only a process kept ready). From here a control's configuration no
|
|
976
|
+
// longer replaces the session's while the tab stays on this conversation,
|
|
977
|
+
// and the conversation belongs to the account it started under, whether a
|
|
978
|
+
// message or a control started it.
|
|
979
|
+
_noteProcessStarted() {
|
|
980
|
+
this._everStarted = true;
|
|
981
|
+
this._accountBound = true;
|
|
982
|
+
// What this process is in, and whether it may be in Bypass (see "what is" below):
|
|
983
|
+
// a process launched in Bypass may be in it from its launch.
|
|
984
|
+
this._cli = { q: this.q, mode: this._launchMode || '', may: this._startAskedBypass === true, at: 0, sends: 0 };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Panel sessions: a turn at the session's process. Socket messages are
|
|
988
|
+
// dispatched as they arrive and not awaited, so a message that ends the
|
|
989
|
+
// process and waits for it to close (a control after New chat or a session
|
|
990
|
+
// switch, a prompt that changes the conversation, a conversation rewind) used
|
|
991
|
+
// to leave a window in which the next message started a replacement, which
|
|
992
|
+
// the first one then took for its own. Now the message that waits on a
|
|
993
|
+
// closing process holds the turn until what it does next is done, and the
|
|
994
|
+
// messages of `takesTurn` that arrive meanwhile wait in line, first come
|
|
995
|
+
// first served (handleMessage). While nobody holds it, nothing waits: a
|
|
996
|
+
// message is handled in the tick it arrives, exactly as before there were turns.
|
|
997
|
+
// `take()` returns null when the turn is the caller's at once (it was free, or
|
|
998
|
+
// the caller holds it already) and a promise to wait on when it is not.
|
|
999
|
+
// `release()` hands the turn to the next waiter and may be called twice.
|
|
1000
|
+
// A turn is held only across waits that end by themselves (a closing process
|
|
1001
|
+
// is given 5 s): a handler gives it up before it waits on the CLI or on the
|
|
1002
|
+
// disk and takes it again afterwards, so a call that never returns cannot
|
|
1003
|
+
// keep the tab's later messages waiting.
|
|
1004
|
+
_newTurn() {
|
|
1005
|
+
const turn = {
|
|
1006
|
+
held: false,
|
|
1007
|
+
take: () => {
|
|
1008
|
+
if (turn.held) return null;
|
|
1009
|
+
turn.held = true;
|
|
1010
|
+
if (!this._turnBusy) { this._turnBusy = true; return null; }
|
|
1011
|
+
return new Promise((resolve) => { (this._turnWaiters ||= []).push(resolve); });
|
|
1012
|
+
},
|
|
1013
|
+
release: () => {
|
|
1014
|
+
if (!turn.held) return;
|
|
1015
|
+
turn.held = false;
|
|
1016
|
+
const next = this._turnWaiters?.shift();
|
|
1017
|
+
if (next) next(); else this._turnBusy = false;
|
|
1018
|
+
},
|
|
1019
|
+
};
|
|
1020
|
+
return turn;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Where a panel tab's debug log goes when it asked for one: a file in the
|
|
1024
|
+
// directory the host names. Without that directory there is no debug log
|
|
1025
|
+
// (the stream would otherwise land in stderr, which the panel prints).
|
|
1026
|
+
_debugFilePath() {
|
|
1027
|
+
if (!this.panel || !this.panelSession?.start?.debug) return '';
|
|
1028
|
+
const dir = typeof deps.debugDir === 'string' ? deps.debugDir : '';
|
|
1029
|
+
if (!dir) return '';
|
|
1030
|
+
try { mkdirSync(dir, { recursive: true }); } catch { return ''; }
|
|
1031
|
+
const key = String(this.sessionId || this.synabunOwnerKey || 'tab').replace(/[^\w-]/g, '_').slice(0, 80);
|
|
1032
|
+
return join(dir, `claude-${key}.log`);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// A permission update as the /permissions view needs it: its verb, where it
|
|
1036
|
+
// was written, the rules. Nothing else of the object is kept.
|
|
1037
|
+
_noteGranted(updates) {
|
|
1038
|
+
for (const u of Array.isArray(updates) ? updates : []) {
|
|
1039
|
+
if (!u || typeof u !== 'object' || typeof u.type !== 'string') continue;
|
|
1040
|
+
const slim = { type: u.type.slice(0, 40) };
|
|
1041
|
+
if (typeof u.behavior === 'string') slim.behavior = u.behavior.slice(0, 20);
|
|
1042
|
+
slim.destination = typeof u.destination === 'string' ? u.destination.slice(0, 40) : 'session';
|
|
1043
|
+
if (Array.isArray(u.rules)) slim.rules = u.rules.slice(0, 20).map(r => ({ toolName: String(r?.toolName || '').slice(0, 200), ...(r?.ruleContent != null ? { ruleContent: String(r.ruleContent).slice(0, 500) } : {}) }));
|
|
1044
|
+
if (typeof u.mode === 'string') slim.mode = u.mode.slice(0, 40);
|
|
1045
|
+
if (Array.isArray(u.directories)) slim.directories = u.directories.slice(0, 20).map(d => String(d).slice(0, 500));
|
|
1046
|
+
this._grantedRules.push(slim);
|
|
1047
|
+
}
|
|
1048
|
+
if (this._grantedRules.length > 100) this._grantedRules = this._grantedRules.slice(-100);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
_cronList() {
|
|
1052
|
+
return this._sessionCrons.map(c => ({ id: String(c?.id || ''), schedule: String(c?.schedule || ''), recurring: c?.recurring === true, prompt: String(c?.prompt || '').slice(0, 400) }));
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
_announceCrons() {
|
|
1056
|
+
this.sendEvent({ type: 'system', subtype: 'session_crons', session_id: this.sessionId, crons: this._cronList() });
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// ── temporary chat (panel sessions; lib/claude-temporary.js) ────────────────
|
|
1060
|
+
|
|
1061
|
+
// Claude Code keeps a plan in a plans folder whatever the session's options
|
|
1062
|
+
// say (its own, or the one the settings name). The ones this session's tool
|
|
1063
|
+
// calls name are removed when the chat ends.
|
|
1064
|
+
_noteTemporaryPlans(m) {
|
|
1065
|
+
const content = m.message?.content;
|
|
1066
|
+
if (!Array.isArray(content)) return;
|
|
1067
|
+
for (const block of content) {
|
|
1068
|
+
const file = planFileOfToolCall(block, this._tempPlansDirs);
|
|
1069
|
+
if (file) (this._tempPlans ||= new Set()).add(file);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Called when the chat ends, by whichever way. The files are removed once the
|
|
1074
|
+
// pump of the process that wrote them has settled, not before: a write that
|
|
1075
|
+
// was under way would put the file back. `this.pumpPromise` is that process's
|
|
1076
|
+
// pump at every call (the one being ended, or the one that just ended by
|
|
1077
|
+
// itself). The pump settling is not the process being gone (the SDK's cleanup
|
|
1078
|
+
// waits two seconds for it, and the process may live five more), so the paths
|
|
1079
|
+
// stay owed and are swept once more when it can no longer be alive.
|
|
1080
|
+
_removeTemporaryPlans() {
|
|
1081
|
+
if (!this._tempPlans?.size) return;
|
|
1082
|
+
const owed = { plansDirs: this._tempPlansDirs, since: this._tempSince, ended: false, last: false };
|
|
1083
|
+
for (const path of this._tempPlans) _temporaryPlansOwed.set(path, owed);
|
|
1084
|
+
this._tempPlans.clear();
|
|
1085
|
+
Promise.resolve(this.pumpPromise).catch(() => {}).then(() => {
|
|
1086
|
+
owed.ended = true;
|
|
1087
|
+
_removeOwedTemporaryPlans();
|
|
1088
|
+
// (Not kept alive for it: a server that shuts down meanwhile sweeps everything owed, shutdownAllBridges().)
|
|
1089
|
+
setTimeout(() => { owed.last = true; _removeOwedTemporaryPlans(); }, TEMPORARY_PLANS_LAST_SWEEP_MS).unref?.();
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// The tab left its temporary conversation (New chat): what is left of it goes,
|
|
1094
|
+
// and the session is an ordinary one until a temporary chat is chosen again.
|
|
1095
|
+
_leaveTemporary() {
|
|
1096
|
+
this._removeTemporaryPlans();
|
|
1097
|
+
this.temporary = false;
|
|
1098
|
+
this._tempLive = false;
|
|
1099
|
+
this._tempOver = false;
|
|
1100
|
+
this._tempSince = 0;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Whether this message makes, keeps or may not make the conversation a
|
|
1104
|
+
// temporary one. Chosen with the first message of a conversation (`temporary:
|
|
1105
|
+
// true`, and no session id: there is nothing to resume) and fixed from there.
|
|
1106
|
+
// Returns null, or { code, message } when the message must be refused.
|
|
1107
|
+
_takeTemporary(msg, sessionId) {
|
|
1108
|
+
const wants = msg.temporary === true;
|
|
1109
|
+
const leaves = sessionId ? (!!this.sessionId && sessionId !== this.sessionId) : !!this.sessionId;
|
|
1110
|
+
if (leaves) {
|
|
1111
|
+
// Another conversation. One that names a session and says it is temporary
|
|
1112
|
+
// is a temporary chat this session does not hold: it ended with its page.
|
|
1113
|
+
if (wants && sessionId) return { code: 'temporary_ended', message: TEMPORARY_ENDED };
|
|
1114
|
+
if (this.temporary) this._leaveTemporary();
|
|
1115
|
+
this.temporary = wants;
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
// The same conversation. Nothing has started for it yet (at most a process
|
|
1119
|
+
// kept ready by a warm start): the latest word decides.
|
|
1120
|
+
if (!this._everStarted && !this.sessionId) {
|
|
1121
|
+
if (this.temporary && !wants) this._leaveTemporary();
|
|
1122
|
+
this.temporary = wants;
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
// It has started: what it is does not change.
|
|
1126
|
+
if (this.temporary) return this._tempOver ? { code: 'temporary_ended', message: TEMPORARY_ENDED } : null;
|
|
1127
|
+
if (!wants) return null;
|
|
1128
|
+
if (this._everStarted) return { code: 'temporary_refused', message: TEMPORARY_REFUSED };
|
|
1129
|
+
// A session id this session never ran, said to be temporary: a temporary
|
|
1130
|
+
// chat from before the server restarted or the page came back. Over.
|
|
1131
|
+
return { code: 'temporary_ended', message: TEMPORARY_ENDED };
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// What in this message would restart a live temporary chat's process (the
|
|
1135
|
+
// conditions of _handleQuery's restart, judged before anything is applied).
|
|
1136
|
+
_temporaryRestartReason(msg, { cwd, effort, account }) {
|
|
1137
|
+
if (cwd && this.cwd && cwd !== this.cwd) return 'the project folder';
|
|
1138
|
+
if (effort && effort !== this.effort && typeof this.q?.applyFlagSettings !== 'function') return 'the effort level';
|
|
1139
|
+
if (msg.typesafe !== undefined && (msg.typesafe === 'off') !== !!this.typesafeOff) return 'the judgment setting';
|
|
1140
|
+
if (msg.accountId !== undefined && account !== (this.accountId || '')) return 'the Claude account';
|
|
1141
|
+
if (msg.toolPolicy !== undefined && (TOOL_POLICY_DISALLOW[msg.toolPolicy] ? msg.toolPolicy : null) !== (this.toolPolicy || null)) return 'the tool policy';
|
|
1142
|
+
if (msg.session !== undefined && startSignature(normalizePanelSession(msg.session)) !== startSignature(this.panelSession || normalizePanelSession(null))) return 'the session settings';
|
|
1143
|
+
return '';
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// A temporary chat cannot be resumed, so a stalled stream is not answered
|
|
1147
|
+
// with a new process: the turn is stopped and the tab is told. A process
|
|
1148
|
+
// that does not even take the stop is gone, and the chat with it.
|
|
1149
|
+
_stallTemporary() {
|
|
1150
|
+
log('stall in a temporary chat: stopping the turn, no restart');
|
|
1151
|
+
this.lastEventTime = Date.now();
|
|
1152
|
+
this.swallowResults++;
|
|
1153
|
+
this.swallowDeadline = Date.now() + 15_000;
|
|
1154
|
+
const q = this.q;
|
|
1155
|
+
let timer = null;
|
|
1156
|
+
const deadline = new Promise(r => { timer = setTimeout(() => r('timeout'), 10_000); timer.unref?.(); });
|
|
1157
|
+
Promise.race([q.interrupt().then(() => 'ok', () => 'failed'), deadline]).then((how) => {
|
|
1158
|
+
clearTimeout(timer);
|
|
1159
|
+
if (this.destroyed || this.q !== q) return;
|
|
1160
|
+
this.inTurn = false;
|
|
1161
|
+
this.pendingTurns = 0;
|
|
1162
|
+
this.lastActivity = Date.now();
|
|
1163
|
+
if (how === 'ok') {
|
|
1164
|
+
this.send({ type: 'error', message: 'The stream stalled, so the turn was stopped. A temporary chat cannot be restarted: send the message again.' });
|
|
1165
|
+
} else {
|
|
1166
|
+
this._endQuery({ graceful: false });
|
|
1167
|
+
this.send({ type: 'error', code: 'temporary_ended', message: TEMPORARY_ENDED });
|
|
1168
|
+
}
|
|
1169
|
+
this.sendDone(-1);
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// True while the CLI has work it will come back to without a new prompt.
|
|
1174
|
+
_hasPendingWork() {
|
|
1175
|
+
return this._bgTasks.size > 0 || this._sessionCrons.length > 0;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
_finishTurnWithError(message) {
|
|
1179
|
+
this._denyAllPending('Session error');
|
|
1180
|
+
this.inTurn = false;
|
|
1181
|
+
this.pendingTurns = 0;
|
|
1182
|
+
this.send({ type: 'error', message });
|
|
1183
|
+
this.sendDone(-1);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async _endQuery({ graceful = true } = {}) {
|
|
1187
|
+
const q = this.q;
|
|
1188
|
+
this._closeWarm();
|
|
1189
|
+
if (!q) return;
|
|
1190
|
+
// Panel sessions: the process this call ends is the one it found, held here
|
|
1191
|
+
// before the wait below (its input, its pump, its abort controller). A
|
|
1192
|
+
// process started while this one is closing (a recovery, a prompt after an
|
|
1193
|
+
// idle reap) belongs to whoever started it and is never aborted from here.
|
|
1194
|
+
// Any other session reads its own fields at each step, as it always did.
|
|
1195
|
+
const own = this.panel ? { input: this.input, pumpPromise: this.pumpPromise, abortController: this.abortController } : this;
|
|
1196
|
+
// (Panel sessions: a process that may be in Bypass may be until it has really
|
|
1197
|
+
// ended, which is the end of the wait below, not this call. See _mayBypass.)
|
|
1198
|
+
const closing = this.panel && this._cli?.q === q && this._cli.may === true;
|
|
1199
|
+
if (closing) this._closingBypass = (this._closingBypass || 0) + 1;
|
|
1200
|
+
this.q = null;
|
|
1201
|
+
this._onProcessGone();
|
|
1202
|
+
this._denyAllPending('Session ending');
|
|
1203
|
+
// The dying query's in-flight turn can never deliver a result (pump guard
|
|
1204
|
+
// drops late ones) — reset accounting so recreation paths start clean.
|
|
1205
|
+
this.inTurn = false;
|
|
1206
|
+
this.pendingTurns = 0;
|
|
1207
|
+
this.swallowResults = 0;
|
|
1208
|
+
try { own.input?.close(); } catch {}
|
|
1209
|
+
if (graceful && own.pumpPromise) {
|
|
1210
|
+
await Promise.race([own.pumpPromise, new Promise(r => setTimeout(r, 5000))]);
|
|
1211
|
+
}
|
|
1212
|
+
try { own.abortController?.abort(); } catch {}
|
|
1213
|
+
if (closing) { this._closingBypass--; this._factsSettled(); }
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
_recreateQuery(continuationPrompt) {
|
|
1217
|
+
const sid = this.sessionId;
|
|
1218
|
+
return this._endQuery({ graceful: false }).then(() => {
|
|
1219
|
+
if (this.destroyed) return;
|
|
1220
|
+
this.sessionId = sid;
|
|
1221
|
+
this.ensureQuery();
|
|
1222
|
+
if (this._announceRefusedStart()) return;
|
|
1223
|
+
if (continuationPrompt) this._pushUserText(continuationPrompt);
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
_recoverLostSession() {
|
|
1228
|
+
log('session not found — retrying as new conversation');
|
|
1229
|
+
const prompt = this.lastPrompt;
|
|
1230
|
+
this.sessionId = null;
|
|
1231
|
+
this.sendEvent({ type: 'system', subtype: 'session_reset', message: 'Previous session was deleted. Starting fresh conversation.' });
|
|
1232
|
+
this._endQuery({ graceful: false }).then(() => {
|
|
1233
|
+
if (this.destroyed) return;
|
|
1234
|
+
this.sessionId = null;
|
|
1235
|
+
this.ensureQuery();
|
|
1236
|
+
if (this._announceRefusedStart()) return;
|
|
1237
|
+
if (prompt) this._pushUserText(prompt);
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* The user's Claude Code would not start.
|
|
1243
|
+
*
|
|
1244
|
+
* The SDK's own message for this always blames a musl/glibc mismatch, which is
|
|
1245
|
+
* only ever right on Linux — elsewhere the cause is a missing execute bit, a
|
|
1246
|
+
* quarantined download, a build for another processor or a file that is gone.
|
|
1247
|
+
* There is no other runtime to move to (SynaBun carries none), so the turn
|
|
1248
|
+
* ends with what is wrong with that installation and how to fix it.
|
|
1249
|
+
*/
|
|
1250
|
+
_recoverNativeRuntime(err) {
|
|
1251
|
+
const dx = diagnoseLaunchFailure({ err, executable: this._claudeExecutable });
|
|
1252
|
+
log(`Claude Code failed to launch: kind=${dx.kind} executable=${this._claudeExecutable || 'unset'}`);
|
|
1253
|
+
this._finishTurnWithError(dx.message);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
_pushUserText(text, images, { human = false } = {}) {
|
|
1257
|
+
const content = [];
|
|
1258
|
+
if (images?.length) {
|
|
1259
|
+
for (const img of images) {
|
|
1260
|
+
if (!img?.base64) continue;
|
|
1261
|
+
content.push({
|
|
1262
|
+
type: 'image',
|
|
1263
|
+
source: { type: 'base64', media_type: img.mediaType || 'image/png', data: img.base64 },
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if (text) content.push({ type: 'text', text });
|
|
1268
|
+
if (!content.length) return false;
|
|
1269
|
+
this.ensureQuery();
|
|
1270
|
+
if (this._announceRefusedStart()) return false;
|
|
1271
|
+
const ok = this.input.push({
|
|
1272
|
+
type: 'user',
|
|
1273
|
+
message: { role: 'user', content },
|
|
1274
|
+
parent_tool_use_id: null,
|
|
1275
|
+
session_id: this.sessionId || '',
|
|
1276
|
+
// Only a prompt the user typed in the panel is stamped as human input
|
|
1277
|
+
// (never a queued, replayed or generated one).
|
|
1278
|
+
...(human ? { origin: { kind: 'human' } } : {}),
|
|
1279
|
+
});
|
|
1280
|
+
if (ok) {
|
|
1281
|
+
this.inTurn = true;
|
|
1282
|
+
this.pendingTurns++;
|
|
1283
|
+
this.lastEventTime = Date.now();
|
|
1284
|
+
this.lastActivity = Date.now();
|
|
1285
|
+
this._lastPushAt = Date.now();
|
|
1286
|
+
}
|
|
1287
|
+
return ok;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// ── translation: SDKMessage → wire events ─────────────────────────────────
|
|
1291
|
+
|
|
1292
|
+
_translate(m) {
|
|
1293
|
+
switch (m.type) {
|
|
1294
|
+
case 'system': {
|
|
1295
|
+
if (m.subtype === 'init') {
|
|
1296
|
+
this.bootComplete = true;
|
|
1297
|
+
if (m.session_id) {
|
|
1298
|
+
const prev = this.sessionId;
|
|
1299
|
+
this.sessionId = m.session_id;
|
|
1300
|
+
if (prev !== this.sessionId) log('sessionId from init:', this.sessionId);
|
|
1301
|
+
}
|
|
1302
|
+
// (A temporary chat: from here its conversation exists, in this process only.)
|
|
1303
|
+
if (this.temporary) this._tempLive = true;
|
|
1304
|
+
// Panel sessions: the mode the CLI says it is in (see _reportedMode).
|
|
1305
|
+
const told = this.panel ? this._reportedMode(m) : null;
|
|
1306
|
+
this.sendEvent(told ? told.event : m);
|
|
1307
|
+
if (told?.then) this.sendEvent(told.then);
|
|
1308
|
+
this._announceCapabilities();
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
// Panel sessions: a mode change the CLI reports between two inits.
|
|
1312
|
+
if (m.subtype === 'status' && this.panel && m.permissionMode) {
|
|
1313
|
+
const told = this._reportedMode(m);
|
|
1314
|
+
this.sendEvent(told.event);
|
|
1315
|
+
if (told.then) this.sendEvent(told.then);
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
if (m.subtype === 'background_tasks_changed') this._setBackgroundTasks(m.tasks);
|
|
1319
|
+
else if (m.subtype === 'task_notification') this._settleBackgroundAgent(m);
|
|
1320
|
+
else if (m.subtype === 'session_state_changed' && this.panel) this._noteSessionState(m.state);
|
|
1321
|
+
// Background work reporting in is activity: without this, a reaper tick
|
|
1322
|
+
// between "last task finished" and the turn the CLI starts to handle it
|
|
1323
|
+
// would end the session under that turn.
|
|
1324
|
+
if (m.subtype === 'background_tasks_changed' || String(m.subtype || '').startsWith('task_')) {
|
|
1325
|
+
this.lastActivity = Date.now();
|
|
1326
|
+
}
|
|
1327
|
+
// compact_boundary and any future system subtypes pass through
|
|
1328
|
+
this.sendEvent(m);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
case 'stream_event': {
|
|
1332
|
+
this._noteMainThreadOutput(m);
|
|
1333
|
+
this._forwardStreamEvent(m);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
case 'assistant': {
|
|
1337
|
+
this.bootComplete = true;
|
|
1338
|
+
this._noteMainThreadOutput(m);
|
|
1339
|
+
this._flushDeltas();
|
|
1340
|
+
this._synthesizeSubagentStarts(m);
|
|
1341
|
+
if (this.temporary) this._noteTemporaryPlans(m);
|
|
1342
|
+
this.sendEvent({ ...m });
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
case 'user': {
|
|
1346
|
+
if (!m.parent_tool_use_id) this._outputSinceResult = true;
|
|
1347
|
+
this._flushDeltas();
|
|
1348
|
+
this._synthesizeSubagentStops(m);
|
|
1349
|
+
this.sendEvent({ ...m });
|
|
1350
|
+
// The frontend renders tool results from synthetic `tool_result` events
|
|
1351
|
+
// (it has no handler for tool_result blocks inside user messages) — emit
|
|
1352
|
+
// one per block so existing updateToolResult() keeps working.
|
|
1353
|
+
const content = m.message?.content;
|
|
1354
|
+
if (Array.isArray(content)) {
|
|
1355
|
+
for (const block of content) {
|
|
1356
|
+
if (block?.type === 'tool_result') {
|
|
1357
|
+
this.sendEvent({
|
|
1358
|
+
type: 'tool_result',
|
|
1359
|
+
tool_use_id: block.tool_use_id,
|
|
1360
|
+
content: block.content,
|
|
1361
|
+
is_error: !!block.is_error,
|
|
1362
|
+
parent_tool_use_id: m.parent_tool_use_id ?? null,
|
|
1363
|
+
session_id: m.session_id,
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
case 'result': {
|
|
1371
|
+
this._flushDeltas();
|
|
1372
|
+
this._handleResult(m);
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
default:
|
|
1376
|
+
this.sendEvent(m);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Main-thread (not subagent) output means the CLI is in a turn. Most turns start
|
|
1381
|
+
// with a prompt we pushed; the rest the CLI starts itself — a background agent
|
|
1382
|
+
// finished, a scheduled wakeup fired. Those have to be opened here, or the
|
|
1383
|
+
// reaper, detach and the stall watchdog treat a working session as idle and the
|
|
1384
|
+
// panel shows nothing running.
|
|
1385
|
+
_noteMainThreadOutput(m) {
|
|
1386
|
+
if (m.parent_tool_use_id) return;
|
|
1387
|
+
this._outputSinceResult = true;
|
|
1388
|
+
this.lastActivity = Date.now();
|
|
1389
|
+
if (this.inTurn) return;
|
|
1390
|
+
// The tail of a turn we just interrupted is still draining.
|
|
1391
|
+
if (this.swallowResults > 0 && Date.now() < this.swallowDeadline) return;
|
|
1392
|
+
this.inTurn = true;
|
|
1393
|
+
this.pendingTurns = Math.max(this.pendingTurns, 1);
|
|
1394
|
+
this.lastEventTime = Date.now();
|
|
1395
|
+
log(`turn started by the CLI for session ${this.sessionId || '(new)'}`);
|
|
1396
|
+
this.send({ type: 'turn_started' });
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// The CLI's own word on whether it is in a turn. `idle` comes after a turn's
|
|
1400
|
+
// result: if this session still counts a turn open then, its count is off (the
|
|
1401
|
+
// same drift the stall watchdog finds much later). Close it after a short
|
|
1402
|
+
// wait, under the watchdog's own conditions.
|
|
1403
|
+
_noteSessionState(state) {
|
|
1404
|
+
if (state !== 'idle' || !this.inTurn) return;
|
|
1405
|
+
clearTimeout(this._idleCheck);
|
|
1406
|
+
this._idleCheck = setTimeout(() => {
|
|
1407
|
+
this._idleCheck = null;
|
|
1408
|
+
if (this.destroyed || !this.q || !this.inTurn || this.pendingPerms.size > 0) return;
|
|
1409
|
+
if (this._lastResultAt > this._lastPushAt && !this._outputSinceResult && !this._resultOwesTurn) {
|
|
1410
|
+
log('session reported idle with a turn still counted open; closing it');
|
|
1411
|
+
this.inTurn = false;
|
|
1412
|
+
this.pendingTurns = 0;
|
|
1413
|
+
this.lastActivity = Date.now();
|
|
1414
|
+
this.sendDone(0);
|
|
1415
|
+
}
|
|
1416
|
+
}, 2000);
|
|
1417
|
+
this._idleCheck.unref?.();
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// background_tasks_changed carries the full set (replace, never merge). Ambient
|
|
1421
|
+
// tasks count: a Monitor the user asked for is ambient, and ending the Query
|
|
1422
|
+
// would kill it. The panel leaves them out of its indicator.
|
|
1423
|
+
_setBackgroundTasks(tasks) {
|
|
1424
|
+
this._bgTasks.clear();
|
|
1425
|
+
for (const t of Array.isArray(tasks) ? tasks : []) {
|
|
1426
|
+
if (!t?.task_id) continue;
|
|
1427
|
+
this._bgTasks.set(t.task_id, {
|
|
1428
|
+
task_id: t.task_id,
|
|
1429
|
+
task_type: t.task_type || '',
|
|
1430
|
+
description: t.description || '',
|
|
1431
|
+
ambient: !!t.ambient,
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// A background agent reported back: close its card with the real outcome.
|
|
1437
|
+
_settleBackgroundAgent(m) {
|
|
1438
|
+
const id = m.tool_use_id;
|
|
1439
|
+
if (!id || !this._bgAgentIds.delete(id)) return;
|
|
1440
|
+
this.sendEvent({
|
|
1441
|
+
type: 'subagent', subtype: 'stop', tool_use_id: id,
|
|
1442
|
+
is_error: m.status !== 'completed', status: m.status || '',
|
|
1443
|
+
summary: typeof m.summary === 'string' ? m.summary : '',
|
|
1444
|
+
session_id: this.sessionId,
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
_recordCost(m) {
|
|
1449
|
+
// Cost: total_cost_usd is cumulative for the process; on resumed sessions it
|
|
1450
|
+
// may or may not include pre-resume history. Self-calibrating baseline:
|
|
1451
|
+
if (typeof m.total_cost_usd === 'number' && m.total_cost_usd > 0) {
|
|
1452
|
+
const sid = m.session_id || this.sessionId;
|
|
1453
|
+
if (sid) {
|
|
1454
|
+
if (!this.costBySession.has(sid)) {
|
|
1455
|
+
// (A temporary chat has no stored cost: no record names it.)
|
|
1456
|
+
const stored = this.temporary ? 0 : (() => { try { return deps.getSessionCost(sid) || 0; } catch { return 0; } })();
|
|
1457
|
+
this.costBySession.set(sid, stored);
|
|
1458
|
+
}
|
|
1459
|
+
let base = this.costBySession.get(sid) || 0;
|
|
1460
|
+
let delta = m.total_cost_usd - base;
|
|
1461
|
+
if (delta < 0) {
|
|
1462
|
+
// Process-lifetime semantics (doesn't include resumed history) — recalibrate.
|
|
1463
|
+
log(`cost-semantics=process sid=${sid} base=${base} reported=${m.total_cost_usd}`);
|
|
1464
|
+
base = 0;
|
|
1465
|
+
delta = m.total_cost_usd;
|
|
1466
|
+
}
|
|
1467
|
+
this.costBySession.set(sid, m.total_cost_usd);
|
|
1468
|
+
// (A temporary chat: the money is counted, the session is not named.)
|
|
1469
|
+
if (delta > 0) { try { deps.addCost(delta, this.temporary ? null : sid); } catch {} }
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
_handleResult(m) {
|
|
1475
|
+
// Interrupt-produced result (abort / stall recovery): the client already got
|
|
1476
|
+
// its terminal signal — forward the event + cost, but skip turn accounting.
|
|
1477
|
+
if (this.swallowResults > 0 && Date.now() < this.swallowDeadline) {
|
|
1478
|
+
this.swallowResults--;
|
|
1479
|
+
if (m.session_id) this.sessionId = m.session_id;
|
|
1480
|
+
this._recordCost(m);
|
|
1481
|
+
this.sendEvent({ ...m });
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
this.swallowResults = 0; // expired entries never linger past a real result
|
|
1485
|
+
|
|
1486
|
+
// queued_turn_count is how many sends the CLI still holds. 0 means no more
|
|
1487
|
+
// results are coming: the CLI merges prompts sent close together into one
|
|
1488
|
+
// turn, so counting one result per push could leave a turn open forever.
|
|
1489
|
+
this.pendingTurns = m.queued_turn_count === 0 ? 0 : Math.max(0, this.pendingTurns - 1);
|
|
1490
|
+
this.inTurn = this.pendingTurns > 0;
|
|
1491
|
+
this._lastResultAt = Date.now();
|
|
1492
|
+
this._resultOwesTurn = m.queued_turn_count > 0;
|
|
1493
|
+
this._outputSinceResult = false;
|
|
1494
|
+
this.stallRetries = 0;
|
|
1495
|
+
this.lastActivity = Date.now();
|
|
1496
|
+
|
|
1497
|
+
if (m.subtype === 'error_during_execution' || m.subtype === 'error') {
|
|
1498
|
+
const errMsg = (m.errors && m.errors[0]) || m.error || m.result || 'unknown';
|
|
1499
|
+
log('CLI error result:', String(errMsg).slice(0, 200));
|
|
1500
|
+
if (typeof errMsg === 'string' && errMsg.includes('No conversation found')) {
|
|
1501
|
+
this._recoverLostSession();
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
if (m.session_id) this.sessionId = m.session_id;
|
|
1506
|
+
this._recordCost(m);
|
|
1507
|
+
this.sendEvent({ ...m });
|
|
1508
|
+
this.sendDone(m.subtype === 'success' ? 0 : 1);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// Coalesce text/thinking deltas per content block; everything else forwards raw.
|
|
1512
|
+
_forwardStreamEvent(m) {
|
|
1513
|
+
const ev = m.event;
|
|
1514
|
+
const isTextDelta = ev?.type === 'content_block_delta' &&
|
|
1515
|
+
(ev.delta?.type === 'text_delta' || ev.delta?.type === 'thinking_delta');
|
|
1516
|
+
if (!isTextDelta) {
|
|
1517
|
+
this._flushDeltas();
|
|
1518
|
+
this._sendStreamEvent(m);
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
const key = `${m.parent_tool_use_id || ''}:${ev.index}`;
|
|
1522
|
+
let slot = this._deltaBuf.get(key);
|
|
1523
|
+
if (!slot) {
|
|
1524
|
+
slot = { m, text: '', kind: ev.delta.type, timer: null };
|
|
1525
|
+
this._deltaBuf.set(key, slot);
|
|
1526
|
+
slot.timer = setTimeout(() => this._flushDelta(key), DELTA_FLUSH_MS);
|
|
1527
|
+
}
|
|
1528
|
+
slot.text += ev.delta.type === 'text_delta' ? (ev.delta.text || '') : (ev.delta.thinking || '');
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
_flushDelta(key) {
|
|
1532
|
+
const slot = this._deltaBuf.get(key);
|
|
1533
|
+
if (!slot) return;
|
|
1534
|
+
this._deltaBuf.delete(key);
|
|
1535
|
+
clearTimeout(slot.timer);
|
|
1536
|
+
if (!slot.text) return;
|
|
1537
|
+
const ev = slot.m.event;
|
|
1538
|
+
const merged = {
|
|
1539
|
+
...slot.m,
|
|
1540
|
+
event: {
|
|
1541
|
+
...ev,
|
|
1542
|
+
delta: slot.kind === 'text_delta'
|
|
1543
|
+
? { ...ev.delta, text: slot.text }
|
|
1544
|
+
: { ...ev.delta, thinking: slot.text },
|
|
1545
|
+
},
|
|
1546
|
+
};
|
|
1547
|
+
this._sendStreamEvent(merged);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
_flushDeltas() {
|
|
1551
|
+
for (const key of [...this._deltaBuf.keys()]) this._flushDelta(key);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
_sendStreamEvent(m) {
|
|
1555
|
+
// Drop live deltas under WS backpressure — final assistant events carry the content.
|
|
1556
|
+
if (!this.orphanBuffer && this.ws && this.ws.bufferedAmount > WS_BACKPRESSURE_BYTES) return;
|
|
1557
|
+
this.sendEvent({ ...m });
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Synthetic subagent lifecycle events: dumb-simple signal for the frontend
|
|
1561
|
+
// (which also gets the full nested feed via parent_tool_use_id passthrough).
|
|
1562
|
+
_synthesizeSubagentStarts(m) {
|
|
1563
|
+
const content = m.message?.content;
|
|
1564
|
+
if (!Array.isArray(content)) return;
|
|
1565
|
+
for (const block of content) {
|
|
1566
|
+
if (block?.type === 'tool_use' && (block.name === 'Task' || block.name === 'Agent')) {
|
|
1567
|
+
this._taskIds = this._taskIds || new Set();
|
|
1568
|
+
this._taskIds.add(block.id);
|
|
1569
|
+
this.sendEvent({
|
|
1570
|
+
type: 'subagent', subtype: 'start', tool_use_id: block.id,
|
|
1571
|
+
description: block.input?.description || '',
|
|
1572
|
+
subagent_type: block.input?.subagent_type || '',
|
|
1573
|
+
session_id: this.sessionId,
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
_synthesizeSubagentStops(m) {
|
|
1580
|
+
if (!this._taskIds?.size) return;
|
|
1581
|
+
const content = m.message?.content;
|
|
1582
|
+
if (!Array.isArray(content)) return;
|
|
1583
|
+
// A Task/Agent call that went to the background returns at once with a
|
|
1584
|
+
// placeholder result; the agent keeps running and reports through
|
|
1585
|
+
// task_notification (_settleBackgroundAgent) — or dies with the process.
|
|
1586
|
+
const launchStatus = m.tool_use_result?.status;
|
|
1587
|
+
const launched = launchStatus === 'async_launched' || launchStatus === 'remote_launched';
|
|
1588
|
+
for (const block of content) {
|
|
1589
|
+
if (block?.type === 'tool_result' && this._taskIds.has(block.tool_use_id)) {
|
|
1590
|
+
this._taskIds.delete(block.tool_use_id);
|
|
1591
|
+
if (launched && !block.is_error) {
|
|
1592
|
+
this._bgAgentIds.add(block.tool_use_id);
|
|
1593
|
+
this.sendEvent({
|
|
1594
|
+
type: 'subagent', subtype: 'background', tool_use_id: block.tool_use_id,
|
|
1595
|
+
session_id: this.sessionId,
|
|
1596
|
+
});
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
this.sendEvent({
|
|
1600
|
+
type: 'subagent', subtype: 'stop', tool_use_id: block.tool_use_id,
|
|
1601
|
+
is_error: !!block.is_error, session_id: this.sessionId,
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
async _announceCapabilities() {
|
|
1608
|
+
const q = this.q;
|
|
1609
|
+
if (!q) return;
|
|
1610
|
+
try {
|
|
1611
|
+
const commands = await q.supportedCommands();
|
|
1612
|
+
if (this.q === q && Array.isArray(commands)) {
|
|
1613
|
+
this.sendEvent({ type: 'system', subtype: 'commands_list', commands });
|
|
1614
|
+
}
|
|
1615
|
+
} catch (err) { log('supportedCommands failed:', err.message); }
|
|
1616
|
+
try {
|
|
1617
|
+
const status = await q.mcpServerStatus();
|
|
1618
|
+
if (this.q === q && Array.isArray(status)) {
|
|
1619
|
+
this.sendEvent({ type: 'system', subtype: 'mcp_status', servers: status });
|
|
1620
|
+
}
|
|
1621
|
+
} catch { /* optional */ }
|
|
1622
|
+
// Panel sessions: what the CLI answered at initialize (account, output styles,
|
|
1623
|
+
// models, agents), once per Query. init repeats every turn; this does not change.
|
|
1624
|
+
if (this.panel && this._infoAnnouncedFor !== q && typeof q.initializationResult === 'function') {
|
|
1625
|
+
this._infoAnnouncedFor = q;
|
|
1626
|
+
try {
|
|
1627
|
+
const r = await q.initializationResult();
|
|
1628
|
+
if (this.q === q && r && typeof r === 'object') {
|
|
1629
|
+
this.sendEvent({
|
|
1630
|
+
type: 'system', subtype: 'session_info',
|
|
1631
|
+
info: {
|
|
1632
|
+
account: r.account || null,
|
|
1633
|
+
outputStyle: r.output_style || '',
|
|
1634
|
+
availableOutputStyles: Array.isArray(r.available_output_styles) ? r.available_output_styles : [],
|
|
1635
|
+
models: Array.isArray(r.models) ? r.models : [],
|
|
1636
|
+
agents: Array.isArray(r.agents) ? r.agents : [],
|
|
1637
|
+
fastModeState: r.fast_mode_state || '',
|
|
1638
|
+
},
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
} catch (err) { log('initializationResult failed:', err.message); }
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// ── the latest statement of the mode wins ────────────────────────────────────
|
|
1646
|
+
|
|
1647
|
+
// The mode is stated by a `set_permission_mode`, by a query that names one (or
|
|
1648
|
+
// leaves it to the user's settings) and by the answer to a plan card. A switch
|
|
1649
|
+
// that is still on its way when the next statement is made (the plan
|
|
1650
|
+
// approval's own, 50 ms later; a call the CLI has not answered yet) is void:
|
|
1651
|
+
// it is not sent, it assigns nothing, it announces nothing and its failure
|
|
1652
|
+
// puts no earlier mode back. So is one whose process was ended or replaced
|
|
1653
|
+
// meanwhile: the next process starts in the mode the session holds then.
|
|
1654
|
+
// _stateMode() counts a statement and returns its number; _modeSwitchStands()
|
|
1655
|
+
// says whether a switch made for statement `stamp` on process `q` still holds.
|
|
1656
|
+
_stateMode() {
|
|
1657
|
+
this._modeStatements = (this._modeStatements || 0) + 1;
|
|
1658
|
+
return this._modeStatements;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
_modeSwitchStands(q, stamp) {
|
|
1662
|
+
return !this.destroyed && !!q && this.q === q && this._modeStatements === stamp;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// ── numbered statements (panel sessions) ─────────────────────────────────────
|
|
1666
|
+
|
|
1667
|
+
// The page owns the tab's chosen mode and this session owns the mode it is
|
|
1668
|
+
// really in. The page numbers every statement it makes of the tab's mode
|
|
1669
|
+
// (`modeSeq`, growing per tab: a pick, a message that names a mode, a plan
|
|
1670
|
+
// card answer, a restatement). The number of the latest one applied goes back
|
|
1671
|
+
// with every report of the mode (init, status, mode_changed, the answer to
|
|
1672
|
+
// reattach), so the page can tell a report that is older than its own latest
|
|
1673
|
+
// statement and ignore it. Messages are not all handled in the order sent
|
|
1674
|
+
// (some wait their turn, a card's answer does not): a statement older than
|
|
1675
|
+
// the one applied is not applied. Returns false for such a statement. A
|
|
1676
|
+
// message without a number (a page that was not reloaded, any other host) is
|
|
1677
|
+
// applied as it always was.
|
|
1678
|
+
_takeStatement(seq) {
|
|
1679
|
+
if (!this.panel || !Number.isSafeInteger(seq) || seq < 1) return true;
|
|
1680
|
+
if (this._pageSeq !== undefined && seq < this._pageSeq) return false;
|
|
1681
|
+
this._pageSeq = seq;
|
|
1682
|
+
return true;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* A mode report as it goes to the page, once the page numbers its statements:
|
|
1687
|
+
* the mode the CLI is in (not the one the event was built for, when the switch
|
|
1688
|
+
* to that one is still under way: then `switching` names it), the number of
|
|
1689
|
+
* the latest statement applied, and whether the session may be in Bypass.
|
|
1690
|
+
*/
|
|
1691
|
+
_numbered(ev) {
|
|
1692
|
+
return this.panel && this._pageSeq !== undefined ? { ...ev, ...this._modeFacts(ev.mode) } : ev;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** A numbered statement older than the one applied was not applied: the page is told the number this session holds, takes it and says its mode again. */
|
|
1696
|
+
_statementDropped() {
|
|
1697
|
+
if (this.panel && this._pageSeq !== undefined) this.sendEvent({ type: 'mode_behind', modeSeq: this._pageSeq });
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// ── what is, and whether it may be Bypass (panel sessions) ───────────────────
|
|
1701
|
+
|
|
1702
|
+
// Two records, never one for the other. `this.permissionMode` is the mode the
|
|
1703
|
+
// tab stated: what the next process starts in, and what a switch is sent for.
|
|
1704
|
+
// `this._cli` is what the current process is in: the mode the CLI last
|
|
1705
|
+
// acknowledged (a setPermissionMode call that resolved) or reported, never one
|
|
1706
|
+
// that was only asked for. Reports to the page carry the second; a switch that
|
|
1707
|
+
// is under way is named beside it (`switching`), and one that failed or was
|
|
1708
|
+
// refused leaves it where it is and is told (`failed`).
|
|
1709
|
+
// "May be in Bypass" is a fact of its own that errs toward warning. A process
|
|
1710
|
+
// may be in Bypass from the moment a switch into it is sent, or it is launched
|
|
1711
|
+
// in it, or it says so itself. Only three things end that:
|
|
1712
|
+
// - the CLI acknowledged a switch to another mode that was sent after it
|
|
1713
|
+
// (`at`: the count of switches sent when the session last entered, or may
|
|
1714
|
+
// have entered, Bypass);
|
|
1715
|
+
// - the process has ended;
|
|
1716
|
+
// - the process says it is in another mode and this session never sent it
|
|
1717
|
+
// into Bypass itself (`sends`): then whatever Bypass it was in it entered
|
|
1718
|
+
// at its launch or by itself, and its own messages are in order.
|
|
1719
|
+
// Nothing else does: not a switch away that was asked for and not answered,
|
|
1720
|
+
// not one that failed, and not a report of the CLI's after a switch into
|
|
1721
|
+
// Bypass of this session's own, which may have been written before it landed
|
|
1722
|
+
// (an acknowledgement and a message do not arrive in the order written).
|
|
1723
|
+
_cliOf(q) {
|
|
1724
|
+
if (this._cli?.q !== q) this._cli = { q, mode: '', may: false, at: 0, sends: 0 };
|
|
1725
|
+
return this._cli;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// (Also while the mode the tab stated is Bypass: a switch into it may still be
|
|
1729
|
+
// to send, and the next process starts in it. And while a process kept ready
|
|
1730
|
+
// for the next message lives that was started in Bypass: every process this
|
|
1731
|
+
// session owns counts, the live one, a closing one and the warm one.)
|
|
1732
|
+
_mayBypass() {
|
|
1733
|
+
const c = this._cli;
|
|
1734
|
+
return (!!this.q && c?.q === this.q && c.may === true) || this._warm?.bypass === true || (this._closingBypass || 0) > 0 || this.permissionMode === BYPASS;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// The facts every numbered mode report carries. `named`: the mode the report
|
|
1738
|
+
// is about (the tab's stated mode by default). Whenever the CLI is not in that
|
|
1739
|
+
// mode the report says both: `mode` is where the CLI is, `switching` the mode
|
|
1740
|
+
// the session holds for the tab (the switch to it is under way, still to be
|
|
1741
|
+
// sent, or failed and is sent again with the next message). The page never
|
|
1742
|
+
// has to take the first for the second.
|
|
1743
|
+
// The statement number orders what the page says; these facts need an order
|
|
1744
|
+
// of their own, because several reports go out between two statements and a
|
|
1745
|
+
// report can reach the page long after it was written (it waits in the buffer
|
|
1746
|
+
// of a detached session and is replayed after the answer to reattach, which
|
|
1747
|
+
// is written later and sent first). `modeRev` only grows for this session and
|
|
1748
|
+
// is given when the report is created, so a buffered message keeps the one it
|
|
1749
|
+
// was created with; `modeRevOf` names this session object, whose count it is
|
|
1750
|
+
// (a session created anew, after a server restart or for a tab that had
|
|
1751
|
+
// none, counts from 1 again under another id). The page lets a report with a
|
|
1752
|
+
// lower revision of the same session set the fact and nothing else.
|
|
1753
|
+
_modeFacts(named = this.permissionMode || 'default') {
|
|
1754
|
+
const c = this._cli;
|
|
1755
|
+
const mode = (this.q && c?.q === this.q && c.mode) || named;
|
|
1756
|
+
this._modeRev = (this._modeRev || 0) + 1;
|
|
1757
|
+
const facts = { mode, modeSeq: this._pageSeq, mayBypass: this._mayBypass(), modeRev: this._modeRev, modeRevOf: (this._modeRevOf ||= randomUUID()) };
|
|
1758
|
+
if (named && mode !== named) facts.switching = named;
|
|
1759
|
+
this._toldFacts = `${mode}|${facts.mayBypass}|${facts.switching || ''}`;
|
|
1760
|
+
return facts;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// The facts changed and nothing says so by itself (an acknowledgement nobody
|
|
1764
|
+
// announces, a switch that failed, the process ending): the page is told with
|
|
1765
|
+
// a `mode_state`, which is never a line in its transcript. `extra`: `failed`.
|
|
1766
|
+
_tellFacts(extra) {
|
|
1767
|
+
if (this.destroyed || !this.panel || this._pageSeq === undefined) return;
|
|
1768
|
+
const told = this._toldFacts;
|
|
1769
|
+
const facts = this._modeFacts();
|
|
1770
|
+
// (A page that was told nothing yet is told nothing calm: its first report says it.)
|
|
1771
|
+
if (!extra && (this._toldFacts === told || (told === undefined && !facts.mayBypass && !facts.switching))) return;
|
|
1772
|
+
this.sendEvent({ type: 'mode_state', ...facts, ...(extra || {}) });
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
/** Checked once whoever is handling the change had its say (the handler that awaited the switch announces it itself). */
|
|
1776
|
+
_factsSettled() {
|
|
1777
|
+
if (!this.panel || this._factsTimer || this.destroyed) return;
|
|
1778
|
+
this._factsTimer = setTimeout(() => { this._factsTimer = null; this._tellFacts(); }, 0);
|
|
1779
|
+
this._factsTimer.unref?.();
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// A switch of this session's own on the CLI, counted while the CLI has not
|
|
1783
|
+
// answered it. What the CLI reports in that time was written before the
|
|
1784
|
+
// switch landed: it is that switch's echo or older news, never a decision of
|
|
1785
|
+
// the CLI's that came after it (see _reportedMode). This replaces judging by
|
|
1786
|
+
// how recent the switch was.
|
|
1787
|
+
_switchMode(q, mode) {
|
|
1788
|
+
if (this._modeCalls?.q !== q) this._modeCalls = { q, open: 0, sent: 0 };
|
|
1789
|
+
const calls = this._modeCalls;
|
|
1790
|
+
calls.open++;
|
|
1791
|
+
calls.sent++;
|
|
1792
|
+
// From the moment a switch into Bypass is sent the process may be in it; the
|
|
1793
|
+
// CLI acknowledging a switch to another mode that was sent later ends that.
|
|
1794
|
+
const cli = this._cliOf(q);
|
|
1795
|
+
const n = calls.sent;
|
|
1796
|
+
if (mode === BYPASS) { cli.may = true; cli.at = n; cli.sends++; }
|
|
1797
|
+
// (The caller gets the CLI's own promise: counting adds no step to its answer.)
|
|
1798
|
+
const call = q.setPermissionMode(mode);
|
|
1799
|
+
call.then(() => {
|
|
1800
|
+
calls.open--;
|
|
1801
|
+
cli.mode = mode;
|
|
1802
|
+
if (mode !== BYPASS && n > cli.at) cli.may = false;
|
|
1803
|
+
this._factsSettled();
|
|
1804
|
+
}, () => { calls.open--; this._factsSettled(); });
|
|
1805
|
+
return call;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
_modeSwitchUnderWay() {
|
|
1809
|
+
if (!this.q) return false;
|
|
1810
|
+
if (this._modeCalls?.q === this.q && this._modeCalls.open > 0) return true;
|
|
1811
|
+
// A plan approval's switch that waits for its 50 ms (see _handleExitPlan).
|
|
1812
|
+
const due = this._planSwitchDue;
|
|
1813
|
+
return !!due && due.q === this.q && due.stamp === this._modeStatements;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/** Whether this session switched the mode of its current process since it started. */
|
|
1817
|
+
_switchedSinceStart() {
|
|
1818
|
+
return this._modeCalls?.q === this.q && this._modeCalls.sent > 0;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// The tab goes back to following the user's settings: it never picked a mode
|
|
1822
|
+
// and left plan mode. The next process starts in the mode they name
|
|
1823
|
+
// (permissions.defaultMode, which only the CLI reads). A live one is switched
|
|
1824
|
+
// to that mode when this process showed it: the first init of a process that
|
|
1825
|
+
// started from the settings. A process that started in a stated mode never
|
|
1826
|
+
// said what the settings name, so it runs in Default until it is restarted,
|
|
1827
|
+
// and the tab is told. Returns { mode, reason } ('' when there is no process).
|
|
1828
|
+
_returnToSettings() {
|
|
1829
|
+
this._modeFromSettings = true;
|
|
1830
|
+
const q = this.q;
|
|
1831
|
+
if (!q) return { mode: '', reason: '' };
|
|
1832
|
+
const known = this._settingsModeOf?.q === q ? this._settingsModeOf.mode : '';
|
|
1833
|
+
const mode = known || 'default';
|
|
1834
|
+
this.permissionMode = mode;
|
|
1835
|
+
if (mode === BYPASS) this._bypassHeld = true;
|
|
1836
|
+
return { mode, reason: known ? '' : SETTINGS_MODE_UNKNOWN };
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// ── permission mode (panel sessions) ─────────────────────────────────────────
|
|
1840
|
+
|
|
1841
|
+
// The CLI's own word on the permission mode: every turn's init, and a status
|
|
1842
|
+
// message when it changes in between. `this.permissionMode` is the mode the
|
|
1843
|
+
// user put the tab in; the CLI reporting Bypass is accepted only when that is
|
|
1844
|
+
// Bypass, or when this is the first init of a process that left the choice to
|
|
1845
|
+
// the user's settings (permissions.defaultMode; the CLI itself drops one that a
|
|
1846
|
+
// repository committed) and that this session has not switched since. Any
|
|
1847
|
+
// other Bypass is not the session's mode: it is switched back at once, and the
|
|
1848
|
+
// event goes on with the tab's own mode, so the page never takes it for a
|
|
1849
|
+
// choice. Returns { event, then? }.
|
|
1850
|
+
// Once the page numbers its statements, what goes on to it is this session's
|
|
1851
|
+
// record of the mode with the number of the latest statement applied, and the
|
|
1852
|
+
// record follows the CLI where the CLI decided: a status with nothing of this
|
|
1853
|
+
// session's under way is the CLI changing its mode itself (the model entered
|
|
1854
|
+
// plan mode, a permission card's rule switched it), and that is the mode now.
|
|
1855
|
+
// An init that differs from a stated mode is a snapshot written before the
|
|
1856
|
+
// switch landed (the CLI says every change with a status): it is not taken.
|
|
1857
|
+
_reportedMode(m) {
|
|
1858
|
+
const reported = m.permissionMode;
|
|
1859
|
+
const init = m.subtype === 'init';
|
|
1860
|
+
const first = init && !this._startModeSeen;
|
|
1861
|
+
if (init) this._startModeSeen = true;
|
|
1862
|
+
if (!PANEL_PERMISSION_MODES.includes(reported)) return { event: m };
|
|
1863
|
+
const q = this.q;
|
|
1864
|
+
const numbered = this._pageSeq !== undefined;
|
|
1865
|
+
const underWay = this._modeSwitchUnderWay();
|
|
1866
|
+
const untouched = !this._switchedSinceStart(); // the process is still in the mode it started in, as far as this session goes
|
|
1867
|
+
const cli = this._cliOf(q);
|
|
1868
|
+
// (Numbered) the event as it goes on: what the CLI is in, the number, and whether it may be in Bypass (_modeFacts).
|
|
1869
|
+
const own = () => {
|
|
1870
|
+
if (!numbered) return m;
|
|
1871
|
+
const { mode, ...facts } = this._modeFacts();
|
|
1872
|
+
return { ...m, permissionMode: mode, ...facts };
|
|
1873
|
+
};
|
|
1874
|
+
if (reported !== BYPASS) {
|
|
1875
|
+
// (Numbered) written before a switch of this session's own landed.
|
|
1876
|
+
if (numbered && underWay) return { event: own() };
|
|
1877
|
+
// Whether this report says where the process is now. While the process may
|
|
1878
|
+
// be in a Bypass this session sent it into, it does not: it may have been
|
|
1879
|
+
// written before that switch landed. A process this session never sent
|
|
1880
|
+
// into Bypass says its own mode in order, and that ends a Bypass it
|
|
1881
|
+
// started in or entered by itself.
|
|
1882
|
+
const ordered = cli.sends === 0;
|
|
1883
|
+
const trusted = cli.may !== true || ordered;
|
|
1884
|
+
const taken = () => { cli.mode = reported; if (ordered) cli.may = false; };
|
|
1885
|
+
// The CLI picked the mode from the user's settings: that is the mode now.
|
|
1886
|
+
if (init && this._modeFromSettings && (!numbered || untouched)) {
|
|
1887
|
+
this.permissionMode = reported;
|
|
1888
|
+
if (trusted) taken();
|
|
1889
|
+
if (first && untouched) this._settingsModeOf = { q, mode: reported };
|
|
1890
|
+
return { event: own() };
|
|
1891
|
+
}
|
|
1892
|
+
// The process was started in Bypass for the tab and did not take it. (A
|
|
1893
|
+
// switch to Bypass sent before this init is not that: it is still on its way.)
|
|
1894
|
+
if (first && this._startAskedBypass && this.permissionMode === BYPASS) {
|
|
1895
|
+
this.permissionMode = reported;
|
|
1896
|
+
if (trusted) taken();
|
|
1897
|
+
return { event: own(), then: this._numbered({ type: 'mode_changed', mode: reported, reason: BYPASS_NOT_TAKEN }) };
|
|
1898
|
+
}
|
|
1899
|
+
// (Numbered) the CLI changed its mode itself: this session's record follows.
|
|
1900
|
+
// Not while the process may be in a Bypass of this session's own sending:
|
|
1901
|
+
// then the tab's Bypass stays the tab's, with one exception. Plan mode is
|
|
1902
|
+
// something the session enters by itself (the model's EnterPlanMode) and
|
|
1903
|
+
// leaves back to the tab's own mode, so it is followed; the session may
|
|
1904
|
+
// still be in Bypass all the same, and is said to be.
|
|
1905
|
+
if (numbered && !init) {
|
|
1906
|
+
if (trusted) { this.permissionMode = reported; taken(); }
|
|
1907
|
+
else if (reported === 'plan') { this.permissionMode = reported; cli.mode = reported; }
|
|
1908
|
+
}
|
|
1909
|
+
return { event: own() };
|
|
1910
|
+
}
|
|
1911
|
+
// The CLI says it is in Bypass: from here it may be, whatever was sent to it
|
|
1912
|
+
// before (only a switch away that is sent after this ends it).
|
|
1913
|
+
cli.may = true;
|
|
1914
|
+
cli.at = this._modeCalls?.q === q ? this._modeCalls.sent : 0;
|
|
1915
|
+
cli.mode = BYPASS;
|
|
1916
|
+
if (this.permissionMode === BYPASS) return { event: own() };
|
|
1917
|
+
if (first && this._modeFromSettings && untouched) {
|
|
1918
|
+
this.permissionMode = BYPASS;
|
|
1919
|
+
this._bypassHeld = true;
|
|
1920
|
+
this._settingsModeOf = { q, mode: BYPASS };
|
|
1921
|
+
return { event: own() };
|
|
1922
|
+
}
|
|
1923
|
+
const mode = this.permissionMode || 'default';
|
|
1924
|
+
// Its own switch can still be on its way to the CLI (the user left Bypass, a
|
|
1925
|
+
// plan was approved into another mode): then this report is older than that
|
|
1926
|
+
// switch, and the mode is only stated again.
|
|
1927
|
+
const echo = underWay;
|
|
1928
|
+
// Whose Bypass it was decides the sentence: the mode the user's settings
|
|
1929
|
+
// started this process in, one the session was in before by their choice,
|
|
1930
|
+
// or nobody's they know of.
|
|
1931
|
+
const reason = echo ? '' : (first && this._startedFromSettings) ? BYPASS_FROM_SETTINGS : this._bypassHeld ? BYPASS_LEFT : BYPASS_FOREIGN;
|
|
1932
|
+
log(`permission mode: the CLI reported ${BYPASS}, the tab is in ${mode}${echo ? ' (a switch is under way)' : ''}; switching back`);
|
|
1933
|
+
if (q) {
|
|
1934
|
+
this._switchMode(q, mode).catch((err) => {
|
|
1935
|
+
// A session that cannot be taken out of a Bypass that is not its mode does not go on.
|
|
1936
|
+
log('setPermissionMode (leaving a Bypass the tab did not choose) failed:', err?.message || err);
|
|
1937
|
+
if (this.q !== q || this.destroyed) return;
|
|
1938
|
+
this._endQuery({ graceful: false });
|
|
1939
|
+
this.send({ type: 'error', message: 'This session was switched to Bypass by something other than you and could not be switched back, so it was stopped. Your next message resumes the conversation.' });
|
|
1940
|
+
this.sendDone(-1);
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
return { event: numbered ? own() : { ...m, permissionMode: mode }, then: this._numbered({ type: 'mode_changed', mode, ...(reason ? { reason } : {}) }) };
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// The CLI refused Bypass on the live session (the user's settings or a policy
|
|
1947
|
+
// turn it off): the tab goes back to `fallback`, on the CLI too, and is told why.
|
|
1948
|
+
_bypassRefused(q, why, fallback = 'default') {
|
|
1949
|
+
if (this.destroyed || this.permissionMode !== BYPASS) return;
|
|
1950
|
+
this.permissionMode = fallback === BYPASS ? 'default' : fallback;
|
|
1951
|
+
if (q && this.q === q) this._switchMode(q, this.permissionMode).catch(() => {});
|
|
1952
|
+
this.sendEvent(this._numbered({ type: 'mode_changed', mode: this.permissionMode, reason: bypassRefusalText(why) }));
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// ── permissions ────────────────────────────────────────────────────────────
|
|
1956
|
+
|
|
1957
|
+
async _onCanUseTool(toolName, input, context) {
|
|
1958
|
+
const { signal, suggestions } = context;
|
|
1959
|
+
this.lastActivity = Date.now();
|
|
1960
|
+
if (toolName === 'AskUserQuestion') return this._promptUser(toolName, input, signal, suggestions, 'ask');
|
|
1961
|
+
if (toolName === 'ExitPlanMode') return this._handleExitPlan(input, signal);
|
|
1962
|
+
if (AUTO_ALLOW.has(toolName)) return { behavior: 'allow', updatedInput: input };
|
|
1963
|
+
// Opt-in (the assistant brain): the plan policy answers first — before session
|
|
1964
|
+
// "always allow", so an always-allowed edit is still refused. A null answer is
|
|
1965
|
+
// asked as outside plan mode, under the approval mode an approved plan continues in.
|
|
1966
|
+
if (this.permissionMode === 'plan' && typeof this.opts?.planPermission === 'function') {
|
|
1967
|
+
const decision = this.opts.planPermission(toolName, input, { approvalMode: this._planExitMode() });
|
|
1968
|
+
if (decision === 'allow') return { behavior: 'allow', updatedInput: input };
|
|
1969
|
+
if (decision === 'deny') {
|
|
1970
|
+
this.send({ type: 'stderr', text: `Plan mode: declined ${toolName}` });
|
|
1971
|
+
return { behavior: 'deny', message: this.opts.planDenyMessage?.(toolName) || `Plan mode: ${toolName} was not run.` };
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
if (this.alwaysAllowed.has(toolName)) return { behavior: 'allow', updatedInput: input };
|
|
1975
|
+
return this._promptUser(toolName, input, signal, suggestions, 'perm', context);
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
/** The approval mode an approved plan continues in (opts.planExitMode; bypass only when the session allows it). */
|
|
1979
|
+
_planExitMode() {
|
|
1980
|
+
const configured = ['acceptEdits', 'bypassPermissions'].includes(this.opts?.planExitMode) ? this.opts.planExitMode : 'default';
|
|
1981
|
+
return configured === 'bypassPermissions' && this.opts?.allowDangerouslySkipPermissions !== true ? 'default' : configured;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
_promptUser(toolName, input, signal, suggestions, kind, context = null) {
|
|
1985
|
+
const requestId = `perm-${randomUUID()}`;
|
|
1986
|
+
const request = { tool_name: toolName, subtype: 'can_use_tool', input: input || {} };
|
|
1987
|
+
if (suggestions?.length) request.suggestions = suggestions;
|
|
1988
|
+
// Panel sessions: what the CLI itself says about this ask (its sentence, the
|
|
1989
|
+
// reason, the path or MCP server involved) and how it wants it presented.
|
|
1990
|
+
if (this.panel && context) {
|
|
1991
|
+
const c = context;
|
|
1992
|
+
if (c.title) request.title = String(c.title);
|
|
1993
|
+
if (c.displayName) request.display_name = String(c.displayName);
|
|
1994
|
+
if (c.description) request.description = String(c.description);
|
|
1995
|
+
if (c.decisionReason) request.decision_reason = String(c.decisionReason);
|
|
1996
|
+
if (c.blockedPath) request.blocked_path = String(c.blockedPath);
|
|
1997
|
+
if (c.mcpServer?.name) request.mcp_server = { name: String(c.mcpServer.name), source: String(c.mcpServer.source || '') };
|
|
1998
|
+
if (c.matchedAskRule) request.matched_ask_rule = c.matchedAskRule;
|
|
1999
|
+
if (c.agentID) request.agent_id = String(c.agentID);
|
|
2000
|
+
if (c.toolUseID) request.tool_use_id = String(c.toolUseID);
|
|
2001
|
+
if (c.defaultToNo === true) request.default_to_no = true;
|
|
2002
|
+
if (c.suppressAlwaysAllowRule === true) request.suppress_always = true;
|
|
2003
|
+
}
|
|
2004
|
+
// Kept on the entry so reattach() can re-send it to a reloaded page.
|
|
2005
|
+
const wire = { type: 'control_request', request_id: requestId, request };
|
|
2006
|
+
this.send(wire);
|
|
2007
|
+
log(`permission request ${requestId} tool=${toolName} kind=${kind}`);
|
|
2008
|
+
return new Promise((resolve) => {
|
|
2009
|
+
const entry = { toolName, resolve, input, kind, createdAt: Date.now(), wire, toolUseId: this.panel ? (context?.toolUseID || '') : '' };
|
|
2010
|
+
this.pendingPerms.set(requestId, entry);
|
|
2011
|
+
if (signal) {
|
|
2012
|
+
signal.addEventListener('abort', () => {
|
|
2013
|
+
if (this.pendingPerms.delete(requestId)) {
|
|
2014
|
+
this.send({ type: 'control_cancelled', request_id: requestId });
|
|
2015
|
+
resolve({ behavior: 'deny', message: 'Turn was interrupted' });
|
|
2016
|
+
}
|
|
2017
|
+
}, { once: true });
|
|
2018
|
+
}
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// An MCP server asks the user for input (a form) or sends them to a URL. Same
|
|
2023
|
+
// pending map as permission prompts, so the card survives a page reload and a
|
|
2024
|
+
// closed turn cancels it. Resolves with an MCP ElicitResult.
|
|
2025
|
+
_promptElicitation(request, signal) {
|
|
2026
|
+
const requestId = `elicit-${randomUUID()}`;
|
|
2027
|
+
const wire = {
|
|
2028
|
+
type: 'control_request',
|
|
2029
|
+
request_id: requestId,
|
|
2030
|
+
request: {
|
|
2031
|
+
subtype: 'elicitation',
|
|
2032
|
+
server_name: String(request.serverName || ''),
|
|
2033
|
+
message: String(request.message || ''),
|
|
2034
|
+
mode: request.mode === 'url' ? 'url' : 'form',
|
|
2035
|
+
...(request.url ? { url: String(request.url) } : {}),
|
|
2036
|
+
...(request.elicitationId ? { elicitation_id: String(request.elicitationId) } : {}),
|
|
2037
|
+
...(request.requestedSchema && typeof request.requestedSchema === 'object' ? { requested_schema: request.requestedSchema } : {}),
|
|
2038
|
+
...(request.title ? { title: String(request.title) } : {}),
|
|
2039
|
+
...(request.displayName ? { display_name: String(request.displayName) } : {}),
|
|
2040
|
+
...(request.description ? { description: String(request.description) } : {}),
|
|
2041
|
+
},
|
|
2042
|
+
};
|
|
2043
|
+
this.send(wire);
|
|
2044
|
+
log(`elicitation request ${requestId} server=${request.serverName || '?'} mode=${wire.request.mode}`);
|
|
2045
|
+
return new Promise((resolve) => {
|
|
2046
|
+
this.pendingPerms.set(requestId, { toolName: 'elicitation', resolve, input: null, kind: 'elicit', createdAt: Date.now(), wire });
|
|
2047
|
+
if (signal) {
|
|
2048
|
+
signal.addEventListener('abort', () => {
|
|
2049
|
+
if (this.pendingPerms.delete(requestId)) {
|
|
2050
|
+
this.send({ type: 'control_cancelled', request_id: requestId });
|
|
2051
|
+
resolve({ action: 'cancel' });
|
|
2052
|
+
}
|
|
2053
|
+
}, { once: true });
|
|
2054
|
+
}
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
async _handleExitPlan(input, signal) {
|
|
2059
|
+
// Author the plan file BEFORE the approval card (ordering contract: the
|
|
2060
|
+
// frontend's eager-capture path expects plan_file_written first).
|
|
2061
|
+
const planBody = (input?.plan || '').trim();
|
|
2062
|
+
// (A temporary chat: SynaBun keeps no copy of the plan; the card shows it.)
|
|
2063
|
+
if (planBody && !this.temporary) {
|
|
2064
|
+
try {
|
|
2065
|
+
const workDir = validateWorkDir(this.cwd) || deps.PACKAGE_ROOT;
|
|
2066
|
+
const result = deps.writePlanFile(planBody, { cwd: workDir, projectPath: workDir });
|
|
2067
|
+
if (result?.ok) {
|
|
2068
|
+
this.sendEvent({ type: 'system', subtype: 'plan_file_written', path: result.path, name: result.name });
|
|
2069
|
+
log('plan authored:', result.path);
|
|
2070
|
+
}
|
|
2071
|
+
} catch (err) { log('plan author error:', err.message); }
|
|
2072
|
+
}
|
|
2073
|
+
const res = await this._promptUser('ExitPlanMode', input, signal, null, 'plan');
|
|
2074
|
+
if (res.behavior !== 'allow') return res;
|
|
2075
|
+
// planDecision is attached by _resolvePermission from the client response.
|
|
2076
|
+
const decision = res._planDecision || 'default';
|
|
2077
|
+
// A plain approval continues in the approval mode the session was set to
|
|
2078
|
+
// before planning (opts.planExitMode: the assistant's plan + bypass/accept).
|
|
2079
|
+
// (Panel sessions: the plan card's own Bypass choice, on a process launched able to take it.)
|
|
2080
|
+
const bypassOk = this.opts?.allowDangerouslySkipPermissions === true || (this.panel && this._bypassLaunched === true);
|
|
2081
|
+
let targetMode = decision === 'acceptEdits' ? 'acceptEdits'
|
|
2082
|
+
: (decision === 'bypassPermissions' && bypassOk) ? 'bypassPermissions'
|
|
2083
|
+
: this._planExitMode();
|
|
2084
|
+
// (Panel sessions: the answer is a numbered statement of the tab's mode. One
|
|
2085
|
+
// older than the statement applied approves the plan and states no mode. A
|
|
2086
|
+
// page that numbers them may also name where the plan leaves to more exactly
|
|
2087
|
+
// than the three decisions (`planExit`: the mode the tab was in before the
|
|
2088
|
+
// plan, or 'settings' for a tab that never picked one). Read here only, for
|
|
2089
|
+
// a plan request.)
|
|
2090
|
+
let toSettings = null;
|
|
2091
|
+
if (this.panel) {
|
|
2092
|
+
if (!this._takeStatement(res._planSeq)) { this._statementDropped(); return { behavior: 'allow', updatedInput: res.updatedInput || input }; }
|
|
2093
|
+
const exit = this._pageSeq !== undefined ? res._planExit : undefined;
|
|
2094
|
+
if (exit === 'settings') { toSettings = this._returnToSettings(); targetMode = toSettings.mode || 'default'; }
|
|
2095
|
+
else if (PANEL_PERMISSION_MODES.includes(exit) && exit !== 'plan' && (exit !== BYPASS || bypassOk)) targetMode = exit;
|
|
2096
|
+
}
|
|
2097
|
+
// Approved: plan-mode permission handling ends now, not when the switch below lands.
|
|
2098
|
+
this.permissionMode = targetMode;
|
|
2099
|
+
const stamp = this._stateMode();
|
|
2100
|
+
const q = this.q;
|
|
2101
|
+
// (Panel sessions: the CLI goes back to the mode it was in before the plan
|
|
2102
|
+
// by itself, and may say so before the switch below lands: until then that
|
|
2103
|
+
// switch is under way (_modeSwitchUnderWay). And the answer to the card is
|
|
2104
|
+
// the tab's own pick: the user's settings no longer choose the mode of a
|
|
2105
|
+
// process started after it, unless the answer went back to them.)
|
|
2106
|
+
if (this.panel) {
|
|
2107
|
+
this._modeFromSettings = !!toSettings;
|
|
2108
|
+
if (targetMode === BYPASS) this._bypassHeld = true;
|
|
2109
|
+
this._planSwitchDue = { q, stamp };
|
|
2110
|
+
}
|
|
2111
|
+
setTimeout(() => {
|
|
2112
|
+
if (this.panel && this._planSwitchDue?.stamp === stamp) this._planSwitchDue = null;
|
|
2113
|
+
// Void once the mode was stated again or the process is gone (see _stateMode).
|
|
2114
|
+
if (!this._modeSwitchStands(q, stamp)) return;
|
|
2115
|
+
(this.panel ? this._switchMode(q, targetMode) : q.setPermissionMode(targetMode))
|
|
2116
|
+
.then(() => {
|
|
2117
|
+
if (!this._modeSwitchStands(q, stamp)) return;
|
|
2118
|
+
this.permissionMode = targetMode;
|
|
2119
|
+
this.sendEvent(this._numbered({ type: 'mode_changed', mode: targetMode, ...(toSettings ? { fromSettings: true, ...(toSettings.reason ? { reason: toSettings.reason } : {}) } : {}) }));
|
|
2120
|
+
})
|
|
2121
|
+
.catch(err => {
|
|
2122
|
+
log('setPermissionMode after plan approve failed:', err.message);
|
|
2123
|
+
if (this.panel && targetMode === BYPASS && this._modeSwitchStands(q, stamp)) this._bypassRefused(q, err.message);
|
|
2124
|
+
});
|
|
2125
|
+
}, 50);
|
|
2126
|
+
return { behavior: 'allow', updatedInput: res.updatedInput || input };
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// AskUserQuestion answers arrive as updatedInput {questions, answers} from the
|
|
2130
|
+
// existing frontend card. The CLI's AskUserQuestion tool reads ONLY the
|
|
2131
|
+
// top-level `answers` map, looked up by each question's exact `question` text
|
|
2132
|
+
// (verified against the CLI itself: missing/empty map → "The user did not
|
|
2133
|
+
// answer the questions."). So: keep the original questions, pass the answers
|
|
2134
|
+
// map through, and re-key any answer the panel stored under a fallback key
|
|
2135
|
+
// (text/header) back onto the question text.
|
|
2136
|
+
_normalizeAskInput(originalInput, updatedInput) {
|
|
2137
|
+
if (!updatedInput) return originalInput;
|
|
2138
|
+
const rawAnswers = updatedInput.answers;
|
|
2139
|
+
if (!rawAnswers || typeof rawAnswers !== 'object') {
|
|
2140
|
+
return { ...originalInput, ...updatedInput };
|
|
2141
|
+
}
|
|
2142
|
+
const readAnswer = (v) => {
|
|
2143
|
+
if (v == null) return '';
|
|
2144
|
+
if (Array.isArray(v?.answers)) return v.answers.join(', ');
|
|
2145
|
+
if (typeof v === 'string') return v;
|
|
2146
|
+
if (typeof v === 'object') { try { return JSON.stringify(v); } catch { return String(v); } }
|
|
2147
|
+
return String(v);
|
|
2148
|
+
};
|
|
2149
|
+
const questions = Array.isArray(originalInput?.questions) ? originalInput.questions : [];
|
|
2150
|
+
const answerKeys = Object.keys(rawAnswers);
|
|
2151
|
+
// Keep every answer under its original key (the panel already keys by question text)
|
|
2152
|
+
const answers = {};
|
|
2153
|
+
for (const k of answerKeys) {
|
|
2154
|
+
const a = readAnswer(rawAnswers[k]);
|
|
2155
|
+
if (a) answers[k] = a;
|
|
2156
|
+
}
|
|
2157
|
+
// Ensure each question's EXACT text has an entry — re-key fallback-keyed answers
|
|
2158
|
+
questions.forEach((q, i) => {
|
|
2159
|
+
const qText = q?.question || '';
|
|
2160
|
+
if (!qText || answers[qText] != null) return;
|
|
2161
|
+
for (const key of [q?.text, q?.header, q?.id, String(i), answerKeys[i]].filter(Boolean)) {
|
|
2162
|
+
if (answers[key] != null) { answers[qText] = answers[key]; break; }
|
|
2163
|
+
}
|
|
2164
|
+
});
|
|
2165
|
+
// Panel sessions: what the user added to a choice (notes, the preview they
|
|
2166
|
+
// picked) travels with the answers.
|
|
2167
|
+
if (this.panel && updatedInput.annotations && typeof updatedInput.annotations === 'object') {
|
|
2168
|
+
return { ...originalInput, answers, annotations: updatedInput.annotations };
|
|
2169
|
+
}
|
|
2170
|
+
return { ...originalInput, answers };
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
_resolvePermission(requestId, innerResponse) {
|
|
2174
|
+
const entry = this.pendingPerms.get(requestId);
|
|
2175
|
+
if (!entry) { log('control_response for unknown/expired request:', requestId); return; }
|
|
2176
|
+
this.pendingPerms.delete(requestId);
|
|
2177
|
+
const behavior = innerResponse?.behavior;
|
|
2178
|
+
const latencyMs = Date.now() - entry.createdAt;
|
|
2179
|
+
if (entry.kind === 'elicit') {
|
|
2180
|
+
const action = ['accept', 'decline', 'cancel'].includes(innerResponse?.action) ? innerResponse.action : 'decline';
|
|
2181
|
+
log(`elicitation resolved ${requestId} action=${action} latency=${latencyMs}ms`);
|
|
2182
|
+
const content = innerResponse?.content;
|
|
2183
|
+
entry.resolve(action === 'accept' && content && typeof content === 'object' ? { action, content } : { action });
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
log(`permission resolved ${requestId} tool=${entry.toolName} behavior=${behavior} latency=${latencyMs}ms`);
|
|
2187
|
+
|
|
2188
|
+
if (behavior === 'allow') {
|
|
2189
|
+
if (innerResponse.always && entry.kind === 'perm') this.alwaysAllowed.add(entry.toolName);
|
|
2190
|
+
let updatedInput = innerResponse.updatedInput || entry.input;
|
|
2191
|
+
if (entry.kind === 'ask') updatedInput = this._normalizeAskInput(entry.input, innerResponse.updatedInput);
|
|
2192
|
+
const result = { behavior: 'allow', updatedInput };
|
|
2193
|
+
if (Array.isArray(innerResponse.updatedPermissions) && innerResponse.updatedPermissions.length) {
|
|
2194
|
+
result.updatedPermissions = innerResponse.updatedPermissions;
|
|
2195
|
+
// Panel sessions: a permission card is not a way into Bypass (the mode
|
|
2196
|
+
// control and the plan card are). A mode switch to it is taken out.
|
|
2197
|
+
if (this.panel) {
|
|
2198
|
+
const kept = result.updatedPermissions.filter(u => !(u && typeof u === 'object' && u.type === 'setMode' && u.mode === BYPASS));
|
|
2199
|
+
if (kept.length !== result.updatedPermissions.length) log(`permission answer ${requestId}: dropped a switch to ${BYPASS}`);
|
|
2200
|
+
if (kept.length) result.updatedPermissions = kept; else delete result.updatedPermissions;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
if (entry.kind === 'plan') {
|
|
2204
|
+
result._planDecision = innerResponse.planDecision || 'default';
|
|
2205
|
+
// Panel sessions: the answer's statement number, and where the plan leaves to (see _handleExitPlan).
|
|
2206
|
+
if (this.panel) { result._planSeq = innerResponse.modeSeq; result._planExit = innerResponse.planExit; }
|
|
2207
|
+
}
|
|
2208
|
+
// Panel sessions tell the CLI which call this answers and whether the
|
|
2209
|
+
// approval was for this once or came with rules.
|
|
2210
|
+
if (this.panel && entry.kind === 'perm') {
|
|
2211
|
+
if (entry.toolUseId) result.toolUseID = entry.toolUseId;
|
|
2212
|
+
result.decisionClassification = result.updatedPermissions?.length ? 'user_permanent' : 'user_temporary';
|
|
2213
|
+
if (result.updatedPermissions?.length) this._noteGranted(result.updatedPermissions);
|
|
2214
|
+
}
|
|
2215
|
+
entry.resolve(result);
|
|
2216
|
+
} else {
|
|
2217
|
+
const message = innerResponse?.message
|
|
2218
|
+
|| (entry.kind === 'plan'
|
|
2219
|
+
? 'User wants to keep planning. Revise the plan based on their feedback.'
|
|
2220
|
+
: `User denied permission for ${entry.toolName}`);
|
|
2221
|
+
const result = { behavior: 'deny', message };
|
|
2222
|
+
if (this.panel && entry.kind === 'perm') {
|
|
2223
|
+
// "Deny and stop": the turn ends here instead of the model trying another way.
|
|
2224
|
+
if (innerResponse?.interrupt === true) result.interrupt = true;
|
|
2225
|
+
if (entry.toolUseId) result.toolUseID = entry.toolUseId;
|
|
2226
|
+
result.decisionClassification = 'user_reject';
|
|
2227
|
+
}
|
|
2228
|
+
entry.resolve(result);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
_denyAllPending(reason) {
|
|
2233
|
+
for (const [rid, entry] of this.pendingPerms) {
|
|
2234
|
+
entry.resolve(entry.kind === 'elicit' ? { action: 'cancel' } : { behavior: 'deny', message: reason });
|
|
2235
|
+
this.send({ type: 'control_cancelled', request_id: rid });
|
|
2236
|
+
}
|
|
2237
|
+
this.pendingPerms.clear();
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
// ── public API for other hosts of a session (the Assistant brain) ────────────
|
|
2241
|
+
// What a host may change on a session without reaching into its fields.
|
|
2242
|
+
|
|
2243
|
+
/** The approval mode an approved plan continues in ('default' | 'acceptEdits' | 'bypassPermissions'). */
|
|
2244
|
+
setPlanExitMode(mode) {
|
|
2245
|
+
// A copy: the options object may be shared or frozen (panel sessions share one).
|
|
2246
|
+
this.opts = { ...this.opts, planExitMode: mode };
|
|
2247
|
+
return this._planExitMode();
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/**
|
|
2251
|
+
* Switch the model before a turn: on the live query when there is one, and
|
|
2252
|
+
* for the next query either way. Never throws.
|
|
2253
|
+
* @returns {{ ok: boolean, changed: boolean, error?: string }}
|
|
2254
|
+
*/
|
|
2255
|
+
async setModel(model) {
|
|
2256
|
+
const want = model || null;
|
|
2257
|
+
if ((this.model || null) === want) return { ok: true, changed: false };
|
|
2258
|
+
if (typeof this.q?.setModel === 'function') {
|
|
2259
|
+
try { await this.q.setModel(want || undefined); }
|
|
2260
|
+
catch (error) { return { ok: false, changed: false, error: error?.message || String(error) }; }
|
|
2261
|
+
}
|
|
2262
|
+
this.model = want; // ensureQuery() reads it when the next query is created
|
|
2263
|
+
return { ok: true, changed: true };
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
// ── watchdog / reapers ─────────────────────────────────────────────────────
|
|
2267
|
+
|
|
2268
|
+
_checkStall() {
|
|
2269
|
+
if (this.destroyed || !this.q || !this.inTurn) return;
|
|
2270
|
+
if (this.pendingPerms.size > 0) { this.lastEventTime = Date.now(); return; }
|
|
2271
|
+
const silentSec = Math.round((Date.now() - this.lastEventTime) / 1000);
|
|
2272
|
+
if (!this.bootComplete) {
|
|
2273
|
+
if (silentSec >= BOOT_TIMEOUT_SEC) {
|
|
2274
|
+
log(`boot timeout — ${silentSec}s without init`);
|
|
2275
|
+
this._denyAllPending('Boot timeout');
|
|
2276
|
+
this._endQuery({ graceful: false });
|
|
2277
|
+
this.inTurn = false; this.pendingTurns = 0;
|
|
2278
|
+
this.send({ type: 'error', message: `Session failed to start within ${BOOT_TIMEOUT_SEC}s. MCP servers or hooks may be hanging.` });
|
|
2279
|
+
this.sendDone(-1);
|
|
2280
|
+
}
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
const killSec = claudeStallSeconds(this.effort, 90);
|
|
2284
|
+
if (silentSec >= STALL_WARN_SEC && silentSec < killSec) {
|
|
2285
|
+
log(`stall warning: silent ${silentSec}s (kill at ${killSec}s) retries=${this.stallRetries}`);
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
if (silentSec < killSec) return;
|
|
2289
|
+
// No main-thread output since the last result, nothing pushed since, and that
|
|
2290
|
+
// result said nothing was queued: the CLI is not in a turn — our count is off.
|
|
2291
|
+
// Close it here. A stall retry would restart the CLI, killing its background
|
|
2292
|
+
// work and injecting a "continue" prompt nobody sent.
|
|
2293
|
+
if (this._lastResultAt > this._lastPushAt && !this._outputSinceResult && !this._resultOwesTurn) {
|
|
2294
|
+
log('turn accounting drift — the CLI has no turn in flight; closing it');
|
|
2295
|
+
this.inTurn = false;
|
|
2296
|
+
this.pendingTurns = 0;
|
|
2297
|
+
this.lastActivity = Date.now();
|
|
2298
|
+
this.sendDone(0);
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
if (this.temporary) { this._stallTemporary(); return; }
|
|
2302
|
+
if (this.stallRetries < MAX_RETRIES) {
|
|
2303
|
+
this.stallRetries++;
|
|
2304
|
+
log(`stall retry ${this.stallRetries}/${MAX_RETRIES} — interrupt → recreate with resume`);
|
|
2305
|
+
this.sendEvent({ type: 'system', subtype: 'retry', message: `Stream stalled — retrying (${this.stallRetries}/${MAX_RETRIES})...` });
|
|
2306
|
+
// The stalled turn is in flight by definition — swallow its interrupt-result
|
|
2307
|
+
// so the client doesn't get a premature done mid-recovery.
|
|
2308
|
+
this.swallowResults++;
|
|
2309
|
+
this.swallowDeadline = Date.now() + 15_000;
|
|
2310
|
+
const q = this.q;
|
|
2311
|
+
const interruptDeadline = new Promise(r => setTimeout(() => r('timeout'), 10_000));
|
|
2312
|
+
Promise.race([q.interrupt().then(() => 'ok').catch(() => 'failed'), interruptDeadline])
|
|
2313
|
+
.then(async () => {
|
|
2314
|
+
if (this.destroyed) return;
|
|
2315
|
+
// Jitter + global slot: de-phase correlated stall recoveries so N
|
|
2316
|
+
// sessions don't respawn N CLIs in the same instant.
|
|
2317
|
+
await new Promise(r => setTimeout(r, Math.random() * 3000));
|
|
2318
|
+
if (this.destroyed) return;
|
|
2319
|
+
await _acquireRecreateSlot();
|
|
2320
|
+
try {
|
|
2321
|
+
if (this.destroyed) return;
|
|
2322
|
+
await this._recreateQuery('The previous turn was interrupted by an API stream drop. Please continue from where you left off.');
|
|
2323
|
+
} finally {
|
|
2324
|
+
_releaseRecreateSlot();
|
|
2325
|
+
}
|
|
2326
|
+
});
|
|
2327
|
+
} else {
|
|
2328
|
+
log('stall timeout — max retries exhausted');
|
|
2329
|
+
this._endQuery({ graceful: false });
|
|
2330
|
+
this.inTurn = false; this.pendingTurns = 0;
|
|
2331
|
+
this.send({ type: 'error', message: `Session stalled after ${MAX_RETRIES} retries. The API stream keeps dropping. Try a simpler message or switch to a faster model.` });
|
|
2332
|
+
this.sendDone(-1);
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
_maybeReapIdle() {
|
|
2337
|
+
if (this.destroyed || !this.q || this.inTurn || this.pendingPerms.size > 0) return;
|
|
2338
|
+
// Background agents, shells and scheduled wakeups run inside this CLI process
|
|
2339
|
+
// after the turn that started them has ended. Ending the Query kills them.
|
|
2340
|
+
if (this._hasPendingWork()) return;
|
|
2341
|
+
// A temporary chat cannot be resumed: it lives as long as its tab does.
|
|
2342
|
+
if (this.temporary) return;
|
|
2343
|
+
if (Date.now() - this.lastActivity < IDLE_REAP_MS) return;
|
|
2344
|
+
log(`idle reap: ending query for session ${this.sessionId || '(new)'} — next prompt resumes transparently`);
|
|
2345
|
+
this._endQuery({ graceful: true });
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
// ── inbound WS messages ────────────────────────────────────────────────────
|
|
2349
|
+
|
|
2350
|
+
async handleMessage(msg, turn = null) {
|
|
2351
|
+
// Panel sessions: while another message is between ending a process and
|
|
2352
|
+
// starting the next, a message that can start or end one waits in line
|
|
2353
|
+
// (see _newTurn). It is handled with a turn of its own, which it takes
|
|
2354
|
+
// before it waits on a closing process itself.
|
|
2355
|
+
if (this.panel && !turn && takesTurn(msg)) {
|
|
2356
|
+
const mine = this._newTurn();
|
|
2357
|
+
try {
|
|
2358
|
+
if (this._turnBusy) {
|
|
2359
|
+
await mine.take();
|
|
2360
|
+
// The tab went away while the message waited: there is no one left to answer.
|
|
2361
|
+
if (this.destroyed) { log(`${msg.type} dropped: the session was closed while it waited its turn`); return; }
|
|
2362
|
+
}
|
|
2363
|
+
return await this.handleMessage(msg, mine);
|
|
2364
|
+
} finally { mine.release(); }
|
|
2365
|
+
}
|
|
2366
|
+
if (this.panel && controlCanStart(msg)) {
|
|
2367
|
+
if (!this._everStarted) this._establishFromControl(msg);
|
|
2368
|
+
else if (this._controlLeftConversation(msg)) {
|
|
2369
|
+
// The tab is on another conversation than the one this session's
|
|
2370
|
+
// process was started for: that process is not what the control runs
|
|
2371
|
+
// on. It ends, as it does for a prompt, and the control's configuration
|
|
2372
|
+
// is the one of a conversation no process has started for yet.
|
|
2373
|
+
log(`control ${msg.type} for another conversation (was ${this.sessionId || 'none'}): ending the query`);
|
|
2374
|
+
// The turn is this message's from here (it is free, or already its own).
|
|
2375
|
+
{ const waiting = turn?.take(); if (waiting) await waiting; }
|
|
2376
|
+
await this._endQuery({ graceful: true });
|
|
2377
|
+
if (this.destroyed) return;
|
|
2378
|
+
// No message started a process during that wait: the ones that can are
|
|
2379
|
+
// waiting their turn behind this one. A recovery that was already under
|
|
2380
|
+
// way (a stall retry) is not a message and may have: what it restarted
|
|
2381
|
+
// is the conversation the tab left, so it ends too, at once (nothing
|
|
2382
|
+
// is awaited when the end is not graceful).
|
|
2383
|
+
if (this.q) this._endQuery({ graceful: false });
|
|
2384
|
+
this._everStarted = false;
|
|
2385
|
+
this._accountBound = false;
|
|
2386
|
+
this._establishFromControl(msg);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
switch (msg.type) {
|
|
2390
|
+
case 'query': {
|
|
2391
|
+
// (Panel sessions: a prompt that restarts the process holds the turn until the new one exists.)
|
|
2392
|
+
const restarting = this._handleQuery(msg);
|
|
2393
|
+
if (restarting) { const waiting = turn?.take(); if (waiting) await waiting; }
|
|
2394
|
+
return restarting;
|
|
2395
|
+
}
|
|
2396
|
+
case 'warm':
|
|
2397
|
+
// Panel sessions only: the user started typing. Start the CLI process
|
|
2398
|
+
// now (same configuration as the message that follows), unless one is
|
|
2399
|
+
// already there.
|
|
2400
|
+
if (!this.panel || this.q || this._warm) return;
|
|
2401
|
+
return this._handleQuery(msg, { warmOnly: true });
|
|
2402
|
+
case 'control_response': {
|
|
2403
|
+
const rid = msg.request_id || msg.response?.request_id;
|
|
2404
|
+
const inner = msg.response?.response || msg.response || {};
|
|
2405
|
+
if (rid) this._resolvePermission(rid, inner);
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
case 'compact': {
|
|
2409
|
+
// Panel sessions: a start that is refused is said before anything is announced.
|
|
2410
|
+
if (this.panel && !this.q) { this.ensureQuery(); if (this._announceRefusedStart()) return; }
|
|
2411
|
+
this.sendEvent({ type: 'system', subtype: 'compact_started', message: 'Compacting context...' });
|
|
2412
|
+
this._pushUserText('/compact');
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
case 'abort': {
|
|
2416
|
+
// Swallow the result interrupt() will produce — but only when a turn is
|
|
2417
|
+
// actually in flight, and with an expiry so a no-result interrupt can't
|
|
2418
|
+
// permanently eat the next real turn's done.
|
|
2419
|
+
if (this.inTurn) { this.swallowResults++; this.swallowDeadline = Date.now() + 15_000; }
|
|
2420
|
+
this._denyAllPending('Turn was interrupted');
|
|
2421
|
+
this.inTurn = false;
|
|
2422
|
+
this.pendingTurns = 0;
|
|
2423
|
+
const q = this.q;
|
|
2424
|
+
turn?.release(); // (panel sessions: a stop that waited in line holds nothing while the CLI answers)
|
|
2425
|
+
if (q) { try { await q.interrupt(); } catch (err) { log('interrupt failed:', err.message); } }
|
|
2426
|
+
this.send({ type: 'aborted' });
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
case 'set_permission_mode': {
|
|
2430
|
+
const mode = msg.mode;
|
|
2431
|
+
// Panel sessions, a page that numbers its statements: back to the mode
|
|
2432
|
+
// the user's settings name (a tab that never picked a mode left plan mode).
|
|
2433
|
+
const toSettings = this.panel && msg.fromSettings === true && Number.isSafeInteger(msg.modeSeq);
|
|
2434
|
+
if (!toSettings && !(this.panel ? PANEL_PERMISSION_MODES : CLASSIC_PERMISSION_MODES).includes(mode)) return;
|
|
2435
|
+
// (Panel sessions: a numbered statement older than the one applied is not applied.)
|
|
2436
|
+
if (!this._takeStatement(msg.modeSeq)) { this._statementDropped(); return; }
|
|
2437
|
+
if (toSettings) {
|
|
2438
|
+
const back = this._returnToSettings();
|
|
2439
|
+
this._warmStillFits();
|
|
2440
|
+
const stamp = this._stateMode();
|
|
2441
|
+
turn?.release();
|
|
2442
|
+
const q = this.q;
|
|
2443
|
+
if (q) {
|
|
2444
|
+
try {
|
|
2445
|
+
await this._switchMode(q, back.mode);
|
|
2446
|
+
} catch (err) {
|
|
2447
|
+
log('setPermissionMode (back to the settings\' mode) failed:', err.message);
|
|
2448
|
+
if (back.mode === BYPASS) { if (this._modeSwitchStands(q, stamp)) this._bypassRefused(q, err.message); return; }
|
|
2449
|
+
// The session is where it was: the page is told so, with what it may still be in.
|
|
2450
|
+
if (this._modeSwitchStands(q, stamp)) this._tellFacts({ failed: back.mode });
|
|
2451
|
+
this.send({ type: 'error', message: `Could not switch permission mode: ${err.message}` });
|
|
2452
|
+
return;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
if (this._modeStatements !== stamp) return;
|
|
2456
|
+
this.sendEvent(this._numbered({ type: 'mode_changed', mode: back.mode, fromSettings: true, ...(back.reason ? { reason: back.reason } : {}) }));
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
// Panel sessions: Bypass where no process can be launched able to take
|
|
2460
|
+
// it (see panelBypassBlock) is refused here, and the tab keeps its mode.
|
|
2461
|
+
const previous = this.permissionMode;
|
|
2462
|
+
if (this.panel && mode === BYPASS) {
|
|
2463
|
+
const block = panelBypassBlock() || (this.q && !this._bypassLaunched ? bypassRefusalText('not launched') : '');
|
|
2464
|
+
if (block) {
|
|
2465
|
+
// (A numbered statement that is refused still decides the tab's mode: the one it is in instead, away from the settings.)
|
|
2466
|
+
const kept = previous === BYPASS ? 'default' : (previous || 'default');
|
|
2467
|
+
if (this._pageSeq !== undefined) { this._modeFromSettings = false; this.permissionMode = kept; this._stateMode(); this._warmStillFits(); }
|
|
2468
|
+
this.sendEvent(this._numbered({ type: 'mode_changed', mode: kept, reason: block }));
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
this._modeFromSettings = false;
|
|
2473
|
+
this.permissionMode = mode;
|
|
2474
|
+
if (this.panel && mode === BYPASS) this._bypassHeld = true;
|
|
2475
|
+
// (Panel sessions: a process kept ready that was started in another mode is ended; see _warmStillFits.)
|
|
2476
|
+
if (this.panel) this._warmStillFits();
|
|
2477
|
+
const stamp = this._stateMode();
|
|
2478
|
+
turn?.release(); // (panel sessions: as for a stop)
|
|
2479
|
+
if (this.q) {
|
|
2480
|
+
const q = this.q;
|
|
2481
|
+
try {
|
|
2482
|
+
await (this.panel ? this._switchMode(q, mode) : q.setPermissionMode(mode));
|
|
2483
|
+
} catch (err) {
|
|
2484
|
+
log('setPermissionMode failed:', err.message);
|
|
2485
|
+
// Panel sessions: a Bypass the CLI refused leaves the tab in the mode it had
|
|
2486
|
+
// (not when the mode was stated again meanwhile, or the process is
|
|
2487
|
+
// another one: then the refusal is about neither; see _stateMode).
|
|
2488
|
+
if (this.panel && mode === BYPASS) { if (this._modeSwitchStands(q, stamp)) this._bypassRefused(q, err.message, previous); return; }
|
|
2489
|
+
// (Panel sessions: a switch that failed or was refused leaves the session
|
|
2490
|
+
// in the mode the CLI is in, a Bypass it could not leave included: the
|
|
2491
|
+
// page is told that, not only that there was an error.)
|
|
2492
|
+
if (this.panel && this._modeSwitchStands(q, stamp)) this._tellFacts({ failed: mode });
|
|
2493
|
+
this.send({ type: 'error', message: `Could not switch permission mode: ${err.message}` });
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
// (Panel sessions: a switch the CLI answered after the mode was stated
|
|
2498
|
+
// again announces nothing: the tab is not in it.)
|
|
2499
|
+
if (this.panel && this._modeStatements !== stamp) return;
|
|
2500
|
+
this.sendEvent(this._numbered({ type: 'mode_changed', mode }));
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
case 'rewind': {
|
|
2504
|
+
// (A temporary chat has no checkpoints.)
|
|
2505
|
+
if (this.temporary) { this.send({ type: 'rewind_result', ok: false, code: 'temporary', error: TEMPORARY_NO_REWIND }); return; }
|
|
2506
|
+
const uuid = msg.userMessageUuid;
|
|
2507
|
+
// The idle reaper may have ended the Query — resume transparently
|
|
2508
|
+
if (!this.q && this.sessionId && uuid) this.ensureQuery();
|
|
2509
|
+
if (!uuid || !this.q) {
|
|
2510
|
+
this.send({ type: 'rewind_result', ok: false, error: this.q ? 'Missing userMessageUuid' : (this._startRefused?.message || 'No active session'), ...(!this.q && this._startRefused?.code ? { code: this._startRefused.code } : {}) });
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
// (Panel sessions: the turn is given up for the CLI's answer, see _newTurn.)
|
|
2514
|
+
turn?.release();
|
|
2515
|
+
try {
|
|
2516
|
+
const r = await this.q.rewindFiles(uuid);
|
|
2517
|
+
if (r && r.canRewind === false) {
|
|
2518
|
+
this.send({ type: 'rewind_result', ok: false, error: r.error || 'Rewind not available for this checkpoint' });
|
|
2519
|
+
} else if (this.panel) {
|
|
2520
|
+
// What the rewind changed, for the panel's result line.
|
|
2521
|
+
const stats = slimRewind(r);
|
|
2522
|
+
this.send({ type: 'rewind_result', ok: true, userMessageUuid: uuid, fileCount: stats.fileCount, insertions: stats.insertions, deletions: stats.deletions, skippedLinks: stats.skippedLinks });
|
|
2523
|
+
} else {
|
|
2524
|
+
this.send({ type: 'rewind_result', ok: true, userMessageUuid: uuid });
|
|
2525
|
+
}
|
|
2526
|
+
} catch (err) {
|
|
2527
|
+
this.send({ type: 'rewind_result', ok: false, error: err.message });
|
|
2528
|
+
}
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
case 'mcp_status': {
|
|
2532
|
+
if (!this.q) { this.sendEvent({ type: 'system', subtype: 'mcp_status', servers: [] }); return; }
|
|
2533
|
+
try {
|
|
2534
|
+
const servers = await this.q.mcpServerStatus();
|
|
2535
|
+
this.sendEvent({ type: 'system', subtype: 'mcp_status', servers: servers || [] });
|
|
2536
|
+
} catch (err) { log('mcpServerStatus failed:', err.message); }
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
case 'reload_skills': {
|
|
2540
|
+
// Panel sessions only: re-read skills from disk and refresh the slash menu.
|
|
2541
|
+
if (!this.panel) return;
|
|
2542
|
+
if (!this.q || typeof this.q.reloadSkills !== 'function') {
|
|
2543
|
+
this.send({ type: 'reload_result', what: 'skills', ok: false, error: 'No active session yet. Skills load fresh when the session starts.' });
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
const q = this.q;
|
|
2547
|
+
try {
|
|
2548
|
+
await q.reloadSkills();
|
|
2549
|
+
const commands = await q.supportedCommands();
|
|
2550
|
+
if (this.q === q && Array.isArray(commands)) {
|
|
2551
|
+
this.sendEvent({ type: 'system', subtype: 'commands_list', commands });
|
|
2552
|
+
this.send({ type: 'reload_result', what: 'skills', ok: true, count: commands.length });
|
|
2553
|
+
}
|
|
2554
|
+
} catch (err) {
|
|
2555
|
+
this.send({ type: 'reload_result', what: 'skills', ok: false, error: err.message });
|
|
2556
|
+
}
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
case 'rewind_conversation': {
|
|
2560
|
+
// Panel sessions only: drop the conversation back to one entry (the
|
|
2561
|
+
// model forgets what came after), optionally restoring the files to
|
|
2562
|
+
// the prompt that followed it. The session restarts, resumed at that entry.
|
|
2563
|
+
if (!this.panel) return;
|
|
2564
|
+
let at = typeof msg.messageUuid === 'string' ? msg.messageUuid : '';
|
|
2565
|
+
const fail = (error, code) => this.send({ type: 'rewind_conversation_result', ok: false, error, ...(code ? { code } : {}) });
|
|
2566
|
+
if (this.temporary) { fail(TEMPORARY_NO_REWIND, 'temporary'); return; }
|
|
2567
|
+
if (!at || !this.sessionId) { fail(at ? 'No session to rewind' : 'Missing message id'); return; }
|
|
2568
|
+
if (this.inTurn) { fail('Claude is still working. Stop the turn first.'); return; }
|
|
2569
|
+
// The turn (see _newTurn) is given up while this waits on the disk and
|
|
2570
|
+
// on the CLI, and other messages are handled meanwhile. One that moved
|
|
2571
|
+
// the tab to another conversation, or started a turn, stops the rewind:
|
|
2572
|
+
// going on would rewind the wrong conversation, or end that turn.
|
|
2573
|
+
const moves = this._conversationMoves || 0;
|
|
2574
|
+
const pushedAt = this._lastPushAt;
|
|
2575
|
+
const MOVED_ON = 'The tab moved to another conversation while this rewind was under way, so the conversation was not rewound.';
|
|
2576
|
+
// Where to resume is the entry the prompt follows in the transcript. The
|
|
2577
|
+
// panel's own answer comes from the rows it rendered, and a turn can end
|
|
2578
|
+
// on an entry it has no row for (a tool-result carrier, an attachment):
|
|
2579
|
+
// resuming before that would drop the kept turn's last output. So when
|
|
2580
|
+
// the host can read the transcript, only its answer is used: a lookup
|
|
2581
|
+
// that fails or does not find the prompt refuses the rewind before a
|
|
2582
|
+
// file is touched or the session restarted. The panel's value stands
|
|
2583
|
+
// only for a host that wired no lookup.
|
|
2584
|
+
if (typeof deps.transcriptParentOf === 'function') {
|
|
2585
|
+
if (typeof msg.userMessageUuid !== 'string' || !msg.userMessageUuid) { fail('The rewind did not say which prompt to go back to.'); return; }
|
|
2586
|
+
let parent = null;
|
|
2587
|
+
turn?.release();
|
|
2588
|
+
try { parent = await deps.transcriptParentOf({ sessionId: this.sessionId, uuid: msg.userMessageUuid, cwd: this.cwd, accountId: this.accountId || '' }); } catch { parent = null; }
|
|
2589
|
+
await turn?.take();
|
|
2590
|
+
if (parent === '') { fail('This is the first message of the conversation: there is nothing before it to go back to.'); return; }
|
|
2591
|
+
if (typeof parent !== 'string' || !parent) { fail('The transcript of this session could not be read to find what comes before that message, so nothing was rewound. Try again once the turn is written, or check that the tab\'s project is the one the session belongs to.'); return; }
|
|
2592
|
+
at = parent;
|
|
2593
|
+
if (this.inTurn || this.destroyed) { fail('Claude is still working. Stop the turn first.'); return; }
|
|
2594
|
+
if ((this._conversationMoves || 0) !== moves) { fail(MOVED_ON); return; }
|
|
2595
|
+
}
|
|
2596
|
+
let files = null;
|
|
2597
|
+
if (typeof msg.userMessageUuid === 'string' && msg.userMessageUuid) {
|
|
2598
|
+
if (!this.q) this.ensureQuery();
|
|
2599
|
+
if (!this.q) { fail(this._startRefused?.message || 'No active session', this._startRefused?.code); return; }
|
|
2600
|
+
turn?.release();
|
|
2601
|
+
try {
|
|
2602
|
+
const r = await this.q.rewindFiles(msg.userMessageUuid);
|
|
2603
|
+
if (r && r.canRewind === false) { fail(r.error || 'Files cannot be rewound to this message'); return; }
|
|
2604
|
+
files = slimRewind(r);
|
|
2605
|
+
} catch (err) { fail(err.message); return; }
|
|
2606
|
+
await turn?.take();
|
|
2607
|
+
if (this.destroyed) return;
|
|
2608
|
+
// The files are restored; the conversation is rewound only when
|
|
2609
|
+
// nothing else happened to the session meanwhile (see `moves` above).
|
|
2610
|
+
if ((this._conversationMoves || 0) !== moves) { fail(`${MOVED_ON} The files were restored.`); return; }
|
|
2611
|
+
// (A turn the CLI started by itself is ended by the restart, as it always was: only one a message started stops the rewind.)
|
|
2612
|
+
if (this.inTurn && this._lastPushAt !== pushedAt) { fail('Claude started working while the files were being restored, so the conversation was not rewound. The files were restored: stop the turn, then rewind again.'); return; }
|
|
2613
|
+
}
|
|
2614
|
+
{ const waiting = turn?.take(); if (waiting) await waiting; }
|
|
2615
|
+
await this._endQuery({ graceful: true });
|
|
2616
|
+
if (this.destroyed) return;
|
|
2617
|
+
this._resumeAt = at;
|
|
2618
|
+
this.ensureQuery();
|
|
2619
|
+
if (this._startRefused) { fail(this._startRefused.message, this._startRefused.code); return; }
|
|
2620
|
+
this.send({ type: 'rewind_conversation_result', ok: true, messageUuid: at, userMessageUuid: msg.userMessageUuid || '', ...(files ? { fileCount: files.fileCount, insertions: files.insertions, deletions: files.deletions } : {}) });
|
|
2621
|
+
return;
|
|
2622
|
+
}
|
|
2623
|
+
case 'session_request': {
|
|
2624
|
+
// Panel sessions only: one of the Query's control calls, answered as a card's data.
|
|
2625
|
+
if (!this.panel) return;
|
|
2626
|
+
const id = typeof msg.id === 'string' ? msg.id.slice(0, 80) : '';
|
|
2627
|
+
const what = SESSION_REQUESTS.includes(msg.what) ? msg.what : '';
|
|
2628
|
+
if (!id || !what) return;
|
|
2629
|
+
// The session's own record of granted permission rules, and dropping the
|
|
2630
|
+
// session-scoped ones. The SDK has no call that removes a live rule: they
|
|
2631
|
+
// live in the CLI process, so forgetting them ends it (the next message
|
|
2632
|
+
// resumes the conversation in a new one).
|
|
2633
|
+
if (what === 'permission_rules') { this.send({ type: 'session_response', id, what, ok: true, data: { granted: [...this._grantedRules] } }); return; }
|
|
2634
|
+
// A temporary chat: forgetting the session's rules ends its process, and a rewind needs checkpoints it does not keep.
|
|
2635
|
+
if (this.temporary && (what === 'forget_session_rules' || what === 'rewind_preview')) {
|
|
2636
|
+
this.send({ type: 'session_response', id, what, ok: false, code: 'temporary', error: what === 'rewind_preview' ? TEMPORARY_NO_REWIND : `${TEMPORARY_NO_RESTART} The rules granted here end with it.` });
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
if (what === 'forget_session_rules') {
|
|
2640
|
+
if (this.inTurn || this.pendingPerms.size) { this.send({ type: 'session_response', id, what, ok: false, error: 'Claude is still working. Stop the turn first.' }); return; }
|
|
2641
|
+
// Ending the process also ends what runs in it without a turn:
|
|
2642
|
+
// background tasks and scheduled wake-ups. That is said, and done only
|
|
2643
|
+
// when the request confirms it.
|
|
2644
|
+
if (this.q && this._hasPendingWork() && msg.args?.confirm !== true) {
|
|
2645
|
+
const backgroundTasks = [...this._bgTasks.values()].slice(0, 50).map(t => ({ task_id: String(t?.task_id || ''), task_type: String(t?.task_type || ''), description: String(t?.description || '').slice(0, 300) }));
|
|
2646
|
+
const wakeups = this._cronList().slice(0, 50);
|
|
2647
|
+
const parts = [];
|
|
2648
|
+
if (backgroundTasks.length) parts.push(`${backgroundTasks.length} background task${backgroundTasks.length === 1 ? '' : 's'}`);
|
|
2649
|
+
if (wakeups.length) parts.push(`${wakeups.length} scheduled wake-up${wakeups.length === 1 ? '' : 's'}`);
|
|
2650
|
+
this.send({ type: 'session_response', id, what, ok: false, code: 'confirm_required', error: `Forgetting the rules restarts the session, which ends ${parts.join(' and ')}. Confirm to go on.`, data: { backgroundTasks, wakeups } });
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
const restarted = !!this.q;
|
|
2654
|
+
{ const waiting = turn?.take(); if (waiting) await waiting; }
|
|
2655
|
+
if (this.q) await this._endQuery({ graceful: true });
|
|
2656
|
+
this._grantedRules = this._grantedRules.filter(u => u.destination && u.destination !== 'session');
|
|
2657
|
+
this.send({ type: 'session_response', id, what, ok: true, data: { granted: [...this._grantedRules], restarted } });
|
|
2658
|
+
return;
|
|
2659
|
+
}
|
|
2660
|
+
// The tab's own remote MCP servers, changed on the live session. The call
|
|
2661
|
+
// replaces the dynamic set, so the host's entries (SynaBun) are always
|
|
2662
|
+
// part of it; the tab's settings are updated to match, so the next
|
|
2663
|
+
// message does not restart the session over a change that is already live.
|
|
2664
|
+
if (what === 'mcp_set_servers') {
|
|
2665
|
+
const args = msg.args && typeof msg.args === 'object' ? msg.args : {};
|
|
2666
|
+
const q = this.q;
|
|
2667
|
+
try {
|
|
2668
|
+
if (!q) throw new Error('No active session. The servers start with the session.');
|
|
2669
|
+
if (typeof q.setMcpServers !== 'function') throw new Error('Not supported by this Claude Code version');
|
|
2670
|
+
const current = this.panelSession || normalizePanelSession(null);
|
|
2671
|
+
const start = { ...current.start, mcpServers: normalizeMcpServers(args.servers) };
|
|
2672
|
+
const merged = mergeTabMcpServers(this._hostMcpServers, start);
|
|
2673
|
+
const r = await q.setMcpServers(merged.servers);
|
|
2674
|
+
if (this.q === q) this.panelSession = { ...current, start };
|
|
2675
|
+
const errors = r?.errors && typeof r.errors === 'object' ? Object.fromEntries(Object.entries(r.errors).slice(0, 20).map(([k, v]) => [String(k).slice(0, 80), String(v).slice(0, 300)])) : {};
|
|
2676
|
+
const servers = typeof q.mcpServerStatus === 'function' ? slimMcpStatus(await q.mcpServerStatus()) : [];
|
|
2677
|
+
this.send({ type: 'session_response', id, what, ok: true, data: { added: Array.isArray(r?.added) ? r.added : [], removed: Array.isArray(r?.removed) ? r.removed : [], errors, notes: merged.notes, applied: start.mcpServers, servers } });
|
|
2678
|
+
if (servers.length && this.q === q) this.sendEvent({ type: 'system', subtype: 'mcp_status', servers });
|
|
2679
|
+
} catch (err) {
|
|
2680
|
+
this.send({ type: 'session_response', id, what, ok: false, error: err?.message || String(err) });
|
|
2681
|
+
}
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
// A rewind preview on a session the idle reaper ended: resume it, as rewind itself does.
|
|
2685
|
+
if (!this.q && what === 'rewind_preview' && this.sessionId) {
|
|
2686
|
+
this.ensureQuery();
|
|
2687
|
+
if (!this.q && this._startRefused) { this.send({ type: 'session_response', id, what, ok: false, code: this._startRefused.code, error: this._startRefused.message }); return; }
|
|
2688
|
+
}
|
|
2689
|
+
const q = this.q;
|
|
2690
|
+
// (A rewind preview holds a turn until here: given up for the CLI's answer, see _newTurn.)
|
|
2691
|
+
turn?.release();
|
|
2692
|
+
try {
|
|
2693
|
+
const data = await runSessionRequest(q, what, msg.args && typeof msg.args === 'object' ? msg.args : {});
|
|
2694
|
+
this.send({ type: 'session_response', id, what, ok: true, data });
|
|
2695
|
+
// The statusline's dots follow a reconnect or a toggle.
|
|
2696
|
+
if (data?.servers && this.q === q) this.sendEvent({ type: 'system', subtype: 'mcp_status', servers: data.servers });
|
|
2697
|
+
} catch (err) {
|
|
2698
|
+
this.send({ type: 'session_response', id, what, ok: false, error: err?.message || String(err) });
|
|
2699
|
+
}
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
case 'stop_task':
|
|
2703
|
+
case 'background_tasks': {
|
|
2704
|
+
// Panel sessions only: stop one background task, or send running
|
|
2705
|
+
// foreground work (one tool call, or all of it) to the background.
|
|
2706
|
+
if (!this.panel) return;
|
|
2707
|
+
const action = msg.type;
|
|
2708
|
+
const q = this.q;
|
|
2709
|
+
if (!q) { this.send({ type: 'task_control_result', action, ok: false, error: 'No active session' }); return; }
|
|
2710
|
+
try {
|
|
2711
|
+
if (action === 'stop_task') {
|
|
2712
|
+
const taskId = typeof msg.taskId === 'string' ? msg.taskId : '';
|
|
2713
|
+
if (!taskId || typeof q.stopTask !== 'function') throw new Error(taskId ? 'Not supported by this Claude Code version' : 'Missing task id');
|
|
2714
|
+
await q.stopTask(taskId);
|
|
2715
|
+
this.send({ type: 'task_control_result', action, ok: true, taskId });
|
|
2716
|
+
} else {
|
|
2717
|
+
if (typeof q.backgroundTasks !== 'function') throw new Error('Not supported by this Claude Code version');
|
|
2718
|
+
const toolUseId = typeof msg.toolUseId === 'string' && msg.toolUseId ? msg.toolUseId : undefined;
|
|
2719
|
+
const moved = await q.backgroundTasks(toolUseId);
|
|
2720
|
+
this.send({ type: 'task_control_result', action, ok: moved !== false, ...(moved === false ? { error: 'Nothing running in the foreground matched' } : {}) });
|
|
2721
|
+
}
|
|
2722
|
+
} catch (err) {
|
|
2723
|
+
this.send({ type: 'task_control_result', action, ok: false, error: err.message });
|
|
2724
|
+
}
|
|
2725
|
+
return;
|
|
2726
|
+
}
|
|
2727
|
+
case 'reload_plugins': {
|
|
2728
|
+
// Panel sessions only: pick up plugins installed or changed since the session started.
|
|
2729
|
+
if (!this.panel) return;
|
|
2730
|
+
if (!this.q || typeof this.q.reloadPlugins !== 'function') {
|
|
2731
|
+
this.send({ type: 'reload_result', what: 'plugins', ok: false, error: 'No active session yet. Plugins load fresh when the session starts.' });
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
const q = this.q;
|
|
2735
|
+
try {
|
|
2736
|
+
const r = await q.reloadPlugins();
|
|
2737
|
+
if (this.q !== q) return;
|
|
2738
|
+
if (Array.isArray(r?.commands)) this.sendEvent({ type: 'system', subtype: 'commands_list', commands: r.commands });
|
|
2739
|
+
if (Array.isArray(r?.mcpServers)) this.sendEvent({ type: 'system', subtype: 'mcp_status', servers: r.mcpServers });
|
|
2740
|
+
const plugins = Array.isArray(r?.plugins) ? r.plugins.map(p => ({ name: p?.name || '', version: p?.version || '', source: p?.source || '' })) : [];
|
|
2741
|
+
this.sendEvent({ type: 'system', subtype: 'plugins_list', plugins, agents: Array.isArray(r?.agents) ? r.agents : [], errorCount: Number(r?.error_count) || 0 });
|
|
2742
|
+
this.send({ type: 'reload_result', what: 'plugins', ok: true, count: plugins.length, errorCount: Number(r?.error_count) || 0 });
|
|
2743
|
+
} catch (err) {
|
|
2744
|
+
this.send({ type: 'reload_result', what: 'plugins', ok: false, error: err.message });
|
|
2745
|
+
}
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
case 'heartbeat': {
|
|
2749
|
+
if (msg.windowId) this.windowId = msg.windowId;
|
|
2750
|
+
if (msg.sessionId && this.windowId && !this.temporary) {
|
|
2751
|
+
try { deps.heartbeatLock(msg.sessionId, this.windowId); } catch {}
|
|
2752
|
+
}
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
default:
|
|
2756
|
+
// 'reattach' handled at the connection layer
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
// `establishOnly` (with `warmOnly`; panel sessions, see _establishFromControl):
|
|
2762
|
+
// apply the configuration of a query that has no prompt, and start nothing.
|
|
2763
|
+
_handleQuery(msg, { warmOnly = false, establishOnly = false } = {}) {
|
|
2764
|
+
let { prompt, cwd, sessionId, model, effort, images, windowId, permissionMode } = msg;
|
|
2765
|
+
if (warmOnly) { prompt = ''; images = null; }
|
|
2766
|
+
if (windowId) this.windowId = windowId;
|
|
2767
|
+
if (model && model.includes(':')) model = deps.toCliModelName(model);
|
|
2768
|
+
if (!warmOnly && !prompt && !images?.length) {
|
|
2769
|
+
this.send({ type: 'error', message: 'No prompt provided' });
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
// Panel sessions: the account comes first. An account that is no longer set
|
|
2773
|
+
// up, or another account in the middle of a conversation, refuses the query
|
|
2774
|
+
// before a lock is taken or a process is started.
|
|
2775
|
+
let queryAccount = null;
|
|
2776
|
+
if (this.panel) {
|
|
2777
|
+
queryAccount = this._resolveQueryAccount(msg.accountId, sessionId);
|
|
2778
|
+
if (queryAccount.code) {
|
|
2779
|
+
if (!warmOnly) this.send({ type: 'error', code: queryAccount.code, message: queryAccount.message });
|
|
2780
|
+
if (establishOnly) this._coldRefusal = { code: queryAccount.code, message: queryAccount.message };
|
|
2781
|
+
return;
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
// Panel sessions: a temporary chat is chosen with the first message of a
|
|
2786
|
+
// conversation and fixed from there (see _takeTemporary). A message that
|
|
2787
|
+
// cannot be answered as one is refused before anything is applied.
|
|
2788
|
+
if (this.panel) {
|
|
2789
|
+
let refused = this._takeTemporary(msg, sessionId);
|
|
2790
|
+
if (!refused && this.temporary && this.q && !warmOnly) {
|
|
2791
|
+
const why = this._temporaryRestartReason(msg, { cwd, effort, account: queryAccount.account });
|
|
2792
|
+
if (why) refused = { code: 'temporary_restart', message: `${TEMPORARY_NO_RESTART} So ${why} cannot change here: put it back, or start a new chat with the new setting.` };
|
|
2793
|
+
}
|
|
2794
|
+
if (refused) {
|
|
2795
|
+
if (!warmOnly) { this.send({ type: 'error', code: refused.code, message: refused.message }); this.sendDone(-1); }
|
|
2796
|
+
if (establishOnly) this._coldRefusal = refused;
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
// Session lock — identical to legacy (a warm start takes none: nothing is sent yet)
|
|
2802
|
+
// (A temporary chat takes none: no other window can open it.)
|
|
2803
|
+
if (sessionId && this.windowId && (!warmOnly || establishOnly) && !this.temporary) {
|
|
2804
|
+
try {
|
|
2805
|
+
const lockResult = deps.acquireSessionLock(sessionId, this.windowId, null);
|
|
2806
|
+
if (!lockResult.ok) {
|
|
2807
|
+
if (establishOnly) { this._coldRefusal = { code: 'session_locked', message: 'Session locked by another window' }; return; }
|
|
2808
|
+
this.send({ type: 'error', message: 'Session locked by another window' });
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
} catch {}
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
// SynaBun skill injection runs FIRST; unrecognized /commands pass through to the CLI
|
|
2815
|
+
if (prompt && prompt.startsWith('/')) {
|
|
2816
|
+
try {
|
|
2817
|
+
const injected = deps.maybeInjectSkillPrompt(prompt);
|
|
2818
|
+
if (injected.command === 'clear' && !injected.skill) return; // client-only
|
|
2819
|
+
if (injected.skill) {
|
|
2820
|
+
log('skill injection: /' + injected.command);
|
|
2821
|
+
prompt = injected.prompt;
|
|
2822
|
+
}
|
|
2823
|
+
} catch {}
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// Config changes that require a fresh process (cwd/effort map to spawn flags)
|
|
2827
|
+
const cwdChanged = cwd && this.cwd && cwd !== this.cwd;
|
|
2828
|
+
// A session that started without an effort still switches when one is picked.
|
|
2829
|
+
let effortChanged = !!effort && effort !== this.effort;
|
|
2830
|
+
const sessionChanged = sessionId && this.sessionId && sessionId !== this.sessionId;
|
|
2831
|
+
// No sessionId from a tab whose session has one: the tab started a new chat
|
|
2832
|
+
// (New chat, project switch). Continuing here would answer it with the old
|
|
2833
|
+
// conversation's context — or resume the old session after an idle reap.
|
|
2834
|
+
const newChat = !sessionId && !!this.sessionId;
|
|
2835
|
+
// (A panel tab that started on the default model can still pick one later.)
|
|
2836
|
+
const modelChanged = model && (this.model || this.panel) && model !== this.model;
|
|
2837
|
+
// The env is fixed at spawn, so flipping judgments on a live query needs a fresh process.
|
|
2838
|
+
const typesafeOff = msg.typesafe === undefined ? !!this.typesafeOff : msg.typesafe === 'off';
|
|
2839
|
+
const typesafeChanged = !!this.q && typesafeOff !== !!this.typesafeOff;
|
|
2840
|
+
this.typesafeOff = typesafeOff;
|
|
2841
|
+
// Panel sessions: what the client can render, and the tab's tool policy.
|
|
2842
|
+
// Removing tools is a start-up option, so a change needs a fresh process.
|
|
2843
|
+
let policyChanged = false;
|
|
2844
|
+
if (this.panel) {
|
|
2845
|
+
if (Array.isArray(msg.features)) this.clientFeatures = new Set(msg.features.filter(f => typeof f === 'string'));
|
|
2846
|
+
if (typeof msg.title === 'string') this._title = msg.title.trim().slice(0, 200);
|
|
2847
|
+
// The account is part of the process environment: a change restarts the
|
|
2848
|
+
// session (a conversation of one account cannot be resumed under another;
|
|
2849
|
+
// the lost-session recovery starts a fresh one and says so).
|
|
2850
|
+
// (Validated above, in _resolveQueryAccount.) Each reason to restart is
|
|
2851
|
+
// added to the others: a later unchanged setting must not cancel it.
|
|
2852
|
+
if (msg.accountId !== undefined) {
|
|
2853
|
+
const account = queryAccount.account;
|
|
2854
|
+
if (this.q && account !== (this.accountId || '')) policyChanged = true;
|
|
2855
|
+
this.accountId = account;
|
|
2856
|
+
}
|
|
2857
|
+
// A message (not a warm start) is what makes the conversation this account's.
|
|
2858
|
+
// (A control's configuration binds nothing by itself: the process it starts does, in ensureQuery.)
|
|
2859
|
+
if (!warmOnly) this._accountBound = true;
|
|
2860
|
+
// From here the session has its tab's configuration: it may start a process.
|
|
2861
|
+
this._established = true;
|
|
2862
|
+
if (msg.toolPolicy !== undefined) {
|
|
2863
|
+
const policy = TOOL_POLICY_DISALLOW[msg.toolPolicy] ? msg.toolPolicy : null;
|
|
2864
|
+
if (this.q && policy !== (this.toolPolicy || null)) policyChanged = true;
|
|
2865
|
+
this.toolPolicy = policy;
|
|
2866
|
+
}
|
|
2867
|
+
if (msg.session !== undefined) {
|
|
2868
|
+
const next = normalizePanelSession(msg.session);
|
|
2869
|
+
// What is fixed at start changed: the session restarts with it.
|
|
2870
|
+
if (this.q && startSignature(next) !== startSignature(this.panelSession || normalizePanelSession(null))) policyChanged = true;
|
|
2871
|
+
this.panelSession = next;
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
// Panel sessions: counted, for a control that waits and must know the tab
|
|
2876
|
+
// stayed where it was (the conversation rewind).
|
|
2877
|
+
if (this.panel && (newChat || sessionChanged)) this._conversationMoves = (this._conversationMoves || 0) + 1;
|
|
2878
|
+
if (newChat) this.sessionId = null;
|
|
2879
|
+
if (sessionId) this.sessionId = sessionId;
|
|
2880
|
+
if (cwd) this.cwd = cwd;
|
|
2881
|
+
// A panel tab that sends no effort means "the model's default": a level that
|
|
2882
|
+
// was set goes back to it, live when a session exists.
|
|
2883
|
+
const effortCleared = this.panel && !effort && !!this.effort;
|
|
2884
|
+
if (effort) this.effort = effort;
|
|
2885
|
+
else if (effortCleared) this.effort = null;
|
|
2886
|
+
if (model) this.model = model;
|
|
2887
|
+
// Panel sessions: a tab in Bypass where no process can be launched able to
|
|
2888
|
+
// take it runs in Default, and is told (see panelBypassBlock).
|
|
2889
|
+
// (Panel sessions: the mode a message states is a numbered statement like
|
|
2890
|
+
// any other. One older than the statement applied states no mode; the rest
|
|
2891
|
+
// of the message stands.)
|
|
2892
|
+
const statesMode = !this.panel || this._takeStatement(msg.modeSeq);
|
|
2893
|
+
if (!statesMode) { permissionMode = ''; this._statementDropped(); }
|
|
2894
|
+
if (this.panel && permissionMode === BYPASS) {
|
|
2895
|
+
const block = panelBypassBlock();
|
|
2896
|
+
if (block) { permissionMode = 'default'; this.sendEvent(this._numbered({ type: 'mode_changed', mode: 'default', reason: block })); }
|
|
2897
|
+
}
|
|
2898
|
+
let backToSettings = null;
|
|
2899
|
+
if (permissionMode && (this.panel ? PANEL_PERMISSION_MODES : CLASSIC_PERMISSION_MODES).includes(permissionMode)) {
|
|
2900
|
+
this.permissionMode = permissionMode;
|
|
2901
|
+
this._modeFromSettings = false;
|
|
2902
|
+
if (this.panel && permissionMode === BYPASS) this._bypassHeld = true;
|
|
2903
|
+
this._stateMode();
|
|
2904
|
+
} else if (this.panel && statesMode && !permissionMode && msg.modeFromSettings === true) {
|
|
2905
|
+
// The tab never picked a mode: the user's settings decide (permissions.defaultMode).
|
|
2906
|
+
// A live process that was put in a stated mode meanwhile (the tab was in
|
|
2907
|
+
// plan mode and left it) goes back to the settings' mode now.
|
|
2908
|
+
if (this.q && !warmOnly && this._modeFromSettings !== true) backToSettings = this._returnToSettings();
|
|
2909
|
+
this._modeFromSettings = true;
|
|
2910
|
+
this._stateMode();
|
|
2911
|
+
}
|
|
2912
|
+
if (warmOnly) {
|
|
2913
|
+
// Configuration only: no prompt, no turn. (No live query here: see the `warm` case.)
|
|
2914
|
+
// (A starting control's configuration starts nothing; a process kept ready for another one is ended.)
|
|
2915
|
+
if (establishOnly) this._warmStillFits();
|
|
2916
|
+
if (!establishOnly && !this.q && !this.destroyed) this.ensureQuery({ warm: true });
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
this.lastPrompt = prompt;
|
|
2920
|
+
|
|
2921
|
+
// Effort alone switches on the live CLI. A restart would cold-start the
|
|
2922
|
+
// session and kill any background agents it is running.
|
|
2923
|
+
if (this.q && effortChanged && !cwdChanged && !sessionChanged && !newChat
|
|
2924
|
+
&& typeof this.q.applyFlagSettings === 'function') {
|
|
2925
|
+
const effortLevel = CLAUDE_EFFORT_LEVELS.includes(effort) ? effort : null;
|
|
2926
|
+
this.q.applyFlagSettings({ effortLevel }).catch(err => log('applyFlagSettings(effort) failed:', err.message));
|
|
2927
|
+
effortChanged = false;
|
|
2928
|
+
}
|
|
2929
|
+
if (this.q && this.panel && !cwdChanged && !sessionChanged && !newChat && !policyChanged
|
|
2930
|
+
&& typeof this.q.applyFlagSettings === 'function') {
|
|
2931
|
+
// Fast mode, output style and the main-thread agent switch on the live
|
|
2932
|
+
// session; so does a return to the default effort.
|
|
2933
|
+
const patch = liveSettingsPatch(this._liveApplied, this.panelSession?.live) || {};
|
|
2934
|
+
if (effortCleared) patch.effortLevel = null;
|
|
2935
|
+
if (Object.keys(patch).length) {
|
|
2936
|
+
this._liveApplied = { ...(this.panelSession?.live || {}) };
|
|
2937
|
+
this.q.applyFlagSettings(patch).catch(err => {
|
|
2938
|
+
log('applyFlagSettings(session) failed:', err.message);
|
|
2939
|
+
this.send({ type: 'stderr', text: `Could not apply the session settings: ${err.message}` });
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
if (this.q && (cwdChanged || effortChanged || sessionChanged || newChat || typesafeChanged || policyChanged)) {
|
|
2945
|
+
log(`config change (cwd=${!!cwdChanged} effort=${!!effortChanged} session=${!!sessionChanged} newChat=${newChat} typesafe=${typesafeChanged}${policyChanged ? ' toolPolicy=true' : ''}) — recreating query`);
|
|
2946
|
+
const restarted = this._endQuery({ graceful: true }).then(() => {
|
|
2947
|
+
if (this.destroyed) return;
|
|
2948
|
+
this.ensureQuery();
|
|
2949
|
+
this._pushUserText(prompt, images, { human: this.panel && msg.typed === true });
|
|
2950
|
+
});
|
|
2951
|
+
// Panel sessions: the message holds its turn until the restart is done
|
|
2952
|
+
// (handleMessage waits on what this returns), so the next message meets
|
|
2953
|
+
// the new process and not the gap before it.
|
|
2954
|
+
return this.panel ? restarted : undefined;
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
const hadQuery = !!this.q;
|
|
2958
|
+
this._pushUserText(prompt, images, { human: this.panel && msg.typed === true });
|
|
2959
|
+
|
|
2960
|
+
if (hadQuery && modelChanged && this.q) {
|
|
2961
|
+
this.q.setModel(model).catch(err => log('setModel failed:', err.message));
|
|
2962
|
+
}
|
|
2963
|
+
if (hadQuery && permissionMode && this.q) {
|
|
2964
|
+
const q = this.q;
|
|
2965
|
+
const stated = this.permissionMode;
|
|
2966
|
+
const stamp = this._modeStatements;
|
|
2967
|
+
// (Panel sessions: a Bypass the CLI refuses leaves the tab in Default, and it is told,
|
|
2968
|
+
// unless the mode was stated again or the process replaced meanwhile.)
|
|
2969
|
+
(this.panel ? this._switchMode(q, stated) : q.setPermissionMode(stated)).catch((err) => { if (this.panel && stated === BYPASS && this._modeSwitchStands(q, stamp)) this._bypassRefused(q, err?.message); });
|
|
2970
|
+
}
|
|
2971
|
+
if (hadQuery && backToSettings?.mode && this.q) {
|
|
2972
|
+
const q = this.q;
|
|
2973
|
+
const stamp = this._modeStatements;
|
|
2974
|
+
this._switchMode(q, backToSettings.mode).catch((err) => { if (backToSettings.mode === BYPASS && this._modeSwitchStands(q, stamp)) this._bypassRefused(q, err?.message); });
|
|
2975
|
+
if (backToSettings.reason) this.sendEvent(this._numbered({ type: 'mode_changed', mode: backToSettings.mode, fromSettings: true, reason: backToSettings.reason }));
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
// ── detach / reattach / destroy ───────────────────────────────────────────
|
|
2980
|
+
|
|
2981
|
+
detach() {
|
|
2982
|
+
if (this.destroyed) return false;
|
|
2983
|
+
// A temporary chat whose page is gone is over: it is never kept for a reattach.
|
|
2984
|
+
if (this.temporary) return false;
|
|
2985
|
+
// Background work counts: a page reload must not kill agents that are still
|
|
2986
|
+
// running after their turn ended — the reloaded tab reattaches to them.
|
|
2987
|
+
const busy = this.inTurn || this.pendingPerms.size > 0 || this._hasPendingWork();
|
|
2988
|
+
if (!this.windowId || !busy || !this.q) return false;
|
|
2989
|
+
this.orphanBuffer = [];
|
|
2990
|
+
this.orphanResynced = false;
|
|
2991
|
+
this.ws = null;
|
|
2992
|
+
const key = _orphanKey(this.windowId, this.sessionId);
|
|
2993
|
+
_detached.set(key, this);
|
|
2994
|
+
this.orphanKillTimer = setTimeout(() => {
|
|
2995
|
+
log(`orphan grace expired for ${key} — destroying session`);
|
|
2996
|
+
_detached.delete(key);
|
|
2997
|
+
this.destroy();
|
|
2998
|
+
}, ORPHAN_GRACE_MS);
|
|
2999
|
+
log(`detached session ${key} (turn in flight: ${this.inTurn}, pending perms: ${this.pendingPerms.size}, background tasks: ${this._bgTasks.size}, crons: ${this._sessionCrons.length})`);
|
|
3000
|
+
return true;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
reattach(ws) {
|
|
3004
|
+
clearTimeout(this.orphanKillTimer);
|
|
3005
|
+
this.orphanKillTimer = null;
|
|
3006
|
+
this.ws = ws;
|
|
3007
|
+
const buffer = this.orphanBuffer || [];
|
|
3008
|
+
this.orphanBuffer = null;
|
|
3009
|
+
// reattach_result FIRST: the client must restore running/session state before
|
|
3010
|
+
// any replayed control_request re-arms its permission buffering.
|
|
3011
|
+
const result = {
|
|
3012
|
+
type: 'reattach_result',
|
|
3013
|
+
ok: true,
|
|
3014
|
+
sessionId: this.sessionId,
|
|
3015
|
+
running: this.inTurn || this.pendingPerms.size > 0,
|
|
3016
|
+
backgroundTasks: [...this._bgTasks.values()],
|
|
3017
|
+
};
|
|
3018
|
+
// A reloaded page lost its task and wakeup lists: hand both back in full
|
|
3019
|
+
// (backgroundTasks above; the wakeups with their schedule and prompt).
|
|
3020
|
+
if (this.panel) { result.sessionCrons = this._sessionCrons.length; result.sessionCronList = this._cronList(); result.grantedRules = [...this._grantedRules]; }
|
|
3021
|
+
// (A page that numbers its statements: the mode this session is in, and the
|
|
3022
|
+
// number of the latest statement it applied. The page says its own again
|
|
3023
|
+
// when that is behind.
|
|
3024
|
+
// What it is in is the mode the CLI acknowledged or reported, with a switch
|
|
3025
|
+
// under way named beside it and whether the session may be in Bypass.)
|
|
3026
|
+
if (this.panel && this._pageSeq !== undefined) { Object.assign(result, this._modeFacts()); if (this._modeFromSettings) result.fromSettings = true; }
|
|
3027
|
+
if (this.orphanResynced) result.resynced = true;
|
|
3028
|
+
this.orphanResynced = false;
|
|
3029
|
+
if (ws.readyState === 1) { try { ws.send(JSON.stringify(result)); } catch {} }
|
|
3030
|
+
// A reloaded page lost the cards for requests issued before the socket
|
|
3031
|
+
// dropped, and the turn would wait on them forever. Re-send every pending one;
|
|
3032
|
+
// the panel ignores a request_id it already shows.
|
|
3033
|
+
for (const entry of this.pendingPerms.values()) {
|
|
3034
|
+
if (entry.wire && ws.readyState === 1) { try { ws.send(JSON.stringify(entry.wire)); } catch {} }
|
|
3035
|
+
}
|
|
3036
|
+
// Chunked replay — serializing + sending up to ORPHAN_BUFFER_CAP events in
|
|
3037
|
+
// one tick stalls every other session on this single-threaded server.
|
|
3038
|
+
// ~50 events per slice, pausing while the socket buffer is saturated.
|
|
3039
|
+
// Live events generated mid-replay queue behind the buffered ones
|
|
3040
|
+
// (this._replaying in send()) so ordering is preserved.
|
|
3041
|
+
this._replaying = true;
|
|
3042
|
+
this._replayQueue = [];
|
|
3043
|
+
const REPLAY_SLICE = 50;
|
|
3044
|
+
let idx = 0;
|
|
3045
|
+
const pump = () => {
|
|
3046
|
+
if (this.destroyed || this.ws !== ws || ws.readyState !== 1) {
|
|
3047
|
+
this._replaying = false;
|
|
3048
|
+
this._replayQueue = null;
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
if (ws.bufferedAmount > WS_BACKPRESSURE_BYTES) {
|
|
3052
|
+
setTimeout(pump, 50);
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
3055
|
+
let n = 0;
|
|
3056
|
+
while (idx < buffer.length && n++ < REPLAY_SLICE) {
|
|
3057
|
+
try { ws.send(JSON.stringify(buffer[idx])); } catch {}
|
|
3058
|
+
idx++;
|
|
3059
|
+
}
|
|
3060
|
+
if (idx < buffer.length) { setImmediate(pump); return; }
|
|
3061
|
+
// Flush events that arrived mid-replay, then resume direct sends
|
|
3062
|
+
const queued = this._replayQueue || [];
|
|
3063
|
+
this._replaying = false;
|
|
3064
|
+
this._replayQueue = null;
|
|
3065
|
+
for (const evt of queued) {
|
|
3066
|
+
if (ws.readyState === 1) { try { ws.send(JSON.stringify(evt)); } catch {} }
|
|
3067
|
+
}
|
|
3068
|
+
log(`reattached session ${_orphanKey(this.windowId, this.sessionId)}, replayed ${buffer.length} events (+${queued.length} live)`);
|
|
3069
|
+
};
|
|
3070
|
+
pump();
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
destroy() {
|
|
3074
|
+
if (this.destroyed) return;
|
|
3075
|
+
this.destroyed = true;
|
|
3076
|
+
this._closeWarm();
|
|
3077
|
+
clearTimeout(this._watchdogStartTimer);
|
|
3078
|
+
clearInterval(this._watchdog);
|
|
3079
|
+
clearInterval(this._lockBeat);
|
|
3080
|
+
clearInterval(this._idleReaper);
|
|
3081
|
+
clearTimeout(this.orphanKillTimer);
|
|
3082
|
+
clearTimeout(this._idleCheck);
|
|
3083
|
+
this._replaying = false;
|
|
3084
|
+
this._replayQueue = null;
|
|
3085
|
+
for (const slot of this._deltaBuf.values()) clearTimeout(slot.timer);
|
|
3086
|
+
this._deltaBuf.clear();
|
|
3087
|
+
this._denyAllPending('Session closed');
|
|
3088
|
+
try { this.input?.close(); } catch {}
|
|
3089
|
+
try { this.abortController?.abort(); } catch {}
|
|
3090
|
+
this.q = null;
|
|
3091
|
+
if (this.temporary) this._removeTemporaryPlans();
|
|
3092
|
+
if (this.windowId) {
|
|
3093
|
+
try { deps.releaseAllLocks(this.windowId); } catch {}
|
|
3094
|
+
const key = _orphanKey(this.windowId, this.sessionId);
|
|
3095
|
+
if (_detached.get(key) === this) _detached.delete(key);
|
|
3096
|
+
}
|
|
3097
|
+
// Release this chat session's owned browser tab so the shared browser can
|
|
3098
|
+
// grace-reap once no owners remain. Best-effort, in-process on the NI side.
|
|
3099
|
+
try { deps.releaseBrowserOwner?.(this.synabunOwnerKey); } catch {}
|
|
3100
|
+
_allSessions.delete(this);
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
// ── connection layer ─────────────────────────────────────────────────────────
|
|
3105
|
+
|
|
3106
|
+
export function createClaudeBridge(ws) {
|
|
3107
|
+
if (!deps) throw new Error('claude-agent-bridge not configured — call configureClaudeBridge(deps) first');
|
|
3108
|
+
|
|
3109
|
+
let session = null;
|
|
3110
|
+
|
|
3111
|
+
// Engine hello — lets the frontend gate SDK-only UI
|
|
3112
|
+
try {
|
|
3113
|
+
ws.send(JSON.stringify({ type: 'engine', engine: 'sdk', sdkVersion: SDK_VERSION, capabilities: panelCapabilities() }));
|
|
3114
|
+
} catch {}
|
|
3115
|
+
|
|
3116
|
+
// Ping/pong keepalive (same cadence as legacy)
|
|
3117
|
+
let alive = true;
|
|
3118
|
+
ws.on('pong', () => { alive = true; });
|
|
3119
|
+
const pingInterval = setInterval(() => {
|
|
3120
|
+
if (!alive) { try { ws.terminate(); } catch {} clearInterval(pingInterval); return; }
|
|
3121
|
+
alive = false;
|
|
3122
|
+
if (ws.readyState === 1) ws.ping();
|
|
3123
|
+
}, 30_000);
|
|
3124
|
+
|
|
3125
|
+
ws.on('message', (raw) => {
|
|
3126
|
+
let msg;
|
|
3127
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
3128
|
+
try {
|
|
3129
|
+
if (msg.type === 'dispose') {
|
|
3130
|
+
// The tab was closed. End its session now instead of orphaning it for a
|
|
3131
|
+
// reattach that no tab will ever send.
|
|
3132
|
+
if (session) { session.destroy(); session = null; }
|
|
3133
|
+
return;
|
|
3134
|
+
}
|
|
3135
|
+
if (msg.type === 'reattach') {
|
|
3136
|
+
const wid = msg.windowId;
|
|
3137
|
+
if (!wid) return;
|
|
3138
|
+
const key = _orphanKey(wid, msg.sessionId);
|
|
3139
|
+
const orphan = _detached.get(key);
|
|
3140
|
+
if (!orphan || orphan.destroyed) {
|
|
3141
|
+
if (orphan) _detached.delete(key);
|
|
3142
|
+
try { ws.send(JSON.stringify({ type: 'reattach_result', ok: false })) } catch {}
|
|
3143
|
+
// No orphan: fall through to a fresh session bound to this windowId
|
|
3144
|
+
if (!session) { session = new ClaudeSession(ws, PANEL_SESSION_OPTS); }
|
|
3145
|
+
session.windowId = wid;
|
|
3146
|
+
if (msg.sessionId) session.sessionId = msg.sessionId;
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
_detached.delete(key);
|
|
3150
|
+
if (session && session !== orphan) session.destroy();
|
|
3151
|
+
session = orphan;
|
|
3152
|
+
session.reattach(ws);
|
|
3153
|
+
return;
|
|
3154
|
+
}
|
|
3155
|
+
if (!session) {
|
|
3156
|
+
session = new ClaudeSession(ws, PANEL_SESSION_OPTS);
|
|
3157
|
+
}
|
|
3158
|
+
session.handleMessage(msg);
|
|
3159
|
+
} catch (err) {
|
|
3160
|
+
log('message handling error:', err.message);
|
|
3161
|
+
}
|
|
3162
|
+
});
|
|
3163
|
+
|
|
3164
|
+
ws.on('close', () => {
|
|
3165
|
+
clearInterval(pingInterval);
|
|
3166
|
+
if (!session) return;
|
|
3167
|
+
if (session.detach()) return; // turn in flight → orphaned for reattach
|
|
3168
|
+
session.destroy();
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
// End one sidepanel session by window + Claude session id (tray ×, or a closed
|
|
3173
|
+
// tab whose socket was already gone). Never touches a session that is still
|
|
3174
|
+
// bound to an open socket — that one belongs to a live tab.
|
|
3175
|
+
export function killSession(windowId, sessionId) {
|
|
3176
|
+
if (!windowId || !sessionId) return false;
|
|
3177
|
+
const target = _detached.get(_orphanKey(windowId, sessionId))
|
|
3178
|
+
|| [..._allSessions].find(s => !s.destroyed && s.windowId === windowId
|
|
3179
|
+
&& s.sessionId === sessionId && !(s.ws && s.ws.readyState === 1));
|
|
3180
|
+
if (!target) return false;
|
|
3181
|
+
target.destroy();
|
|
3182
|
+
return true;
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
// Whether a session has a live CLI process behind it (a tab is using it, or it
|
|
3186
|
+
// is running detached). The server asks before deleting a transcript.
|
|
3187
|
+
export function isSessionLive(sessionId) {
|
|
3188
|
+
if (!sessionId) return false;
|
|
3189
|
+
return [..._allSessions].some(s => !s.destroyed && s.sessionId === sessionId && !!s.q);
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
export function shutdownAllBridges() {
|
|
3193
|
+
for (const session of [..._allSessions]) {
|
|
3194
|
+
try { session.destroy(); } catch {}
|
|
3195
|
+
}
|
|
3196
|
+
_detached.clear();
|
|
3197
|
+
// (A temporary chat's plan files that are still owed: the last chance, with no process waited for.)
|
|
3198
|
+
_removeOwedTemporaryPlans({ all: true });
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
export function bridgeStats() {
|
|
3202
|
+
return { sessions: _allSessions.size, detached: _detached.size, sdkVersion: SDK_VERSION };
|
|
3203
|
+
}
|