@nghyane/arcane 0.1.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 +3 -0
- package/README.md +12 -0
- package/examples/README.md +21 -0
- package/examples/custom-tools/README.md +109 -0
- package/examples/custom-tools/hello/index.ts +20 -0
- package/examples/custom-tools/todo/index.ts +206 -0
- package/examples/extensions/README.md +143 -0
- package/examples/extensions/api-demo.ts +89 -0
- package/examples/extensions/chalk-logger.ts +25 -0
- package/examples/extensions/hello.ts +32 -0
- package/examples/extensions/pirate.ts +43 -0
- package/examples/extensions/plan-mode.ts +550 -0
- package/examples/extensions/reload-runtime.ts +37 -0
- package/examples/extensions/todo.ts +296 -0
- package/examples/extensions/tools.ts +144 -0
- package/examples/extensions/with-deps/index.ts +35 -0
- package/examples/extensions/with-deps/package-lock.json +31 -0
- package/examples/extensions/with-deps/package.json +16 -0
- package/examples/hooks/README.md +56 -0
- package/examples/hooks/auto-commit-on-exit.ts +48 -0
- package/examples/hooks/confirm-destructive.ts +58 -0
- package/examples/hooks/custom-compaction.ts +116 -0
- package/examples/hooks/dirty-repo-guard.ts +51 -0
- package/examples/hooks/file-trigger.ts +40 -0
- package/examples/hooks/git-checkpoint.ts +52 -0
- package/examples/hooks/handoff.ts +150 -0
- package/examples/hooks/permission-gate.ts +33 -0
- package/examples/hooks/protected-paths.ts +29 -0
- package/examples/hooks/qna.ts +119 -0
- package/examples/hooks/status-line.ts +39 -0
- package/examples/sdk/01-minimal.ts +21 -0
- package/examples/sdk/02-custom-model.ts +49 -0
- package/examples/sdk/03-custom-prompt.ts +43 -0
- package/examples/sdk/04-skills.ts +43 -0
- package/examples/sdk/06-extensions.ts +80 -0
- package/examples/sdk/06-hooks.ts +61 -0
- package/examples/sdk/07-context-files.ts +35 -0
- package/examples/sdk/08-prompt-templates.ts +36 -0
- package/examples/sdk/08-slash-commands.ts +41 -0
- package/examples/sdk/09-api-keys-and-oauth.ts +54 -0
- package/examples/sdk/11-sessions.ts +47 -0
- package/examples/sdk/README.md +150 -0
- package/package.json +464 -0
- package/scripts/format-prompts.ts +184 -0
- package/scripts/generate-docs-index.ts +40 -0
- package/scripts/generate-template.ts +32 -0
- package/src/bun-imports.d.ts +22 -0
- package/src/capability/context-file.ts +39 -0
- package/src/capability/extension-module.ts +33 -0
- package/src/capability/extension.ts +47 -0
- package/src/capability/fs.ts +89 -0
- package/src/capability/hook.ts +39 -0
- package/src/capability/index.ts +432 -0
- package/src/capability/instruction.ts +36 -0
- package/src/capability/mcp.ts +60 -0
- package/src/capability/prompt.ts +34 -0
- package/src/capability/rule.ts +223 -0
- package/src/capability/settings.ts +34 -0
- package/src/capability/skill.ts +48 -0
- package/src/capability/slash-command.ts +39 -0
- package/src/capability/ssh.ts +41 -0
- package/src/capability/system-prompt.ts +34 -0
- package/src/capability/tool.ts +37 -0
- package/src/capability/types.ts +156 -0
- package/src/cli/args.ts +259 -0
- package/src/cli/config-cli.ts +357 -0
- package/src/cli/file-processor.ts +124 -0
- package/src/cli/grep-cli.ts +152 -0
- package/src/cli/jupyter-cli.ts +106 -0
- package/src/cli/list-models.ts +103 -0
- package/src/cli/plugin-cli.ts +661 -0
- package/src/cli/session-picker.ts +42 -0
- package/src/cli/setup-cli.ts +376 -0
- package/src/cli/shell-cli.ts +174 -0
- package/src/cli/ssh-cli.ts +179 -0
- package/src/cli/stats-cli.ts +197 -0
- package/src/cli/update-cli.ts +286 -0
- package/src/cli/web-search-cli.ts +143 -0
- package/src/cli.ts +65 -0
- package/src/commands/commit.ts +36 -0
- package/src/commands/config.ts +51 -0
- package/src/commands/grep.ts +41 -0
- package/src/commands/jupyter.ts +32 -0
- package/src/commands/launch.ts +139 -0
- package/src/commands/plugin.ts +70 -0
- package/src/commands/setup.ts +42 -0
- package/src/commands/shell.ts +29 -0
- package/src/commands/ssh.ts +60 -0
- package/src/commands/stats.ts +29 -0
- package/src/commands/update.ts +21 -0
- package/src/commands/web-search.ts +42 -0
- package/src/commit/agentic/agent.ts +311 -0
- package/src/commit/agentic/fallback.ts +96 -0
- package/src/commit/agentic/index.ts +359 -0
- package/src/commit/agentic/prompts/analyze-file.md +22 -0
- package/src/commit/agentic/prompts/session-user.md +25 -0
- package/src/commit/agentic/prompts/split-confirm.md +1 -0
- package/src/commit/agentic/prompts/system.md +38 -0
- package/src/commit/agentic/state.ts +69 -0
- package/src/commit/agentic/tools/analyze-file.ts +118 -0
- package/src/commit/agentic/tools/git-file-diff.ts +194 -0
- package/src/commit/agentic/tools/git-hunk.ts +50 -0
- package/src/commit/agentic/tools/git-overview.ts +84 -0
- package/src/commit/agentic/tools/index.ts +56 -0
- package/src/commit/agentic/tools/propose-changelog.ts +128 -0
- package/src/commit/agentic/tools/propose-commit.ts +154 -0
- package/src/commit/agentic/tools/recent-commits.ts +81 -0
- package/src/commit/agentic/tools/split-commit.ts +280 -0
- package/src/commit/agentic/topo-sort.ts +44 -0
- package/src/commit/agentic/trivial.ts +51 -0
- package/src/commit/agentic/validation.ts +200 -0
- package/src/commit/analysis/conventional.ts +165 -0
- package/src/commit/analysis/index.ts +4 -0
- package/src/commit/analysis/scope.ts +242 -0
- package/src/commit/analysis/summary.ts +112 -0
- package/src/commit/analysis/validation.ts +66 -0
- package/src/commit/changelog/detect.ts +37 -0
- package/src/commit/changelog/generate.ts +110 -0
- package/src/commit/changelog/index.ts +234 -0
- package/src/commit/changelog/parse.ts +44 -0
- package/src/commit/cli.ts +93 -0
- package/src/commit/git/diff.ts +148 -0
- package/src/commit/git/errors.ts +9 -0
- package/src/commit/git/index.ts +211 -0
- package/src/commit/git/operations.ts +54 -0
- package/src/commit/index.ts +5 -0
- package/src/commit/map-reduce/index.ts +64 -0
- package/src/commit/map-reduce/map-phase.ts +178 -0
- package/src/commit/map-reduce/reduce-phase.ts +145 -0
- package/src/commit/map-reduce/utils.ts +9 -0
- package/src/commit/message.ts +11 -0
- package/src/commit/model-selection.ts +69 -0
- package/src/commit/pipeline.ts +243 -0
- package/src/commit/prompts/analysis-system.md +148 -0
- package/src/commit/prompts/analysis-user.md +38 -0
- package/src/commit/prompts/changelog-system.md +50 -0
- package/src/commit/prompts/changelog-user.md +18 -0
- package/src/commit/prompts/file-observer-system.md +24 -0
- package/src/commit/prompts/file-observer-user.md +8 -0
- package/src/commit/prompts/reduce-system.md +50 -0
- package/src/commit/prompts/reduce-user.md +17 -0
- package/src/commit/prompts/summary-retry.md +3 -0
- package/src/commit/prompts/summary-system.md +38 -0
- package/src/commit/prompts/summary-user.md +13 -0
- package/src/commit/prompts/types-description.md +2 -0
- package/src/commit/types.ts +109 -0
- package/src/commit/utils/exclusions.ts +42 -0
- package/src/config/file-lock.ts +121 -0
- package/src/config/keybindings.ts +280 -0
- package/src/config/model-registry.ts +1140 -0
- package/src/config/model-resolver.ts +812 -0
- package/src/config/prompt-templates.ts +526 -0
- package/src/config/resolve-config-value.ts +92 -0
- package/src/config/settings-schema.ts +1236 -0
- package/src/config/settings.ts +706 -0
- package/src/config.ts +414 -0
- package/src/cursor.ts +239 -0
- package/src/debug/index.ts +431 -0
- package/src/debug/log-formatting.ts +60 -0
- package/src/debug/log-viewer.ts +903 -0
- package/src/debug/profiler.ts +158 -0
- package/src/debug/report-bundle.ts +366 -0
- package/src/debug/system-info.ts +112 -0
- package/src/discovery/agents-md.ts +68 -0
- package/src/discovery/agents.ts +199 -0
- package/src/discovery/builtin.ts +815 -0
- package/src/discovery/claude-plugins.ts +205 -0
- package/src/discovery/claude.ts +506 -0
- package/src/discovery/cline.ts +83 -0
- package/src/discovery/codex.ts +532 -0
- package/src/discovery/cursor.ts +218 -0
- package/src/discovery/gemini.ts +395 -0
- package/src/discovery/github.ts +117 -0
- package/src/discovery/helpers.ts +698 -0
- package/src/discovery/index.ts +89 -0
- package/src/discovery/mcp-json.ts +156 -0
- package/src/discovery/opencode.ts +394 -0
- package/src/discovery/ssh.ts +160 -0
- package/src/discovery/vscode.ts +103 -0
- package/src/discovery/windsurf.ts +145 -0
- package/src/exa/company.ts +57 -0
- package/src/exa/index.ts +62 -0
- package/src/exa/linkedin.ts +57 -0
- package/src/exa/mcp-client.ts +289 -0
- package/src/exa/render.ts +244 -0
- package/src/exa/researcher.ts +89 -0
- package/src/exa/search.ts +330 -0
- package/src/exa/types.ts +166 -0
- package/src/exa/websets.ts +247 -0
- package/src/exec/bash-executor.ts +184 -0
- package/src/exec/exec.ts +53 -0
- package/src/export/custom-share.ts +65 -0
- package/src/export/html/index.ts +162 -0
- package/src/export/html/template.css +889 -0
- package/src/export/html/template.generated.ts +2 -0
- package/src/export/html/template.html +45 -0
- package/src/export/html/template.js +1329 -0
- package/src/export/html/template.macro.ts +24 -0
- package/src/export/html/vendor/highlight.min.js +1213 -0
- package/src/export/html/vendor/marked.min.js +6 -0
- package/src/export/ttsr.ts +434 -0
- package/src/extensibility/custom-commands/bundled/review/index.ts +433 -0
- package/src/extensibility/custom-commands/index.ts +15 -0
- package/src/extensibility/custom-commands/loader.ts +231 -0
- package/src/extensibility/custom-commands/types.ts +111 -0
- package/src/extensibility/custom-tools/index.ts +22 -0
- package/src/extensibility/custom-tools/loader.ts +235 -0
- package/src/extensibility/custom-tools/types.ts +226 -0
- package/src/extensibility/custom-tools/wrapper.ts +45 -0
- package/src/extensibility/extensions/index.ts +136 -0
- package/src/extensibility/extensions/loader.ts +520 -0
- package/src/extensibility/extensions/runner.ts +774 -0
- package/src/extensibility/extensions/types.ts +1293 -0
- package/src/extensibility/extensions/wrapper.ts +188 -0
- package/src/extensibility/hooks/index.ts +16 -0
- package/src/extensibility/hooks/loader.ts +273 -0
- package/src/extensibility/hooks/runner.ts +441 -0
- package/src/extensibility/hooks/tool-wrapper.ts +106 -0
- package/src/extensibility/hooks/types.ts +817 -0
- package/src/extensibility/plugins/doctor.ts +65 -0
- package/src/extensibility/plugins/git-url.ts +281 -0
- package/src/extensibility/plugins/index.ts +33 -0
- package/src/extensibility/plugins/installer.ts +192 -0
- package/src/extensibility/plugins/loader.ts +338 -0
- package/src/extensibility/plugins/manager.ts +716 -0
- package/src/extensibility/plugins/parser.ts +105 -0
- package/src/extensibility/plugins/types.ts +190 -0
- package/src/extensibility/skills.ts +385 -0
- package/src/extensibility/slash-commands.ts +287 -0
- package/src/extensibility/tool-proxy.ts +25 -0
- package/src/index.ts +275 -0
- package/src/internal-urls/agent-protocol.ts +136 -0
- package/src/internal-urls/artifact-protocol.ts +97 -0
- package/src/internal-urls/docs-index.generated.ts +54 -0
- package/src/internal-urls/docs-protocol.ts +84 -0
- package/src/internal-urls/index.ts +31 -0
- package/src/internal-urls/json-query.ts +126 -0
- package/src/internal-urls/memory-protocol.ts +133 -0
- package/src/internal-urls/router.ts +70 -0
- package/src/internal-urls/rule-protocol.ts +55 -0
- package/src/internal-urls/skill-protocol.ts +111 -0
- package/src/internal-urls/types.ts +52 -0
- package/src/ipy/executor.ts +556 -0
- package/src/ipy/gateway-coordinator.ts +426 -0
- package/src/ipy/kernel.ts +892 -0
- package/src/ipy/modules.ts +109 -0
- package/src/ipy/prelude.py +831 -0
- package/src/ipy/prelude.ts +3 -0
- package/src/ipy/runtime.ts +222 -0
- package/src/lsp/client.ts +867 -0
- package/src/lsp/clients/biome-client.ts +202 -0
- package/src/lsp/clients/index.ts +50 -0
- package/src/lsp/clients/lsp-linter-client.ts +93 -0
- package/src/lsp/clients/swiftlint-client.ts +120 -0
- package/src/lsp/config.ts +397 -0
- package/src/lsp/defaults.json +464 -0
- package/src/lsp/edits.ts +109 -0
- package/src/lsp/index.ts +1268 -0
- package/src/lsp/lspmux.ts +250 -0
- package/src/lsp/render.ts +689 -0
- package/src/lsp/types.ts +414 -0
- package/src/lsp/utils.ts +549 -0
- package/src/main.ts +773 -0
- package/src/mcp/client.ts +239 -0
- package/src/mcp/config-writer.ts +215 -0
- package/src/mcp/config.ts +363 -0
- package/src/mcp/index.ts +55 -0
- package/src/mcp/json-rpc.ts +84 -0
- package/src/mcp/loader.ts +124 -0
- package/src/mcp/manager.ts +490 -0
- package/src/mcp/oauth-discovery.ts +274 -0
- package/src/mcp/oauth-flow.ts +229 -0
- package/src/mcp/render.ts +123 -0
- package/src/mcp/tool-bridge.ts +372 -0
- package/src/mcp/tool-cache.ts +121 -0
- package/src/mcp/transports/http.ts +332 -0
- package/src/mcp/transports/index.ts +6 -0
- package/src/mcp/transports/stdio.ts +281 -0
- package/src/mcp/types.ts +248 -0
- package/src/memories/index.ts +1099 -0
- package/src/memories/storage.ts +563 -0
- package/src/modes/components/agent-dashboard.ts +1130 -0
- package/src/modes/components/assistant-message.ts +144 -0
- package/src/modes/components/bash-execution.ts +218 -0
- package/src/modes/components/bordered-loader.ts +41 -0
- package/src/modes/components/branch-summary-message.ts +45 -0
- package/src/modes/components/codemode-group.ts +369 -0
- package/src/modes/components/compaction-summary-message.ts +51 -0
- package/src/modes/components/countdown-timer.ts +46 -0
- package/src/modes/components/custom-editor.ts +181 -0
- package/src/modes/components/custom-message.ts +91 -0
- package/src/modes/components/diff.ts +186 -0
- package/src/modes/components/dynamic-border.ts +25 -0
- package/src/modes/components/extensions/extension-dashboard.ts +325 -0
- package/src/modes/components/extensions/extension-list.ts +484 -0
- package/src/modes/components/extensions/index.ts +9 -0
- package/src/modes/components/extensions/inspector-panel.ts +321 -0
- package/src/modes/components/extensions/state-manager.ts +586 -0
- package/src/modes/components/extensions/types.ts +191 -0
- package/src/modes/components/footer.ts +315 -0
- package/src/modes/components/history-search.ts +157 -0
- package/src/modes/components/hook-editor.ts +101 -0
- package/src/modes/components/hook-input.ts +72 -0
- package/src/modes/components/hook-message.ts +100 -0
- package/src/modes/components/hook-selector.ts +155 -0
- package/src/modes/components/index.ts +41 -0
- package/src/modes/components/keybinding-hints.ts +65 -0
- package/src/modes/components/login-dialog.ts +164 -0
- package/src/modes/components/mcp-add-wizard.ts +1295 -0
- package/src/modes/components/model-selector.ts +625 -0
- package/src/modes/components/oauth-selector.ts +210 -0
- package/src/modes/components/plugin-settings.ts +477 -0
- package/src/modes/components/python-execution.ts +196 -0
- package/src/modes/components/queue-mode-selector.ts +56 -0
- package/src/modes/components/read-tool-group.ts +119 -0
- package/src/modes/components/session-selector.ts +242 -0
- package/src/modes/components/settings-defs.ts +340 -0
- package/src/modes/components/settings-selector.ts +529 -0
- package/src/modes/components/show-images-selector.ts +45 -0
- package/src/modes/components/skill-message.ts +90 -0
- package/src/modes/components/status-line/index.ts +4 -0
- package/src/modes/components/status-line/presets.ts +94 -0
- package/src/modes/components/status-line/segments.ts +352 -0
- package/src/modes/components/status-line/separators.ts +55 -0
- package/src/modes/components/status-line/types.ts +75 -0
- package/src/modes/components/status-line-segment-editor.ts +354 -0
- package/src/modes/components/status-line.ts +421 -0
- package/src/modes/components/theme-selector.ts +63 -0
- package/src/modes/components/thinking-selector.ts +64 -0
- package/src/modes/components/todo-display.ts +115 -0
- package/src/modes/components/todo-reminder.ts +40 -0
- package/src/modes/components/tool-execution.ts +703 -0
- package/src/modes/components/tree-selector.ts +904 -0
- package/src/modes/components/ttsr-notification.ts +80 -0
- package/src/modes/components/user-message-selector.ts +146 -0
- package/src/modes/components/user-message.ts +22 -0
- package/src/modes/components/visual-truncate.ts +63 -0
- package/src/modes/components/welcome.ts +247 -0
- package/src/modes/controllers/command-controller.ts +1120 -0
- package/src/modes/controllers/event-controller.ts +479 -0
- package/src/modes/controllers/extension-ui-controller.ts +778 -0
- package/src/modes/controllers/input-controller.ts +671 -0
- package/src/modes/controllers/mcp-command-controller.ts +1315 -0
- package/src/modes/controllers/selector-controller.ts +712 -0
- package/src/modes/controllers/ssh-command-controller.ts +452 -0
- package/src/modes/index.ts +15 -0
- package/src/modes/interactive-mode.ts +1027 -0
- package/src/modes/print-mode.ts +191 -0
- package/src/modes/rpc/rpc-client.ts +583 -0
- package/src/modes/rpc/rpc-mode.ts +700 -0
- package/src/modes/rpc/rpc-types.ts +236 -0
- package/src/modes/theme/dark.json +95 -0
- package/src/modes/theme/defaults/alabaster.json +93 -0
- package/src/modes/theme/defaults/amethyst.json +96 -0
- package/src/modes/theme/defaults/anthracite.json +93 -0
- package/src/modes/theme/defaults/basalt.json +91 -0
- package/src/modes/theme/defaults/birch.json +95 -0
- package/src/modes/theme/defaults/dark-abyss.json +91 -0
- package/src/modes/theme/defaults/dark-arctic.json +104 -0
- package/src/modes/theme/defaults/dark-aurora.json +95 -0
- package/src/modes/theme/defaults/dark-catppuccin.json +107 -0
- package/src/modes/theme/defaults/dark-cavern.json +91 -0
- package/src/modes/theme/defaults/dark-copper.json +95 -0
- package/src/modes/theme/defaults/dark-cosmos.json +90 -0
- package/src/modes/theme/defaults/dark-cyberpunk.json +102 -0
- package/src/modes/theme/defaults/dark-dracula.json +98 -0
- package/src/modes/theme/defaults/dark-eclipse.json +91 -0
- package/src/modes/theme/defaults/dark-ember.json +95 -0
- package/src/modes/theme/defaults/dark-equinox.json +90 -0
- package/src/modes/theme/defaults/dark-forest.json +96 -0
- package/src/modes/theme/defaults/dark-github.json +105 -0
- package/src/modes/theme/defaults/dark-gruvbox.json +112 -0
- package/src/modes/theme/defaults/dark-lavender.json +95 -0
- package/src/modes/theme/defaults/dark-lunar.json +89 -0
- package/src/modes/theme/defaults/dark-midnight.json +95 -0
- package/src/modes/theme/defaults/dark-monochrome.json +94 -0
- package/src/modes/theme/defaults/dark-monokai.json +98 -0
- package/src/modes/theme/defaults/dark-nebula.json +90 -0
- package/src/modes/theme/defaults/dark-nord.json +97 -0
- package/src/modes/theme/defaults/dark-ocean.json +101 -0
- package/src/modes/theme/defaults/dark-one.json +100 -0
- package/src/modes/theme/defaults/dark-rainforest.json +91 -0
- package/src/modes/theme/defaults/dark-reef.json +91 -0
- package/src/modes/theme/defaults/dark-retro.json +92 -0
- package/src/modes/theme/defaults/dark-rose-pine.json +96 -0
- package/src/modes/theme/defaults/dark-sakura.json +95 -0
- package/src/modes/theme/defaults/dark-slate.json +95 -0
- package/src/modes/theme/defaults/dark-solarized.json +97 -0
- package/src/modes/theme/defaults/dark-solstice.json +90 -0
- package/src/modes/theme/defaults/dark-starfall.json +91 -0
- package/src/modes/theme/defaults/dark-sunset.json +99 -0
- package/src/modes/theme/defaults/dark-swamp.json +90 -0
- package/src/modes/theme/defaults/dark-synthwave.json +103 -0
- package/src/modes/theme/defaults/dark-taiga.json +91 -0
- package/src/modes/theme/defaults/dark-terminal.json +95 -0
- package/src/modes/theme/defaults/dark-tokyo-night.json +101 -0
- package/src/modes/theme/defaults/dark-tundra.json +91 -0
- package/src/modes/theme/defaults/dark-twilight.json +91 -0
- package/src/modes/theme/defaults/dark-volcanic.json +91 -0
- package/src/modes/theme/defaults/graphite.json +92 -0
- package/src/modes/theme/defaults/index.ts +195 -0
- package/src/modes/theme/defaults/light-arctic.json +107 -0
- package/src/modes/theme/defaults/light-aurora-day.json +91 -0
- package/src/modes/theme/defaults/light-canyon.json +91 -0
- package/src/modes/theme/defaults/light-catppuccin.json +106 -0
- package/src/modes/theme/defaults/light-cirrus.json +90 -0
- package/src/modes/theme/defaults/light-coral.json +95 -0
- package/src/modes/theme/defaults/light-cyberpunk.json +96 -0
- package/src/modes/theme/defaults/light-dawn.json +90 -0
- package/src/modes/theme/defaults/light-dunes.json +91 -0
- package/src/modes/theme/defaults/light-eucalyptus.json +95 -0
- package/src/modes/theme/defaults/light-forest.json +100 -0
- package/src/modes/theme/defaults/light-frost.json +95 -0
- package/src/modes/theme/defaults/light-github.json +115 -0
- package/src/modes/theme/defaults/light-glacier.json +91 -0
- package/src/modes/theme/defaults/light-gruvbox.json +108 -0
- package/src/modes/theme/defaults/light-haze.json +90 -0
- package/src/modes/theme/defaults/light-honeycomb.json +95 -0
- package/src/modes/theme/defaults/light-lagoon.json +91 -0
- package/src/modes/theme/defaults/light-lavender.json +95 -0
- package/src/modes/theme/defaults/light-meadow.json +91 -0
- package/src/modes/theme/defaults/light-mint.json +95 -0
- package/src/modes/theme/defaults/light-monochrome.json +101 -0
- package/src/modes/theme/defaults/light-ocean.json +99 -0
- package/src/modes/theme/defaults/light-one.json +99 -0
- package/src/modes/theme/defaults/light-opal.json +91 -0
- package/src/modes/theme/defaults/light-orchard.json +91 -0
- package/src/modes/theme/defaults/light-paper.json +95 -0
- package/src/modes/theme/defaults/light-prism.json +90 -0
- package/src/modes/theme/defaults/light-retro.json +98 -0
- package/src/modes/theme/defaults/light-sand.json +95 -0
- package/src/modes/theme/defaults/light-savanna.json +91 -0
- package/src/modes/theme/defaults/light-solarized.json +102 -0
- package/src/modes/theme/defaults/light-soleil.json +90 -0
- package/src/modes/theme/defaults/light-sunset.json +99 -0
- package/src/modes/theme/defaults/light-synthwave.json +98 -0
- package/src/modes/theme/defaults/light-tokyo-night.json +111 -0
- package/src/modes/theme/defaults/light-wetland.json +91 -0
- package/src/modes/theme/defaults/light-zenith.json +89 -0
- package/src/modes/theme/defaults/limestone.json +94 -0
- package/src/modes/theme/defaults/mahogany.json +97 -0
- package/src/modes/theme/defaults/marble.json +93 -0
- package/src/modes/theme/defaults/obsidian.json +91 -0
- package/src/modes/theme/defaults/onyx.json +91 -0
- package/src/modes/theme/defaults/pearl.json +93 -0
- package/src/modes/theme/defaults/porcelain.json +91 -0
- package/src/modes/theme/defaults/quartz.json +96 -0
- package/src/modes/theme/defaults/sandstone.json +95 -0
- package/src/modes/theme/defaults/titanium.json +90 -0
- package/src/modes/theme/light.json +93 -0
- package/src/modes/theme/mermaid-cache.ts +111 -0
- package/src/modes/theme/theme-schema.json +429 -0
- package/src/modes/theme/theme.ts +2333 -0
- package/src/modes/types.ts +216 -0
- package/src/modes/utils/ui-helpers.ts +529 -0
- package/src/patch/applicator.ts +1482 -0
- package/src/patch/diff.ts +425 -0
- package/src/patch/fuzzy.ts +784 -0
- package/src/patch/hashline.ts +972 -0
- package/src/patch/index.ts +964 -0
- package/src/patch/normalize.ts +397 -0
- package/src/patch/normative.ts +72 -0
- package/src/patch/parser.ts +532 -0
- package/src/patch/shared.ts +400 -0
- package/src/patch/types.ts +292 -0
- package/src/priority.json +35 -0
- package/src/prompts/agents/explore.md +48 -0
- package/src/prompts/agents/frontmatter.md +9 -0
- package/src/prompts/agents/init.md +36 -0
- package/src/prompts/agents/librarian.md +53 -0
- package/src/prompts/agents/oracle.md +51 -0
- package/src/prompts/agents/reviewer.md +70 -0
- package/src/prompts/agents/task.md +14 -0
- package/src/prompts/compaction/branch-summary-context.md +5 -0
- package/src/prompts/compaction/branch-summary-preamble.md +2 -0
- package/src/prompts/compaction/branch-summary.md +30 -0
- package/src/prompts/compaction/compaction-short-summary.md +9 -0
- package/src/prompts/compaction/compaction-summary-context.md +5 -0
- package/src/prompts/compaction/compaction-summary.md +38 -0
- package/src/prompts/compaction/compaction-turn-prefix.md +17 -0
- package/src/prompts/compaction/compaction-update-summary.md +45 -0
- package/src/prompts/memories/consolidation.md +30 -0
- package/src/prompts/memories/read_path.md +11 -0
- package/src/prompts/memories/stage_one_input.md +6 -0
- package/src/prompts/memories/stage_one_system.md +21 -0
- package/src/prompts/review-request.md +64 -0
- package/src/prompts/system/agent-creation-architect.md +65 -0
- package/src/prompts/system/agent-creation-user.md +6 -0
- package/src/prompts/system/custom-system-prompt.md +68 -0
- package/src/prompts/system/file-operations.md +10 -0
- package/src/prompts/system/subagent-submit-reminder.md +11 -0
- package/src/prompts/system/subagent-system-prompt.md +31 -0
- package/src/prompts/system/subagent-user-prompt.md +8 -0
- package/src/prompts/system/summarization-system.md +3 -0
- package/src/prompts/system/system-prompt.md +300 -0
- package/src/prompts/system/title-system.md +2 -0
- package/src/prompts/system/ttsr-interrupt.md +7 -0
- package/src/prompts/system/web-search.md +28 -0
- package/src/prompts/tools/ask.md +44 -0
- package/src/prompts/tools/bash.md +24 -0
- package/src/prompts/tools/browser.md +33 -0
- package/src/prompts/tools/calculator.md +12 -0
- package/src/prompts/tools/explore.md +29 -0
- package/src/prompts/tools/fetch.md +16 -0
- package/src/prompts/tools/find.md +18 -0
- package/src/prompts/tools/gemini-image.md +23 -0
- package/src/prompts/tools/grep.md +28 -0
- package/src/prompts/tools/hashline.md +232 -0
- package/src/prompts/tools/librarian.md +24 -0
- package/src/prompts/tools/lsp.md +28 -0
- package/src/prompts/tools/oracle.md +26 -0
- package/src/prompts/tools/patch.md +74 -0
- package/src/prompts/tools/python.md +66 -0
- package/src/prompts/tools/read.md +36 -0
- package/src/prompts/tools/replace.md +38 -0
- package/src/prompts/tools/reviewer.md +41 -0
- package/src/prompts/tools/ssh.md +51 -0
- package/src/prompts/tools/task-summary.md +28 -0
- package/src/prompts/tools/task.md +275 -0
- package/src/prompts/tools/todo-write.md +65 -0
- package/src/prompts/tools/undo-edit.md +7 -0
- package/src/prompts/tools/web-search.md +19 -0
- package/src/prompts/tools/write.md +18 -0
- package/src/sdk.ts +1287 -0
- package/src/secrets/index.ts +116 -0
- package/src/secrets/obfuscator.ts +269 -0
- package/src/secrets/regex.ts +21 -0
- package/src/session/agent-session.ts +4669 -0
- package/src/session/agent-storage.ts +621 -0
- package/src/session/artifacts.ts +132 -0
- package/src/session/auth-storage.ts +1433 -0
- package/src/session/blob-store.ts +103 -0
- package/src/session/compaction/branch-summarization.ts +315 -0
- package/src/session/compaction/compaction.ts +864 -0
- package/src/session/compaction/index.ts +7 -0
- package/src/session/compaction/pruning.ts +91 -0
- package/src/session/compaction/utils.ts +171 -0
- package/src/session/history-storage.ts +170 -0
- package/src/session/messages.ts +317 -0
- package/src/session/session-manager.ts +2276 -0
- package/src/session/session-storage.ts +342 -0
- package/src/session/streaming-output.ts +565 -0
- package/src/slash-commands/builtin-registry.ts +439 -0
- package/src/ssh/config-writer.ts +183 -0
- package/src/ssh/connection-manager.ts +444 -0
- package/src/ssh/ssh-executor.ts +127 -0
- package/src/ssh/sshfs-mount.ts +135 -0
- package/src/stt/downloader.ts +71 -0
- package/src/stt/index.ts +3 -0
- package/src/stt/recorder.ts +351 -0
- package/src/stt/setup.ts +52 -0
- package/src/stt/stt-controller.ts +160 -0
- package/src/stt/transcribe.py +70 -0
- package/src/stt/transcriber.ts +91 -0
- package/src/system-prompt.ts +685 -0
- package/src/task/agents.ts +155 -0
- package/src/task/batch.ts +102 -0
- package/src/task/commands.ts +134 -0
- package/src/task/discovery.ts +126 -0
- package/src/task/executor.ts +908 -0
- package/src/task/index.ts +223 -0
- package/src/task/output-manager.ts +107 -0
- package/src/task/parallel.ts +84 -0
- package/src/task/render.ts +326 -0
- package/src/task/subprocess-tool-registry.ts +88 -0
- package/src/task/template.ts +32 -0
- package/src/task/types.ts +144 -0
- package/src/tools/ask.ts +523 -0
- package/src/tools/bash-interactive.ts +419 -0
- package/src/tools/bash-interceptor.ts +105 -0
- package/src/tools/bash-normalize.ts +107 -0
- package/src/tools/bash-skill-urls.ts +177 -0
- package/src/tools/bash.ts +347 -0
- package/src/tools/browser.ts +1374 -0
- package/src/tools/calculator.ts +537 -0
- package/src/tools/context.ts +39 -0
- package/src/tools/explore.ts +23 -0
- package/src/tools/fetch.ts +1091 -0
- package/src/tools/find.ts +540 -0
- package/src/tools/fs-cache-invalidation.ts +28 -0
- package/src/tools/gemini-image.ts +907 -0
- package/src/tools/grep.ts +489 -0
- package/src/tools/index.ts +337 -0
- package/src/tools/json-tree.ts +231 -0
- package/src/tools/jtd-to-json-schema.ts +247 -0
- package/src/tools/jtd-to-typescript.ts +198 -0
- package/src/tools/librarian.ts +33 -0
- package/src/tools/list-limit.ts +40 -0
- package/src/tools/notebook.ts +287 -0
- package/src/tools/oracle.ts +40 -0
- package/src/tools/output-meta.ts +459 -0
- package/src/tools/output-utils.ts +63 -0
- package/src/tools/path-utils.ts +116 -0
- package/src/tools/puppeteer/00_stealth_tampering.txt +63 -0
- package/src/tools/puppeteer/01_stealth_activity.txt +20 -0
- package/src/tools/puppeteer/02_stealth_hairline.txt +11 -0
- package/src/tools/puppeteer/03_stealth_botd.txt +384 -0
- package/src/tools/puppeteer/04_stealth_iframe.txt +81 -0
- package/src/tools/puppeteer/05_stealth_webgl.txt +75 -0
- package/src/tools/puppeteer/06_stealth_screen.txt +72 -0
- package/src/tools/puppeteer/07_stealth_fonts.txt +97 -0
- package/src/tools/puppeteer/08_stealth_audio.txt +51 -0
- package/src/tools/puppeteer/09_stealth_locale.txt +46 -0
- package/src/tools/puppeteer/10_stealth_plugins.txt +206 -0
- package/src/tools/puppeteer/11_stealth_hardware.txt +8 -0
- package/src/tools/puppeteer/12_stealth_codecs.txt +40 -0
- package/src/tools/puppeteer/13_stealth_worker.txt +74 -0
- package/src/tools/python.ts +1118 -0
- package/src/tools/read.ts +1193 -0
- package/src/tools/render-utils.ts +680 -0
- package/src/tools/renderers.ts +60 -0
- package/src/tools/reviewer-tool.ts +41 -0
- package/src/tools/ssh.ts +326 -0
- package/src/tools/subagent-tool.ts +169 -0
- package/src/tools/submit-result.ts +152 -0
- package/src/tools/todo-write.ts +255 -0
- package/src/tools/tool-errors.ts +92 -0
- package/src/tools/tool-result.ts +86 -0
- package/src/tools/undo-edit.ts +145 -0
- package/src/tools/undo-history.ts +22 -0
- package/src/tools/write.ts +274 -0
- package/src/tui/code-cell.ts +108 -0
- package/src/tui/file-list.ts +47 -0
- package/src/tui/index.ts +11 -0
- package/src/tui/output-block.ts +144 -0
- package/src/tui/status-line.ts +39 -0
- package/src/tui/tree-list.ts +53 -0
- package/src/tui/types.ts +16 -0
- package/src/tui/utils.ts +116 -0
- package/src/utils/changelog.ts +98 -0
- package/src/utils/event-bus.ts +33 -0
- package/src/utils/external-editor.ts +59 -0
- package/src/utils/file-display-mode.ts +36 -0
- package/src/utils/file-mentions.ts +384 -0
- package/src/utils/frontmatter.ts +101 -0
- package/src/utils/fuzzy.ts +108 -0
- package/src/utils/ignore-files.ts +119 -0
- package/src/utils/image-convert.ts +27 -0
- package/src/utils/image-resize.ts +236 -0
- package/src/utils/mime.ts +30 -0
- package/src/utils/open.ts +20 -0
- package/src/utils/shell-snapshot.ts +199 -0
- package/src/utils/timings.ts +26 -0
- package/src/utils/title-generator.ts +167 -0
- package/src/utils/tools-manager.ts +362 -0
- package/src/web/scrapers/artifacthub.ts +215 -0
- package/src/web/scrapers/arxiv.ts +88 -0
- package/src/web/scrapers/aur.ts +175 -0
- package/src/web/scrapers/biorxiv.ts +141 -0
- package/src/web/scrapers/bluesky.ts +284 -0
- package/src/web/scrapers/brew.ts +177 -0
- package/src/web/scrapers/cheatsh.ts +78 -0
- package/src/web/scrapers/chocolatey.ts +158 -0
- package/src/web/scrapers/choosealicense.ts +110 -0
- package/src/web/scrapers/cisa-kev.ts +100 -0
- package/src/web/scrapers/clojars.ts +180 -0
- package/src/web/scrapers/coingecko.ts +184 -0
- package/src/web/scrapers/crates-io.ts +128 -0
- package/src/web/scrapers/crossref.ts +149 -0
- package/src/web/scrapers/devto.ts +177 -0
- package/src/web/scrapers/discogs.ts +307 -0
- package/src/web/scrapers/discourse.ts +221 -0
- package/src/web/scrapers/dockerhub.ts +160 -0
- package/src/web/scrapers/fdroid.ts +158 -0
- package/src/web/scrapers/firefox-addons.ts +214 -0
- package/src/web/scrapers/flathub.ts +239 -0
- package/src/web/scrapers/github-gist.ts +68 -0
- package/src/web/scrapers/github.ts +490 -0
- package/src/web/scrapers/gitlab.ts +456 -0
- package/src/web/scrapers/go-pkg.ts +275 -0
- package/src/web/scrapers/hackage.ts +94 -0
- package/src/web/scrapers/hackernews.ts +208 -0
- package/src/web/scrapers/hex.ts +121 -0
- package/src/web/scrapers/huggingface.ts +385 -0
- package/src/web/scrapers/iacr.ts +86 -0
- package/src/web/scrapers/index.ts +249 -0
- package/src/web/scrapers/jetbrains-marketplace.ts +169 -0
- package/src/web/scrapers/lemmy.ts +220 -0
- package/src/web/scrapers/lobsters.ts +186 -0
- package/src/web/scrapers/mastodon.ts +310 -0
- package/src/web/scrapers/maven.ts +152 -0
- package/src/web/scrapers/mdn.ts +172 -0
- package/src/web/scrapers/metacpan.ts +253 -0
- package/src/web/scrapers/musicbrainz.ts +272 -0
- package/src/web/scrapers/npm.ts +114 -0
- package/src/web/scrapers/nuget.ts +205 -0
- package/src/web/scrapers/nvd.ts +243 -0
- package/src/web/scrapers/ollama.ts +265 -0
- package/src/web/scrapers/open-vsx.ts +119 -0
- package/src/web/scrapers/opencorporates.ts +275 -0
- package/src/web/scrapers/openlibrary.ts +319 -0
- package/src/web/scrapers/orcid.ts +298 -0
- package/src/web/scrapers/osv.ts +192 -0
- package/src/web/scrapers/packagist.ts +174 -0
- package/src/web/scrapers/pub-dev.ts +185 -0
- package/src/web/scrapers/pubmed.ts +177 -0
- package/src/web/scrapers/pypi.ts +129 -0
- package/src/web/scrapers/rawg.ts +124 -0
- package/src/web/scrapers/readthedocs.ts +125 -0
- package/src/web/scrapers/reddit.ts +104 -0
- package/src/web/scrapers/repology.ts +262 -0
- package/src/web/scrapers/rfc.ts +209 -0
- package/src/web/scrapers/rubygems.ts +117 -0
- package/src/web/scrapers/searchcode.ts +217 -0
- package/src/web/scrapers/sec-edgar.ts +274 -0
- package/src/web/scrapers/semantic-scholar.ts +190 -0
- package/src/web/scrapers/snapcraft.ts +200 -0
- package/src/web/scrapers/sourcegraph.ts +373 -0
- package/src/web/scrapers/spdx.ts +121 -0
- package/src/web/scrapers/spotify.ts +217 -0
- package/src/web/scrapers/stackoverflow.ts +124 -0
- package/src/web/scrapers/terraform.ts +304 -0
- package/src/web/scrapers/tldr.ts +51 -0
- package/src/web/scrapers/twitter.ts +97 -0
- package/src/web/scrapers/types.ts +200 -0
- package/src/web/scrapers/utils.ts +142 -0
- package/src/web/scrapers/vimeo.ts +152 -0
- package/src/web/scrapers/vscode-marketplace.ts +195 -0
- package/src/web/scrapers/w3c.ts +163 -0
- package/src/web/scrapers/wikidata.ts +357 -0
- package/src/web/scrapers/wikipedia.ts +95 -0
- package/src/web/scrapers/youtube.ts +312 -0
- package/src/web/search/auth.ts +178 -0
- package/src/web/search/index.ts +598 -0
- package/src/web/search/provider.ts +77 -0
- package/src/web/search/providers/anthropic.ts +284 -0
- package/src/web/search/providers/base.ts +22 -0
- package/src/web/search/providers/brave.ts +165 -0
- package/src/web/search/providers/codex.ts +377 -0
- package/src/web/search/providers/exa.ts +158 -0
- package/src/web/search/providers/gemini.ts +437 -0
- package/src/web/search/providers/jina.ts +99 -0
- package/src/web/search/providers/kimi.ts +196 -0
- package/src/web/search/providers/perplexity.ts +546 -0
- package/src/web/search/providers/synthetic.ts +136 -0
- package/src/web/search/providers/zai.ts +352 -0
- package/src/web/search/render.ts +299 -0
- package/src/web/search/types.ts +437 -0
|
@@ -0,0 +1,4669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentSession - Core abstraction for agent lifecycle and session management.
|
|
3
|
+
*
|
|
4
|
+
* This class is shared between all run modes (interactive, print, rpc).
|
|
5
|
+
* It encapsulates:
|
|
6
|
+
* - Agent state access
|
|
7
|
+
* - Event subscription with automatic session persistence
|
|
8
|
+
* - Model and thinking level management
|
|
9
|
+
* - Compaction (manual and auto)
|
|
10
|
+
* - Bash execution
|
|
11
|
+
* - Session switching and branching
|
|
12
|
+
*
|
|
13
|
+
* Modes use this class and add their own I/O layer on top.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
type Agent,
|
|
21
|
+
AgentBusyError,
|
|
22
|
+
type AgentEvent,
|
|
23
|
+
type AgentMessage,
|
|
24
|
+
type AgentState,
|
|
25
|
+
type AgentTool,
|
|
26
|
+
type ThinkingLevel,
|
|
27
|
+
} from "@nghyane/arcane-agent";
|
|
28
|
+
import type {
|
|
29
|
+
AssistantMessage,
|
|
30
|
+
ImageContent,
|
|
31
|
+
Message,
|
|
32
|
+
Model,
|
|
33
|
+
ProviderSessionState,
|
|
34
|
+
TextContent,
|
|
35
|
+
ToolCall,
|
|
36
|
+
ToolChoice,
|
|
37
|
+
Usage,
|
|
38
|
+
UsageReport,
|
|
39
|
+
} from "@nghyane/arcane-ai";
|
|
40
|
+
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@nghyane/arcane-ai";
|
|
41
|
+
import { abortableSleep, isEnoent, logger } from "@nghyane/arcane-utils";
|
|
42
|
+
import { getAgentDbPath } from "@nghyane/arcane-utils/dirs";
|
|
43
|
+
import type { Rule } from "../capability/rule";
|
|
44
|
+
import { MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "../config/model-registry";
|
|
45
|
+
import { expandRoleAlias, parseModelString } from "../config/model-resolver";
|
|
46
|
+
import {
|
|
47
|
+
expandPromptTemplate,
|
|
48
|
+
type PromptTemplate,
|
|
49
|
+
parseCommandArgs,
|
|
50
|
+
renderPromptTemplate,
|
|
51
|
+
} from "../config/prompt-templates";
|
|
52
|
+
import type { Settings, SkillsSettings } from "../config/settings";
|
|
53
|
+
import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
|
|
54
|
+
import { exportSessionToHtml } from "../export/html";
|
|
55
|
+
import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
|
|
56
|
+
import type { LoadedCustomCommand } from "../extensibility/custom-commands";
|
|
57
|
+
import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
|
|
58
|
+
import { CustomToolAdapter } from "../extensibility/custom-tools/wrapper";
|
|
59
|
+
import type {
|
|
60
|
+
ExtensionCommandContext,
|
|
61
|
+
ExtensionRunner,
|
|
62
|
+
ExtensionUIContext,
|
|
63
|
+
MessageEndEvent,
|
|
64
|
+
MessageStartEvent,
|
|
65
|
+
MessageUpdateEvent,
|
|
66
|
+
SessionBeforeBranchResult,
|
|
67
|
+
SessionBeforeCompactResult,
|
|
68
|
+
SessionBeforeSwitchResult,
|
|
69
|
+
SessionBeforeTreeResult,
|
|
70
|
+
ToolExecutionEndEvent,
|
|
71
|
+
ToolExecutionStartEvent,
|
|
72
|
+
ToolExecutionUpdateEvent,
|
|
73
|
+
TreePreparation,
|
|
74
|
+
TurnEndEvent,
|
|
75
|
+
TurnStartEvent,
|
|
76
|
+
} from "../extensibility/extensions";
|
|
77
|
+
import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
|
|
78
|
+
import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
|
|
79
|
+
import type { HookCommandContext } from "../extensibility/hooks/types";
|
|
80
|
+
import type { Skill, SkillWarning } from "../extensibility/skills";
|
|
81
|
+
import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
|
|
82
|
+
import { executePython as executePythonCommand, type PythonResult } from "../ipy/executor";
|
|
83
|
+
import { getCurrentThemeName, theme } from "../modes/theme/theme";
|
|
84
|
+
import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch";
|
|
85
|
+
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
|
|
86
|
+
import type { SecretObfuscator } from "../secrets/obfuscator";
|
|
87
|
+
import { closeAllConnections } from "../ssh/connection-manager";
|
|
88
|
+
import { unmountAll } from "../ssh/sshfs-mount";
|
|
89
|
+
import { outputMeta } from "../tools/output-meta";
|
|
90
|
+
import { resolveToCwd } from "../tools/path-utils";
|
|
91
|
+
import type { TodoItem } from "../tools/todo-write";
|
|
92
|
+
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
93
|
+
import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
|
|
94
|
+
import {
|
|
95
|
+
type CompactionResult,
|
|
96
|
+
calculateContextTokens,
|
|
97
|
+
collectEntriesForBranchSummary,
|
|
98
|
+
compact,
|
|
99
|
+
estimateTokens,
|
|
100
|
+
generateBranchSummary,
|
|
101
|
+
prepareCompaction,
|
|
102
|
+
shouldCompact,
|
|
103
|
+
} from "./compaction";
|
|
104
|
+
import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "./compaction/pruning";
|
|
105
|
+
import {
|
|
106
|
+
type BashExecutionMessage,
|
|
107
|
+
type BranchSummaryMessage,
|
|
108
|
+
bashExecutionToText,
|
|
109
|
+
type CompactionSummaryMessage,
|
|
110
|
+
type CustomMessage,
|
|
111
|
+
type FileMentionMessage,
|
|
112
|
+
type HookMessage,
|
|
113
|
+
type PythonExecutionMessage,
|
|
114
|
+
pythonExecutionToText,
|
|
115
|
+
} from "./messages";
|
|
116
|
+
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
|
|
117
|
+
import { getLatestCompactionEntry } from "./session-manager";
|
|
118
|
+
|
|
119
|
+
/** Session-specific events that extend the core AgentEvent */
|
|
120
|
+
export type AgentSessionEvent =
|
|
121
|
+
| AgentEvent
|
|
122
|
+
| { type: "auto_compaction_start"; reason: "threshold" | "overflow" }
|
|
123
|
+
| {
|
|
124
|
+
type: "auto_compaction_end";
|
|
125
|
+
result: CompactionResult | undefined;
|
|
126
|
+
aborted: boolean;
|
|
127
|
+
willRetry: boolean;
|
|
128
|
+
errorMessage?: string;
|
|
129
|
+
}
|
|
130
|
+
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
|
|
131
|
+
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
|
|
132
|
+
| { type: "ttsr_triggered"; rules: Rule[] }
|
|
133
|
+
| { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number };
|
|
134
|
+
|
|
135
|
+
/** Listener function for agent session events */
|
|
136
|
+
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
|
137
|
+
|
|
138
|
+
// ============================================================================
|
|
139
|
+
// Types
|
|
140
|
+
// ============================================================================
|
|
141
|
+
|
|
142
|
+
export interface AgentSessionConfig {
|
|
143
|
+
agent: Agent;
|
|
144
|
+
sessionManager: SessionManager;
|
|
145
|
+
settings: Settings;
|
|
146
|
+
/** Models to cycle through with Ctrl+P (from --models flag) */
|
|
147
|
+
scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;
|
|
148
|
+
/** Prompt templates for expansion */
|
|
149
|
+
promptTemplates?: PromptTemplate[];
|
|
150
|
+
/** File-based slash commands for expansion */
|
|
151
|
+
slashCommands?: FileSlashCommand[];
|
|
152
|
+
/** Extension runner (created in main.ts with wrapped tools) */
|
|
153
|
+
extensionRunner?: ExtensionRunner;
|
|
154
|
+
/** Loaded skills (already discovered by SDK) */
|
|
155
|
+
skills?: Skill[];
|
|
156
|
+
/** Skill loading warnings (already captured by SDK) */
|
|
157
|
+
skillWarnings?: SkillWarning[];
|
|
158
|
+
/** Custom commands (TypeScript slash commands) */
|
|
159
|
+
customCommands?: LoadedCustomCommand[];
|
|
160
|
+
skillsSettings?: Required<SkillsSettings>;
|
|
161
|
+
/** Model registry for API key resolution and model discovery */
|
|
162
|
+
modelRegistry: ModelRegistry;
|
|
163
|
+
/** Tool registry for LSP and settings */
|
|
164
|
+
toolRegistry?: Map<string, AgentTool>;
|
|
165
|
+
/** System prompt builder that can consider tool availability */
|
|
166
|
+
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>;
|
|
167
|
+
/** TTSR manager for time-traveling stream rules */
|
|
168
|
+
ttsrManager?: TtsrManager;
|
|
169
|
+
/** Force X-Initiator: agent for GitHub Copilot model selections in this session. */
|
|
170
|
+
forceCopilotAgentInitiator?: boolean;
|
|
171
|
+
/** Secret obfuscator for deobfuscating streaming edit content */
|
|
172
|
+
obfuscator?: SecretObfuscator;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Options for AgentSession.prompt() */
|
|
176
|
+
export interface PromptOptions {
|
|
177
|
+
/** Whether to expand file-based prompt templates (default: true) */
|
|
178
|
+
expandPromptTemplates?: boolean;
|
|
179
|
+
/** Image attachments */
|
|
180
|
+
images?: ImageContent[];
|
|
181
|
+
/** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). */
|
|
182
|
+
streamingBehavior?: "steer" | "followUp";
|
|
183
|
+
/** Optional tool choice override for the next LLM call. */
|
|
184
|
+
toolChoice?: ToolChoice;
|
|
185
|
+
/** Mark the user message as synthetic (system-injected). */
|
|
186
|
+
synthetic?: boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Result from cycleModel() */
|
|
190
|
+
export interface ModelCycleResult {
|
|
191
|
+
model: Model;
|
|
192
|
+
thinkingLevel: ThinkingLevel;
|
|
193
|
+
/** Whether cycling through scoped models (--models flag) or all available */
|
|
194
|
+
isScoped: boolean;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Result from cycleRoleModels() */
|
|
198
|
+
export interface RoleModelCycleResult {
|
|
199
|
+
model: Model;
|
|
200
|
+
thinkingLevel: ThinkingLevel;
|
|
201
|
+
role: ModelRole;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Session statistics for /session command */
|
|
205
|
+
export interface SessionStats {
|
|
206
|
+
sessionFile: string | undefined;
|
|
207
|
+
sessionId: string;
|
|
208
|
+
userMessages: number;
|
|
209
|
+
assistantMessages: number;
|
|
210
|
+
toolCalls: number;
|
|
211
|
+
toolResults: number;
|
|
212
|
+
totalMessages: number;
|
|
213
|
+
tokens: {
|
|
214
|
+
input: number;
|
|
215
|
+
output: number;
|
|
216
|
+
cacheRead: number;
|
|
217
|
+
cacheWrite: number;
|
|
218
|
+
total: number;
|
|
219
|
+
};
|
|
220
|
+
cost: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Result from handoff() */
|
|
224
|
+
export interface HandoffResult {
|
|
225
|
+
document: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Internal marker for hook messages queued through the agent loop */
|
|
229
|
+
// ============================================================================
|
|
230
|
+
// Constants
|
|
231
|
+
// ============================================================================
|
|
232
|
+
|
|
233
|
+
/** Standard thinking levels */
|
|
234
|
+
const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];
|
|
235
|
+
|
|
236
|
+
/** Thinking levels including xhigh (for supported models) */
|
|
237
|
+
const THINKING_LEVELS_WITH_XHIGH: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
238
|
+
|
|
239
|
+
const noOpUIContext: ExtensionUIContext = {
|
|
240
|
+
select: async (_title, _options, _dialogOptions) => undefined,
|
|
241
|
+
confirm: async (_title, _message, _dialogOptions) => false,
|
|
242
|
+
input: async (_title, _placeholder, _dialogOptions) => undefined,
|
|
243
|
+
notify: () => {},
|
|
244
|
+
onTerminalInput: () => () => {},
|
|
245
|
+
setStatus: () => {},
|
|
246
|
+
setWorkingMessage: () => {},
|
|
247
|
+
setWidget: () => {},
|
|
248
|
+
setTitle: () => {},
|
|
249
|
+
custom: async () => undefined as never,
|
|
250
|
+
setEditorText: () => {},
|
|
251
|
+
pasteToEditor: () => {},
|
|
252
|
+
getEditorText: () => "",
|
|
253
|
+
editor: async () => undefined,
|
|
254
|
+
get theme() {
|
|
255
|
+
return theme;
|
|
256
|
+
},
|
|
257
|
+
getAllThemes: () => Promise.resolve([]),
|
|
258
|
+
getTheme: () => Promise.resolve(undefined),
|
|
259
|
+
setTheme: _theme => Promise.resolve({ success: false, error: "UI not available" }),
|
|
260
|
+
setFooter: () => {},
|
|
261
|
+
setHeader: () => {},
|
|
262
|
+
setEditorComponent: () => {},
|
|
263
|
+
getToolsExpanded: () => false,
|
|
264
|
+
setToolsExpanded: () => {},
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
async function cleanupSshResources(): Promise<void> {
|
|
268
|
+
const results = await Promise.allSettled([closeAllConnections(), unmountAll()]);
|
|
269
|
+
for (const result of results) {
|
|
270
|
+
if (result.status === "rejected") {
|
|
271
|
+
logger.warn("SSH cleanup failed", { error: String(result.reason) });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ============================================================================
|
|
277
|
+
// AgentSession Class
|
|
278
|
+
// ============================================================================
|
|
279
|
+
|
|
280
|
+
export class AgentSession {
|
|
281
|
+
readonly agent: Agent;
|
|
282
|
+
readonly sessionManager: SessionManager;
|
|
283
|
+
readonly settings: Settings;
|
|
284
|
+
|
|
285
|
+
#scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;
|
|
286
|
+
#promptTemplates: PromptTemplate[];
|
|
287
|
+
#slashCommands: FileSlashCommand[];
|
|
288
|
+
|
|
289
|
+
// Event subscription state
|
|
290
|
+
#unsubscribeAgent?: () => void;
|
|
291
|
+
#eventListeners: AgentSessionEventListener[] = [];
|
|
292
|
+
|
|
293
|
+
/** Tracks pending steering messages for UI display. Removed when delivered. */
|
|
294
|
+
#steeringMessages: string[] = [];
|
|
295
|
+
/** Tracks pending follow-up messages for UI display. Removed when delivered. */
|
|
296
|
+
#followUpMessages: string[] = [];
|
|
297
|
+
/** Messages queued to be included with the next user prompt as context ("asides"). */
|
|
298
|
+
#pendingNextTurnMessages: CustomMessage[] = [];
|
|
299
|
+
|
|
300
|
+
// Compaction state
|
|
301
|
+
#compactionAbortController: AbortController | undefined = undefined;
|
|
302
|
+
#autoCompactionAbortController: AbortController | undefined = undefined;
|
|
303
|
+
|
|
304
|
+
// Branch summarization state
|
|
305
|
+
#branchSummaryAbortController: AbortController | undefined = undefined;
|
|
306
|
+
|
|
307
|
+
// Handoff state
|
|
308
|
+
#handoffAbortController: AbortController | undefined = undefined;
|
|
309
|
+
#skipPostTurnMaintenanceAssistantTimestamp: number | undefined = undefined;
|
|
310
|
+
|
|
311
|
+
// Retry state
|
|
312
|
+
#retryAbortController: AbortController | undefined = undefined;
|
|
313
|
+
#retryAttempt = 0;
|
|
314
|
+
#retryPromise: Promise<void> | undefined = undefined;
|
|
315
|
+
#retryResolve: (() => void) | undefined = undefined;
|
|
316
|
+
|
|
317
|
+
// Todo completion reminder state
|
|
318
|
+
#todoReminderCount = 0;
|
|
319
|
+
|
|
320
|
+
// Bash execution state
|
|
321
|
+
#bashAbortController: AbortController | undefined = undefined;
|
|
322
|
+
#pendingBashMessages: BashExecutionMessage[] = [];
|
|
323
|
+
|
|
324
|
+
// Python execution state
|
|
325
|
+
#pythonAbortController: AbortController | undefined = undefined;
|
|
326
|
+
#pendingPythonMessages: PythonExecutionMessage[] = [];
|
|
327
|
+
|
|
328
|
+
// Extension system
|
|
329
|
+
#extensionRunner: ExtensionRunner | undefined = undefined;
|
|
330
|
+
#turnIndex = 0;
|
|
331
|
+
|
|
332
|
+
#skills: Skill[];
|
|
333
|
+
#skillWarnings: SkillWarning[];
|
|
334
|
+
|
|
335
|
+
// Custom commands (TypeScript slash commands)
|
|
336
|
+
#customCommands: LoadedCustomCommand[] = [];
|
|
337
|
+
|
|
338
|
+
#skillsSettings: Required<SkillsSettings> | undefined;
|
|
339
|
+
|
|
340
|
+
// Model registry for API key resolution
|
|
341
|
+
#modelRegistry: ModelRegistry;
|
|
342
|
+
|
|
343
|
+
// Tool registry and prompt builder for extensions
|
|
344
|
+
#toolRegistry: Map<string, AgentTool>;
|
|
345
|
+
#rebuildSystemPrompt: ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>) | undefined;
|
|
346
|
+
#baseSystemPrompt: string;
|
|
347
|
+
#forceCopilotAgentInitiator = false;
|
|
348
|
+
|
|
349
|
+
// TTSR manager for time-traveling stream rules
|
|
350
|
+
#ttsrManager: TtsrManager | undefined = undefined;
|
|
351
|
+
#pendingTtsrInjections: Rule[] = [];
|
|
352
|
+
#ttsrAbortPending = false;
|
|
353
|
+
#ttsrRetryToken = 0;
|
|
354
|
+
|
|
355
|
+
#streamingEditAbortTriggered = false;
|
|
356
|
+
#streamingEditCheckedLineCounts = new Map<string, number>();
|
|
357
|
+
#streamingEditFileCache = new Map<string, string>();
|
|
358
|
+
#promptInFlight = false;
|
|
359
|
+
#obfuscator: SecretObfuscator | undefined;
|
|
360
|
+
#promptGeneration = 0;
|
|
361
|
+
#providerSessionState = new Map<string, ProviderSessionState>();
|
|
362
|
+
|
|
363
|
+
constructor(config: AgentSessionConfig) {
|
|
364
|
+
this.agent = config.agent;
|
|
365
|
+
this.sessionManager = config.sessionManager;
|
|
366
|
+
this.settings = config.settings;
|
|
367
|
+
this.#scopedModels = config.scopedModels ?? [];
|
|
368
|
+
this.#promptTemplates = config.promptTemplates ?? [];
|
|
369
|
+
this.#slashCommands = config.slashCommands ?? [];
|
|
370
|
+
this.#extensionRunner = config.extensionRunner;
|
|
371
|
+
this.#skills = config.skills ?? [];
|
|
372
|
+
this.#skillWarnings = config.skillWarnings ?? [];
|
|
373
|
+
this.#customCommands = config.customCommands ?? [];
|
|
374
|
+
this.#skillsSettings = config.skillsSettings;
|
|
375
|
+
this.#modelRegistry = config.modelRegistry;
|
|
376
|
+
this.#toolRegistry = config.toolRegistry ?? new Map();
|
|
377
|
+
this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
|
|
378
|
+
this.#baseSystemPrompt = this.agent.state.systemPrompt;
|
|
379
|
+
this.#ttsrManager = config.ttsrManager;
|
|
380
|
+
this.#forceCopilotAgentInitiator = config.forceCopilotAgentInitiator ?? false;
|
|
381
|
+
this.#obfuscator = config.obfuscator;
|
|
382
|
+
this.agent.providerSessionState = this.#providerSessionState;
|
|
383
|
+
|
|
384
|
+
// Always subscribe to agent events for internal handling
|
|
385
|
+
// (session persistence, hooks, auto-compaction, retry logic)
|
|
386
|
+
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Model registry for API key resolution and model discovery */
|
|
390
|
+
get modelRegistry(): ModelRegistry {
|
|
391
|
+
return this.#modelRegistry;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Provider-scoped mutable state store for transport/session caches. */
|
|
395
|
+
get providerSessionState(): Map<string, ProviderSessionState> {
|
|
396
|
+
return this.#providerSessionState;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** TTSR manager for time-traveling stream rules */
|
|
400
|
+
get ttsrManager(): TtsrManager | undefined {
|
|
401
|
+
return this.#ttsrManager;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Whether a TTSR abort is pending (stream was aborted to inject rules) */
|
|
405
|
+
get isTtsrAbortPending(): boolean {
|
|
406
|
+
return this.#ttsrAbortPending;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// =========================================================================
|
|
410
|
+
// Event Subscription
|
|
411
|
+
// =========================================================================
|
|
412
|
+
|
|
413
|
+
/** Emit an event to all listeners */
|
|
414
|
+
#emit(event: AgentSessionEvent): void {
|
|
415
|
+
// Copy array before iteration to avoid mutation during iteration
|
|
416
|
+
const listeners = [...this.#eventListeners];
|
|
417
|
+
for (const l of listeners) {
|
|
418
|
+
l(event);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
|
|
423
|
+
await this.#emitExtensionEvent(event);
|
|
424
|
+
this.#emit(event);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Track last assistant message for auto-compaction check
|
|
428
|
+
#lastAssistantMessage: AssistantMessage | undefined = undefined;
|
|
429
|
+
|
|
430
|
+
/** Internal handler for agent events - shared by subscribe and reconnect */
|
|
431
|
+
#handleAgentEvent = async (event: AgentEvent): Promise<void> => {
|
|
432
|
+
// When a user message starts, check if it's from either queue and remove it BEFORE emitting
|
|
433
|
+
// This ensures the UI sees the updated queue state
|
|
434
|
+
if (event.type === "message_start" && event.message.role === "user") {
|
|
435
|
+
const messageText = this.#getUserMessageText(event.message);
|
|
436
|
+
if (messageText) {
|
|
437
|
+
// Check steering queue first
|
|
438
|
+
const steeringIndex = this.#steeringMessages.indexOf(messageText);
|
|
439
|
+
if (steeringIndex !== -1) {
|
|
440
|
+
this.#steeringMessages.splice(steeringIndex, 1);
|
|
441
|
+
} else {
|
|
442
|
+
// Check follow-up queue
|
|
443
|
+
const followUpIndex = this.#followUpMessages.indexOf(messageText);
|
|
444
|
+
if (followUpIndex !== -1) {
|
|
445
|
+
this.#followUpMessages.splice(followUpIndex, 1);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
await this.#emitSessionEvent(event);
|
|
452
|
+
|
|
453
|
+
if (event.type === "turn_start") {
|
|
454
|
+
this.#resetStreamingEditState();
|
|
455
|
+
// TTSR: Reset buffer on turn start
|
|
456
|
+
this.#ttsrManager?.resetBuffer();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// TTSR: Increment message count on turn end (for repeat-after-gap tracking)
|
|
460
|
+
if (event.type === "turn_end" && this.#ttsrManager) {
|
|
461
|
+
this.#ttsrManager.incrementMessageCount();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// TTSR: Check for pattern matches on assistant text/thinking and tool argument deltas
|
|
465
|
+
if (event.type === "message_update" && this.#ttsrManager?.hasRules()) {
|
|
466
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
467
|
+
let matchContext: TtsrMatchContext | undefined;
|
|
468
|
+
|
|
469
|
+
if (assistantEvent.type === "text_delta") {
|
|
470
|
+
matchContext = { source: "text" };
|
|
471
|
+
} else if (assistantEvent.type === "thinking_delta") {
|
|
472
|
+
matchContext = { source: "thinking" };
|
|
473
|
+
} else if (assistantEvent.type === "toolcall_delta") {
|
|
474
|
+
matchContext = this.#getTtsrToolMatchContext(event.message, assistantEvent.contentIndex);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (matchContext && "delta" in assistantEvent) {
|
|
478
|
+
const matches = this.#ttsrManager.checkDelta(assistantEvent.delta, matchContext);
|
|
479
|
+
if (matches.length > 0) {
|
|
480
|
+
// Queue rules for injection; mark as injected only after successful enqueue.
|
|
481
|
+
|
|
482
|
+
this.#addPendingTtsrInjections(matches);
|
|
483
|
+
|
|
484
|
+
if (this.#shouldInterruptForTtsrMatch(matchContext)) {
|
|
485
|
+
// Abort the stream immediately — do not gate on extension callbacks
|
|
486
|
+
this.#ttsrAbortPending = true;
|
|
487
|
+
this.agent.abort();
|
|
488
|
+
// Notify extensions (fire-and-forget, does not block abort)
|
|
489
|
+
this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
|
|
490
|
+
// Schedule retry after a short delay
|
|
491
|
+
const retryToken = ++this.#ttsrRetryToken;
|
|
492
|
+
const generation = this.#promptGeneration;
|
|
493
|
+
const targetMessageTimestamp =
|
|
494
|
+
event.message.role === "assistant" ? event.message.timestamp : undefined;
|
|
495
|
+
setTimeout(async () => {
|
|
496
|
+
if (this.#ttsrRetryToken !== retryToken) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp);
|
|
501
|
+
if (
|
|
502
|
+
!this.#ttsrAbortPending ||
|
|
503
|
+
this.#promptGeneration !== generation ||
|
|
504
|
+
targetAssistantIndex === -1
|
|
505
|
+
) {
|
|
506
|
+
this.#ttsrAbortPending = false;
|
|
507
|
+
this.#pendingTtsrInjections = [];
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
this.#ttsrAbortPending = false;
|
|
511
|
+
const ttsrSettings = this.#ttsrManager?.getSettings();
|
|
512
|
+
if (ttsrSettings?.contextMode === "discard") {
|
|
513
|
+
// Remove the partial/aborted assistant turn from agent state
|
|
514
|
+
this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex));
|
|
515
|
+
}
|
|
516
|
+
// Inject TTSR rules as system reminder before retry
|
|
517
|
+
const injection = this.#getTtsrInjectionContent();
|
|
518
|
+
if (injection) {
|
|
519
|
+
const details = { rules: injection.rules.map(rule => rule.name) };
|
|
520
|
+
this.agent.appendMessage({
|
|
521
|
+
role: "custom",
|
|
522
|
+
customType: "ttsr-injection",
|
|
523
|
+
content: injection.content,
|
|
524
|
+
display: false,
|
|
525
|
+
details,
|
|
526
|
+
timestamp: Date.now(),
|
|
527
|
+
});
|
|
528
|
+
this.sessionManager.appendCustomMessageEntry(
|
|
529
|
+
"ttsr-injection",
|
|
530
|
+
injection.content,
|
|
531
|
+
false,
|
|
532
|
+
details,
|
|
533
|
+
);
|
|
534
|
+
this.#markTtsrInjected(details.rules);
|
|
535
|
+
}
|
|
536
|
+
this.agent.continue().catch(() => {});
|
|
537
|
+
}, 50);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "toolcall_start") {
|
|
545
|
+
this.#preCacheStreamingEditFile(event);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (
|
|
549
|
+
event.type === "message_update" &&
|
|
550
|
+
(event.assistantMessageEvent.type === "toolcall_end" || event.assistantMessageEvent.type === "toolcall_delta")
|
|
551
|
+
) {
|
|
552
|
+
this.#maybeAbortStreamingEdit(event);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Handle session persistence
|
|
556
|
+
if (event.type === "message_end") {
|
|
557
|
+
// Check if this is a hook/custom message
|
|
558
|
+
if (event.message.role === "hookMessage" || event.message.role === "custom") {
|
|
559
|
+
// Persist as CustomMessageEntry
|
|
560
|
+
this.sessionManager.appendCustomMessageEntry(
|
|
561
|
+
event.message.customType,
|
|
562
|
+
event.message.content,
|
|
563
|
+
event.message.display,
|
|
564
|
+
event.message.details,
|
|
565
|
+
);
|
|
566
|
+
if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
|
|
567
|
+
this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
|
|
568
|
+
}
|
|
569
|
+
} else if (
|
|
570
|
+
event.message.role === "user" ||
|
|
571
|
+
event.message.role === "assistant" ||
|
|
572
|
+
event.message.role === "toolResult" ||
|
|
573
|
+
event.message.role === "fileMention"
|
|
574
|
+
) {
|
|
575
|
+
// Regular LLM message - persist as SessionMessageEntry
|
|
576
|
+
this.sessionManager.appendMessage(event.message);
|
|
577
|
+
}
|
|
578
|
+
// Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
|
|
579
|
+
|
|
580
|
+
// Track assistant message for auto-compaction (checked on agent_end)
|
|
581
|
+
if (event.message.role === "assistant") {
|
|
582
|
+
this.#lastAssistantMessage = event.message;
|
|
583
|
+
const assistantMsg = event.message as AssistantMessage;
|
|
584
|
+
this.#queueDeferredTtsrInjectionIfNeeded(assistantMsg);
|
|
585
|
+
if (this.#handoffAbortController) {
|
|
586
|
+
this.#skipPostTurnMaintenanceAssistantTimestamp = assistantMsg.timestamp;
|
|
587
|
+
}
|
|
588
|
+
if (
|
|
589
|
+
assistantMsg.stopReason !== "error" &&
|
|
590
|
+
assistantMsg.stopReason !== "aborted" &&
|
|
591
|
+
this.#retryAttempt > 0
|
|
592
|
+
) {
|
|
593
|
+
await this.#emitSessionEvent({
|
|
594
|
+
type: "auto_retry_end",
|
|
595
|
+
success: true,
|
|
596
|
+
attempt: this.#retryAttempt,
|
|
597
|
+
});
|
|
598
|
+
this.#retryAttempt = 0;
|
|
599
|
+
this.#resolveRetry();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (event.message.role === "toolResult") {
|
|
604
|
+
const { toolName, $normative, toolCallId, details, isError, content } = event.message as {
|
|
605
|
+
toolName?: string;
|
|
606
|
+
toolCallId?: string;
|
|
607
|
+
details?: { path?: string };
|
|
608
|
+
$normative?: Record<string, unknown>;
|
|
609
|
+
isError?: boolean;
|
|
610
|
+
content?: Array<TextContent | ImageContent>;
|
|
611
|
+
};
|
|
612
|
+
if ($normative && toolCallId && this.settings.get("normativeRewrite")) {
|
|
613
|
+
await this.#rewriteToolCallArgs(toolCallId, $normative);
|
|
614
|
+
}
|
|
615
|
+
// Invalidate streaming edit cache when edit tool completes to prevent stale data
|
|
616
|
+
if (toolName === "edit" && details?.path) {
|
|
617
|
+
this.#invalidateFileCacheForPath(details.path);
|
|
618
|
+
}
|
|
619
|
+
if (toolName === "todo_write" && isError) {
|
|
620
|
+
const errorText = content?.find(part => part.type === "text")?.text;
|
|
621
|
+
const reminderText = [
|
|
622
|
+
"<system_reminder>",
|
|
623
|
+
"todo_write failed, so todo progress is not visible to the user.",
|
|
624
|
+
errorText ? `Failure: ${errorText}` : "Failure: todo_write returned an error.",
|
|
625
|
+
"Fix the todo payload and call todo_write again before continuing.",
|
|
626
|
+
"</system_reminder>",
|
|
627
|
+
].join("\n");
|
|
628
|
+
await this.sendCustomMessage(
|
|
629
|
+
{
|
|
630
|
+
customType: "todo-write-error-reminder",
|
|
631
|
+
content: reminderText,
|
|
632
|
+
display: false,
|
|
633
|
+
details: { toolName, errorText },
|
|
634
|
+
},
|
|
635
|
+
{ deliverAs: "nextTurn" },
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Check auto-retry and auto-compaction after agent completes
|
|
642
|
+
if (event.type === "agent_end" && this.#lastAssistantMessage) {
|
|
643
|
+
const msg = this.#lastAssistantMessage;
|
|
644
|
+
this.#lastAssistantMessage = undefined;
|
|
645
|
+
|
|
646
|
+
if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
|
|
647
|
+
this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Check for retryable errors first (overloaded, rate limit, server errors)
|
|
652
|
+
if (this.#isRetryableError(msg)) {
|
|
653
|
+
const didRetry = await this.#handleRetryableError(msg);
|
|
654
|
+
if (didRetry) return; // Retry was initiated, don't proceed to compaction
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
await this.#checkCompaction(msg);
|
|
658
|
+
|
|
659
|
+
// Check for incomplete todos (unless there was an error or abort)
|
|
660
|
+
if (msg.stopReason !== "error" && msg.stopReason !== "aborted") {
|
|
661
|
+
await this.#checkTodoCompletion();
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
/** Resolve the pending retry promise */
|
|
667
|
+
#resolveRetry(): void {
|
|
668
|
+
if (this.#retryResolve) {
|
|
669
|
+
this.#retryResolve();
|
|
670
|
+
this.#retryResolve = undefined;
|
|
671
|
+
this.#retryPromise = undefined;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Get TTSR injection payload and clear pending injections. */
|
|
676
|
+
#getTtsrInjectionContent(): { content: string; rules: Rule[] } | undefined {
|
|
677
|
+
if (this.#pendingTtsrInjections.length === 0) return undefined;
|
|
678
|
+
const rules = this.#pendingTtsrInjections;
|
|
679
|
+
const content = rules
|
|
680
|
+
.map(r => renderPromptTemplate(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content }))
|
|
681
|
+
.join("\n\n");
|
|
682
|
+
this.#pendingTtsrInjections = [];
|
|
683
|
+
return { content, rules };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
#addPendingTtsrInjections(rules: Rule[]): void {
|
|
687
|
+
const seen = new Set(this.#pendingTtsrInjections.map(rule => rule.name));
|
|
688
|
+
for (const rule of rules) {
|
|
689
|
+
if (seen.has(rule.name)) continue;
|
|
690
|
+
this.#pendingTtsrInjections.push(rule);
|
|
691
|
+
seen.add(rule.name);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
#extractTtsrRuleNames(details: unknown): string[] {
|
|
696
|
+
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
|
697
|
+
return [];
|
|
698
|
+
}
|
|
699
|
+
const rules = (details as { rules?: unknown }).rules;
|
|
700
|
+
if (!Array.isArray(rules)) {
|
|
701
|
+
return [];
|
|
702
|
+
}
|
|
703
|
+
return rules.filter((ruleName): ruleName is string => typeof ruleName === "string");
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
#markTtsrInjected(ruleNames: string[]): void {
|
|
707
|
+
const uniqueRuleNames = Array.from(
|
|
708
|
+
new Set(ruleNames.map(ruleName => ruleName.trim()).filter(ruleName => ruleName.length > 0)),
|
|
709
|
+
);
|
|
710
|
+
if (uniqueRuleNames.length === 0) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
this.#ttsrManager?.markInjectedByNames(uniqueRuleNames);
|
|
714
|
+
this.sessionManager.appendTtsrInjection(uniqueRuleNames);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
#findTtsrAssistantIndex(targetTimestamp: number | undefined): number {
|
|
718
|
+
const messages = this.agent.state.messages;
|
|
719
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
720
|
+
const message = messages[i];
|
|
721
|
+
if (message.role !== "assistant") {
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
if (targetTimestamp === undefined || message.timestamp === targetTimestamp) {
|
|
725
|
+
return i;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return -1;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
#shouldInterruptForTtsrMatch(matchContext: TtsrMatchContext): boolean {
|
|
732
|
+
const mode = this.#ttsrManager?.getSettings().interruptMode ?? "always";
|
|
733
|
+
if (mode === "never") {
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
if (mode === "prose-only") {
|
|
737
|
+
return matchContext.source === "text" || matchContext.source === "thinking";
|
|
738
|
+
}
|
|
739
|
+
if (mode === "tool-only") {
|
|
740
|
+
return matchContext.source === "tool";
|
|
741
|
+
}
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
#queueDeferredTtsrInjectionIfNeeded(assistantMsg: AssistantMessage): void {
|
|
746
|
+
if (this.#ttsrAbortPending || this.#pendingTtsrInjections.length === 0) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
|
|
750
|
+
this.#pendingTtsrInjections = [];
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const injection = this.#getTtsrInjectionContent();
|
|
755
|
+
if (!injection) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
this.agent.followUp({
|
|
759
|
+
role: "custom",
|
|
760
|
+
customType: "ttsr-injection",
|
|
761
|
+
content: injection.content,
|
|
762
|
+
display: false,
|
|
763
|
+
details: { rules: injection.rules.map(rule => rule.name) },
|
|
764
|
+
timestamp: Date.now(),
|
|
765
|
+
});
|
|
766
|
+
// Mark as injected after this custom message is delivered and persisted (handled in message_end).
|
|
767
|
+
// followUp() only enqueues; resume on the next tick once streaming settles.
|
|
768
|
+
setTimeout(() => {
|
|
769
|
+
if (this.agent.state.isStreaming || !this.agent.hasQueuedMessages()) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
this.agent.continue().catch(() => {});
|
|
773
|
+
}, 0);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Build TTSR match context for tool call argument deltas. */
|
|
777
|
+
#getTtsrToolMatchContext(message: AgentMessage, contentIndex: number): TtsrMatchContext {
|
|
778
|
+
const context: TtsrMatchContext = { source: "tool" };
|
|
779
|
+
if (message.role !== "assistant") {
|
|
780
|
+
return context;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const content = message.content;
|
|
784
|
+
if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) {
|
|
785
|
+
return context;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const block = content[contentIndex];
|
|
789
|
+
if (!block || typeof block !== "object" || block.type !== "toolCall") {
|
|
790
|
+
return context;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const toolCall = block as ToolCall;
|
|
794
|
+
context.toolName = toolCall.name;
|
|
795
|
+
context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
|
|
796
|
+
context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
|
|
797
|
+
return context;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Extract path-like arguments from tool call payload for TTSR glob matching. */
|
|
801
|
+
#extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
|
|
802
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
|
803
|
+
return undefined;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const rawPaths: string[] = [];
|
|
807
|
+
for (const [key, value] of Object.entries(args)) {
|
|
808
|
+
const normalizedKey = key.toLowerCase();
|
|
809
|
+
if (typeof value === "string" && (normalizedKey === "path" || normalizedKey.endsWith("path"))) {
|
|
810
|
+
rawPaths.push(value);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (Array.isArray(value) && (normalizedKey === "paths" || normalizedKey.endsWith("paths"))) {
|
|
814
|
+
for (const candidate of value) {
|
|
815
|
+
if (typeof candidate === "string") {
|
|
816
|
+
rawPaths.push(candidate);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const normalizedPaths = rawPaths.flatMap(pathValue => this.#normalizeTtsrPathCandidates(pathValue));
|
|
823
|
+
if (normalizedPaths.length === 0) {
|
|
824
|
+
return undefined;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return Array.from(new Set(normalizedPaths));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Convert a path argument into stable relative/absolute candidates for glob checks. */
|
|
831
|
+
#normalizeTtsrPathCandidates(rawPath: string): string[] {
|
|
832
|
+
const trimmed = rawPath.trim();
|
|
833
|
+
if (trimmed.length === 0) {
|
|
834
|
+
return [];
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const normalizedInput = trimmed.replaceAll("\\", "/");
|
|
838
|
+
const candidates = new Set<string>([normalizedInput]);
|
|
839
|
+
if (normalizedInput.startsWith("./")) {
|
|
840
|
+
candidates.add(normalizedInput.slice(2));
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const cwd = this.sessionManager.getCwd();
|
|
844
|
+
const absolutePath = path.isAbsolute(trimmed) ? path.normalize(trimmed) : path.resolve(cwd, trimmed);
|
|
845
|
+
candidates.add(absolutePath.replaceAll("\\", "/"));
|
|
846
|
+
|
|
847
|
+
const relativePath = path.relative(cwd, absolutePath).replaceAll("\\", "/");
|
|
848
|
+
if (relativePath && relativePath !== "." && !relativePath.startsWith("../") && relativePath !== "..") {
|
|
849
|
+
candidates.add(relativePath);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return Array.from(candidates);
|
|
853
|
+
}
|
|
854
|
+
/** Extract text content from a message */
|
|
855
|
+
#getUserMessageText(message: Message): string {
|
|
856
|
+
if (message.role !== "user") return "";
|
|
857
|
+
const content = message.content;
|
|
858
|
+
if (typeof content === "string") return content;
|
|
859
|
+
const textBlocks = content.filter(c => c.type === "text");
|
|
860
|
+
const text = textBlocks.map(c => (c as TextContent).text).join("");
|
|
861
|
+
if (text.length > 0) return text;
|
|
862
|
+
const hasImages = content.some(c => c.type === "image");
|
|
863
|
+
return hasImages ? "[Image]" : "";
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Find the last assistant message in agent state (including aborted ones) */
|
|
867
|
+
#findLastAssistantMessage(): AssistantMessage | undefined {
|
|
868
|
+
const messages = this.agent.state.messages;
|
|
869
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
870
|
+
const msg = messages[i];
|
|
871
|
+
if (msg.role === "assistant") {
|
|
872
|
+
return msg as AssistantMessage;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return undefined;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
#resetStreamingEditState(): void {
|
|
879
|
+
this.#streamingEditAbortTriggered = false;
|
|
880
|
+
this.#streamingEditCheckedLineCounts.clear();
|
|
881
|
+
this.#streamingEditFileCache.clear();
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async #preCacheStreamingEditFile(event: AgentEvent): Promise<void> {
|
|
885
|
+
if (!this.settings.get("edit.streamingAbort")) return;
|
|
886
|
+
if (event.type !== "message_update") return;
|
|
887
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
888
|
+
if (assistantEvent.type !== "toolcall_start") return;
|
|
889
|
+
if (event.message.role !== "assistant") return;
|
|
890
|
+
|
|
891
|
+
const contentIndex = assistantEvent.contentIndex;
|
|
892
|
+
const messageContent = event.message.content;
|
|
893
|
+
if (!Array.isArray(messageContent) || contentIndex >= messageContent.length) return;
|
|
894
|
+
const toolCall = messageContent[contentIndex] as ToolCall;
|
|
895
|
+
if (toolCall.name !== "edit") return;
|
|
896
|
+
|
|
897
|
+
const args = toolCall.arguments;
|
|
898
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return;
|
|
899
|
+
if ("old_text" in args || "new_text" in args) return;
|
|
900
|
+
|
|
901
|
+
const path = typeof args.path === "string" ? args.path : undefined;
|
|
902
|
+
if (!path) return;
|
|
903
|
+
|
|
904
|
+
const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
|
|
905
|
+
this.#ensureFileCache(resolvedPath);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
#ensureFileCache(resolvedPath: string): void {
|
|
909
|
+
if (this.#streamingEditFileCache.has(resolvedPath)) return;
|
|
910
|
+
|
|
911
|
+
try {
|
|
912
|
+
const rawText = fs.readFileSync(resolvedPath, "utf-8");
|
|
913
|
+
const { text } = stripBom(rawText);
|
|
914
|
+
this.#streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
|
|
915
|
+
} catch {
|
|
916
|
+
// Don't cache on read errors (including ENOENT) - let the edit tool handle them
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** Invalidate cache for a file after an edit completes to prevent stale data */
|
|
921
|
+
#invalidateFileCacheForPath(path: string): void {
|
|
922
|
+
const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
|
|
923
|
+
this.#streamingEditFileCache.delete(resolvedPath);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
#maybeAbortStreamingEdit(event: AgentEvent): void {
|
|
927
|
+
if (!this.settings.get("edit.streamingAbort")) return;
|
|
928
|
+
if (this.#streamingEditAbortTriggered) return;
|
|
929
|
+
if (event.type !== "message_update") return;
|
|
930
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
931
|
+
if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return;
|
|
932
|
+
if (event.message.role !== "assistant") return;
|
|
933
|
+
|
|
934
|
+
const contentIndex = assistantEvent.contentIndex;
|
|
935
|
+
const messageContent = event.message.content;
|
|
936
|
+
if (!Array.isArray(messageContent) || contentIndex >= messageContent.length) return;
|
|
937
|
+
const toolCall = messageContent[contentIndex] as ToolCall;
|
|
938
|
+
if (toolCall.name !== "edit" || !toolCall.id) return;
|
|
939
|
+
|
|
940
|
+
const args = toolCall.arguments;
|
|
941
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return;
|
|
942
|
+
if ("old_text" in args || "new_text" in args) return;
|
|
943
|
+
|
|
944
|
+
const path = typeof args.path === "string" ? args.path : undefined;
|
|
945
|
+
const diff = typeof args.diff === "string" ? args.diff : undefined;
|
|
946
|
+
const op = typeof args.op === "string" ? args.op : undefined;
|
|
947
|
+
if (!path || !diff) return;
|
|
948
|
+
if (op && op !== "update") return;
|
|
949
|
+
|
|
950
|
+
if (!diff.includes("\n")) return;
|
|
951
|
+
const lastNewlineIndex = diff.lastIndexOf("\n");
|
|
952
|
+
if (lastNewlineIndex < 0) return;
|
|
953
|
+
const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
|
|
954
|
+
if (diffForCheck.trim().length === 0) return;
|
|
955
|
+
|
|
956
|
+
let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
|
|
957
|
+
if (!normalizedDiff) return;
|
|
958
|
+
// Deobfuscate the diff so removed lines match real file content
|
|
959
|
+
if (this.#obfuscator) normalizedDiff = this.#obfuscator.deobfuscate(normalizedDiff);
|
|
960
|
+
if (!normalizedDiff) return;
|
|
961
|
+
const lines = normalizedDiff.split("\n");
|
|
962
|
+
const hasChangeLine = lines.some(line => line.startsWith("+") || line.startsWith("-"));
|
|
963
|
+
if (!hasChangeLine) return;
|
|
964
|
+
|
|
965
|
+
const lineCount = lines.length;
|
|
966
|
+
const lastChecked = this.#streamingEditCheckedLineCounts.get(toolCall.id);
|
|
967
|
+
if (lastChecked !== undefined && lineCount <= lastChecked) return;
|
|
968
|
+
this.#streamingEditCheckedLineCounts.set(toolCall.id, lineCount);
|
|
969
|
+
|
|
970
|
+
const rename = typeof args.rename === "string" ? args.rename : undefined;
|
|
971
|
+
|
|
972
|
+
const removedLines = lines
|
|
973
|
+
.filter(line => line.startsWith("-") && !line.startsWith("--- "))
|
|
974
|
+
.map(line => line.slice(1));
|
|
975
|
+
if (removedLines.length > 0) {
|
|
976
|
+
const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
|
|
977
|
+
let cachedContent = this.#streamingEditFileCache.get(resolvedPath);
|
|
978
|
+
if (cachedContent === undefined) {
|
|
979
|
+
this.#ensureFileCache(resolvedPath);
|
|
980
|
+
cachedContent = this.#streamingEditFileCache.get(resolvedPath);
|
|
981
|
+
}
|
|
982
|
+
if (cachedContent !== undefined) {
|
|
983
|
+
const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line)));
|
|
984
|
+
if (missing) {
|
|
985
|
+
this.#streamingEditAbortTriggered = true;
|
|
986
|
+
logger.warn("Streaming edit aborted due to patch preview failure", {
|
|
987
|
+
toolCallId: toolCall.id,
|
|
988
|
+
path,
|
|
989
|
+
error: `Failed to find expected lines in ${path}:\n${missing}`,
|
|
990
|
+
});
|
|
991
|
+
this.agent.abort();
|
|
992
|
+
}
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (assistantEvent.type === "toolcall_delta") return;
|
|
996
|
+
void this.#checkRemovedLinesAsync(toolCall.id, path, resolvedPath, removedLines);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
if (assistantEvent.type === "toolcall_delta") return;
|
|
1001
|
+
void this.#checkPreviewPatchAsync(toolCall.id, path, rename, normalizedDiff);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async #checkRemovedLinesAsync(
|
|
1005
|
+
toolCallId: string,
|
|
1006
|
+
path: string,
|
|
1007
|
+
resolvedPath: string,
|
|
1008
|
+
removedLines: string[],
|
|
1009
|
+
): Promise<void> {
|
|
1010
|
+
if (this.#streamingEditAbortTriggered) return;
|
|
1011
|
+
try {
|
|
1012
|
+
const { text } = stripBom(await Bun.file(resolvedPath).text());
|
|
1013
|
+
const normalizedContent = normalizeToLF(text);
|
|
1014
|
+
const missing = removedLines.find(line => !normalizedContent.includes(normalizeToLF(line)));
|
|
1015
|
+
if (missing) {
|
|
1016
|
+
this.#streamingEditAbortTriggered = true;
|
|
1017
|
+
logger.warn("Streaming edit aborted due to patch preview failure", {
|
|
1018
|
+
toolCallId,
|
|
1019
|
+
path,
|
|
1020
|
+
error: `Failed to find expected lines in ${path}:\n${missing}`,
|
|
1021
|
+
});
|
|
1022
|
+
this.agent.abort();
|
|
1023
|
+
}
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
// Ignore ENOENT (file not found) - let the edit tool handle missing files
|
|
1026
|
+
// Also ignore other errors during async fallback
|
|
1027
|
+
if (!isEnoent(err)) {
|
|
1028
|
+
// Log unexpected errors but don't abort
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async #checkPreviewPatchAsync(
|
|
1034
|
+
toolCallId: string,
|
|
1035
|
+
path: string,
|
|
1036
|
+
rename: string | undefined,
|
|
1037
|
+
normalizedDiff: string,
|
|
1038
|
+
): Promise<void> {
|
|
1039
|
+
if (this.#streamingEditAbortTriggered) return;
|
|
1040
|
+
try {
|
|
1041
|
+
await previewPatch(
|
|
1042
|
+
{ path, op: "update", rename, diff: normalizedDiff },
|
|
1043
|
+
{
|
|
1044
|
+
cwd: this.sessionManager.getCwd(),
|
|
1045
|
+
allowFuzzy: this.settings.get("edit.fuzzyMatch"),
|
|
1046
|
+
fuzzyThreshold: this.settings.get("edit.fuzzyThreshold"),
|
|
1047
|
+
},
|
|
1048
|
+
);
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
if (error instanceof ParseError) return;
|
|
1051
|
+
this.#streamingEditAbortTriggered = true;
|
|
1052
|
+
logger.warn("Streaming edit aborted due to patch preview failure", {
|
|
1053
|
+
toolCallId,
|
|
1054
|
+
path,
|
|
1055
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1056
|
+
});
|
|
1057
|
+
this.agent.abort();
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Rewrite tool call arguments in agent state and persisted session history. */
|
|
1062
|
+
async #rewriteToolCallArgs(toolCallId: string, args: Record<string, unknown>): Promise<void> {
|
|
1063
|
+
let updated = false;
|
|
1064
|
+
const messages = this.agent.state.messages;
|
|
1065
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1066
|
+
const msg = messages[i];
|
|
1067
|
+
if (msg.role !== "assistant") continue;
|
|
1068
|
+
const assistantMsg = msg as AssistantMessage;
|
|
1069
|
+
if (!Array.isArray(assistantMsg.content)) continue;
|
|
1070
|
+
for (const block of assistantMsg.content) {
|
|
1071
|
+
if (typeof block !== "object" || block === null) continue;
|
|
1072
|
+
if (!("type" in block) || (block as { type?: string }).type !== "toolCall") continue;
|
|
1073
|
+
const toolCall = block as { id?: string; arguments?: Record<string, unknown> };
|
|
1074
|
+
if (toolCall.id === toolCallId) {
|
|
1075
|
+
toolCall.arguments = args;
|
|
1076
|
+
updated = true;
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
if (updated) break;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (updated) {
|
|
1084
|
+
await this.sessionManager.rewriteAssistantToolCallArgs(toolCallId, args);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** Emit extension events based on session events */
|
|
1089
|
+
async #emitExtensionEvent(event: AgentSessionEvent): Promise<void> {
|
|
1090
|
+
if (!this.#extensionRunner) return;
|
|
1091
|
+
if (event.type === "agent_start") {
|
|
1092
|
+
this.#turnIndex = 0;
|
|
1093
|
+
await this.#extensionRunner.emit({ type: "agent_start" });
|
|
1094
|
+
} else if (event.type === "agent_end") {
|
|
1095
|
+
await this.#extensionRunner.emit({ type: "agent_end", messages: event.messages });
|
|
1096
|
+
} else if (event.type === "turn_start") {
|
|
1097
|
+
const hookEvent: TurnStartEvent = {
|
|
1098
|
+
type: "turn_start",
|
|
1099
|
+
turnIndex: this.#turnIndex,
|
|
1100
|
+
timestamp: Date.now(),
|
|
1101
|
+
};
|
|
1102
|
+
await this.#extensionRunner.emit(hookEvent);
|
|
1103
|
+
} else if (event.type === "turn_end") {
|
|
1104
|
+
const hookEvent: TurnEndEvent = {
|
|
1105
|
+
type: "turn_end",
|
|
1106
|
+
turnIndex: this.#turnIndex,
|
|
1107
|
+
message: event.message,
|
|
1108
|
+
toolResults: event.toolResults,
|
|
1109
|
+
};
|
|
1110
|
+
await this.#extensionRunner.emit(hookEvent);
|
|
1111
|
+
this.#turnIndex++;
|
|
1112
|
+
} else if (event.type === "message_start") {
|
|
1113
|
+
const extensionEvent: MessageStartEvent = {
|
|
1114
|
+
type: "message_start",
|
|
1115
|
+
message: event.message,
|
|
1116
|
+
};
|
|
1117
|
+
await this.#extensionRunner.emit(extensionEvent);
|
|
1118
|
+
} else if (event.type === "message_update") {
|
|
1119
|
+
const extensionEvent: MessageUpdateEvent = {
|
|
1120
|
+
type: "message_update",
|
|
1121
|
+
message: event.message,
|
|
1122
|
+
assistantMessageEvent: event.assistantMessageEvent,
|
|
1123
|
+
};
|
|
1124
|
+
await this.#extensionRunner.emit(extensionEvent);
|
|
1125
|
+
} else if (event.type === "message_end") {
|
|
1126
|
+
const extensionEvent: MessageEndEvent = {
|
|
1127
|
+
type: "message_end",
|
|
1128
|
+
message: event.message,
|
|
1129
|
+
};
|
|
1130
|
+
await this.#extensionRunner.emit(extensionEvent);
|
|
1131
|
+
} else if (event.type === "tool_execution_start") {
|
|
1132
|
+
const extensionEvent: ToolExecutionStartEvent = {
|
|
1133
|
+
type: "tool_execution_start",
|
|
1134
|
+
toolCallId: event.toolCallId,
|
|
1135
|
+
toolName: event.toolName,
|
|
1136
|
+
args: event.args,
|
|
1137
|
+
intent: event.intent,
|
|
1138
|
+
};
|
|
1139
|
+
await this.#extensionRunner.emit(extensionEvent);
|
|
1140
|
+
} else if (event.type === "tool_execution_update") {
|
|
1141
|
+
const extensionEvent: ToolExecutionUpdateEvent = {
|
|
1142
|
+
type: "tool_execution_update",
|
|
1143
|
+
toolCallId: event.toolCallId,
|
|
1144
|
+
toolName: event.toolName,
|
|
1145
|
+
args: event.args,
|
|
1146
|
+
partialResult: event.partialResult,
|
|
1147
|
+
};
|
|
1148
|
+
await this.#extensionRunner.emit(extensionEvent);
|
|
1149
|
+
} else if (event.type === "tool_execution_end") {
|
|
1150
|
+
const extensionEvent: ToolExecutionEndEvent = {
|
|
1151
|
+
type: "tool_execution_end",
|
|
1152
|
+
toolCallId: event.toolCallId,
|
|
1153
|
+
toolName: event.toolName,
|
|
1154
|
+
result: event.result,
|
|
1155
|
+
isError: event.isError ?? false,
|
|
1156
|
+
};
|
|
1157
|
+
await this.#extensionRunner.emit(extensionEvent);
|
|
1158
|
+
} else if (event.type === "auto_compaction_start") {
|
|
1159
|
+
await this.#extensionRunner.emit({ type: "auto_compaction_start", reason: event.reason });
|
|
1160
|
+
} else if (event.type === "auto_compaction_end") {
|
|
1161
|
+
await this.#extensionRunner.emit({
|
|
1162
|
+
type: "auto_compaction_end",
|
|
1163
|
+
result: event.result,
|
|
1164
|
+
aborted: event.aborted,
|
|
1165
|
+
willRetry: event.willRetry,
|
|
1166
|
+
errorMessage: event.errorMessage,
|
|
1167
|
+
});
|
|
1168
|
+
} else if (event.type === "auto_retry_start") {
|
|
1169
|
+
await this.#extensionRunner.emit({
|
|
1170
|
+
type: "auto_retry_start",
|
|
1171
|
+
attempt: event.attempt,
|
|
1172
|
+
maxAttempts: event.maxAttempts,
|
|
1173
|
+
delayMs: event.delayMs,
|
|
1174
|
+
errorMessage: event.errorMessage,
|
|
1175
|
+
});
|
|
1176
|
+
} else if (event.type === "auto_retry_end") {
|
|
1177
|
+
await this.#extensionRunner.emit({
|
|
1178
|
+
type: "auto_retry_end",
|
|
1179
|
+
success: event.success,
|
|
1180
|
+
attempt: event.attempt,
|
|
1181
|
+
finalError: event.finalError,
|
|
1182
|
+
});
|
|
1183
|
+
} else if (event.type === "ttsr_triggered") {
|
|
1184
|
+
await this.#extensionRunner.emit({ type: "ttsr_triggered", rules: event.rules });
|
|
1185
|
+
} else if (event.type === "todo_reminder") {
|
|
1186
|
+
await this.#extensionRunner.emit({
|
|
1187
|
+
type: "todo_reminder",
|
|
1188
|
+
todos: event.todos,
|
|
1189
|
+
attempt: event.attempt,
|
|
1190
|
+
maxAttempts: event.maxAttempts,
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Subscribe to agent events.
|
|
1197
|
+
* Session persistence is handled internally (saves messages on message_end).
|
|
1198
|
+
* Multiple listeners can be added. Returns unsubscribe function for this listener.
|
|
1199
|
+
*/
|
|
1200
|
+
subscribe(listener: AgentSessionEventListener): () => void {
|
|
1201
|
+
this.#eventListeners.push(listener);
|
|
1202
|
+
|
|
1203
|
+
// Return unsubscribe function for this specific listener
|
|
1204
|
+
return () => {
|
|
1205
|
+
const index = this.#eventListeners.indexOf(listener);
|
|
1206
|
+
if (index !== -1) {
|
|
1207
|
+
this.#eventListeners.splice(index, 1);
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Temporarily disconnect from agent events.
|
|
1214
|
+
* User listeners are preserved and will receive events again after resubscribe().
|
|
1215
|
+
* Used internally during operations that need to pause event processing.
|
|
1216
|
+
*/
|
|
1217
|
+
#disconnectFromAgent(): void {
|
|
1218
|
+
if (this.#unsubscribeAgent) {
|
|
1219
|
+
this.#unsubscribeAgent();
|
|
1220
|
+
this.#unsubscribeAgent = undefined;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Reconnect to agent events after _disconnectFromAgent().
|
|
1226
|
+
* Preserves all existing listeners.
|
|
1227
|
+
*/
|
|
1228
|
+
#reconnectToAgent(): void {
|
|
1229
|
+
if (this.#unsubscribeAgent) return; // Already connected
|
|
1230
|
+
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Remove all listeners, flush pending writes, and disconnect from agent.
|
|
1235
|
+
* Call this when completely done with the session.
|
|
1236
|
+
*/
|
|
1237
|
+
async dispose(): Promise<void> {
|
|
1238
|
+
await this.sessionManager.flush();
|
|
1239
|
+
await cleanupSshResources();
|
|
1240
|
+
for (const state of this.#providerSessionState.values()) {
|
|
1241
|
+
state.close();
|
|
1242
|
+
}
|
|
1243
|
+
this.#providerSessionState.clear();
|
|
1244
|
+
this.#disconnectFromAgent();
|
|
1245
|
+
this.#eventListeners = [];
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// =========================================================================
|
|
1249
|
+
// Read-only State Access
|
|
1250
|
+
// =========================================================================
|
|
1251
|
+
|
|
1252
|
+
/** Full agent state */
|
|
1253
|
+
get state(): AgentState {
|
|
1254
|
+
return this.agent.state;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/** Current model (may be undefined if not yet selected) */
|
|
1258
|
+
get model(): Model | undefined {
|
|
1259
|
+
return this.agent.state.model;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
#applySessionModelOverrides(model: Model): Model {
|
|
1263
|
+
if (!this.#forceCopilotAgentInitiator || model.provider !== "github-copilot") {
|
|
1264
|
+
return model;
|
|
1265
|
+
}
|
|
1266
|
+
return {
|
|
1267
|
+
...model,
|
|
1268
|
+
headers: {
|
|
1269
|
+
...model.headers,
|
|
1270
|
+
"X-Initiator": "agent",
|
|
1271
|
+
},
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** Current thinking level */
|
|
1276
|
+
get thinkingLevel(): ThinkingLevel {
|
|
1277
|
+
return this.agent.state.thinkingLevel;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/** Whether agent is currently streaming a response */
|
|
1281
|
+
get isStreaming(): boolean {
|
|
1282
|
+
return this.agent.state.isStreaming || this.#promptInFlight;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** Current effective system prompt (includes any per-turn extension modifications) */
|
|
1286
|
+
get systemPrompt(): string {
|
|
1287
|
+
return this.agent.state.systemPrompt;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** Current retry attempt (0 if not retrying) */
|
|
1291
|
+
get retryAttempt(): number {
|
|
1292
|
+
return this.#retryAttempt;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Get the names of currently active tools.
|
|
1297
|
+
* Returns the names of tools currently set on the agent.
|
|
1298
|
+
*/
|
|
1299
|
+
getActiveToolNames(): string[] {
|
|
1300
|
+
return this.agent.state.tools.map(t => t.name);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/** Whether the edit tool is registered in this session. */
|
|
1304
|
+
get hasEditTool(): boolean {
|
|
1305
|
+
return this.#toolRegistry.has("edit");
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Get a tool by name from the registry.
|
|
1310
|
+
*/
|
|
1311
|
+
getToolByName(name: string): AgentTool | undefined {
|
|
1312
|
+
return this.#toolRegistry.get(name);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Get all configured tool names (built-in via --tools or default, plus custom tools).
|
|
1317
|
+
*/
|
|
1318
|
+
getAllToolNames(): string[] {
|
|
1319
|
+
return Array.from(this.#toolRegistry.keys());
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Set active tools by name.
|
|
1324
|
+
* Only tools in the registry can be enabled. Unknown tool names are ignored.
|
|
1325
|
+
* Also rebuilds the system prompt to reflect the new tool set.
|
|
1326
|
+
* Changes take effect on the next agent turn.
|
|
1327
|
+
*/
|
|
1328
|
+
async setActiveToolsByName(toolNames: string[]): Promise<void> {
|
|
1329
|
+
const tools: AgentTool[] = [];
|
|
1330
|
+
const validToolNames: string[] = [];
|
|
1331
|
+
for (const name of toolNames) {
|
|
1332
|
+
const tool = this.#toolRegistry.get(name);
|
|
1333
|
+
if (tool) {
|
|
1334
|
+
tools.push(tool);
|
|
1335
|
+
validToolNames.push(name);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
this.agent.setTools(tools);
|
|
1339
|
+
|
|
1340
|
+
// Rebuild base system prompt with new tool set
|
|
1341
|
+
if (this.#rebuildSystemPrompt) {
|
|
1342
|
+
this.#baseSystemPrompt = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
|
|
1343
|
+
this.agent.setSystemPrompt(this.#baseSystemPrompt);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** Rebuild the base system prompt using the current active tool set. */
|
|
1348
|
+
async refreshBaseSystemPrompt(): Promise<void> {
|
|
1349
|
+
if (!this.#rebuildSystemPrompt) return;
|
|
1350
|
+
const activeToolNames = this.getActiveToolNames();
|
|
1351
|
+
this.#baseSystemPrompt = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
|
|
1352
|
+
this.agent.setSystemPrompt(this.#baseSystemPrompt);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Replace MCP tools in the registry and activate the latest MCP tool set immediately.
|
|
1357
|
+
* This allows /mcp add/remove/reauth to take effect without restarting the session.
|
|
1358
|
+
*/
|
|
1359
|
+
async refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
|
|
1360
|
+
const prefix = "mcp_";
|
|
1361
|
+
const existingNames = Array.from(this.#toolRegistry.keys());
|
|
1362
|
+
for (const name of existingNames) {
|
|
1363
|
+
if (name.startsWith(prefix)) {
|
|
1364
|
+
this.#toolRegistry.delete(name);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const getCustomToolContext = (): CustomToolContext => ({
|
|
1369
|
+
sessionManager: this.sessionManager,
|
|
1370
|
+
modelRegistry: this.#modelRegistry,
|
|
1371
|
+
model: this.model,
|
|
1372
|
+
isIdle: () => !this.isStreaming,
|
|
1373
|
+
hasQueuedMessages: () => this.queuedMessageCount > 0,
|
|
1374
|
+
abort: () => {
|
|
1375
|
+
this.agent.abort();
|
|
1376
|
+
},
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
for (const customTool of mcpTools) {
|
|
1380
|
+
const wrapped = CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool;
|
|
1381
|
+
const finalTool = (
|
|
1382
|
+
this.#extensionRunner ? new ExtensionToolWrapper(wrapped, this.#extensionRunner) : wrapped
|
|
1383
|
+
) as AgentTool;
|
|
1384
|
+
this.#toolRegistry.set(finalTool.name, finalTool);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const currentActive = this.getActiveToolNames().filter(
|
|
1388
|
+
name => !name.startsWith(prefix) && this.#toolRegistry.has(name),
|
|
1389
|
+
);
|
|
1390
|
+
const mcpToolNames = Array.from(this.#toolRegistry.keys()).filter(name => name.startsWith(prefix));
|
|
1391
|
+
const nextActive = [...currentActive];
|
|
1392
|
+
for (const name of mcpToolNames) {
|
|
1393
|
+
if (!nextActive.includes(name)) {
|
|
1394
|
+
nextActive.push(name);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
await this.setActiveToolsByName(nextActive);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** Whether auto-compaction is currently running */
|
|
1402
|
+
get isCompacting(): boolean {
|
|
1403
|
+
return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** All messages including custom types like BashExecutionMessage */
|
|
1407
|
+
get messages(): AgentMessage[] {
|
|
1408
|
+
return this.agent.state.messages;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Current steering mode */
|
|
1412
|
+
get steeringMode(): "all" | "one-at-a-time" {
|
|
1413
|
+
return this.agent.getSteeringMode();
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/** Current follow-up mode */
|
|
1417
|
+
get followUpMode(): "all" | "one-at-a-time" {
|
|
1418
|
+
return this.agent.getFollowUpMode();
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** Current interrupt mode */
|
|
1422
|
+
get interruptMode(): "immediate" | "wait" {
|
|
1423
|
+
return this.agent.getInterruptMode();
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/** Current session file path, or undefined if sessions are disabled */
|
|
1427
|
+
get sessionFile(): string | undefined {
|
|
1428
|
+
return this.sessionManager.getSessionFile();
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/** Current session ID */
|
|
1432
|
+
get sessionId(): string {
|
|
1433
|
+
return this.sessionManager.getSessionId();
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** Current session display name, if set */
|
|
1437
|
+
get sessionName(): string | undefined {
|
|
1438
|
+
return this.sessionManager.getSessionName();
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/** Scoped models for cycling (from --models flag) */
|
|
1442
|
+
get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel: ThinkingLevel }> {
|
|
1443
|
+
return this.#scopedModels;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
resolveRoleModel(role: ModelRole): Model | undefined {
|
|
1447
|
+
return this.#resolveRoleModel(role, this.#modelRegistry.getAvailable(), this.model);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
get promptTemplates(): ReadonlyArray<PromptTemplate> {
|
|
1451
|
+
return this.#promptTemplates;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** Replace file-based slash commands used for prompt expansion. */
|
|
1455
|
+
setSlashCommands(slashCommands: FileSlashCommand[]): void {
|
|
1456
|
+
this.#slashCommands = [...slashCommands];
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/** Custom commands (TypeScript slash commands) */
|
|
1460
|
+
get customCommands(): ReadonlyArray<LoadedCustomCommand> {
|
|
1461
|
+
return this.#customCommands;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// =========================================================================
|
|
1465
|
+
// Prompting
|
|
1466
|
+
// =========================================================================
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Send a prompt to the agent.
|
|
1470
|
+
* - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
|
|
1471
|
+
* - Expands file-based prompt templates by default
|
|
1472
|
+
* - During streaming, queues via steer() or followUp() based on streamingBehavior option
|
|
1473
|
+
* - Validates model and API key before sending (when not streaming)
|
|
1474
|
+
* @throws Error if streaming and no streamingBehavior specified
|
|
1475
|
+
* @throws Error if no model selected or no API key available (when not streaming)
|
|
1476
|
+
*/
|
|
1477
|
+
async prompt(text: string, options?: PromptOptions): Promise<void> {
|
|
1478
|
+
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
|
|
1479
|
+
|
|
1480
|
+
// Handle extension commands first (execute immediately, even during streaming)
|
|
1481
|
+
if (expandPromptTemplates && text.startsWith("/")) {
|
|
1482
|
+
const handled = await this.#tryExecuteExtensionCommand(text);
|
|
1483
|
+
if (handled) {
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// Try custom commands (TypeScript slash commands)
|
|
1488
|
+
const customResult = await this.#tryExecuteCustomCommand(text);
|
|
1489
|
+
if (customResult !== null) {
|
|
1490
|
+
if (customResult === "") {
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
text = customResult;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// Try file-based slash commands (markdown files from commands/ directories)
|
|
1497
|
+
// Only if text still starts with "/" (wasn't transformed by custom command)
|
|
1498
|
+
if (text.startsWith("/")) {
|
|
1499
|
+
text = expandSlashCommand(text, this.#slashCommands);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// Expand file-based prompt templates if requested
|
|
1504
|
+
const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
|
|
1505
|
+
|
|
1506
|
+
// If streaming, queue via steer() or followUp() based on option
|
|
1507
|
+
if (this.isStreaming) {
|
|
1508
|
+
if (!options?.streamingBehavior) {
|
|
1509
|
+
throw new AgentBusyError();
|
|
1510
|
+
}
|
|
1511
|
+
if (options.streamingBehavior === "followUp") {
|
|
1512
|
+
await this.#queueFollowUp(expandedText, options?.images);
|
|
1513
|
+
} else {
|
|
1514
|
+
await this.#queueSteer(expandedText, options?.images);
|
|
1515
|
+
}
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
|
|
1520
|
+
if (options?.images) {
|
|
1521
|
+
userContent.push(...options.images);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
await this.#promptWithMessage(
|
|
1525
|
+
{
|
|
1526
|
+
role: "user",
|
|
1527
|
+
content: userContent,
|
|
1528
|
+
synthetic: options?.synthetic,
|
|
1529
|
+
timestamp: Date.now(),
|
|
1530
|
+
},
|
|
1531
|
+
expandedText,
|
|
1532
|
+
options,
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
async promptCustomMessage<T = unknown>(
|
|
1537
|
+
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
|
|
1538
|
+
options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice">,
|
|
1539
|
+
): Promise<void> {
|
|
1540
|
+
const textContent =
|
|
1541
|
+
typeof message.content === "string"
|
|
1542
|
+
? message.content
|
|
1543
|
+
: message.content
|
|
1544
|
+
.filter((content): content is TextContent => content.type === "text")
|
|
1545
|
+
.map(content => content.text)
|
|
1546
|
+
.join("");
|
|
1547
|
+
|
|
1548
|
+
if (this.isStreaming) {
|
|
1549
|
+
if (!options?.streamingBehavior) {
|
|
1550
|
+
throw new AgentBusyError();
|
|
1551
|
+
}
|
|
1552
|
+
await this.sendCustomMessage(message, { deliverAs: options.streamingBehavior });
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const customMessage: CustomMessage<T> = {
|
|
1557
|
+
role: "custom",
|
|
1558
|
+
customType: message.customType,
|
|
1559
|
+
content: message.content,
|
|
1560
|
+
display: message.display,
|
|
1561
|
+
details: message.details,
|
|
1562
|
+
timestamp: Date.now(),
|
|
1563
|
+
};
|
|
1564
|
+
|
|
1565
|
+
await this.#promptWithMessage(customMessage, textContent, options);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
async #promptWithMessage(
|
|
1569
|
+
message: AgentMessage,
|
|
1570
|
+
expandedText: string,
|
|
1571
|
+
options?: Pick<PromptOptions, "toolChoice" | "images">,
|
|
1572
|
+
): Promise<void> {
|
|
1573
|
+
this.#promptInFlight = true;
|
|
1574
|
+
const generation = this.#promptGeneration;
|
|
1575
|
+
try {
|
|
1576
|
+
// Flush any pending bash messages before the new prompt
|
|
1577
|
+
this.#flushPendingBashMessages();
|
|
1578
|
+
this.#flushPendingPythonMessages();
|
|
1579
|
+
|
|
1580
|
+
// Reset todo reminder count on new user prompt
|
|
1581
|
+
this.#todoReminderCount = 0;
|
|
1582
|
+
|
|
1583
|
+
// Validate model
|
|
1584
|
+
if (!this.model) {
|
|
1585
|
+
throw new Error(
|
|
1586
|
+
"No model selected.\n\n" +
|
|
1587
|
+
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}\n\n` +
|
|
1588
|
+
"Then use /model to select a model.",
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// Validate API key
|
|
1593
|
+
const apiKey = await this.#modelRegistry.getApiKey(this.model, this.sessionId);
|
|
1594
|
+
if (!apiKey) {
|
|
1595
|
+
throw new Error(
|
|
1596
|
+
`No API key found for ${this.model.provider}.\n\n` +
|
|
1597
|
+
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}`,
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Check if we need to compact before sending (catches aborted responses)
|
|
1602
|
+
const lastAssistant = this.#findLastAssistantMessage();
|
|
1603
|
+
if (lastAssistant) {
|
|
1604
|
+
await this.#checkCompaction(lastAssistant, false);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// Build messages array (custom messages if any, then user message)
|
|
1608
|
+
const messages: AgentMessage[] = [];
|
|
1609
|
+
|
|
1610
|
+
messages.push(message);
|
|
1611
|
+
|
|
1612
|
+
// Early bail-out: if a newer abort/prompt cycle started during setup,
|
|
1613
|
+
// return before mutating shared state (nextTurn messages, system prompt).
|
|
1614
|
+
if (this.#promptGeneration !== generation) {
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// Inject any pending "nextTurn" messages as context alongside the user message
|
|
1619
|
+
for (const msg of this.#pendingNextTurnMessages) {
|
|
1620
|
+
messages.push(msg);
|
|
1621
|
+
}
|
|
1622
|
+
this.#pendingNextTurnMessages = [];
|
|
1623
|
+
|
|
1624
|
+
// Auto-read @filepath mentions
|
|
1625
|
+
const fileMentions = extractFileMentions(expandedText);
|
|
1626
|
+
if (fileMentions.length > 0) {
|
|
1627
|
+
const fileMentionMessages = await generateFileMentionMessages(fileMentions, this.sessionManager.getCwd(), {
|
|
1628
|
+
autoResizeImages: this.settings.get("images.autoResize"),
|
|
1629
|
+
useHashLines: resolveFileDisplayMode(this).hashLines,
|
|
1630
|
+
});
|
|
1631
|
+
messages.push(...fileMentionMessages);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// Emit before_agent_start extension event
|
|
1635
|
+
if (this.#extensionRunner) {
|
|
1636
|
+
const result = await this.#extensionRunner.emitBeforeAgentStart(
|
|
1637
|
+
expandedText,
|
|
1638
|
+
options?.images,
|
|
1639
|
+
this.#baseSystemPrompt,
|
|
1640
|
+
);
|
|
1641
|
+
if (result?.messages) {
|
|
1642
|
+
for (const msg of result.messages) {
|
|
1643
|
+
messages.push({
|
|
1644
|
+
role: "custom",
|
|
1645
|
+
customType: msg.customType,
|
|
1646
|
+
content: msg.content,
|
|
1647
|
+
display: msg.display,
|
|
1648
|
+
details: msg.details,
|
|
1649
|
+
timestamp: Date.now(),
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
if (result?.systemPrompt !== undefined) {
|
|
1655
|
+
this.agent.setSystemPrompt(result.systemPrompt);
|
|
1656
|
+
} else {
|
|
1657
|
+
this.agent.setSystemPrompt(this.#baseSystemPrompt);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Bail out if a newer abort/prompt cycle has started since we began setup
|
|
1662
|
+
if (this.#promptGeneration !== generation) {
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
|
|
1667
|
+
await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
|
|
1668
|
+
await this.#waitForRetry();
|
|
1669
|
+
} finally {
|
|
1670
|
+
this.#promptInFlight = false;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Try to execute an extension command. Returns true if command was found and executed.
|
|
1676
|
+
*/
|
|
1677
|
+
async #tryExecuteExtensionCommand(text: string): Promise<boolean> {
|
|
1678
|
+
if (!this.#extensionRunner) return false;
|
|
1679
|
+
|
|
1680
|
+
// Parse command name and args
|
|
1681
|
+
const spaceIndex = text.indexOf(" ");
|
|
1682
|
+
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
|
1683
|
+
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
|
|
1684
|
+
|
|
1685
|
+
const command = this.#extensionRunner.getCommand(commandName);
|
|
1686
|
+
if (!command) return false;
|
|
1687
|
+
|
|
1688
|
+
// Get command context from extension runner (includes session control methods)
|
|
1689
|
+
const ctx = this.#extensionRunner.createCommandContext();
|
|
1690
|
+
|
|
1691
|
+
try {
|
|
1692
|
+
await command.handler(args, ctx);
|
|
1693
|
+
return true;
|
|
1694
|
+
} catch (err) {
|
|
1695
|
+
// Emit error via extension runner
|
|
1696
|
+
this.#extensionRunner.emitError({
|
|
1697
|
+
extensionPath: `command:${commandName}`,
|
|
1698
|
+
event: "command",
|
|
1699
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1700
|
+
});
|
|
1701
|
+
return true;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
#createCommandContext(): ExtensionCommandContext {
|
|
1706
|
+
if (this.#extensionRunner) {
|
|
1707
|
+
return this.#extensionRunner.createCommandContext();
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
return {
|
|
1711
|
+
ui: noOpUIContext,
|
|
1712
|
+
hasUI: false,
|
|
1713
|
+
cwd: this.sessionManager.getCwd(),
|
|
1714
|
+
sessionManager: this.sessionManager,
|
|
1715
|
+
modelRegistry: this.#modelRegistry,
|
|
1716
|
+
model: this.model ?? undefined,
|
|
1717
|
+
isIdle: () => !this.isStreaming,
|
|
1718
|
+
abort: () => {
|
|
1719
|
+
void this.abort();
|
|
1720
|
+
},
|
|
1721
|
+
hasPendingMessages: () => this.queuedMessageCount > 0,
|
|
1722
|
+
shutdown: () => {
|
|
1723
|
+
void this.dispose();
|
|
1724
|
+
process.exit(0);
|
|
1725
|
+
},
|
|
1726
|
+
hasQueuedMessages: () => this.queuedMessageCount > 0,
|
|
1727
|
+
getContextUsage: () => this.getContextUsage(),
|
|
1728
|
+
waitForIdle: () => this.agent.waitForIdle(),
|
|
1729
|
+
newSession: async options => {
|
|
1730
|
+
const success = await this.newSession({ parentSession: options?.parentSession });
|
|
1731
|
+
if (!success) {
|
|
1732
|
+
return { cancelled: true };
|
|
1733
|
+
}
|
|
1734
|
+
if (options?.setup) {
|
|
1735
|
+
await options.setup(this.sessionManager);
|
|
1736
|
+
}
|
|
1737
|
+
return { cancelled: false };
|
|
1738
|
+
},
|
|
1739
|
+
branch: async entryId => {
|
|
1740
|
+
const result = await this.branch(entryId);
|
|
1741
|
+
return { cancelled: result.cancelled };
|
|
1742
|
+
},
|
|
1743
|
+
navigateTree: async (targetId, options) => {
|
|
1744
|
+
const result = await this.navigateTree(targetId, { summarize: options?.summarize });
|
|
1745
|
+
return { cancelled: result.cancelled };
|
|
1746
|
+
},
|
|
1747
|
+
compact: async instructionsOrOptions => {
|
|
1748
|
+
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
|
|
1749
|
+
const options =
|
|
1750
|
+
instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
|
|
1751
|
+
await this.compact(instructions, options);
|
|
1752
|
+
},
|
|
1753
|
+
switchSession: async sessionPath => {
|
|
1754
|
+
const success = await this.switchSession(sessionPath);
|
|
1755
|
+
return { cancelled: !success };
|
|
1756
|
+
},
|
|
1757
|
+
reload: async () => {
|
|
1758
|
+
await this.reload();
|
|
1759
|
+
},
|
|
1760
|
+
getSystemPrompt: () => this.systemPrompt,
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* Try to execute a custom command. Returns the prompt string if found, null otherwise.
|
|
1766
|
+
* If the command returns void, returns empty string to indicate it was handled.
|
|
1767
|
+
*/
|
|
1768
|
+
async #tryExecuteCustomCommand(text: string): Promise<string | null> {
|
|
1769
|
+
if (this.#customCommands.length === 0) return null;
|
|
1770
|
+
|
|
1771
|
+
// Parse command name and args
|
|
1772
|
+
const spaceIndex = text.indexOf(" ");
|
|
1773
|
+
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
|
1774
|
+
const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
|
|
1775
|
+
|
|
1776
|
+
// Find matching command
|
|
1777
|
+
const loaded = this.#customCommands.find(c => c.command.name === commandName);
|
|
1778
|
+
if (!loaded) return null;
|
|
1779
|
+
|
|
1780
|
+
// Get command context from extension runner (includes session control methods)
|
|
1781
|
+
const baseCtx = this.#createCommandContext();
|
|
1782
|
+
const ctx = {
|
|
1783
|
+
...baseCtx,
|
|
1784
|
+
hasQueuedMessages: baseCtx.hasPendingMessages,
|
|
1785
|
+
} as unknown as HookCommandContext;
|
|
1786
|
+
|
|
1787
|
+
try {
|
|
1788
|
+
const args = parseCommandArgs(argsString);
|
|
1789
|
+
const result = await loaded.command.execute(args, ctx);
|
|
1790
|
+
// If result is a string, it's a prompt to send to LLM
|
|
1791
|
+
// If void/undefined, command handled everything
|
|
1792
|
+
return result ?? "";
|
|
1793
|
+
} catch (err) {
|
|
1794
|
+
// Emit error via extension runner
|
|
1795
|
+
if (this.#extensionRunner) {
|
|
1796
|
+
this.#extensionRunner.emitError({
|
|
1797
|
+
extensionPath: `custom-command:${commandName}`,
|
|
1798
|
+
event: "command",
|
|
1799
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1800
|
+
});
|
|
1801
|
+
} else {
|
|
1802
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1803
|
+
logger.error("Custom command failed", { commandName, error: message });
|
|
1804
|
+
}
|
|
1805
|
+
return ""; // Command was handled (with error)
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* Queue a steering message to interrupt the agent mid-run.
|
|
1811
|
+
*/
|
|
1812
|
+
async steer(text: string, images?: ImageContent[]): Promise<void> {
|
|
1813
|
+
if (text.startsWith("/")) {
|
|
1814
|
+
this.#throwIfExtensionCommand(text);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
|
|
1818
|
+
await this.#queueSteer(expandedText, images);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/**
|
|
1822
|
+
* Queue a follow-up message to process after the agent would otherwise stop.
|
|
1823
|
+
*/
|
|
1824
|
+
async followUp(text: string, images?: ImageContent[]): Promise<void> {
|
|
1825
|
+
if (text.startsWith("/")) {
|
|
1826
|
+
this.#throwIfExtensionCommand(text);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
|
|
1830
|
+
await this.#queueFollowUp(expandedText, images);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/**
|
|
1834
|
+
* Internal: Queue a steering message (already expanded, no extension command check).
|
|
1835
|
+
*/
|
|
1836
|
+
async #queueSteer(text: string, images?: ImageContent[]): Promise<void> {
|
|
1837
|
+
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
|
1838
|
+
this.#steeringMessages.push(displayText);
|
|
1839
|
+
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
|
|
1840
|
+
if (images && images.length > 0) {
|
|
1841
|
+
content.push(...images);
|
|
1842
|
+
}
|
|
1843
|
+
this.agent.steer({
|
|
1844
|
+
role: "user",
|
|
1845
|
+
content,
|
|
1846
|
+
timestamp: Date.now(),
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/**
|
|
1851
|
+
* Internal: Queue a follow-up message (already expanded, no extension command check).
|
|
1852
|
+
*/
|
|
1853
|
+
async #queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
|
|
1854
|
+
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
|
1855
|
+
this.#followUpMessages.push(displayText);
|
|
1856
|
+
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
|
|
1857
|
+
if (images && images.length > 0) {
|
|
1858
|
+
content.push(...images);
|
|
1859
|
+
}
|
|
1860
|
+
this.agent.followUp({
|
|
1861
|
+
role: "user",
|
|
1862
|
+
content,
|
|
1863
|
+
timestamp: Date.now(),
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* Throw an error if the text is an extension command.
|
|
1869
|
+
*/
|
|
1870
|
+
#throwIfExtensionCommand(text: string): void {
|
|
1871
|
+
if (!this.#extensionRunner) return;
|
|
1872
|
+
|
|
1873
|
+
const spaceIndex = text.indexOf(" ");
|
|
1874
|
+
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
|
1875
|
+
const command = this.#extensionRunner.getCommand(commandName);
|
|
1876
|
+
|
|
1877
|
+
if (command) {
|
|
1878
|
+
throw new Error(
|
|
1879
|
+
`Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`,
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/**
|
|
1885
|
+
* Send a custom message to the session. Creates a CustomMessageEntry.
|
|
1886
|
+
*
|
|
1887
|
+
* Handles three cases:
|
|
1888
|
+
* - Streaming: queue as steer/follow-up or store for next turn
|
|
1889
|
+
* - Not streaming + triggerTurn: appends to state/session, starts new turn
|
|
1890
|
+
* - Not streaming + no trigger: appends to state/session, no turn
|
|
1891
|
+
*/
|
|
1892
|
+
async sendCustomMessage<T = unknown>(
|
|
1893
|
+
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
|
|
1894
|
+
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
1895
|
+
): Promise<void> {
|
|
1896
|
+
const appMessage: CustomMessage<T> = {
|
|
1897
|
+
role: "custom",
|
|
1898
|
+
customType: message.customType,
|
|
1899
|
+
content: message.content,
|
|
1900
|
+
display: message.display,
|
|
1901
|
+
details: message.details,
|
|
1902
|
+
timestamp: Date.now(),
|
|
1903
|
+
};
|
|
1904
|
+
if (this.isStreaming) {
|
|
1905
|
+
if (options?.deliverAs === "nextTurn") {
|
|
1906
|
+
this.#pendingNextTurnMessages.push(appMessage);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
if (options?.deliverAs === "followUp") {
|
|
1911
|
+
this.agent.followUp(appMessage);
|
|
1912
|
+
} else {
|
|
1913
|
+
this.agent.steer(appMessage);
|
|
1914
|
+
}
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
if (options?.triggerTurn) {
|
|
1919
|
+
await this.agent.prompt(appMessage);
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
this.agent.appendMessage(appMessage);
|
|
1924
|
+
this.sessionManager.appendCustomMessageEntry(
|
|
1925
|
+
message.customType,
|
|
1926
|
+
message.content,
|
|
1927
|
+
message.display,
|
|
1928
|
+
message.details,
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/**
|
|
1933
|
+
* Send a user message to the agent. Always triggers a turn.
|
|
1934
|
+
* When the agent is streaming, use deliverAs to specify how to queue the message.
|
|
1935
|
+
*
|
|
1936
|
+
* @param content User message content (string or content array)
|
|
1937
|
+
* @param options.deliverAs Delivery mode when streaming: "steer" or "followUp"
|
|
1938
|
+
*/
|
|
1939
|
+
async sendUserMessage(
|
|
1940
|
+
content: string | (TextContent | ImageContent)[],
|
|
1941
|
+
options?: { deliverAs?: "steer" | "followUp" },
|
|
1942
|
+
): Promise<void> {
|
|
1943
|
+
// Normalize content to text string + optional images
|
|
1944
|
+
let text: string;
|
|
1945
|
+
let images: ImageContent[] | undefined;
|
|
1946
|
+
|
|
1947
|
+
if (typeof content === "string") {
|
|
1948
|
+
text = content;
|
|
1949
|
+
} else {
|
|
1950
|
+
const textParts: string[] = [];
|
|
1951
|
+
images = [];
|
|
1952
|
+
for (const part of content) {
|
|
1953
|
+
if (part.type === "text") {
|
|
1954
|
+
textParts.push(part.text);
|
|
1955
|
+
} else {
|
|
1956
|
+
images.push(part);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
text = textParts.join("\n");
|
|
1960
|
+
if (images.length === 0) images = undefined;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
|
|
1964
|
+
await this.prompt(text, {
|
|
1965
|
+
expandPromptTemplates: false,
|
|
1966
|
+
streamingBehavior: options?.deliverAs,
|
|
1967
|
+
images,
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* Clear queued messages and return them.
|
|
1973
|
+
* Useful for restoring to editor when user aborts.
|
|
1974
|
+
*/
|
|
1975
|
+
clearQueue(): { steering: string[]; followUp: string[] } {
|
|
1976
|
+
const steering = [...this.#steeringMessages];
|
|
1977
|
+
const followUp = [...this.#followUpMessages];
|
|
1978
|
+
this.#steeringMessages = [];
|
|
1979
|
+
this.#followUpMessages = [];
|
|
1980
|
+
this.agent.clearAllQueues();
|
|
1981
|
+
return { steering, followUp };
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
/** Number of pending messages (includes both steering and follow-up) */
|
|
1985
|
+
get queuedMessageCount(): number {
|
|
1986
|
+
return this.#steeringMessages.length + this.#followUpMessages.length;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/** Get pending messages (read-only) */
|
|
1990
|
+
getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } {
|
|
1991
|
+
return { steering: this.#steeringMessages, followUp: this.#followUpMessages };
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/**
|
|
1995
|
+
* Pop the last queued message (steering first, then follow-up).
|
|
1996
|
+
* Used by dequeue keybinding to restore messages to editor one at a time.
|
|
1997
|
+
*/
|
|
1998
|
+
popLastQueuedMessage(): string | undefined {
|
|
1999
|
+
// Pop from steering first (LIFO)
|
|
2000
|
+
if (this.#steeringMessages.length > 0) {
|
|
2001
|
+
const message = this.#steeringMessages.pop();
|
|
2002
|
+
this.agent.popLastSteer();
|
|
2003
|
+
return message;
|
|
2004
|
+
}
|
|
2005
|
+
// Then from follow-up
|
|
2006
|
+
if (this.#followUpMessages.length > 0) {
|
|
2007
|
+
const message = this.#followUpMessages.pop();
|
|
2008
|
+
this.agent.popLastFollowUp();
|
|
2009
|
+
return message;
|
|
2010
|
+
}
|
|
2011
|
+
return undefined;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
get skillsSettings(): Required<SkillsSettings> | undefined {
|
|
2015
|
+
return this.#skillsSettings;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/** Skills loaded by SDK (empty if --no-skills or skills: [] was passed) */
|
|
2019
|
+
get skills(): readonly Skill[] {
|
|
2020
|
+
return this.#skills;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
/** Skill loading warnings captured by SDK */
|
|
2024
|
+
get skillWarnings(): readonly SkillWarning[] {
|
|
2025
|
+
return this.#skillWarnings;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/**
|
|
2029
|
+
* Abort current operation and wait for agent to become idle.
|
|
2030
|
+
*/
|
|
2031
|
+
async abort(): Promise<void> {
|
|
2032
|
+
this.abortRetry();
|
|
2033
|
+
this.#promptGeneration++;
|
|
2034
|
+
this.agent.abort();
|
|
2035
|
+
await this.agent.waitForIdle();
|
|
2036
|
+
// Clear promptInFlight: waitForIdle resolves when the agent loop's finally
|
|
2037
|
+
// block runs (#resolveRunningPrompt), but #promptWithMessage's finally
|
|
2038
|
+
// (#promptInFlight = false) fires on a later microtask. Without this,
|
|
2039
|
+
// isStreaming stays true and a subsequent prompt() throws.
|
|
2040
|
+
this.#promptInFlight = false;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/**
|
|
2044
|
+
* Start a new session, optionally with initial messages and parent tracking.
|
|
2045
|
+
* Clears all messages and starts a new session.
|
|
2046
|
+
* Listeners are preserved and will continue receiving events.
|
|
2047
|
+
* @param options - Optional initial messages and parent session path
|
|
2048
|
+
* @returns true if completed, false if cancelled by hook
|
|
2049
|
+
*/
|
|
2050
|
+
async newSession(options?: NewSessionOptions): Promise<boolean> {
|
|
2051
|
+
const previousSessionFile = this.sessionFile;
|
|
2052
|
+
|
|
2053
|
+
// Emit session_before_switch event with reason "new" (can be cancelled)
|
|
2054
|
+
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
|
|
2055
|
+
const result = (await this.#extensionRunner.emit({
|
|
2056
|
+
type: "session_before_switch",
|
|
2057
|
+
reason: "new",
|
|
2058
|
+
})) as SessionBeforeSwitchResult | undefined;
|
|
2059
|
+
|
|
2060
|
+
if (result?.cancel) {
|
|
2061
|
+
return false;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
this.#disconnectFromAgent();
|
|
2066
|
+
await this.abort();
|
|
2067
|
+
this.agent.reset();
|
|
2068
|
+
await this.sessionManager.flush();
|
|
2069
|
+
await this.sessionManager.newSession(options);
|
|
2070
|
+
this.agent.sessionId = this.sessionManager.getSessionId();
|
|
2071
|
+
this.#steeringMessages = [];
|
|
2072
|
+
this.#followUpMessages = [];
|
|
2073
|
+
this.#pendingNextTurnMessages = [];
|
|
2074
|
+
|
|
2075
|
+
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
|
|
2076
|
+
|
|
2077
|
+
this.#todoReminderCount = 0;
|
|
2078
|
+
this.#reconnectToAgent();
|
|
2079
|
+
|
|
2080
|
+
// Emit session_switch event with reason "new" to hooks
|
|
2081
|
+
if (this.#extensionRunner) {
|
|
2082
|
+
await this.#extensionRunner.emit({
|
|
2083
|
+
type: "session_switch",
|
|
2084
|
+
reason: "new",
|
|
2085
|
+
previousSessionFile,
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
return true;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
/**
|
|
2093
|
+
* Set a display name for the current session.
|
|
2094
|
+
*/
|
|
2095
|
+
setSessionName(name: string): void {
|
|
2096
|
+
this.sessionManager.setSessionName(name);
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/**
|
|
2100
|
+
* Fork the current session, creating a new session file with the exact same state.
|
|
2101
|
+
* Copies all entries and artifacts to the new session.
|
|
2102
|
+
* Unlike newSession(), this preserves all messages in the agent state.
|
|
2103
|
+
* @returns true if completed, false if cancelled by hook or not persisting
|
|
2104
|
+
*/
|
|
2105
|
+
async fork(): Promise<boolean> {
|
|
2106
|
+
const previousSessionFile = this.sessionFile;
|
|
2107
|
+
|
|
2108
|
+
// Emit session_before_switch event with reason "fork" (can be cancelled)
|
|
2109
|
+
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
|
|
2110
|
+
const result = (await this.#extensionRunner.emit({
|
|
2111
|
+
type: "session_before_switch",
|
|
2112
|
+
reason: "fork",
|
|
2113
|
+
})) as SessionBeforeSwitchResult | undefined;
|
|
2114
|
+
|
|
2115
|
+
if (result?.cancel) {
|
|
2116
|
+
return false;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// Flush current session to ensure all entries are written
|
|
2121
|
+
await this.sessionManager.flush();
|
|
2122
|
+
|
|
2123
|
+
// Fork the session (creates new session file with same entries)
|
|
2124
|
+
const forkResult = await this.sessionManager.fork();
|
|
2125
|
+
if (!forkResult) {
|
|
2126
|
+
return false;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// Copy artifacts directory if it exists
|
|
2130
|
+
const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6);
|
|
2131
|
+
const newArtifactDir = forkResult.newSessionFile.slice(0, -6);
|
|
2132
|
+
|
|
2133
|
+
try {
|
|
2134
|
+
const oldDirStat = await fs.promises.stat(oldArtifactDir);
|
|
2135
|
+
if (oldDirStat.isDirectory()) {
|
|
2136
|
+
await fs.promises.cp(oldArtifactDir, newArtifactDir, { recursive: true });
|
|
2137
|
+
}
|
|
2138
|
+
} catch (err) {
|
|
2139
|
+
if (!isEnoent(err)) {
|
|
2140
|
+
logger.warn("Failed to copy artifacts during fork", {
|
|
2141
|
+
oldArtifactDir,
|
|
2142
|
+
newArtifactDir,
|
|
2143
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// Update agent session ID
|
|
2149
|
+
this.agent.sessionId = this.sessionManager.getSessionId();
|
|
2150
|
+
|
|
2151
|
+
// Emit session_switch event with reason "fork" to hooks
|
|
2152
|
+
if (this.#extensionRunner) {
|
|
2153
|
+
await this.#extensionRunner.emit({
|
|
2154
|
+
type: "session_switch",
|
|
2155
|
+
reason: "fork",
|
|
2156
|
+
previousSessionFile,
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
return true;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
// =========================================================================
|
|
2164
|
+
// Model Management
|
|
2165
|
+
// =========================================================================
|
|
2166
|
+
|
|
2167
|
+
/**
|
|
2168
|
+
* Set model directly.
|
|
2169
|
+
* Validates API key, saves to session and settings.
|
|
2170
|
+
* @throws Error if no API key available for the model
|
|
2171
|
+
*/
|
|
2172
|
+
async setModel(model: Model, role: ModelRole = "default"): Promise<void> {
|
|
2173
|
+
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
|
2174
|
+
if (!apiKey) {
|
|
2175
|
+
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
this.#setModelWithProviderSessionReset(model);
|
|
2179
|
+
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
|
|
2180
|
+
this.settings.setModelRole(role, `${model.provider}/${model.id}`);
|
|
2181
|
+
this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
|
|
2182
|
+
|
|
2183
|
+
// Re-clamp thinking level for new model's capabilities without persisting settings
|
|
2184
|
+
this.setThinkingLevel(this.thinkingLevel);
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
/**
|
|
2188
|
+
* Set model temporarily (for this session only).
|
|
2189
|
+
* Validates API key, saves to session log but NOT to settings.
|
|
2190
|
+
* @throws Error if no API key available for the model
|
|
2191
|
+
*/
|
|
2192
|
+
async setModelTemporary(model: Model): Promise<void> {
|
|
2193
|
+
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
|
2194
|
+
if (!apiKey) {
|
|
2195
|
+
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
this.#setModelWithProviderSessionReset(model);
|
|
2199
|
+
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
|
|
2200
|
+
this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
|
|
2201
|
+
|
|
2202
|
+
// Re-clamp thinking level for new model's capabilities without persisting settings
|
|
2203
|
+
this.setThinkingLevel(this.thinkingLevel);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
/**
|
|
2207
|
+
* Cycle to next/previous model.
|
|
2208
|
+
* Uses scoped models (from --models flag) if available, otherwise all available models.
|
|
2209
|
+
* @param direction - "forward" (default) or "backward"
|
|
2210
|
+
* @returns The new model info, or undefined if only one model available
|
|
2211
|
+
*/
|
|
2212
|
+
async cycleModel(direction: "forward" | "backward" = "forward"): Promise<ModelCycleResult | undefined> {
|
|
2213
|
+
if (this.#scopedModels.length > 0) {
|
|
2214
|
+
return this.#cycleScopedModel(direction);
|
|
2215
|
+
}
|
|
2216
|
+
return this.#cycleAvailableModel(direction);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* Cycle through configured role models in a fixed order.
|
|
2221
|
+
* Skips missing roles.
|
|
2222
|
+
* @param roleOrder - Order of roles to cycle through (e.g., ["oracle", "default", "fast"])
|
|
2223
|
+
* @param options - Optional settings: `temporary` to not persist to settings
|
|
2224
|
+
*/
|
|
2225
|
+
async cycleRoleModels(
|
|
2226
|
+
roleOrder: readonly ModelRole[],
|
|
2227
|
+
options?: { temporary?: boolean },
|
|
2228
|
+
): Promise<RoleModelCycleResult | undefined> {
|
|
2229
|
+
const availableModels = this.#modelRegistry.getAvailable();
|
|
2230
|
+
if (availableModels.length === 0) return undefined;
|
|
2231
|
+
|
|
2232
|
+
const currentModel = this.model;
|
|
2233
|
+
if (!currentModel) return undefined;
|
|
2234
|
+
const roleModels: Array<{ role: ModelRole; model: Model }> = [];
|
|
2235
|
+
|
|
2236
|
+
for (const role of roleOrder) {
|
|
2237
|
+
const roleModelStr =
|
|
2238
|
+
role === "default"
|
|
2239
|
+
? (this.settings.getModelRole("default") ?? `${currentModel.provider}/${currentModel.id}`)
|
|
2240
|
+
: this.settings.getModelRole(role);
|
|
2241
|
+
if (!roleModelStr) continue;
|
|
2242
|
+
|
|
2243
|
+
const expandedRoleModelStr = expandRoleAlias(roleModelStr, this.settings);
|
|
2244
|
+
const parsed = parseModelString(expandedRoleModelStr);
|
|
2245
|
+
let match: Model | undefined;
|
|
2246
|
+
if (parsed) {
|
|
2247
|
+
match = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
|
|
2248
|
+
}
|
|
2249
|
+
if (!match) {
|
|
2250
|
+
match = availableModels.find(m => m.id.toLowerCase() === expandedRoleModelStr.toLowerCase());
|
|
2251
|
+
}
|
|
2252
|
+
if (!match) continue;
|
|
2253
|
+
|
|
2254
|
+
roleModels.push({ role, model: match });
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
if (roleModels.length <= 1) return undefined;
|
|
2258
|
+
|
|
2259
|
+
const lastRole = this.sessionManager.getLastModelChangeRole();
|
|
2260
|
+
let currentIndex = lastRole
|
|
2261
|
+
? roleModels.findIndex(entry => entry.role === lastRole)
|
|
2262
|
+
: roleModels.findIndex(entry => modelsAreEqual(entry.model, currentModel));
|
|
2263
|
+
if (currentIndex === -1) currentIndex = 0;
|
|
2264
|
+
|
|
2265
|
+
const nextIndex = (currentIndex + 1) % roleModels.length;
|
|
2266
|
+
const next = roleModels[nextIndex];
|
|
2267
|
+
|
|
2268
|
+
if (options?.temporary) {
|
|
2269
|
+
await this.setModelTemporary(next.model);
|
|
2270
|
+
} else {
|
|
2271
|
+
await this.setModel(next.model, next.role);
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
async #getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel: ThinkingLevel }>> {
|
|
2278
|
+
const apiKeysByProvider = new Map<string, string | undefined>();
|
|
2279
|
+
const result: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];
|
|
2280
|
+
|
|
2281
|
+
for (const scoped of this.#scopedModels) {
|
|
2282
|
+
const provider = scoped.model.provider;
|
|
2283
|
+
let apiKey: string | undefined;
|
|
2284
|
+
if (apiKeysByProvider.has(provider)) {
|
|
2285
|
+
apiKey = apiKeysByProvider.get(provider);
|
|
2286
|
+
} else {
|
|
2287
|
+
apiKey = await this.#modelRegistry.getApiKeyForProvider(provider, this.sessionId);
|
|
2288
|
+
apiKeysByProvider.set(provider, apiKey);
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
if (apiKey) {
|
|
2292
|
+
result.push(scoped);
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
return result;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
async #cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
|
|
2300
|
+
const scopedModels = await this.#getScopedModelsWithApiKey();
|
|
2301
|
+
if (scopedModels.length <= 1) return undefined;
|
|
2302
|
+
|
|
2303
|
+
const currentModel = this.model;
|
|
2304
|
+
let currentIndex = scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel));
|
|
2305
|
+
|
|
2306
|
+
if (currentIndex === -1) currentIndex = 0;
|
|
2307
|
+
const len = scopedModels.length;
|
|
2308
|
+
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
|
|
2309
|
+
const next = scopedModels[nextIndex];
|
|
2310
|
+
|
|
2311
|
+
// Apply model
|
|
2312
|
+
this.#setModelWithProviderSessionReset(next.model);
|
|
2313
|
+
this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
|
|
2314
|
+
this.settings.setModelRole("default", `${next.model.provider}/${next.model.id}`);
|
|
2315
|
+
this.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
|
|
2316
|
+
|
|
2317
|
+
// Apply thinking level (setThinkingLevel clamps to model capabilities)
|
|
2318
|
+
this.setThinkingLevel(next.thinkingLevel);
|
|
2319
|
+
|
|
2320
|
+
return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
async #cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
|
|
2324
|
+
const availableModels = this.#modelRegistry.getAvailable();
|
|
2325
|
+
if (availableModels.length <= 1) return undefined;
|
|
2326
|
+
|
|
2327
|
+
const currentModel = this.model;
|
|
2328
|
+
let currentIndex = availableModels.findIndex(m => modelsAreEqual(m, currentModel));
|
|
2329
|
+
|
|
2330
|
+
if (currentIndex === -1) currentIndex = 0;
|
|
2331
|
+
const len = availableModels.length;
|
|
2332
|
+
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
|
|
2333
|
+
const nextModel = availableModels[nextIndex];
|
|
2334
|
+
|
|
2335
|
+
const apiKey = await this.#modelRegistry.getApiKey(nextModel, this.sessionId);
|
|
2336
|
+
if (!apiKey) {
|
|
2337
|
+
throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
this.#setModelWithProviderSessionReset(nextModel);
|
|
2341
|
+
this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
|
|
2342
|
+
this.settings.setModelRole("default", `${nextModel.provider}/${nextModel.id}`);
|
|
2343
|
+
this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
|
|
2344
|
+
|
|
2345
|
+
// Re-clamp thinking level for new model's capabilities without persisting settings
|
|
2346
|
+
this.setThinkingLevel(this.thinkingLevel);
|
|
2347
|
+
|
|
2348
|
+
return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/**
|
|
2352
|
+
* Get all available models with valid API keys.
|
|
2353
|
+
*/
|
|
2354
|
+
getAvailableModels(): Model[] {
|
|
2355
|
+
return this.#modelRegistry.getAvailable();
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// =========================================================================
|
|
2359
|
+
// Thinking Level Management
|
|
2360
|
+
// =========================================================================
|
|
2361
|
+
|
|
2362
|
+
/**
|
|
2363
|
+
* Set thinking level.
|
|
2364
|
+
* Clamps to model capabilities based on available thinking levels.
|
|
2365
|
+
* Saves to session and settings only if the level actually changes.
|
|
2366
|
+
*/
|
|
2367
|
+
setThinkingLevel(level: ThinkingLevel, persist: boolean = false): void {
|
|
2368
|
+
const availableLevels = this.getAvailableThinkingLevels();
|
|
2369
|
+
const effectiveLevel = availableLevels.includes(level) ? level : this.#clampThinkingLevel(level, availableLevels);
|
|
2370
|
+
|
|
2371
|
+
// Only persist if actually changing
|
|
2372
|
+
const isChanging = effectiveLevel !== this.agent.state.thinkingLevel;
|
|
2373
|
+
|
|
2374
|
+
this.agent.setThinkingLevel(effectiveLevel);
|
|
2375
|
+
|
|
2376
|
+
if (isChanging) {
|
|
2377
|
+
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
|
|
2378
|
+
if (persist) {
|
|
2379
|
+
this.settings.set("defaultThinkingLevel", effectiveLevel);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
/**
|
|
2385
|
+
* Cycle to next thinking level.
|
|
2386
|
+
* @returns New level, or undefined if model doesn't support thinking
|
|
2387
|
+
*/
|
|
2388
|
+
cycleThinkingLevel(): ThinkingLevel | undefined {
|
|
2389
|
+
if (!this.supportsThinking()) return undefined;
|
|
2390
|
+
|
|
2391
|
+
const levels = this.getAvailableThinkingLevels();
|
|
2392
|
+
const currentIndex = levels.indexOf(this.thinkingLevel);
|
|
2393
|
+
const nextIndex = (currentIndex + 1) % levels.length;
|
|
2394
|
+
const nextLevel = levels[nextIndex];
|
|
2395
|
+
|
|
2396
|
+
this.setThinkingLevel(nextLevel);
|
|
2397
|
+
return nextLevel;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/**
|
|
2401
|
+
* Get available thinking levels for current model.
|
|
2402
|
+
* The provider will clamp to what the specific model supports internally.
|
|
2403
|
+
*/
|
|
2404
|
+
getAvailableThinkingLevels(): ThinkingLevel[] {
|
|
2405
|
+
if (!this.supportsThinking()) return ["off"];
|
|
2406
|
+
return this.supportsXhighThinking() ? THINKING_LEVELS_WITH_XHIGH : THINKING_LEVELS;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
/**
|
|
2410
|
+
* Check if current model supports xhigh thinking level.
|
|
2411
|
+
*/
|
|
2412
|
+
supportsXhighThinking(): boolean {
|
|
2413
|
+
return this.model ? supportsXhigh(this.model) : false;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
/**
|
|
2417
|
+
* Check if current model supports thinking/reasoning.
|
|
2418
|
+
*/
|
|
2419
|
+
supportsThinking(): boolean {
|
|
2420
|
+
return !!this.model?.reasoning;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
#clampThinkingLevel(level: ThinkingLevel, availableLevels: ThinkingLevel[]): ThinkingLevel {
|
|
2424
|
+
const ordered = THINKING_LEVELS_WITH_XHIGH;
|
|
2425
|
+
const available = new Set(availableLevels);
|
|
2426
|
+
const requestedIndex = ordered.indexOf(level);
|
|
2427
|
+
if (requestedIndex === -1) {
|
|
2428
|
+
return availableLevels[0] ?? "off";
|
|
2429
|
+
}
|
|
2430
|
+
for (let i = requestedIndex; i < ordered.length; i++) {
|
|
2431
|
+
const candidate = ordered[i];
|
|
2432
|
+
if (available.has(candidate)) return candidate;
|
|
2433
|
+
}
|
|
2434
|
+
for (let i = requestedIndex - 1; i >= 0; i--) {
|
|
2435
|
+
const candidate = ordered[i];
|
|
2436
|
+
if (available.has(candidate)) return candidate;
|
|
2437
|
+
}
|
|
2438
|
+
return availableLevels[0] ?? "off";
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
// =========================================================================
|
|
2442
|
+
// Message Queue Mode Management
|
|
2443
|
+
// =========================================================================
|
|
2444
|
+
|
|
2445
|
+
/**
|
|
2446
|
+
* Set steering mode.
|
|
2447
|
+
* Saves to settings.
|
|
2448
|
+
*/
|
|
2449
|
+
setSteeringMode(mode: "all" | "one-at-a-time"): void {
|
|
2450
|
+
this.agent.setSteeringMode(mode);
|
|
2451
|
+
this.settings.set("steeringMode", mode);
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
/**
|
|
2455
|
+
* Set follow-up mode.
|
|
2456
|
+
* Saves to settings.
|
|
2457
|
+
*/
|
|
2458
|
+
setFollowUpMode(mode: "all" | "one-at-a-time"): void {
|
|
2459
|
+
this.agent.setFollowUpMode(mode);
|
|
2460
|
+
this.settings.set("followUpMode", mode);
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/**
|
|
2464
|
+
* Set interrupt mode.
|
|
2465
|
+
* Saves to settings.
|
|
2466
|
+
*/
|
|
2467
|
+
setInterruptMode(mode: "immediate" | "wait"): void {
|
|
2468
|
+
this.agent.setInterruptMode(mode);
|
|
2469
|
+
this.settings.set("interruptMode", mode);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// =========================================================================
|
|
2473
|
+
// Compaction
|
|
2474
|
+
// =========================================================================
|
|
2475
|
+
|
|
2476
|
+
async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
|
|
2477
|
+
const branchEntries = this.sessionManager.getBranch();
|
|
2478
|
+
const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG);
|
|
2479
|
+
if (result.prunedCount === 0) {
|
|
2480
|
+
return undefined;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
await this.sessionManager.rewriteEntries();
|
|
2484
|
+
const sessionContext = this.sessionManager.buildSessionContext();
|
|
2485
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
2486
|
+
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
2487
|
+
return result;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/**
|
|
2491
|
+
* Manually compact the session context.
|
|
2492
|
+
* Aborts current agent operation first.
|
|
2493
|
+
* @param customInstructions Optional instructions for the compaction summary
|
|
2494
|
+
* @param options Optional callbacks for completion/error handling
|
|
2495
|
+
*/
|
|
2496
|
+
async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
|
|
2497
|
+
this.#disconnectFromAgent();
|
|
2498
|
+
await this.abort();
|
|
2499
|
+
this.#compactionAbortController = new AbortController();
|
|
2500
|
+
|
|
2501
|
+
try {
|
|
2502
|
+
if (!this.model) {
|
|
2503
|
+
throw new Error("No model selected");
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
const compactionSettings = this.settings.getGroup("compaction");
|
|
2507
|
+
const compactionModel = this.model;
|
|
2508
|
+
const apiKey = await this.#modelRegistry.getApiKey(compactionModel, this.sessionId);
|
|
2509
|
+
if (!apiKey) {
|
|
2510
|
+
throw new Error(`No API key for ${compactionModel.provider}`);
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
const pathEntries = this.sessionManager.getBranch();
|
|
2514
|
+
|
|
2515
|
+
const preparation = prepareCompaction(pathEntries, compactionSettings);
|
|
2516
|
+
if (!preparation) {
|
|
2517
|
+
// Check why we can't compact
|
|
2518
|
+
const lastEntry = pathEntries[pathEntries.length - 1];
|
|
2519
|
+
if (lastEntry?.type === "compaction") {
|
|
2520
|
+
throw new Error("Already compacted");
|
|
2521
|
+
}
|
|
2522
|
+
throw new Error("Nothing to compact (session too small)");
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
let hookCompaction: CompactionResult | undefined;
|
|
2526
|
+
let fromExtension = false;
|
|
2527
|
+
let hookContext: string[] | undefined;
|
|
2528
|
+
let hookPrompt: string | undefined;
|
|
2529
|
+
let preserveData: Record<string, unknown> | undefined;
|
|
2530
|
+
|
|
2531
|
+
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
|
|
2532
|
+
const result = (await this.#extensionRunner.emit({
|
|
2533
|
+
type: "session_before_compact",
|
|
2534
|
+
preparation,
|
|
2535
|
+
branchEntries: pathEntries,
|
|
2536
|
+
customInstructions,
|
|
2537
|
+
signal: this.#compactionAbortController.signal,
|
|
2538
|
+
})) as SessionBeforeCompactResult | undefined;
|
|
2539
|
+
|
|
2540
|
+
if (result?.cancel) {
|
|
2541
|
+
throw new Error("Compaction cancelled");
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
if (result?.compaction) {
|
|
2545
|
+
hookCompaction = result.compaction;
|
|
2546
|
+
fromExtension = true;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
|
|
2551
|
+
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
|
2552
|
+
const result = (await this.#extensionRunner.emit({
|
|
2553
|
+
type: "session.compacting",
|
|
2554
|
+
sessionId: this.sessionId,
|
|
2555
|
+
messages: compactMessages,
|
|
2556
|
+
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
|
|
2557
|
+
|
|
2558
|
+
hookContext = result?.context;
|
|
2559
|
+
hookPrompt = result?.prompt;
|
|
2560
|
+
preserveData = result?.preserveData;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
let summary: string;
|
|
2564
|
+
let shortSummary: string | undefined;
|
|
2565
|
+
let firstKeptEntryId: string;
|
|
2566
|
+
let tokensBefore: number;
|
|
2567
|
+
let details: unknown;
|
|
2568
|
+
|
|
2569
|
+
if (hookCompaction) {
|
|
2570
|
+
// Extension provided compaction content
|
|
2571
|
+
summary = hookCompaction.summary;
|
|
2572
|
+
shortSummary = hookCompaction.shortSummary;
|
|
2573
|
+
firstKeptEntryId = hookCompaction.firstKeptEntryId;
|
|
2574
|
+
tokensBefore = hookCompaction.tokensBefore;
|
|
2575
|
+
details = hookCompaction.details;
|
|
2576
|
+
preserveData ??= hookCompaction.preserveData;
|
|
2577
|
+
} else {
|
|
2578
|
+
// Generate compaction result
|
|
2579
|
+
const result = await compact(
|
|
2580
|
+
preparation,
|
|
2581
|
+
compactionModel,
|
|
2582
|
+
apiKey,
|
|
2583
|
+
customInstructions,
|
|
2584
|
+
this.#compactionAbortController.signal,
|
|
2585
|
+
{ promptOverride: hookPrompt, extraContext: hookContext },
|
|
2586
|
+
);
|
|
2587
|
+
summary = result.summary;
|
|
2588
|
+
shortSummary = result.shortSummary;
|
|
2589
|
+
firstKeptEntryId = result.firstKeptEntryId;
|
|
2590
|
+
tokensBefore = result.tokensBefore;
|
|
2591
|
+
details = result.details;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
if (this.#compactionAbortController.signal.aborted) {
|
|
2595
|
+
throw new Error("Compaction cancelled");
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
this.sessionManager.appendCompaction(
|
|
2599
|
+
summary,
|
|
2600
|
+
shortSummary,
|
|
2601
|
+
firstKeptEntryId,
|
|
2602
|
+
tokensBefore,
|
|
2603
|
+
details,
|
|
2604
|
+
fromExtension,
|
|
2605
|
+
preserveData,
|
|
2606
|
+
);
|
|
2607
|
+
const newEntries = this.sessionManager.getEntries();
|
|
2608
|
+
const sessionContext = this.sessionManager.buildSessionContext();
|
|
2609
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
2610
|
+
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
2611
|
+
|
|
2612
|
+
// Get the saved compaction entry for the hook
|
|
2613
|
+
const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
|
|
2614
|
+
| CompactionEntry
|
|
2615
|
+
| undefined;
|
|
2616
|
+
|
|
2617
|
+
if (this.#extensionRunner && savedCompactionEntry) {
|
|
2618
|
+
await this.#extensionRunner.emit({
|
|
2619
|
+
type: "session_compact",
|
|
2620
|
+
compactionEntry: savedCompactionEntry,
|
|
2621
|
+
fromExtension,
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
const compactionResult: CompactionResult = {
|
|
2626
|
+
summary,
|
|
2627
|
+
shortSummary,
|
|
2628
|
+
firstKeptEntryId,
|
|
2629
|
+
tokensBefore,
|
|
2630
|
+
details,
|
|
2631
|
+
preserveData,
|
|
2632
|
+
};
|
|
2633
|
+
options?.onComplete?.(compactionResult);
|
|
2634
|
+
return compactionResult;
|
|
2635
|
+
} catch (error) {
|
|
2636
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2637
|
+
options?.onError?.(err);
|
|
2638
|
+
throw error;
|
|
2639
|
+
} finally {
|
|
2640
|
+
this.#compactionAbortController = undefined;
|
|
2641
|
+
this.#reconnectToAgent();
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
/**
|
|
2646
|
+
* Cancel in-progress compaction (manual or auto).
|
|
2647
|
+
*/
|
|
2648
|
+
abortCompaction(): void {
|
|
2649
|
+
this.#compactionAbortController?.abort();
|
|
2650
|
+
this.#autoCompactionAbortController?.abort();
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
/**
|
|
2654
|
+
* Cancel in-progress branch summarization.
|
|
2655
|
+
*/
|
|
2656
|
+
abortBranchSummary(): void {
|
|
2657
|
+
this.#branchSummaryAbortController?.abort();
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
/**
|
|
2661
|
+
* Cancel in-progress handoff generation.
|
|
2662
|
+
*/
|
|
2663
|
+
abortHandoff(): void {
|
|
2664
|
+
this.#handoffAbortController?.abort();
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
/**
|
|
2668
|
+
* Check if handoff generation is in progress.
|
|
2669
|
+
*/
|
|
2670
|
+
get isGeneratingHandoff(): boolean {
|
|
2671
|
+
return this.#handoffAbortController !== undefined;
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
/**
|
|
2675
|
+
* Generate a handoff document by asking the agent, then start a new session with it.
|
|
2676
|
+
*
|
|
2677
|
+
* This prompts the current agent to write a comprehensive handoff document,
|
|
2678
|
+
* waits for completion, then starts a fresh session with the handoff as context.
|
|
2679
|
+
*
|
|
2680
|
+
* @param customInstructions Optional focus for the handoff document
|
|
2681
|
+
* @returns The handoff document text, or undefined if cancelled/failed
|
|
2682
|
+
*/
|
|
2683
|
+
async handoff(customInstructions?: string): Promise<HandoffResult | undefined> {
|
|
2684
|
+
const entries = this.sessionManager.getBranch();
|
|
2685
|
+
const messageCount = entries.filter(e => e.type === "message").length;
|
|
2686
|
+
|
|
2687
|
+
if (messageCount < 2) {
|
|
2688
|
+
throw new Error("Nothing to hand off (no messages yet)");
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
|
|
2692
|
+
|
|
2693
|
+
this.#handoffAbortController = new AbortController();
|
|
2694
|
+
|
|
2695
|
+
// Build the handoff prompt
|
|
2696
|
+
let handoffPrompt = `Write a comprehensive handoff document that will allow another instance of yourself to seamlessly continue this work. The document should capture everything needed to resume without access to this conversation.
|
|
2697
|
+
|
|
2698
|
+
Use this format:
|
|
2699
|
+
|
|
2700
|
+
## Goal
|
|
2701
|
+
[What the user is trying to accomplish]
|
|
2702
|
+
|
|
2703
|
+
## Constraints & Preferences
|
|
2704
|
+
- [Any constraints, preferences, or requirements mentioned]
|
|
2705
|
+
|
|
2706
|
+
## Progress
|
|
2707
|
+
### Done
|
|
2708
|
+
- [x] [Completed tasks with specifics]
|
|
2709
|
+
|
|
2710
|
+
### In Progress
|
|
2711
|
+
- [ ] [Current work if any]
|
|
2712
|
+
|
|
2713
|
+
### Pending
|
|
2714
|
+
- [ ] [Tasks mentioned but not started]
|
|
2715
|
+
|
|
2716
|
+
## Key Decisions
|
|
2717
|
+
- **[Decision]**: [Rationale]
|
|
2718
|
+
|
|
2719
|
+
## Critical Context
|
|
2720
|
+
- [Code snippets, file paths, error messages, or data essential to continue]
|
|
2721
|
+
- [Repository state if relevant]
|
|
2722
|
+
|
|
2723
|
+
## Next Steps
|
|
2724
|
+
1. [What should happen next]
|
|
2725
|
+
|
|
2726
|
+
Be thorough - include exact file paths, function names, error messages, and technical details. Output ONLY the handoff document, no other text.`;
|
|
2727
|
+
|
|
2728
|
+
if (customInstructions) {
|
|
2729
|
+
handoffPrompt += `\n\nAdditional focus: ${customInstructions}`;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
// Create a promise that resolves when the agent completes
|
|
2733
|
+
let handoffText: string | undefined;
|
|
2734
|
+
const completionPromise = new Promise<void>((resolve, reject) => {
|
|
2735
|
+
const unsubscribe = this.subscribe(event => {
|
|
2736
|
+
if (this.#handoffAbortController?.signal.aborted) {
|
|
2737
|
+
unsubscribe();
|
|
2738
|
+
reject(new Error("Handoff cancelled"));
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
if (event.type === "agent_end") {
|
|
2743
|
+
unsubscribe();
|
|
2744
|
+
// Extract text from the last assistant message
|
|
2745
|
+
const messages = this.agent.state.messages;
|
|
2746
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2747
|
+
const msg = messages[i];
|
|
2748
|
+
if (msg.role === "assistant") {
|
|
2749
|
+
const content = (msg as AssistantMessage).content;
|
|
2750
|
+
const textParts = content
|
|
2751
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
2752
|
+
.map(c => c.text);
|
|
2753
|
+
if (textParts.length > 0) {
|
|
2754
|
+
handoffText = textParts.join("\n");
|
|
2755
|
+
break;
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
resolve();
|
|
2760
|
+
}
|
|
2761
|
+
});
|
|
2762
|
+
});
|
|
2763
|
+
|
|
2764
|
+
try {
|
|
2765
|
+
// Send the prompt and wait for completion
|
|
2766
|
+
await this.prompt(handoffPrompt, { expandPromptTemplates: false });
|
|
2767
|
+
await completionPromise;
|
|
2768
|
+
|
|
2769
|
+
if (!handoffText || this.#handoffAbortController.signal.aborted) {
|
|
2770
|
+
return undefined;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
// Start a new session
|
|
2774
|
+
await this.sessionManager.flush();
|
|
2775
|
+
await this.sessionManager.newSession();
|
|
2776
|
+
this.agent.reset();
|
|
2777
|
+
this.agent.sessionId = this.sessionManager.getSessionId();
|
|
2778
|
+
this.#steeringMessages = [];
|
|
2779
|
+
this.#followUpMessages = [];
|
|
2780
|
+
this.#pendingNextTurnMessages = [];
|
|
2781
|
+
this.#todoReminderCount = 0;
|
|
2782
|
+
|
|
2783
|
+
// Inject the handoff document as a custom message
|
|
2784
|
+
const handoffContent = `<handoff-context>\n${handoffText}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
|
|
2785
|
+
this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true);
|
|
2786
|
+
|
|
2787
|
+
// Rebuild agent messages from session
|
|
2788
|
+
const sessionContext = this.sessionManager.buildSessionContext();
|
|
2789
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
2790
|
+
|
|
2791
|
+
return { document: handoffText };
|
|
2792
|
+
} finally {
|
|
2793
|
+
this.#handoffAbortController = undefined;
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
/**
|
|
2798
|
+
* Check if compaction or context promotion is needed and run it.
|
|
2799
|
+
* Called after agent_end and before prompt submission.
|
|
2800
|
+
*
|
|
2801
|
+
* Three cases (in order):
|
|
2802
|
+
* 1. Overflow + promotion: promote to larger model, retry without compacting
|
|
2803
|
+
* 2. Overflow + no promotion target: compact, auto-retry on same model
|
|
2804
|
+
* 3. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
|
|
2805
|
+
*
|
|
2806
|
+
* @param assistantMessage The assistant message to check
|
|
2807
|
+
* @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
|
|
2808
|
+
*/
|
|
2809
|
+
async #checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<void> {
|
|
2810
|
+
// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
|
|
2811
|
+
if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return;
|
|
2812
|
+
const contextWindow = this.model?.contextWindow ?? 0;
|
|
2813
|
+
// Skip overflow check if the message came from a different model.
|
|
2814
|
+
// This handles the case where user switched from a smaller-context model (e.g. opus)
|
|
2815
|
+
// to a larger-context model (e.g. codex) - the overflow error from the old model
|
|
2816
|
+
// shouldn't trigger compaction for the new model.
|
|
2817
|
+
const sameModel =
|
|
2818
|
+
this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id;
|
|
2819
|
+
// This handles the case where an error was kept after compaction (in the "kept" region).
|
|
2820
|
+
// The error shouldn't trigger another compaction since we already compacted.
|
|
2821
|
+
// Example: opus fails \u2192 switch to codex \u2192 compact \u2192 switch back to opus \u2192 opus error
|
|
2822
|
+
// is still in context but shouldn't trigger compaction again.
|
|
2823
|
+
const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
|
|
2824
|
+
const errorIsFromBeforeCompaction =
|
|
2825
|
+
compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
|
|
2826
|
+
if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) {
|
|
2827
|
+
// Remove the error message from agent state (it IS saved to session for history,
|
|
2828
|
+
// but we don't want it in context for the retry)
|
|
2829
|
+
const messages = this.agent.state.messages;
|
|
2830
|
+
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
|
2831
|
+
this.agent.replaceMessages(messages.slice(0, -1));
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// Try context promotion first \u2014 switch to a larger model and retry without compacting
|
|
2835
|
+
const promoted = await this.#tryContextPromotion(assistantMessage);
|
|
2836
|
+
if (promoted) {
|
|
2837
|
+
// Retry on the promoted (larger) model without compacting
|
|
2838
|
+
setTimeout(() => {
|
|
2839
|
+
this.agent.continue().catch(() => {});
|
|
2840
|
+
}, 100);
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
// No promotion target available \u2014 fall through to compaction
|
|
2845
|
+
const compactionSettings = this.settings.getGroup("compaction");
|
|
2846
|
+
if (compactionSettings.enabled) {
|
|
2847
|
+
await this.#runAutoCompaction("overflow", true);
|
|
2848
|
+
}
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
const compactionSettings = this.settings.getGroup("compaction");
|
|
2852
|
+
if (!compactionSettings.enabled) return;
|
|
2853
|
+
|
|
2854
|
+
// Case 2: Threshold - turn succeeded but context is getting large
|
|
2855
|
+
// Skip if this was an error (non-overflow errors don't have usage data)
|
|
2856
|
+
if (assistantMessage.stopReason === "error") return;
|
|
2857
|
+
const pruneResult = await this.#pruneToolOutputs();
|
|
2858
|
+
let contextTokens = calculateContextTokens(assistantMessage.usage);
|
|
2859
|
+
if (pruneResult) {
|
|
2860
|
+
contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
|
|
2861
|
+
}
|
|
2862
|
+
if (shouldCompact(contextTokens, contextWindow, compactionSettings)) {
|
|
2863
|
+
// Try promotion first — if a larger model is available, switch instead of compacting
|
|
2864
|
+
const promoted = await this.#tryContextPromotion(assistantMessage);
|
|
2865
|
+
if (!promoted) {
|
|
2866
|
+
await this.#runAutoCompaction("threshold", false);
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
/**
|
|
2871
|
+
* Check if agent stopped with incomplete todos and prompt to continue.
|
|
2872
|
+
*/
|
|
2873
|
+
async #checkTodoCompletion(): Promise<void> {
|
|
2874
|
+
const remindersEnabled = this.settings.get("todo.reminders");
|
|
2875
|
+
const todosEnabled = this.settings.get("todo.enabled");
|
|
2876
|
+
if (!remindersEnabled || !todosEnabled) {
|
|
2877
|
+
this.#todoReminderCount = 0;
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
const remindersMax = this.settings.get("todo.reminders.max");
|
|
2882
|
+
if (this.#todoReminderCount >= remindersMax) {
|
|
2883
|
+
logger.debug("Todo completion: max reminders reached", { count: this.#todoReminderCount });
|
|
2884
|
+
return;
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
// Load current todos from artifacts
|
|
2888
|
+
const sessionFile = this.sessionManager.getSessionFile();
|
|
2889
|
+
if (!sessionFile) return;
|
|
2890
|
+
|
|
2891
|
+
const todoPath = `${sessionFile.slice(0, -6)}/todos.json`;
|
|
2892
|
+
|
|
2893
|
+
let todos: TodoItem[];
|
|
2894
|
+
try {
|
|
2895
|
+
const data = await Bun.file(todoPath).json();
|
|
2896
|
+
todos = data?.todos ?? [];
|
|
2897
|
+
} catch (err) {
|
|
2898
|
+
if (isEnoent(err)) {
|
|
2899
|
+
this.#todoReminderCount = 0;
|
|
2900
|
+
}
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// Check for incomplete todos
|
|
2905
|
+
const incomplete = todos.filter(t => t.status !== "completed");
|
|
2906
|
+
if (incomplete.length === 0) {
|
|
2907
|
+
this.#todoReminderCount = 0;
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
// Build reminder message
|
|
2912
|
+
this.#todoReminderCount++;
|
|
2913
|
+
const todoList = incomplete.map(t => `- ${t.content}`).join("\n");
|
|
2914
|
+
const reminder =
|
|
2915
|
+
`<system_reminder>\n` +
|
|
2916
|
+
`You stopped with ${incomplete.length} incomplete todo item(s):\n${todoList}\n\n` +
|
|
2917
|
+
`Please continue working on these tasks or mark them complete if finished.\n` +
|
|
2918
|
+
`(Reminder ${this.#todoReminderCount}/${remindersMax})\n` +
|
|
2919
|
+
`</system_reminder>`;
|
|
2920
|
+
|
|
2921
|
+
logger.debug("Todo completion: sending reminder", {
|
|
2922
|
+
incomplete: incomplete.length,
|
|
2923
|
+
attempt: this.#todoReminderCount,
|
|
2924
|
+
});
|
|
2925
|
+
|
|
2926
|
+
// Emit event for UI to render notification
|
|
2927
|
+
await this.#emitSessionEvent({
|
|
2928
|
+
type: "todo_reminder",
|
|
2929
|
+
todos: incomplete,
|
|
2930
|
+
attempt: this.#todoReminderCount,
|
|
2931
|
+
maxAttempts: remindersMax,
|
|
2932
|
+
});
|
|
2933
|
+
|
|
2934
|
+
// Inject reminder and continue the conversation
|
|
2935
|
+
this.agent.appendMessage({
|
|
2936
|
+
role: "user",
|
|
2937
|
+
content: [{ type: "text", text: reminder }],
|
|
2938
|
+
timestamp: Date.now(),
|
|
2939
|
+
});
|
|
2940
|
+
this.agent.continue().catch(() => {});
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
/**
|
|
2944
|
+
* Attempt context promotion to a larger model.
|
|
2945
|
+
* Returns true if promotion succeeded (caller should retry without compacting).
|
|
2946
|
+
*/
|
|
2947
|
+
async #tryContextPromotion(assistantMessage: AssistantMessage): Promise<boolean> {
|
|
2948
|
+
const promotionSettings = this.settings.getGroup("contextPromotion");
|
|
2949
|
+
if (!promotionSettings.enabled) return false;
|
|
2950
|
+
const currentModel = this.model;
|
|
2951
|
+
if (!currentModel) return false;
|
|
2952
|
+
if (assistantMessage.provider !== currentModel.provider || assistantMessage.model !== currentModel.id)
|
|
2953
|
+
return false;
|
|
2954
|
+
const contextWindow = currentModel.contextWindow ?? 0;
|
|
2955
|
+
if (contextWindow <= 0) return false;
|
|
2956
|
+
const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
|
|
2957
|
+
if (!targetModel) return false;
|
|
2958
|
+
|
|
2959
|
+
try {
|
|
2960
|
+
await this.setModelTemporary(targetModel);
|
|
2961
|
+
logger.debug("Context promotion switched model on overflow", {
|
|
2962
|
+
from: `${currentModel.provider}/${currentModel.id}`,
|
|
2963
|
+
to: `${targetModel.provider}/${targetModel.id}`,
|
|
2964
|
+
});
|
|
2965
|
+
return true;
|
|
2966
|
+
} catch (error) {
|
|
2967
|
+
logger.warn("Context promotion failed", {
|
|
2968
|
+
from: `${currentModel.provider}/${currentModel.id}`,
|
|
2969
|
+
to: `${targetModel.provider}/${targetModel.id}`,
|
|
2970
|
+
error: String(error),
|
|
2971
|
+
});
|
|
2972
|
+
return false;
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
async #resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined> {
|
|
2977
|
+
const availableModels = this.#modelRegistry.getAvailable();
|
|
2978
|
+
if (availableModels.length === 0) return undefined;
|
|
2979
|
+
|
|
2980
|
+
const candidates: Model[] = [];
|
|
2981
|
+
const seen = new Set<string>();
|
|
2982
|
+
const addCandidate = (candidate: Model | undefined): void => {
|
|
2983
|
+
if (!candidate) return;
|
|
2984
|
+
const key = this.#getModelKey(candidate);
|
|
2985
|
+
if (seen.has(key)) return;
|
|
2986
|
+
seen.add(key);
|
|
2987
|
+
candidates.push(candidate);
|
|
2988
|
+
};
|
|
2989
|
+
|
|
2990
|
+
addCandidate(this.#resolveContextPromotionConfiguredTarget(currentModel, availableModels));
|
|
2991
|
+
|
|
2992
|
+
const sameProviderLarger = [...availableModels]
|
|
2993
|
+
.filter(
|
|
2994
|
+
m => m.provider === currentModel.provider && m.api === currentModel.api && m.contextWindow > contextWindow,
|
|
2995
|
+
)
|
|
2996
|
+
.sort((a, b) => a.contextWindow - b.contextWindow);
|
|
2997
|
+
addCandidate(sameProviderLarger[0]);
|
|
2998
|
+
for (const candidate of candidates) {
|
|
2999
|
+
if (modelsAreEqual(candidate, currentModel)) continue;
|
|
3000
|
+
if (candidate.contextWindow <= contextWindow) continue;
|
|
3001
|
+
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
|
|
3002
|
+
if (!apiKey) continue;
|
|
3003
|
+
return candidate;
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
return undefined;
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
#setModelWithProviderSessionReset(model: Model): void {
|
|
3010
|
+
const currentModel = this.model;
|
|
3011
|
+
if (currentModel) {
|
|
3012
|
+
this.#closeProviderSessionsForModelSwitch(currentModel, model);
|
|
3013
|
+
}
|
|
3014
|
+
this.agent.setModel(this.#applySessionModelOverrides(model));
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
#closeCodexProviderSessionsForHistoryRewrite(): void {
|
|
3018
|
+
const currentModel = this.model;
|
|
3019
|
+
if (!currentModel || currentModel.api !== "openai-codex-responses") return;
|
|
3020
|
+
this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
#closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
|
|
3024
|
+
if (currentModel.api !== "openai-codex-responses" && nextModel.api !== "openai-codex-responses") return;
|
|
3025
|
+
|
|
3026
|
+
const providerKey = "openai-codex-responses";
|
|
3027
|
+
const state = this.#providerSessionState.get(providerKey);
|
|
3028
|
+
if (!state) return;
|
|
3029
|
+
|
|
3030
|
+
try {
|
|
3031
|
+
state.close();
|
|
3032
|
+
} catch (error) {
|
|
3033
|
+
logger.warn("Failed to close provider session state during model switch", {
|
|
3034
|
+
providerKey,
|
|
3035
|
+
error: String(error),
|
|
3036
|
+
});
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
this.#providerSessionState.delete(providerKey);
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
#getModelKey(model: Model): string {
|
|
3043
|
+
return `${model.provider}/${model.id}`;
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
#resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
|
|
3047
|
+
const configuredTarget = currentModel.contextPromotionTarget?.trim();
|
|
3048
|
+
if (!configuredTarget) return undefined;
|
|
3049
|
+
|
|
3050
|
+
const parsed = parseModelString(configuredTarget);
|
|
3051
|
+
if (parsed) {
|
|
3052
|
+
const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
|
|
3053
|
+
if (explicitModel) return explicitModel;
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget);
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
#resolveRoleModel(role: ModelRole, availableModels: Model[], currentModel: Model | undefined): Model | undefined {
|
|
3060
|
+
const roleModelStr =
|
|
3061
|
+
role === "default"
|
|
3062
|
+
? (this.settings.getModelRole("default") ??
|
|
3063
|
+
(currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined))
|
|
3064
|
+
: this.settings.getModelRole(role);
|
|
3065
|
+
|
|
3066
|
+
if (!roleModelStr) return undefined;
|
|
3067
|
+
|
|
3068
|
+
const parsed = parseModelString(roleModelStr);
|
|
3069
|
+
if (parsed) {
|
|
3070
|
+
return availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
|
|
3071
|
+
}
|
|
3072
|
+
const roleLower = roleModelStr.toLowerCase();
|
|
3073
|
+
return availableModels.find(m => m.id.toLowerCase() === roleLower);
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
#getCompactionModelCandidates(availableModels: Model[]): Model[] {
|
|
3077
|
+
const candidates: Model[] = [];
|
|
3078
|
+
const seen = new Set<string>();
|
|
3079
|
+
|
|
3080
|
+
const addCandidate = (model: Model | undefined): void => {
|
|
3081
|
+
if (!model) return;
|
|
3082
|
+
const key = this.#getModelKey(model);
|
|
3083
|
+
if (seen.has(key)) return;
|
|
3084
|
+
seen.add(key);
|
|
3085
|
+
candidates.push(model);
|
|
3086
|
+
};
|
|
3087
|
+
|
|
3088
|
+
const currentModel = this.model;
|
|
3089
|
+
for (const role of MODEL_ROLE_IDS) {
|
|
3090
|
+
addCandidate(this.#resolveRoleModel(role, availableModels, currentModel));
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
const sortedByContext = [...availableModels].sort((a, b) => b.contextWindow - a.contextWindow);
|
|
3094
|
+
for (const model of sortedByContext) {
|
|
3095
|
+
if (!seen.has(this.#getModelKey(model))) {
|
|
3096
|
+
addCandidate(model);
|
|
3097
|
+
break;
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
return candidates;
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
/**
|
|
3105
|
+
* Internal: Run auto-compaction with events.
|
|
3106
|
+
*/
|
|
3107
|
+
async #runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<void> {
|
|
3108
|
+
const compactionSettings = this.settings.getGroup("compaction");
|
|
3109
|
+
|
|
3110
|
+
await this.#emitSessionEvent({ type: "auto_compaction_start", reason });
|
|
3111
|
+
// Properly abort and null existing controller before replacing
|
|
3112
|
+
if (this.#autoCompactionAbortController) {
|
|
3113
|
+
this.#autoCompactionAbortController.abort();
|
|
3114
|
+
}
|
|
3115
|
+
this.#autoCompactionAbortController = new AbortController();
|
|
3116
|
+
|
|
3117
|
+
try {
|
|
3118
|
+
if (!this.model) {
|
|
3119
|
+
await this.#emitSessionEvent({
|
|
3120
|
+
type: "auto_compaction_end",
|
|
3121
|
+
result: undefined,
|
|
3122
|
+
aborted: false,
|
|
3123
|
+
willRetry: false,
|
|
3124
|
+
});
|
|
3125
|
+
return;
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
const availableModels = this.#modelRegistry.getAvailable();
|
|
3129
|
+
if (availableModels.length === 0) {
|
|
3130
|
+
await this.#emitSessionEvent({
|
|
3131
|
+
type: "auto_compaction_end",
|
|
3132
|
+
result: undefined,
|
|
3133
|
+
aborted: false,
|
|
3134
|
+
willRetry: false,
|
|
3135
|
+
});
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
const pathEntries = this.sessionManager.getBranch();
|
|
3140
|
+
|
|
3141
|
+
const preparation = prepareCompaction(pathEntries, compactionSettings);
|
|
3142
|
+
if (!preparation) {
|
|
3143
|
+
await this.#emitSessionEvent({
|
|
3144
|
+
type: "auto_compaction_end",
|
|
3145
|
+
result: undefined,
|
|
3146
|
+
aborted: false,
|
|
3147
|
+
willRetry: false,
|
|
3148
|
+
});
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
let hookCompaction: CompactionResult | undefined;
|
|
3153
|
+
let fromExtension = false;
|
|
3154
|
+
let hookContext: string[] | undefined;
|
|
3155
|
+
let hookPrompt: string | undefined;
|
|
3156
|
+
let preserveData: Record<string, unknown> | undefined;
|
|
3157
|
+
|
|
3158
|
+
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
|
|
3159
|
+
const hookResult = (await this.#extensionRunner.emit({
|
|
3160
|
+
type: "session_before_compact",
|
|
3161
|
+
preparation,
|
|
3162
|
+
branchEntries: pathEntries,
|
|
3163
|
+
customInstructions: undefined,
|
|
3164
|
+
signal: this.#autoCompactionAbortController.signal,
|
|
3165
|
+
})) as SessionBeforeCompactResult | undefined;
|
|
3166
|
+
|
|
3167
|
+
if (hookResult?.cancel) {
|
|
3168
|
+
await this.#emitSessionEvent({
|
|
3169
|
+
type: "auto_compaction_end",
|
|
3170
|
+
result: undefined,
|
|
3171
|
+
aborted: true,
|
|
3172
|
+
willRetry: false,
|
|
3173
|
+
});
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
if (hookResult?.compaction) {
|
|
3178
|
+
hookCompaction = hookResult.compaction;
|
|
3179
|
+
fromExtension = true;
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
|
|
3184
|
+
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
|
3185
|
+
const result = (await this.#extensionRunner.emit({
|
|
3186
|
+
type: "session.compacting",
|
|
3187
|
+
sessionId: this.sessionId,
|
|
3188
|
+
messages: compactMessages,
|
|
3189
|
+
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
|
|
3190
|
+
|
|
3191
|
+
hookContext = result?.context;
|
|
3192
|
+
hookPrompt = result?.prompt;
|
|
3193
|
+
preserveData = result?.preserveData;
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
let summary: string;
|
|
3197
|
+
let shortSummary: string | undefined;
|
|
3198
|
+
let firstKeptEntryId: string;
|
|
3199
|
+
let tokensBefore: number;
|
|
3200
|
+
let details: unknown;
|
|
3201
|
+
|
|
3202
|
+
if (hookCompaction) {
|
|
3203
|
+
// Extension provided compaction content
|
|
3204
|
+
summary = hookCompaction.summary;
|
|
3205
|
+
shortSummary = hookCompaction.shortSummary;
|
|
3206
|
+
firstKeptEntryId = hookCompaction.firstKeptEntryId;
|
|
3207
|
+
tokensBefore = hookCompaction.tokensBefore;
|
|
3208
|
+
details = hookCompaction.details;
|
|
3209
|
+
preserveData ??= hookCompaction.preserveData;
|
|
3210
|
+
} else {
|
|
3211
|
+
const candidates = this.#getCompactionModelCandidates(availableModels);
|
|
3212
|
+
const retrySettings = this.settings.getGroup("retry");
|
|
3213
|
+
let compactResult: CompactionResult | undefined;
|
|
3214
|
+
let lastError: unknown;
|
|
3215
|
+
|
|
3216
|
+
for (const candidate of candidates) {
|
|
3217
|
+
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
|
|
3218
|
+
if (!apiKey) continue;
|
|
3219
|
+
|
|
3220
|
+
let attempt = 0;
|
|
3221
|
+
while (true) {
|
|
3222
|
+
try {
|
|
3223
|
+
compactResult = await compact(
|
|
3224
|
+
preparation,
|
|
3225
|
+
candidate,
|
|
3226
|
+
apiKey,
|
|
3227
|
+
undefined,
|
|
3228
|
+
this.#autoCompactionAbortController.signal,
|
|
3229
|
+
{ promptOverride: hookPrompt, extraContext: hookContext },
|
|
3230
|
+
);
|
|
3231
|
+
break;
|
|
3232
|
+
} catch (error) {
|
|
3233
|
+
if (this.#autoCompactionAbortController.signal.aborted) {
|
|
3234
|
+
throw error;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3238
|
+
const retryAfterMs = this.#parseRetryAfterMsFromError(message);
|
|
3239
|
+
const shouldRetry =
|
|
3240
|
+
retrySettings.enabled &&
|
|
3241
|
+
attempt < retrySettings.maxRetries &&
|
|
3242
|
+
(retryAfterMs !== undefined || this.#isRetryableErrorMessage(message));
|
|
3243
|
+
if (!shouldRetry) {
|
|
3244
|
+
lastError = error;
|
|
3245
|
+
break;
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
const baseDelayMs = retrySettings.baseDelayMs * 2 ** attempt;
|
|
3249
|
+
const delayMs = retryAfterMs !== undefined ? Math.max(baseDelayMs, retryAfterMs) : baseDelayMs;
|
|
3250
|
+
|
|
3251
|
+
// If retry delay is too long (>30s), try next candidate instead of waiting
|
|
3252
|
+
const maxAcceptableDelayMs = 30_000;
|
|
3253
|
+
if (delayMs > maxAcceptableDelayMs) {
|
|
3254
|
+
const hasMoreCandidates = candidates.indexOf(candidate) < candidates.length - 1;
|
|
3255
|
+
if (hasMoreCandidates) {
|
|
3256
|
+
logger.warn("Auto-compaction retry delay too long, trying next model", {
|
|
3257
|
+
delayMs,
|
|
3258
|
+
retryAfterMs,
|
|
3259
|
+
error: message,
|
|
3260
|
+
model: `${candidate.provider}/${candidate.id}`,
|
|
3261
|
+
});
|
|
3262
|
+
lastError = error;
|
|
3263
|
+
break; // Exit retry loop, continue to next candidate
|
|
3264
|
+
}
|
|
3265
|
+
// No more candidates - we have to wait
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
attempt++;
|
|
3269
|
+
logger.warn("Auto-compaction failed, retrying", {
|
|
3270
|
+
attempt,
|
|
3271
|
+
maxRetries: retrySettings.maxRetries,
|
|
3272
|
+
delayMs,
|
|
3273
|
+
retryAfterMs,
|
|
3274
|
+
error: message,
|
|
3275
|
+
model: `${candidate.provider}/${candidate.id}`,
|
|
3276
|
+
});
|
|
3277
|
+
await abortableSleep(delayMs, this.#autoCompactionAbortController.signal);
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
if (compactResult) {
|
|
3282
|
+
break;
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3286
|
+
if (!compactResult) {
|
|
3287
|
+
if (lastError) {
|
|
3288
|
+
throw lastError;
|
|
3289
|
+
}
|
|
3290
|
+
throw new Error("Compaction failed: no available model");
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
summary = compactResult.summary;
|
|
3294
|
+
shortSummary = compactResult.shortSummary;
|
|
3295
|
+
firstKeptEntryId = compactResult.firstKeptEntryId;
|
|
3296
|
+
tokensBefore = compactResult.tokensBefore;
|
|
3297
|
+
details = compactResult.details;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
if (this.#autoCompactionAbortController.signal.aborted) {
|
|
3301
|
+
await this.#emitSessionEvent({
|
|
3302
|
+
type: "auto_compaction_end",
|
|
3303
|
+
result: undefined,
|
|
3304
|
+
aborted: true,
|
|
3305
|
+
willRetry: false,
|
|
3306
|
+
});
|
|
3307
|
+
return;
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
this.sessionManager.appendCompaction(
|
|
3311
|
+
summary,
|
|
3312
|
+
shortSummary,
|
|
3313
|
+
firstKeptEntryId,
|
|
3314
|
+
tokensBefore,
|
|
3315
|
+
details,
|
|
3316
|
+
fromExtension,
|
|
3317
|
+
preserveData,
|
|
3318
|
+
);
|
|
3319
|
+
const newEntries = this.sessionManager.getEntries();
|
|
3320
|
+
const sessionContext = this.sessionManager.buildSessionContext();
|
|
3321
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
3322
|
+
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
3323
|
+
|
|
3324
|
+
// Get the saved compaction entry for the hook
|
|
3325
|
+
const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
|
|
3326
|
+
| CompactionEntry
|
|
3327
|
+
| undefined;
|
|
3328
|
+
|
|
3329
|
+
if (this.#extensionRunner && savedCompactionEntry) {
|
|
3330
|
+
await this.#extensionRunner.emit({
|
|
3331
|
+
type: "session_compact",
|
|
3332
|
+
compactionEntry: savedCompactionEntry,
|
|
3333
|
+
fromExtension,
|
|
3334
|
+
});
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
const result: CompactionResult = {
|
|
3338
|
+
summary,
|
|
3339
|
+
shortSummary,
|
|
3340
|
+
firstKeptEntryId,
|
|
3341
|
+
tokensBefore,
|
|
3342
|
+
details,
|
|
3343
|
+
preserveData,
|
|
3344
|
+
};
|
|
3345
|
+
await this.#emitSessionEvent({ type: "auto_compaction_end", result, aborted: false, willRetry });
|
|
3346
|
+
|
|
3347
|
+
if (!willRetry && compactionSettings.autoContinue !== false) {
|
|
3348
|
+
await this.prompt("Continue if you have next steps.", {
|
|
3349
|
+
expandPromptTemplates: false,
|
|
3350
|
+
synthetic: true,
|
|
3351
|
+
});
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
if (willRetry) {
|
|
3355
|
+
const messages = this.agent.state.messages;
|
|
3356
|
+
const lastMsg = messages[messages.length - 1];
|
|
3357
|
+
if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
|
|
3358
|
+
this.agent.replaceMessages(messages.slice(0, -1));
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
setTimeout(() => {
|
|
3362
|
+
this.agent.continue().catch(() => {});
|
|
3363
|
+
}, 100);
|
|
3364
|
+
} else if (this.agent.hasQueuedMessages()) {
|
|
3365
|
+
// Auto-compaction can complete while follow-up/steering/custom messages are waiting.
|
|
3366
|
+
// Kick the loop so queued messages are actually delivered.
|
|
3367
|
+
setTimeout(() => {
|
|
3368
|
+
this.agent.continue().catch(() => {});
|
|
3369
|
+
}, 100);
|
|
3370
|
+
}
|
|
3371
|
+
} catch (error) {
|
|
3372
|
+
if (this.#autoCompactionAbortController?.signal.aborted) {
|
|
3373
|
+
await this.#emitSessionEvent({
|
|
3374
|
+
type: "auto_compaction_end",
|
|
3375
|
+
result: undefined,
|
|
3376
|
+
aborted: true,
|
|
3377
|
+
willRetry: false,
|
|
3378
|
+
});
|
|
3379
|
+
return;
|
|
3380
|
+
}
|
|
3381
|
+
const errorMessage = error instanceof Error ? error.message : "compaction failed";
|
|
3382
|
+
await this.#emitSessionEvent({
|
|
3383
|
+
type: "auto_compaction_end",
|
|
3384
|
+
result: undefined,
|
|
3385
|
+
aborted: false,
|
|
3386
|
+
willRetry: false,
|
|
3387
|
+
errorMessage:
|
|
3388
|
+
reason === "overflow"
|
|
3389
|
+
? `Context overflow recovery failed: ${errorMessage}`
|
|
3390
|
+
: `Auto-compaction failed: ${errorMessage}`,
|
|
3391
|
+
});
|
|
3392
|
+
} finally {
|
|
3393
|
+
this.#autoCompactionAbortController = undefined;
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
/**
|
|
3398
|
+
* Toggle auto-compaction setting.
|
|
3399
|
+
*/
|
|
3400
|
+
setAutoCompactionEnabled(enabled: boolean): void {
|
|
3401
|
+
this.settings.set("compaction.enabled", enabled);
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
/** Whether auto-compaction is enabled */
|
|
3405
|
+
get autoCompactionEnabled(): boolean {
|
|
3406
|
+
return this.settings.get("compaction.enabled");
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
// =========================================================================
|
|
3410
|
+
// Auto-Retry
|
|
3411
|
+
// =========================================================================
|
|
3412
|
+
|
|
3413
|
+
/**
|
|
3414
|
+
* Check if an error is retryable (overloaded, rate limit, server errors).
|
|
3415
|
+
* Context overflow errors are NOT retryable (handled by compaction instead).
|
|
3416
|
+
*/
|
|
3417
|
+
#isRetryableError(message: AssistantMessage): boolean {
|
|
3418
|
+
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
3419
|
+
|
|
3420
|
+
// Context overflow is handled by compaction, not retry
|
|
3421
|
+
const contextWindow = this.model?.contextWindow ?? 0;
|
|
3422
|
+
if (isContextOverflow(message, contextWindow)) return false;
|
|
3423
|
+
|
|
3424
|
+
const err = message.errorMessage;
|
|
3425
|
+
return this.#isRetryableErrorMessage(err);
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
#isRetryableErrorMessage(errorMessage: string): boolean {
|
|
3429
|
+
// Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed, retry delay exceeded
|
|
3430
|
+
return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|retry delay/i.test(
|
|
3431
|
+
errorMessage,
|
|
3432
|
+
);
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
#isUsageLimitErrorMessage(errorMessage: string): boolean {
|
|
3436
|
+
return /usage.?limit|usage_limit_reached|limit_reached/i.test(errorMessage);
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
#parseRetryAfterMsFromError(errorMessage: string): number | undefined {
|
|
3440
|
+
const now = Date.now();
|
|
3441
|
+
const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
|
3442
|
+
if (retryAfterMsMatch) {
|
|
3443
|
+
return Math.max(0, Number(retryAfterMsMatch[1]));
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
const retryAfterMatch = /retry-after\s*[:=]\s*([^\s,;]+)/i.exec(errorMessage);
|
|
3447
|
+
if (retryAfterMatch) {
|
|
3448
|
+
const value = retryAfterMatch[1];
|
|
3449
|
+
const seconds = Number(value);
|
|
3450
|
+
if (!Number.isNaN(seconds)) {
|
|
3451
|
+
return Math.max(0, seconds * 1000);
|
|
3452
|
+
}
|
|
3453
|
+
const dateMs = Date.parse(value);
|
|
3454
|
+
if (!Number.isNaN(dateMs)) {
|
|
3455
|
+
return Math.max(0, dateMs - now);
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
const resetMsMatch = /x-ratelimit-reset-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
|
3460
|
+
if (resetMsMatch) {
|
|
3461
|
+
const resetMs = Number(resetMsMatch[1]);
|
|
3462
|
+
if (!Number.isNaN(resetMs)) {
|
|
3463
|
+
if (resetMs > 1_000_000_000_000) {
|
|
3464
|
+
return Math.max(0, resetMs - now);
|
|
3465
|
+
}
|
|
3466
|
+
return Math.max(0, resetMs);
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
const resetMatch = /x-ratelimit-reset\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
|
3471
|
+
if (resetMatch) {
|
|
3472
|
+
const resetSeconds = Number(resetMatch[1]);
|
|
3473
|
+
if (!Number.isNaN(resetSeconds)) {
|
|
3474
|
+
if (resetSeconds > 1_000_000_000) {
|
|
3475
|
+
return Math.max(0, resetSeconds * 1000 - now);
|
|
3476
|
+
}
|
|
3477
|
+
return Math.max(0, resetSeconds * 1000);
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
return undefined;
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
/**
|
|
3485
|
+
* Handle retryable errors with exponential backoff.
|
|
3486
|
+
* @returns true if retry was initiated, false if max retries exceeded or disabled
|
|
3487
|
+
*/
|
|
3488
|
+
async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
|
|
3489
|
+
const retrySettings = this.settings.getGroup("retry");
|
|
3490
|
+
if (!retrySettings.enabled) return false;
|
|
3491
|
+
|
|
3492
|
+
this.#retryAttempt++;
|
|
3493
|
+
|
|
3494
|
+
// Create retry promise on first attempt so waitForRetry() can await it
|
|
3495
|
+
// Ensure only one promise exists (avoid orphaned promises from concurrent calls)
|
|
3496
|
+
if (!this.#retryPromise) {
|
|
3497
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
3498
|
+
this.#retryPromise = promise;
|
|
3499
|
+
this.#retryResolve = resolve;
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
if (this.#retryAttempt > retrySettings.maxRetries) {
|
|
3503
|
+
// Max retries exceeded, emit final failure and reset
|
|
3504
|
+
await this.#emitSessionEvent({
|
|
3505
|
+
type: "auto_retry_end",
|
|
3506
|
+
success: false,
|
|
3507
|
+
attempt: this.#retryAttempt - 1,
|
|
3508
|
+
finalError: message.errorMessage,
|
|
3509
|
+
});
|
|
3510
|
+
this.#retryAttempt = 0;
|
|
3511
|
+
this.#resolveRetry(); // Resolve so waitForRetry() completes
|
|
3512
|
+
return false;
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
const errorMessage = message.errorMessage || "Unknown error";
|
|
3516
|
+
let delayMs = retrySettings.baseDelayMs * 2 ** (this.#retryAttempt - 1);
|
|
3517
|
+
|
|
3518
|
+
if (this.model && this.#isUsageLimitErrorMessage(errorMessage)) {
|
|
3519
|
+
const retryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
|
|
3520
|
+
const switched = await this.#modelRegistry.authStorage.markUsageLimitReached(
|
|
3521
|
+
this.model.provider,
|
|
3522
|
+
this.sessionId,
|
|
3523
|
+
{
|
|
3524
|
+
retryAfterMs,
|
|
3525
|
+
baseUrl: this.model.baseUrl,
|
|
3526
|
+
},
|
|
3527
|
+
);
|
|
3528
|
+
if (switched) {
|
|
3529
|
+
delayMs = 0;
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
await this.#emitSessionEvent({
|
|
3534
|
+
type: "auto_retry_start",
|
|
3535
|
+
attempt: this.#retryAttempt,
|
|
3536
|
+
maxAttempts: retrySettings.maxRetries,
|
|
3537
|
+
delayMs,
|
|
3538
|
+
errorMessage,
|
|
3539
|
+
});
|
|
3540
|
+
|
|
3541
|
+
// Remove error message from agent state (keep in session for history)
|
|
3542
|
+
const messages = this.agent.state.messages;
|
|
3543
|
+
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
|
3544
|
+
this.agent.replaceMessages(messages.slice(0, -1));
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
// Wait with exponential backoff (abortable)
|
|
3548
|
+
// Properly abort and null existing controller before replacing
|
|
3549
|
+
if (this.#retryAbortController) {
|
|
3550
|
+
this.#retryAbortController.abort();
|
|
3551
|
+
}
|
|
3552
|
+
this.#retryAbortController = new AbortController();
|
|
3553
|
+
try {
|
|
3554
|
+
await abortableSleep(delayMs, this.#retryAbortController.signal);
|
|
3555
|
+
} catch {
|
|
3556
|
+
// Aborted during sleep - emit end event so UI can clean up
|
|
3557
|
+
const attempt = this.#retryAttempt;
|
|
3558
|
+
this.#retryAttempt = 0;
|
|
3559
|
+
this.#retryAbortController = undefined;
|
|
3560
|
+
await this.#emitSessionEvent({
|
|
3561
|
+
type: "auto_retry_end",
|
|
3562
|
+
success: false,
|
|
3563
|
+
attempt,
|
|
3564
|
+
finalError: "Retry cancelled",
|
|
3565
|
+
});
|
|
3566
|
+
this.#resolveRetry();
|
|
3567
|
+
return false;
|
|
3568
|
+
}
|
|
3569
|
+
this.#retryAbortController = undefined;
|
|
3570
|
+
|
|
3571
|
+
// Retry via continue() - use setTimeout to break out of event handler chain
|
|
3572
|
+
setTimeout(() => {
|
|
3573
|
+
this.agent.continue().catch(() => {
|
|
3574
|
+
// Retry failed - will be caught by next agent_end
|
|
3575
|
+
});
|
|
3576
|
+
}, 0);
|
|
3577
|
+
|
|
3578
|
+
return true;
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
/**
|
|
3582
|
+
* Cancel in-progress retry.
|
|
3583
|
+
*/
|
|
3584
|
+
abortRetry(): void {
|
|
3585
|
+
this.#retryAbortController?.abort();
|
|
3586
|
+
// Note: _retryAttempt is reset in the catch block of _autoRetry
|
|
3587
|
+
this.#resolveRetry();
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
/**
|
|
3591
|
+
* Wait for any in-progress retry to complete.
|
|
3592
|
+
* Returns immediately if no retry is in progress.
|
|
3593
|
+
*/
|
|
3594
|
+
async #waitForRetry(): Promise<void> {
|
|
3595
|
+
if (this.#retryPromise) {
|
|
3596
|
+
await this.#retryPromise;
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
async #promptAgentWithIdleRetry(messages: AgentMessage[], options?: { toolChoice?: ToolChoice }): Promise<void> {
|
|
3601
|
+
const deadline = Date.now() + 30_000;
|
|
3602
|
+
for (;;) {
|
|
3603
|
+
try {
|
|
3604
|
+
await this.agent.prompt(messages, options);
|
|
3605
|
+
return;
|
|
3606
|
+
} catch (err) {
|
|
3607
|
+
if (!(err instanceof AgentBusyError)) {
|
|
3608
|
+
throw err;
|
|
3609
|
+
}
|
|
3610
|
+
if (Date.now() >= deadline) {
|
|
3611
|
+
throw new Error("Timed out waiting for prior agent run to finish before prompting.");
|
|
3612
|
+
}
|
|
3613
|
+
await this.agent.waitForIdle();
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
/** Whether auto-retry is currently in progress */
|
|
3619
|
+
get isRetrying(): boolean {
|
|
3620
|
+
return this.#retryPromise !== undefined;
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
/** Whether auto-retry is enabled */
|
|
3624
|
+
get autoRetryEnabled(): boolean {
|
|
3625
|
+
return this.settings.get("retry.enabled") ?? true;
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
/**
|
|
3629
|
+
* Toggle auto-retry setting.
|
|
3630
|
+
*/
|
|
3631
|
+
setAutoRetryEnabled(enabled: boolean): void {
|
|
3632
|
+
this.settings.set("retry.enabled", enabled);
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
// =========================================================================
|
|
3636
|
+
// Bash Execution
|
|
3637
|
+
// =========================================================================
|
|
3638
|
+
|
|
3639
|
+
/**
|
|
3640
|
+
* Execute a bash command.
|
|
3641
|
+
* Adds result to agent context and session.
|
|
3642
|
+
* @param command The bash command to execute
|
|
3643
|
+
* @param onChunk Optional streaming callback for output
|
|
3644
|
+
* @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
|
|
3645
|
+
*/
|
|
3646
|
+
async executeBash(
|
|
3647
|
+
command: string,
|
|
3648
|
+
onChunk?: (chunk: string) => void,
|
|
3649
|
+
options?: { excludeFromContext?: boolean },
|
|
3650
|
+
): Promise<BashResult> {
|
|
3651
|
+
this.#bashAbortController = new AbortController();
|
|
3652
|
+
|
|
3653
|
+
try {
|
|
3654
|
+
const result = await executeBashCommand(command, {
|
|
3655
|
+
onChunk,
|
|
3656
|
+
signal: this.#bashAbortController.signal,
|
|
3657
|
+
sessionKey: this.sessionId,
|
|
3658
|
+
});
|
|
3659
|
+
|
|
3660
|
+
this.recordBashResult(command, result, options);
|
|
3661
|
+
return result;
|
|
3662
|
+
} finally {
|
|
3663
|
+
this.#bashAbortController = undefined;
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
/**
|
|
3668
|
+
* Record a bash execution result in session history.
|
|
3669
|
+
* Used by executeBash and by extensions that handle bash execution themselves.
|
|
3670
|
+
*/
|
|
3671
|
+
recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
|
|
3672
|
+
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
|
|
3673
|
+
const bashMessage: BashExecutionMessage = {
|
|
3674
|
+
role: "bashExecution",
|
|
3675
|
+
command,
|
|
3676
|
+
output: result.output,
|
|
3677
|
+
exitCode: result.exitCode,
|
|
3678
|
+
cancelled: result.cancelled,
|
|
3679
|
+
truncated: result.truncated,
|
|
3680
|
+
meta,
|
|
3681
|
+
timestamp: Date.now(),
|
|
3682
|
+
excludeFromContext: options?.excludeFromContext,
|
|
3683
|
+
};
|
|
3684
|
+
|
|
3685
|
+
// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
|
|
3686
|
+
if (this.isStreaming) {
|
|
3687
|
+
// Queue for later - will be flushed on agent_end
|
|
3688
|
+
this.#pendingBashMessages.push(bashMessage);
|
|
3689
|
+
} else {
|
|
3690
|
+
// Add to agent state immediately
|
|
3691
|
+
this.agent.appendMessage(bashMessage);
|
|
3692
|
+
|
|
3693
|
+
// Save to session
|
|
3694
|
+
this.sessionManager.appendMessage(bashMessage);
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3698
|
+
/**
|
|
3699
|
+
* Cancel running bash command.
|
|
3700
|
+
*/
|
|
3701
|
+
abortBash(): void {
|
|
3702
|
+
this.#bashAbortController?.abort();
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
/** Whether a bash command is currently running */
|
|
3706
|
+
get isBashRunning(): boolean {
|
|
3707
|
+
return this.#bashAbortController !== undefined;
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
/** Whether there are pending bash messages waiting to be flushed */
|
|
3711
|
+
get hasPendingBashMessages(): boolean {
|
|
3712
|
+
return this.#pendingBashMessages.length > 0;
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
/**
|
|
3716
|
+
* Flush pending bash messages to agent state and session.
|
|
3717
|
+
* Called after agent turn completes to maintain proper message ordering.
|
|
3718
|
+
*/
|
|
3719
|
+
#flushPendingBashMessages(): void {
|
|
3720
|
+
if (this.#pendingBashMessages.length === 0) return;
|
|
3721
|
+
|
|
3722
|
+
for (const bashMessage of this.#pendingBashMessages) {
|
|
3723
|
+
// Add to agent state
|
|
3724
|
+
this.agent.appendMessage(bashMessage);
|
|
3725
|
+
|
|
3726
|
+
// Save to session
|
|
3727
|
+
this.sessionManager.appendMessage(bashMessage);
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
this.#pendingBashMessages = [];
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
// =========================================================================
|
|
3734
|
+
// User-Initiated Python Execution
|
|
3735
|
+
// =========================================================================
|
|
3736
|
+
|
|
3737
|
+
/**
|
|
3738
|
+
* Execute Python code in the shared kernel.
|
|
3739
|
+
* Uses the same kernel session as the agent's Python tool, allowing collaborative editing.
|
|
3740
|
+
* @param code The Python code to execute
|
|
3741
|
+
* @param onChunk Optional streaming callback for output
|
|
3742
|
+
* @param options.excludeFromContext If true, execution won't be sent to LLM ($$ prefix)
|
|
3743
|
+
*/
|
|
3744
|
+
async executePython(
|
|
3745
|
+
code: string,
|
|
3746
|
+
onChunk?: (chunk: string) => void,
|
|
3747
|
+
options?: { excludeFromContext?: boolean },
|
|
3748
|
+
): Promise<PythonResult> {
|
|
3749
|
+
this.#pythonAbortController = new AbortController();
|
|
3750
|
+
|
|
3751
|
+
try {
|
|
3752
|
+
// Use the same session ID as the Python tool for kernel sharing
|
|
3753
|
+
const sessionFile = this.sessionManager.getSessionFile();
|
|
3754
|
+
const cwd = this.sessionManager.getCwd();
|
|
3755
|
+
const sessionId = sessionFile ? `session:${sessionFile}:cwd:${cwd}` : `cwd:${cwd}`;
|
|
3756
|
+
|
|
3757
|
+
const result = await executePythonCommand(code, {
|
|
3758
|
+
cwd,
|
|
3759
|
+
sessionId,
|
|
3760
|
+
kernelMode: this.settings.get("python.kernelMode"),
|
|
3761
|
+
useSharedGateway: this.settings.get("python.sharedGateway"),
|
|
3762
|
+
onChunk,
|
|
3763
|
+
signal: this.#pythonAbortController.signal,
|
|
3764
|
+
});
|
|
3765
|
+
|
|
3766
|
+
this.recordPythonResult(code, result, options);
|
|
3767
|
+
return result;
|
|
3768
|
+
} finally {
|
|
3769
|
+
this.#pythonAbortController = undefined;
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
/**
|
|
3774
|
+
* Record a Python execution result in session history.
|
|
3775
|
+
*/
|
|
3776
|
+
recordPythonResult(code: string, result: PythonResult, options?: { excludeFromContext?: boolean }): void {
|
|
3777
|
+
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
|
|
3778
|
+
const pythonMessage: PythonExecutionMessage = {
|
|
3779
|
+
role: "pythonExecution",
|
|
3780
|
+
code,
|
|
3781
|
+
output: result.output,
|
|
3782
|
+
exitCode: result.exitCode,
|
|
3783
|
+
cancelled: result.cancelled,
|
|
3784
|
+
truncated: result.truncated,
|
|
3785
|
+
meta,
|
|
3786
|
+
timestamp: Date.now(),
|
|
3787
|
+
excludeFromContext: options?.excludeFromContext,
|
|
3788
|
+
};
|
|
3789
|
+
|
|
3790
|
+
// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
|
|
3791
|
+
if (this.isStreaming) {
|
|
3792
|
+
this.#pendingPythonMessages.push(pythonMessage);
|
|
3793
|
+
} else {
|
|
3794
|
+
this.agent.appendMessage(pythonMessage);
|
|
3795
|
+
this.sessionManager.appendMessage(pythonMessage);
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
/**
|
|
3800
|
+
* Cancel running Python execution.
|
|
3801
|
+
*/
|
|
3802
|
+
abortPython(): void {
|
|
3803
|
+
this.#pythonAbortController?.abort();
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
/** Whether a Python execution is currently running */
|
|
3807
|
+
get isPythonRunning(): boolean {
|
|
3808
|
+
return this.#pythonAbortController !== undefined;
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
/** Whether there are pending Python messages waiting to be flushed */
|
|
3812
|
+
get hasPendingPythonMessages(): boolean {
|
|
3813
|
+
return this.#pendingPythonMessages.length > 0;
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3816
|
+
/**
|
|
3817
|
+
* Flush pending Python messages to agent state and session.
|
|
3818
|
+
*/
|
|
3819
|
+
#flushPendingPythonMessages(): void {
|
|
3820
|
+
if (this.#pendingPythonMessages.length === 0) return;
|
|
3821
|
+
|
|
3822
|
+
for (const pythonMessage of this.#pendingPythonMessages) {
|
|
3823
|
+
this.agent.appendMessage(pythonMessage);
|
|
3824
|
+
this.sessionManager.appendMessage(pythonMessage);
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
this.#pendingPythonMessages = [];
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
// =========================================================================
|
|
3831
|
+
// Session Management
|
|
3832
|
+
// =========================================================================
|
|
3833
|
+
|
|
3834
|
+
/**
|
|
3835
|
+
* Reload the current session from disk.
|
|
3836
|
+
*
|
|
3837
|
+
* Intended for extension commands and headless modes to re-read the current session
|
|
3838
|
+
* file and re-emit session_switch hooks.
|
|
3839
|
+
*/
|
|
3840
|
+
async reload(): Promise<void> {
|
|
3841
|
+
const sessionFile = this.sessionFile;
|
|
3842
|
+
if (!sessionFile) return;
|
|
3843
|
+
await this.switchSession(sessionFile);
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
/**
|
|
3847
|
+
* Switch to a different session file.
|
|
3848
|
+
* Aborts current operation, loads messages, restores model/thinking.
|
|
3849
|
+
* Listeners are preserved and will continue receiving events.
|
|
3850
|
+
* @returns true if switch completed, false if cancelled by hook
|
|
3851
|
+
*/
|
|
3852
|
+
async switchSession(sessionPath: string): Promise<boolean> {
|
|
3853
|
+
const previousSessionFile = this.sessionManager.getSessionFile();
|
|
3854
|
+
|
|
3855
|
+
// Emit session_before_switch event (can be cancelled)
|
|
3856
|
+
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
|
|
3857
|
+
const result = (await this.#extensionRunner.emit({
|
|
3858
|
+
type: "session_before_switch",
|
|
3859
|
+
reason: "resume",
|
|
3860
|
+
targetSessionFile: sessionPath,
|
|
3861
|
+
})) as SessionBeforeSwitchResult | undefined;
|
|
3862
|
+
|
|
3863
|
+
if (result?.cancel) {
|
|
3864
|
+
return false;
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3868
|
+
this.#disconnectFromAgent();
|
|
3869
|
+
await this.abort();
|
|
3870
|
+
this.#steeringMessages = [];
|
|
3871
|
+
this.#followUpMessages = [];
|
|
3872
|
+
this.#pendingNextTurnMessages = [];
|
|
3873
|
+
|
|
3874
|
+
// Flush pending writes before switching
|
|
3875
|
+
await this.sessionManager.flush();
|
|
3876
|
+
|
|
3877
|
+
// Set new session
|
|
3878
|
+
await this.sessionManager.setSessionFile(sessionPath);
|
|
3879
|
+
this.agent.sessionId = this.sessionManager.getSessionId();
|
|
3880
|
+
|
|
3881
|
+
// Reload messages
|
|
3882
|
+
const sessionContext = this.sessionManager.buildSessionContext();
|
|
3883
|
+
|
|
3884
|
+
// Emit session_switch event to hooks
|
|
3885
|
+
if (this.#extensionRunner) {
|
|
3886
|
+
await this.#extensionRunner.emit({
|
|
3887
|
+
type: "session_switch",
|
|
3888
|
+
reason: "resume",
|
|
3889
|
+
previousSessionFile,
|
|
3890
|
+
});
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
3894
|
+
|
|
3895
|
+
// Restore model if saved
|
|
3896
|
+
const defaultModelStr = sessionContext.models.default;
|
|
3897
|
+
if (defaultModelStr) {
|
|
3898
|
+
const slashIdx = defaultModelStr.indexOf("/");
|
|
3899
|
+
if (slashIdx > 0) {
|
|
3900
|
+
const provider = defaultModelStr.slice(0, slashIdx);
|
|
3901
|
+
const modelId = defaultModelStr.slice(slashIdx + 1);
|
|
3902
|
+
const availableModels = this.#modelRegistry.getAvailable();
|
|
3903
|
+
const match = availableModels.find(m => m.provider === provider && m.id === modelId);
|
|
3904
|
+
if (match) {
|
|
3905
|
+
this.#setModelWithProviderSessionReset(match);
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
|
|
3911
|
+
const defaultThinkingLevel = (this.settings.get("defaultThinkingLevel") ?? "off") as ThinkingLevel;
|
|
3912
|
+
|
|
3913
|
+
if (hasThinkingEntry) {
|
|
3914
|
+
// Restore thinking level if saved (setThinkingLevel clamps to model capabilities)
|
|
3915
|
+
this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel);
|
|
3916
|
+
} else {
|
|
3917
|
+
const availableLevels = this.getAvailableThinkingLevels();
|
|
3918
|
+
const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
|
|
3919
|
+
? defaultThinkingLevel
|
|
3920
|
+
: this.#clampThinkingLevel(defaultThinkingLevel, availableLevels);
|
|
3921
|
+
this.agent.setThinkingLevel(effectiveLevel);
|
|
3922
|
+
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
|
|
3923
|
+
}
|
|
3924
|
+
|
|
3925
|
+
this.#reconnectToAgent();
|
|
3926
|
+
return true;
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
/**
|
|
3930
|
+
* Create a branch from a specific entry.
|
|
3931
|
+
* Emits before_branch/branch session events to hooks.
|
|
3932
|
+
*
|
|
3933
|
+
* @param entryId ID of the entry to branch from
|
|
3934
|
+
* @returns Object with:
|
|
3935
|
+
* - selectedText: The text of the selected user message (for editor pre-fill)
|
|
3936
|
+
* - cancelled: True if a hook cancelled the branch
|
|
3937
|
+
*/
|
|
3938
|
+
async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> {
|
|
3939
|
+
const previousSessionFile = this.sessionFile;
|
|
3940
|
+
const selectedEntry = this.sessionManager.getEntry(entryId);
|
|
3941
|
+
|
|
3942
|
+
if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
|
|
3943
|
+
throw new Error("Invalid entry ID for branching");
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
const selectedText = this.#extractUserMessageText(selectedEntry.message.content);
|
|
3947
|
+
|
|
3948
|
+
let skipConversationRestore = false;
|
|
3949
|
+
|
|
3950
|
+
// Emit session_before_branch event (can be cancelled)
|
|
3951
|
+
if (this.#extensionRunner?.hasHandlers("session_before_branch")) {
|
|
3952
|
+
const result = (await this.#extensionRunner.emit({
|
|
3953
|
+
type: "session_before_branch",
|
|
3954
|
+
entryId,
|
|
3955
|
+
})) as SessionBeforeBranchResult | undefined;
|
|
3956
|
+
|
|
3957
|
+
if (result?.cancel) {
|
|
3958
|
+
return { selectedText, cancelled: true };
|
|
3959
|
+
}
|
|
3960
|
+
skipConversationRestore = result?.skipConversationRestore ?? false;
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
// Clear pending messages (bound to old session state)
|
|
3964
|
+
this.#pendingNextTurnMessages = [];
|
|
3965
|
+
|
|
3966
|
+
// Flush pending writes before branching
|
|
3967
|
+
await this.sessionManager.flush();
|
|
3968
|
+
|
|
3969
|
+
if (!selectedEntry.parentId) {
|
|
3970
|
+
await this.sessionManager.newSession({ parentSession: previousSessionFile });
|
|
3971
|
+
} else {
|
|
3972
|
+
this.sessionManager.createBranchedSession(selectedEntry.parentId);
|
|
3973
|
+
}
|
|
3974
|
+
this.agent.sessionId = this.sessionManager.getSessionId();
|
|
3975
|
+
|
|
3976
|
+
// Reload messages from entries (works for both file and in-memory mode)
|
|
3977
|
+
const sessionContext = this.sessionManager.buildSessionContext();
|
|
3978
|
+
|
|
3979
|
+
// Emit session_branch event to hooks (after branch completes)
|
|
3980
|
+
if (this.#extensionRunner) {
|
|
3981
|
+
await this.#extensionRunner.emit({
|
|
3982
|
+
type: "session_branch",
|
|
3983
|
+
previousSessionFile,
|
|
3984
|
+
});
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
if (!skipConversationRestore) {
|
|
3988
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
return { selectedText, cancelled: false };
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3994
|
+
// =========================================================================
|
|
3995
|
+
// Tree Navigation
|
|
3996
|
+
// =========================================================================
|
|
3997
|
+
|
|
3998
|
+
/**
|
|
3999
|
+
* Navigate to a different node in the session tree.
|
|
4000
|
+
* Unlike branch() which creates a new session file, this stays in the same file.
|
|
4001
|
+
*
|
|
4002
|
+
* @param targetId The entry ID to navigate to
|
|
4003
|
+
* @param options.summarize Whether user wants to summarize abandoned branch
|
|
4004
|
+
* @param options.customInstructions Custom instructions for summarizer
|
|
4005
|
+
* @returns Result with editorText (if user message) and cancelled status
|
|
4006
|
+
*/
|
|
4007
|
+
async navigateTree(
|
|
4008
|
+
targetId: string,
|
|
4009
|
+
options: { summarize?: boolean; customInstructions?: string } = {},
|
|
4010
|
+
): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> {
|
|
4011
|
+
const oldLeafId = this.sessionManager.getLeafId();
|
|
4012
|
+
|
|
4013
|
+
// No-op if already at target
|
|
4014
|
+
if (targetId === oldLeafId) {
|
|
4015
|
+
return { cancelled: false };
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
// Model required for summarization
|
|
4019
|
+
if (options.summarize && !this.model) {
|
|
4020
|
+
throw new Error("No model available for summarization");
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
const targetEntry = this.sessionManager.getEntry(targetId);
|
|
4024
|
+
if (!targetEntry) {
|
|
4025
|
+
throw new Error(`Entry ${targetId} not found`);
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
// Collect entries to summarize (from old leaf to common ancestor)
|
|
4029
|
+
const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
|
|
4030
|
+
this.sessionManager,
|
|
4031
|
+
oldLeafId,
|
|
4032
|
+
targetId,
|
|
4033
|
+
);
|
|
4034
|
+
|
|
4035
|
+
// Prepare event data
|
|
4036
|
+
const preparation: TreePreparation = {
|
|
4037
|
+
targetId,
|
|
4038
|
+
oldLeafId,
|
|
4039
|
+
commonAncestorId,
|
|
4040
|
+
entriesToSummarize,
|
|
4041
|
+
userWantsSummary: options.summarize ?? false,
|
|
4042
|
+
};
|
|
4043
|
+
|
|
4044
|
+
// Set up abort controller for summarization
|
|
4045
|
+
this.#branchSummaryAbortController = new AbortController();
|
|
4046
|
+
let hookSummary: { summary: string; details?: unknown } | undefined;
|
|
4047
|
+
let fromExtension = false;
|
|
4048
|
+
|
|
4049
|
+
// Emit session_before_tree event
|
|
4050
|
+
if (this.#extensionRunner?.hasHandlers("session_before_tree")) {
|
|
4051
|
+
const result = (await this.#extensionRunner.emit({
|
|
4052
|
+
type: "session_before_tree",
|
|
4053
|
+
preparation,
|
|
4054
|
+
signal: this.#branchSummaryAbortController.signal,
|
|
4055
|
+
})) as SessionBeforeTreeResult | undefined;
|
|
4056
|
+
|
|
4057
|
+
if (result?.cancel) {
|
|
4058
|
+
return { cancelled: true };
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
if (result?.summary && options.summarize) {
|
|
4062
|
+
hookSummary = result.summary;
|
|
4063
|
+
fromExtension = true;
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
|
|
4067
|
+
// Run default summarizer if needed
|
|
4068
|
+
let summaryText: string | undefined;
|
|
4069
|
+
let summaryDetails: unknown;
|
|
4070
|
+
if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
|
|
4071
|
+
const model = this.model!;
|
|
4072
|
+
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
|
4073
|
+
if (!apiKey) {
|
|
4074
|
+
throw new Error(`No API key for ${model.provider}`);
|
|
4075
|
+
}
|
|
4076
|
+
const branchSummarySettings = this.settings.getGroup("branchSummary");
|
|
4077
|
+
const result = await generateBranchSummary(entriesToSummarize, {
|
|
4078
|
+
model,
|
|
4079
|
+
apiKey,
|
|
4080
|
+
signal: this.#branchSummaryAbortController.signal,
|
|
4081
|
+
customInstructions: options.customInstructions,
|
|
4082
|
+
reserveTokens: branchSummarySettings.reserveTokens,
|
|
4083
|
+
});
|
|
4084
|
+
this.#branchSummaryAbortController = undefined;
|
|
4085
|
+
if (result.aborted) {
|
|
4086
|
+
return { cancelled: true, aborted: true };
|
|
4087
|
+
}
|
|
4088
|
+
if (result.error) {
|
|
4089
|
+
throw new Error(result.error);
|
|
4090
|
+
}
|
|
4091
|
+
summaryText = result.summary;
|
|
4092
|
+
summaryDetails = {
|
|
4093
|
+
readFiles: result.readFiles || [],
|
|
4094
|
+
modifiedFiles: result.modifiedFiles || [],
|
|
4095
|
+
};
|
|
4096
|
+
} else if (hookSummary) {
|
|
4097
|
+
summaryText = hookSummary.summary;
|
|
4098
|
+
summaryDetails = hookSummary.details;
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
// Determine the new leaf position based on target type
|
|
4102
|
+
let newLeafId: string | null;
|
|
4103
|
+
let editorText: string | undefined;
|
|
4104
|
+
|
|
4105
|
+
if (targetEntry.type === "message" && targetEntry.message.role === "user") {
|
|
4106
|
+
// User message: leaf = parent (null if root), text goes to editor
|
|
4107
|
+
newLeafId = targetEntry.parentId;
|
|
4108
|
+
editorText = this.#extractUserMessageText(targetEntry.message.content);
|
|
4109
|
+
} else if (targetEntry.type === "custom_message") {
|
|
4110
|
+
// Custom message: leaf = parent (null if root), text goes to editor
|
|
4111
|
+
newLeafId = targetEntry.parentId;
|
|
4112
|
+
editorText =
|
|
4113
|
+
typeof targetEntry.content === "string"
|
|
4114
|
+
? targetEntry.content
|
|
4115
|
+
: targetEntry.content
|
|
4116
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
4117
|
+
.map(c => c.text)
|
|
4118
|
+
.join("");
|
|
4119
|
+
} else {
|
|
4120
|
+
// Non-user message: leaf = selected node
|
|
4121
|
+
newLeafId = targetId;
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
// Switch leaf (with or without summary)
|
|
4125
|
+
// Summary is attached at the navigation target position (newLeafId), not the old branch
|
|
4126
|
+
let summaryEntry: BranchSummaryEntry | undefined;
|
|
4127
|
+
if (summaryText) {
|
|
4128
|
+
// Create summary at target position (can be null for root)
|
|
4129
|
+
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
|
|
4130
|
+
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
|
|
4131
|
+
} else if (newLeafId === null) {
|
|
4132
|
+
// No summary, navigating to root - reset leaf
|
|
4133
|
+
this.sessionManager.resetLeaf();
|
|
4134
|
+
} else {
|
|
4135
|
+
// No summary, navigating to non-root
|
|
4136
|
+
this.sessionManager.branch(newLeafId);
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
// Update agent state
|
|
4140
|
+
const sessionContext = this.sessionManager.buildSessionContext();
|
|
4141
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
4142
|
+
|
|
4143
|
+
// Emit session_tree event
|
|
4144
|
+
if (this.#extensionRunner) {
|
|
4145
|
+
await this.#extensionRunner.emit({
|
|
4146
|
+
type: "session_tree",
|
|
4147
|
+
newLeafId: this.sessionManager.getLeafId(),
|
|
4148
|
+
oldLeafId,
|
|
4149
|
+
summaryEntry,
|
|
4150
|
+
fromExtension: summaryText ? fromExtension : undefined,
|
|
4151
|
+
});
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
this.#branchSummaryAbortController = undefined;
|
|
4155
|
+
return { editorText, cancelled: false, summaryEntry };
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
/**
|
|
4159
|
+
* Get all user messages from session for branch selector.
|
|
4160
|
+
*/
|
|
4161
|
+
getUserMessagesForBranching(): Array<{ entryId: string; text: string }> {
|
|
4162
|
+
const entries = this.sessionManager.getEntries();
|
|
4163
|
+
const result: Array<{ entryId: string; text: string }> = [];
|
|
4164
|
+
|
|
4165
|
+
for (const entry of entries) {
|
|
4166
|
+
if (entry.type !== "message") continue;
|
|
4167
|
+
if (entry.message.role !== "user") continue;
|
|
4168
|
+
|
|
4169
|
+
const text = this.#extractUserMessageText(entry.message.content);
|
|
4170
|
+
if (text) {
|
|
4171
|
+
result.push({ entryId: entry.id, text });
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
return result;
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
#extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
|
|
4179
|
+
if (typeof content === "string") return content;
|
|
4180
|
+
if (Array.isArray(content)) {
|
|
4181
|
+
return content
|
|
4182
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
4183
|
+
.map(c => c.text)
|
|
4184
|
+
.join("");
|
|
4185
|
+
}
|
|
4186
|
+
return "";
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
/**
|
|
4190
|
+
* Get session statistics.
|
|
4191
|
+
*/
|
|
4192
|
+
getSessionStats(): SessionStats {
|
|
4193
|
+
const state = this.state;
|
|
4194
|
+
const userMessages = state.messages.filter(m => m.role === "user").length;
|
|
4195
|
+
const assistantMessages = state.messages.filter(m => m.role === "assistant").length;
|
|
4196
|
+
const toolResults = state.messages.filter(m => m.role === "toolResult").length;
|
|
4197
|
+
|
|
4198
|
+
let toolCalls = 0;
|
|
4199
|
+
let totalInput = 0;
|
|
4200
|
+
let totalOutput = 0;
|
|
4201
|
+
let totalCacheRead = 0;
|
|
4202
|
+
let totalCacheWrite = 0;
|
|
4203
|
+
let totalCost = 0;
|
|
4204
|
+
|
|
4205
|
+
const getTaskToolUsage = (details: unknown): Usage | undefined => {
|
|
4206
|
+
if (!details || typeof details !== "object") return undefined;
|
|
4207
|
+
const record = details as Record<string, unknown>;
|
|
4208
|
+
const usage = record.usage;
|
|
4209
|
+
if (!usage || typeof usage !== "object") return undefined;
|
|
4210
|
+
return usage as Usage;
|
|
4211
|
+
};
|
|
4212
|
+
|
|
4213
|
+
for (const message of state.messages) {
|
|
4214
|
+
if (message.role === "assistant") {
|
|
4215
|
+
const assistantMsg = message as AssistantMessage;
|
|
4216
|
+
toolCalls += assistantMsg.content.filter(c => c.type === "toolCall").length;
|
|
4217
|
+
totalInput += assistantMsg.usage.input;
|
|
4218
|
+
totalOutput += assistantMsg.usage.output;
|
|
4219
|
+
totalCacheRead += assistantMsg.usage.cacheRead;
|
|
4220
|
+
totalCacheWrite += assistantMsg.usage.cacheWrite;
|
|
4221
|
+
totalCost += assistantMsg.usage.cost.total;
|
|
4222
|
+
}
|
|
4223
|
+
|
|
4224
|
+
if (message.role === "toolResult" && message.toolName === "task") {
|
|
4225
|
+
const usage = getTaskToolUsage(message.details);
|
|
4226
|
+
if (usage) {
|
|
4227
|
+
totalInput += usage.input;
|
|
4228
|
+
totalOutput += usage.output;
|
|
4229
|
+
totalCacheRead += usage.cacheRead;
|
|
4230
|
+
totalCacheWrite += usage.cacheWrite;
|
|
4231
|
+
totalCost += usage.cost.total;
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
return {
|
|
4237
|
+
sessionFile: this.sessionFile,
|
|
4238
|
+
sessionId: this.sessionId,
|
|
4239
|
+
userMessages,
|
|
4240
|
+
assistantMessages,
|
|
4241
|
+
toolCalls,
|
|
4242
|
+
toolResults,
|
|
4243
|
+
totalMessages: state.messages.length,
|
|
4244
|
+
tokens: {
|
|
4245
|
+
input: totalInput,
|
|
4246
|
+
output: totalOutput,
|
|
4247
|
+
cacheRead: totalCacheRead,
|
|
4248
|
+
cacheWrite: totalCacheWrite,
|
|
4249
|
+
total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
|
|
4250
|
+
},
|
|
4251
|
+
cost: totalCost,
|
|
4252
|
+
};
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
/**
|
|
4256
|
+
* Get current context usage statistics.
|
|
4257
|
+
* Uses the last assistant message's usage data when available,
|
|
4258
|
+
* otherwise estimates tokens for all messages.
|
|
4259
|
+
*/
|
|
4260
|
+
getContextUsage(): ContextUsage | undefined {
|
|
4261
|
+
const model = this.model;
|
|
4262
|
+
if (!model) return undefined;
|
|
4263
|
+
|
|
4264
|
+
const contextWindow = model.contextWindow ?? 0;
|
|
4265
|
+
if (contextWindow <= 0) return undefined;
|
|
4266
|
+
|
|
4267
|
+
// After compaction, the last assistant usage reflects pre-compaction context size.
|
|
4268
|
+
// We can only trust usage from an assistant that responded after the latest compaction.
|
|
4269
|
+
// If no such assistant exists, context token count is unknown until the next LLM response.
|
|
4270
|
+
const branchEntries = this.sessionManager.getBranch();
|
|
4271
|
+
const latestCompaction = getLatestCompactionEntry(branchEntries);
|
|
4272
|
+
|
|
4273
|
+
if (latestCompaction) {
|
|
4274
|
+
// Check if there's a valid assistant usage after the compaction boundary
|
|
4275
|
+
const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
|
|
4276
|
+
let hasPostCompactionUsage = false;
|
|
4277
|
+
for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
|
|
4278
|
+
const entry = branchEntries[i];
|
|
4279
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
4280
|
+
const assistant = entry.message;
|
|
4281
|
+
if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
|
|
4282
|
+
const contextTokens = calculateContextTokens(assistant.usage);
|
|
4283
|
+
if (contextTokens > 0) {
|
|
4284
|
+
hasPostCompactionUsage = true;
|
|
4285
|
+
}
|
|
4286
|
+
break;
|
|
4287
|
+
}
|
|
4288
|
+
}
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
if (!hasPostCompactionUsage) {
|
|
4292
|
+
return { tokens: null, contextWindow, percent: null };
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
const estimate = this.#estimateContextTokens();
|
|
4297
|
+
const percent = (estimate.tokens / contextWindow) * 100;
|
|
4298
|
+
|
|
4299
|
+
return {
|
|
4300
|
+
tokens: estimate.tokens,
|
|
4301
|
+
contextWindow,
|
|
4302
|
+
percent,
|
|
4303
|
+
};
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
async fetchUsageReports(): Promise<UsageReport[] | null> {
|
|
4307
|
+
const authStorage = this.#modelRegistry.authStorage;
|
|
4308
|
+
if (!authStorage.fetchUsageReports) return null;
|
|
4309
|
+
return authStorage.fetchUsageReports({
|
|
4310
|
+
baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
|
|
4311
|
+
});
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4314
|
+
/**
|
|
4315
|
+
* Estimate context tokens from messages, using the last assistant usage when available.
|
|
4316
|
+
*/
|
|
4317
|
+
#estimateContextTokens(): {
|
|
4318
|
+
tokens: number;
|
|
4319
|
+
} {
|
|
4320
|
+
const messages = this.messages;
|
|
4321
|
+
|
|
4322
|
+
// Find last assistant message with usage
|
|
4323
|
+
let lastUsageIndex: number | null = null;
|
|
4324
|
+
let lastUsage: Usage | undefined;
|
|
4325
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4326
|
+
const msg = messages[i];
|
|
4327
|
+
if (msg.role === "assistant") {
|
|
4328
|
+
const assistantMsg = msg as AssistantMessage;
|
|
4329
|
+
if (assistantMsg.usage) {
|
|
4330
|
+
lastUsage = assistantMsg.usage;
|
|
4331
|
+
lastUsageIndex = i;
|
|
4332
|
+
break;
|
|
4333
|
+
}
|
|
4334
|
+
}
|
|
4335
|
+
}
|
|
4336
|
+
|
|
4337
|
+
if (!lastUsage || lastUsageIndex === null) {
|
|
4338
|
+
// No usage data - estimate all messages
|
|
4339
|
+
let estimated = 0;
|
|
4340
|
+
for (const message of messages) {
|
|
4341
|
+
estimated += estimateTokens(message);
|
|
4342
|
+
}
|
|
4343
|
+
return {
|
|
4344
|
+
tokens: estimated,
|
|
4345
|
+
};
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
const usageTokens = calculateContextTokens(lastUsage);
|
|
4349
|
+
let trailingTokens = 0;
|
|
4350
|
+
for (let i = lastUsageIndex + 1; i < messages.length; i++) {
|
|
4351
|
+
trailingTokens += estimateTokens(messages[i]);
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
return {
|
|
4355
|
+
tokens: usageTokens + trailingTokens,
|
|
4356
|
+
};
|
|
4357
|
+
}
|
|
4358
|
+
|
|
4359
|
+
/**
|
|
4360
|
+
* Export session to HTML.
|
|
4361
|
+
* @param outputPath Optional output path (defaults to session directory)
|
|
4362
|
+
* @returns Path to exported file
|
|
4363
|
+
*/
|
|
4364
|
+
async exportToHtml(outputPath?: string): Promise<string> {
|
|
4365
|
+
const themeName = getCurrentThemeName();
|
|
4366
|
+
return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
|
|
4367
|
+
}
|
|
4368
|
+
|
|
4369
|
+
// =========================================================================
|
|
4370
|
+
// Utilities
|
|
4371
|
+
// =========================================================================
|
|
4372
|
+
|
|
4373
|
+
/**
|
|
4374
|
+
* Get text content of last assistant message.
|
|
4375
|
+
* Useful for /copy command.
|
|
4376
|
+
* @returns Text content, or undefined if no assistant message exists
|
|
4377
|
+
*/
|
|
4378
|
+
getLastAssistantText(): string | undefined {
|
|
4379
|
+
const lastAssistant = this.messages
|
|
4380
|
+
.slice()
|
|
4381
|
+
.reverse()
|
|
4382
|
+
.find(m => {
|
|
4383
|
+
if (m.role !== "assistant") return false;
|
|
4384
|
+
const msg = m as AssistantMessage;
|
|
4385
|
+
// Skip aborted messages with no content
|
|
4386
|
+
if (msg.stopReason === "aborted" && msg.content.length === 0) return false;
|
|
4387
|
+
return true;
|
|
4388
|
+
});
|
|
4389
|
+
|
|
4390
|
+
if (!lastAssistant) return undefined;
|
|
4391
|
+
|
|
4392
|
+
let text = "";
|
|
4393
|
+
for (const content of (lastAssistant as AssistantMessage).content) {
|
|
4394
|
+
if (content.type === "text") {
|
|
4395
|
+
text += content.text;
|
|
4396
|
+
}
|
|
4397
|
+
}
|
|
4398
|
+
|
|
4399
|
+
return text.trim() || undefined;
|
|
4400
|
+
}
|
|
4401
|
+
|
|
4402
|
+
/**
|
|
4403
|
+
* Format the entire session as plain text for clipboard export.
|
|
4404
|
+
* Includes user messages, assistant text, thinking blocks, tool calls, and tool results.
|
|
4405
|
+
*/
|
|
4406
|
+
formatSessionAsText(): string {
|
|
4407
|
+
const lines: string[] = [];
|
|
4408
|
+
|
|
4409
|
+
/** Serialize an object as XML parameter elements, one per key. */
|
|
4410
|
+
function formatArgsAsXml(args: Record<string, unknown>, indent = "\t"): string {
|
|
4411
|
+
const parts: string[] = [];
|
|
4412
|
+
for (const [key, value] of Object.entries(args)) {
|
|
4413
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
4414
|
+
parts.push(`${indent}<parameter name="${key}">${text}</parameter>`);
|
|
4415
|
+
}
|
|
4416
|
+
return parts.join("\n");
|
|
4417
|
+
}
|
|
4418
|
+
|
|
4419
|
+
// Include system prompt at the beginning
|
|
4420
|
+
const systemPrompt = this.agent.state.systemPrompt;
|
|
4421
|
+
if (systemPrompt) {
|
|
4422
|
+
lines.push("## System Prompt\n");
|
|
4423
|
+
lines.push(systemPrompt);
|
|
4424
|
+
lines.push("\n");
|
|
4425
|
+
}
|
|
4426
|
+
|
|
4427
|
+
// Include model and thinking level
|
|
4428
|
+
const model = this.agent.state.model;
|
|
4429
|
+
const thinkingLevel = this.agent.state.thinkingLevel;
|
|
4430
|
+
lines.push("## Configuration\n");
|
|
4431
|
+
lines.push(`Model: ${model.provider}/${model.id}`);
|
|
4432
|
+
lines.push(`Thinking Level: ${thinkingLevel}`);
|
|
4433
|
+
lines.push("\n");
|
|
4434
|
+
|
|
4435
|
+
// Include available tools
|
|
4436
|
+
const tools = this.agent.state.tools;
|
|
4437
|
+
|
|
4438
|
+
// Recursively strip all fields starting with 'TypeBox.' from an object
|
|
4439
|
+
function stripTypeBoxFields(obj: any): any {
|
|
4440
|
+
if (Array.isArray(obj)) {
|
|
4441
|
+
return obj.map(stripTypeBoxFields);
|
|
4442
|
+
}
|
|
4443
|
+
if (obj && typeof obj === "object") {
|
|
4444
|
+
const result: Record<string, any> = {};
|
|
4445
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
4446
|
+
if (!k.startsWith("TypeBox.")) {
|
|
4447
|
+
result[k] = stripTypeBoxFields(v);
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4450
|
+
return result;
|
|
4451
|
+
}
|
|
4452
|
+
return obj;
|
|
4453
|
+
}
|
|
4454
|
+
|
|
4455
|
+
if (tools.length > 0) {
|
|
4456
|
+
lines.push("## Available Tools\n");
|
|
4457
|
+
for (const tool of tools) {
|
|
4458
|
+
lines.push(`<tool name="${tool.name}">`);
|
|
4459
|
+
lines.push(tool.description);
|
|
4460
|
+
const parametersClean = stripTypeBoxFields(tool.parameters);
|
|
4461
|
+
lines.push(`\nParameters:\n${formatArgsAsXml(parametersClean as Record<string, unknown>)}`);
|
|
4462
|
+
lines.push("<" + "/tool>\n");
|
|
4463
|
+
}
|
|
4464
|
+
lines.push("\n");
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
for (const msg of this.messages) {
|
|
4468
|
+
if (msg.role === "user") {
|
|
4469
|
+
lines.push("## User\n");
|
|
4470
|
+
if (typeof msg.content === "string") {
|
|
4471
|
+
lines.push(msg.content);
|
|
4472
|
+
} else {
|
|
4473
|
+
for (const c of msg.content) {
|
|
4474
|
+
if (c.type === "text") {
|
|
4475
|
+
lines.push(c.text);
|
|
4476
|
+
} else if (c.type === "image") {
|
|
4477
|
+
lines.push("[Image]");
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
}
|
|
4481
|
+
lines.push("\n");
|
|
4482
|
+
} else if (msg.role === "assistant") {
|
|
4483
|
+
const assistantMsg = msg as AssistantMessage;
|
|
4484
|
+
lines.push("## Assistant\n");
|
|
4485
|
+
|
|
4486
|
+
for (const c of assistantMsg.content) {
|
|
4487
|
+
if (c.type === "text") {
|
|
4488
|
+
lines.push(c.text);
|
|
4489
|
+
} else if (c.type === "thinking") {
|
|
4490
|
+
lines.push("<thinking>");
|
|
4491
|
+
lines.push(c.thinking);
|
|
4492
|
+
lines.push("</thinking>\n");
|
|
4493
|
+
} else if (c.type === "toolCall") {
|
|
4494
|
+
lines.push(`<invoke name="${c.name}">`);
|
|
4495
|
+
if (c.arguments && typeof c.arguments === "object") {
|
|
4496
|
+
lines.push(formatArgsAsXml(c.arguments as Record<string, unknown>));
|
|
4497
|
+
}
|
|
4498
|
+
lines.push("<" + "/invoke>\n");
|
|
4499
|
+
}
|
|
4500
|
+
}
|
|
4501
|
+
lines.push("");
|
|
4502
|
+
} else if (msg.role === "toolResult") {
|
|
4503
|
+
lines.push(`### Tool Result: ${msg.toolName}`);
|
|
4504
|
+
if (msg.isError) {
|
|
4505
|
+
lines.push("(error)");
|
|
4506
|
+
}
|
|
4507
|
+
for (const c of msg.content) {
|
|
4508
|
+
if (c.type === "text") {
|
|
4509
|
+
lines.push("```");
|
|
4510
|
+
lines.push(c.text);
|
|
4511
|
+
lines.push("```");
|
|
4512
|
+
} else if (c.type === "image") {
|
|
4513
|
+
lines.push("[Image output]");
|
|
4514
|
+
}
|
|
4515
|
+
}
|
|
4516
|
+
lines.push("");
|
|
4517
|
+
} else if (msg.role === "bashExecution") {
|
|
4518
|
+
const bashMsg = msg as BashExecutionMessage;
|
|
4519
|
+
if (!bashMsg.excludeFromContext) {
|
|
4520
|
+
lines.push("## Bash Execution\n");
|
|
4521
|
+
lines.push(bashExecutionToText(bashMsg));
|
|
4522
|
+
lines.push("\n");
|
|
4523
|
+
}
|
|
4524
|
+
} else if (msg.role === "pythonExecution") {
|
|
4525
|
+
const pythonMsg = msg as PythonExecutionMessage;
|
|
4526
|
+
if (!pythonMsg.excludeFromContext) {
|
|
4527
|
+
lines.push("## Python Execution\n");
|
|
4528
|
+
lines.push(pythonExecutionToText(pythonMsg));
|
|
4529
|
+
lines.push("\n");
|
|
4530
|
+
}
|
|
4531
|
+
} else if (msg.role === "custom" || msg.role === "hookMessage") {
|
|
4532
|
+
const customMsg = msg as CustomMessage | HookMessage;
|
|
4533
|
+
lines.push(`## ${customMsg.customType}\n`);
|
|
4534
|
+
if (typeof customMsg.content === "string") {
|
|
4535
|
+
lines.push(customMsg.content);
|
|
4536
|
+
} else {
|
|
4537
|
+
for (const c of customMsg.content) {
|
|
4538
|
+
if (c.type === "text") {
|
|
4539
|
+
lines.push(c.text);
|
|
4540
|
+
} else if (c.type === "image") {
|
|
4541
|
+
lines.push("[Image]");
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4544
|
+
}
|
|
4545
|
+
lines.push("\n");
|
|
4546
|
+
} else if (msg.role === "branchSummary") {
|
|
4547
|
+
const branchMsg = msg as BranchSummaryMessage;
|
|
4548
|
+
lines.push("## Branch Summary\n");
|
|
4549
|
+
lines.push(`(from branch: ${branchMsg.fromId})\n`);
|
|
4550
|
+
lines.push(branchMsg.summary);
|
|
4551
|
+
lines.push("\n");
|
|
4552
|
+
} else if (msg.role === "compactionSummary") {
|
|
4553
|
+
const compactMsg = msg as CompactionSummaryMessage;
|
|
4554
|
+
lines.push("## Compaction Summary\n");
|
|
4555
|
+
lines.push(`(${compactMsg.tokensBefore} tokens before compaction)\n`);
|
|
4556
|
+
lines.push(compactMsg.summary);
|
|
4557
|
+
lines.push("\n");
|
|
4558
|
+
} else if (msg.role === "fileMention") {
|
|
4559
|
+
const fileMsg = msg as FileMentionMessage;
|
|
4560
|
+
lines.push("## File Mention\n");
|
|
4561
|
+
for (const file of fileMsg.files) {
|
|
4562
|
+
lines.push(`<file path="${file.path}">`);
|
|
4563
|
+
if (file.content) {
|
|
4564
|
+
lines.push(file.content);
|
|
4565
|
+
}
|
|
4566
|
+
if (file.image) {
|
|
4567
|
+
lines.push("[Image attached]");
|
|
4568
|
+
}
|
|
4569
|
+
lines.push("</file>\n");
|
|
4570
|
+
}
|
|
4571
|
+
lines.push("\n");
|
|
4572
|
+
}
|
|
4573
|
+
}
|
|
4574
|
+
|
|
4575
|
+
return lines.join("\n").trim();
|
|
4576
|
+
}
|
|
4577
|
+
|
|
4578
|
+
/**
|
|
4579
|
+
* Format the conversation as compact context for subagents.
|
|
4580
|
+
* Includes only user messages and assistant text responses.
|
|
4581
|
+
* Excludes: system prompt, tool definitions, tool calls/results, thinking blocks.
|
|
4582
|
+
*/
|
|
4583
|
+
formatCompactContext(): string {
|
|
4584
|
+
const lines: string[] = [];
|
|
4585
|
+
lines.push("# Conversation Context");
|
|
4586
|
+
lines.push("");
|
|
4587
|
+
lines.push(
|
|
4588
|
+
"This is a summary of the parent conversation. Read this if you need additional context about what was discussed or decided.",
|
|
4589
|
+
);
|
|
4590
|
+
lines.push("");
|
|
4591
|
+
|
|
4592
|
+
for (const msg of this.messages) {
|
|
4593
|
+
if (msg.role === "user") {
|
|
4594
|
+
lines.push("## User");
|
|
4595
|
+
lines.push("");
|
|
4596
|
+
if (typeof msg.content === "string") {
|
|
4597
|
+
lines.push(msg.content);
|
|
4598
|
+
} else {
|
|
4599
|
+
for (const c of msg.content) {
|
|
4600
|
+
if (c.type === "text") {
|
|
4601
|
+
lines.push(c.text);
|
|
4602
|
+
} else if (c.type === "image") {
|
|
4603
|
+
lines.push("[Image attached]");
|
|
4604
|
+
}
|
|
4605
|
+
}
|
|
4606
|
+
}
|
|
4607
|
+
lines.push("");
|
|
4608
|
+
} else if (msg.role === "assistant") {
|
|
4609
|
+
const assistantMsg = msg as AssistantMessage;
|
|
4610
|
+
// Only include text content, skip tool calls and thinking
|
|
4611
|
+
const textParts: string[] = [];
|
|
4612
|
+
for (const c of assistantMsg.content) {
|
|
4613
|
+
if (c.type === "text" && c.text.trim()) {
|
|
4614
|
+
textParts.push(c.text);
|
|
4615
|
+
}
|
|
4616
|
+
}
|
|
4617
|
+
if (textParts.length > 0) {
|
|
4618
|
+
lines.push("## Assistant");
|
|
4619
|
+
lines.push("");
|
|
4620
|
+
lines.push(textParts.join("\n\n"));
|
|
4621
|
+
lines.push("");
|
|
4622
|
+
}
|
|
4623
|
+
} else if (msg.role === "fileMention") {
|
|
4624
|
+
const fileMsg = msg as FileMentionMessage;
|
|
4625
|
+
const paths = fileMsg.files.map(f => f.path).join(", ");
|
|
4626
|
+
lines.push(`[Files referenced: ${paths}]`);
|
|
4627
|
+
lines.push("");
|
|
4628
|
+
} else if (msg.role === "compactionSummary") {
|
|
4629
|
+
const compactMsg = msg as CompactionSummaryMessage;
|
|
4630
|
+
lines.push("## Earlier Context (Summarized)");
|
|
4631
|
+
lines.push("");
|
|
4632
|
+
lines.push(compactMsg.summary);
|
|
4633
|
+
lines.push("");
|
|
4634
|
+
}
|
|
4635
|
+
// Skip: toolResult, bashExecution, pythonExecution, branchSummary, custom, hookMessage
|
|
4636
|
+
}
|
|
4637
|
+
|
|
4638
|
+
return lines.join("\n").trim();
|
|
4639
|
+
}
|
|
4640
|
+
|
|
4641
|
+
// =========================================================================
|
|
4642
|
+
// Extension System
|
|
4643
|
+
// =========================================================================
|
|
4644
|
+
|
|
4645
|
+
/**
|
|
4646
|
+
* Check if extensions have handlers for a specific event type.
|
|
4647
|
+
*/
|
|
4648
|
+
hasExtensionHandlers(eventType: string): boolean {
|
|
4649
|
+
return this.#extensionRunner?.hasHandlers(eventType) ?? false;
|
|
4650
|
+
}
|
|
4651
|
+
|
|
4652
|
+
/**
|
|
4653
|
+
* Get the extension runner (for setting UI context and error handlers).
|
|
4654
|
+
*/
|
|
4655
|
+
get extensionRunner(): ExtensionRunner | undefined {
|
|
4656
|
+
return this.#extensionRunner;
|
|
4657
|
+
}
|
|
4658
|
+
|
|
4659
|
+
/**
|
|
4660
|
+
* Emit a custom tool session event (backwards compatibility for older callers).
|
|
4661
|
+
*/
|
|
4662
|
+
async emitCustomToolSessionEvent(reason: "start" | "switch" | "branch" | "tree" | "shutdown"): Promise<void> {
|
|
4663
|
+
if (reason !== "shutdown") return;
|
|
4664
|
+
if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
|
|
4665
|
+
await this.#extensionRunner.emit({ type: "session_shutdown" });
|
|
4666
|
+
}
|
|
4667
|
+
await cleanupSshResources();
|
|
4668
|
+
}
|
|
4669
|
+
}
|