From 29155ec7bc362835070328c4f0502e13e1ab8198 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 13:57:26 +0900 Subject: [PATCH] refactor: wave 1 - extract leaf modules, rename catch-all files, split index.ts hooks - Split 25+ index.ts files into hook.ts + extracted modules - Rename all catch-all utils.ts/helpers.ts to domain-specific names - Split src/tools/lsp/ into ~15 focused modules - Split src/tools/delegate-task/ into ~18 focused modules - Separate shared types from implementation - 155 files changed, 60+ new files created - All typecheck clean, 61 tests pass --- bun.lock | 28 +- src/agents/AGENTS.md | 2 +- src/agents/agent-builder.ts | 52 + src/agents/atlas/agent.ts | 142 +++ src/agents/atlas/index.ts | 147 +-- .../{utils.ts => prompt-section-builder.ts} | 0 src/agents/builtin-agents.ts | 163 +++ src/agents/builtin-agents/agent-overrides.ts | 61 + src/agents/builtin-agents/atlas-agent.ts | 63 + src/agents/builtin-agents/available-skills.ts | 35 + .../builtin-agents/environment-context.ts | 8 + src/agents/builtin-agents/general-agents.ts | 108 ++ src/agents/builtin-agents/hephaestus-agent.ts | 88 ++ src/agents/builtin-agents/model-resolution.ts | 28 + src/agents/builtin-agents/sisyphus-agent.ts | 81 ++ src/agents/env-context.ts | 33 + src/agents/index.ts | 2 +- src/agents/sisyphus-junior/agent.ts | 119 ++ src/agents/sisyphus-junior/index.ts | 125 +- src/agents/utils.test.ts | 6 +- src/agents/utils.ts | 485 -------- src/cli/config-manager.ts | 20 +- .../get-local-version/get-local-version.ts | 111 ++ src/cli/get-local-version/index.ts | 106 +- .../tmux-subagent/pane-state-querier.ts | 2 +- src/features/tool-metadata-store/index.ts | 91 +- src/features/tool-metadata-store/store.ts | 84 ++ src/hooks/agent-usage-reminder/hook.ts | 109 ++ src/hooks/agent-usage-reminder/index.ts | 110 +- src/hooks/anthropic-effort/hook.ts | 56 + src/hooks/anthropic-effort/index.ts | 57 +- src/hooks/auto-slash-command/hook.ts | 145 +++ src/hooks/auto-slash-command/index.ts | 147 +-- src/hooks/background-notification/hook.ts | 26 + src/hooks/background-notification/index.ts | 28 +- .../category-skill-reminder/formatter.ts | 37 + src/hooks/category-skill-reminder/hook.ts | 141 +++ src/hooks/category-skill-reminder/index.ts | 178 +-- src/hooks/comment-checker/cli-runner.ts | 63 + src/hooks/comment-checker/hook.ts | 123 ++ src/hooks/comment-checker/index.ts | 172 +-- src/hooks/comment-checker/pending-calls.ts | 32 + src/hooks/compaction-context-injector/hook.ts | 55 + .../compaction-context-injector/index.ts | 53 +- src/hooks/compaction-todo-preserver/hook.ts | 127 ++ src/hooks/compaction-todo-preserver/index.ts | 129 +-- src/hooks/delegate-task-retry/guidance.ts | 45 + src/hooks/delegate-task-retry/hook.ts | 21 + src/hooks/delegate-task-retry/index.ts | 140 +-- src/hooks/delegate-task-retry/patterns.ts | 77 ++ src/hooks/directory-agents-injector/finder.ts | 38 + src/hooks/directory-agents-injector/hook.ts | 84 ++ src/hooks/directory-agents-injector/index.ts | 154 +-- .../directory-agents-injector/injector.ts | 55 + src/hooks/directory-readme-injector/finder.ts | 33 + src/hooks/directory-readme-injector/hook.ts | 84 ++ src/hooks/directory-readme-injector/index.ts | 149 +-- .../directory-readme-injector/injector.ts | 55 + src/hooks/edit-error-recovery/hook.ts | 57 + src/hooks/edit-error-recovery/index.ts | 62 +- src/hooks/keyword-detector/hook.ts | 115 ++ src/hooks/keyword-detector/index.ts | 106 +- src/hooks/keyword-detector/ultrawork/index.ts | 6 +- .../{utils.ts => source-detector.ts} | 5 +- .../prometheus-md-only/agent-resolution.ts | 52 + src/hooks/prometheus-md-only/hook.ts | 96 ++ src/hooks/prometheus-md-only/index.ts | 186 +-- src/hooks/prometheus-md-only/path-policy.ts | 41 + src/hooks/question-label-truncator/hook.ts | 62 + src/hooks/question-label-truncator/index.ts | 62 +- src/hooks/rules-injector/cache.ts | 27 + src/hooks/rules-injector/hook.ts | 87 ++ src/hooks/rules-injector/index.ts | 191 +-- src/hooks/rules-injector/injector.ts | 126 ++ src/hooks/sisyphus-junior-notepad/hook.ts | 44 + src/hooks/sisyphus-junior-notepad/index.ts | 44 +- src/hooks/stop-continuation-guard/hook.ts | 68 ++ src/hooks/stop-continuation-guard/index.ts | 69 +- src/hooks/subagent-question-blocker/hook.ts | 29 + src/hooks/subagent-question-blocker/index.ts | 30 +- src/hooks/task-reminder/hook.ts | 59 + src/hooks/task-reminder/index.ts | 60 +- src/hooks/task-resume-info/hook.ts | 38 + src/hooks/task-resume-info/index.ts | 37 +- src/hooks/tasks-todowrite-disabler/hook.ts | 33 + src/hooks/tasks-todowrite-disabler/index.ts | 31 +- src/hooks/think-mode/hook.ts | 101 ++ src/hooks/think-mode/index.ts | 105 +- src/hooks/thinking-block-validator/hook.ts | 168 +++ src/hooks/thinking-block-validator/index.ts | 172 +-- src/hooks/write-existing-file-guard/hook.ts | 50 + src/hooks/write-existing-file-guard/index.ts | 44 +- src/shared/index.ts | 17 +- src/shared/model-resolution-pipeline.ts | 38 +- src/shared/model-resolution-types.ts | 30 + src/shared/opencode-config-dir-types.ts | 15 + src/shared/opencode-config-dir.ts | 24 +- src/shared/tmux/tmux-utils.ts | 2 +- .../{utils.ts => result-formatter.ts} | 0 src/tools/ast-grep/tools.ts | 2 +- .../delegate-task/background-continuation.ts | 61 + src/tools/delegate-task/background-task.ts | 87 ++ src/tools/delegate-task/category-resolver.ts | 165 +++ src/tools/delegate-task/error-formatting.ts | 51 + src/tools/delegate-task/executor-types.ts | 33 + src/tools/delegate-task/executor.ts | 1024 +---------------- src/tools/delegate-task/helpers.ts | 101 -- .../delegate-task/model-string-parser.ts | 10 + .../delegate-task/parent-context-resolver.ts | 38 + .../delegate-task/sisyphus-junior-agent.ts | 1 + .../delegate-task/skill-content-resolver.ts | 21 + src/tools/delegate-task/subagent-resolver.ts | 87 ++ src/tools/delegate-task/sync-continuation.ts | 154 +++ src/tools/delegate-task/sync-prompt-sender.ts | 59 + .../delegate-task/sync-result-fetcher.ts | 31 + .../delegate-task/sync-session-creator.ts | 30 + .../delegate-task/sync-session-poller.ts | 80 ++ src/tools/delegate-task/sync-task.ts | 154 +++ src/tools/delegate-task/time-formatter.ts | 13 + .../delegate-task/unstable-agent-task.ts | 158 +++ .../glob/{utils.ts => result-formatter.ts} | 0 src/tools/glob/tools.ts | 2 +- .../grep/{utils.ts => result-formatter.ts} | 0 src/tools/grep/tools.ts | 2 +- src/tools/interactive-bash/index.ts | 2 +- .../{utils.ts => tmux-path-resolver.ts} | 0 src/tools/interactive-bash/tools.ts | 2 +- src/tools/lsp/client.ts | 806 +------------ src/tools/lsp/config.ts | 292 +---- src/tools/lsp/constants.ts | 388 +------ src/tools/lsp/diagnostics-tool.ts | 53 + src/tools/lsp/find-references-tool.ts | 43 + src/tools/lsp/goto-definition-tool.ts | 42 + src/tools/lsp/index.ts | 4 +- src/tools/lsp/language-config.ts | 5 + src/tools/lsp/language-mappings.ts | 171 +++ src/tools/lsp/lsp-client-connection.ts | 66 ++ src/tools/lsp/lsp-client-transport.ts | 194 ++++ src/tools/lsp/lsp-client-wrapper.ts | 100 ++ src/tools/lsp/lsp-client.ts | 129 +++ src/tools/lsp/lsp-formatters.ts | 193 ++++ src/tools/lsp/lsp-process.ts | 186 +++ src/tools/lsp/lsp-server.ts | 197 ++++ src/tools/lsp/rename-tools.ts | 53 + src/tools/lsp/server-config-loader.ts | 115 ++ src/tools/lsp/server-definitions.ts | 91 ++ src/tools/lsp/server-installation.ts | 69 ++ src/tools/lsp/server-resolution.ts | 109 ++ src/tools/lsp/symbols-tool.ts | 76 ++ src/tools/lsp/tools.ts | 266 +---- src/tools/lsp/utils.test.ts | 2 +- src/tools/lsp/utils.ts | 406 ------- src/tools/lsp/workspace-edit.ts | 121 ++ .../{utils.ts => session-formatter.ts} | 0 src/tools/session-manager/tools.ts | 2 +- src/tools/session-manager/utils.test.ts | 2 +- 156 files changed, 7280 insertions(+), 6771 deletions(-) create mode 100644 src/agents/agent-builder.ts create mode 100644 src/agents/atlas/agent.ts rename src/agents/atlas/{utils.ts => prompt-section-builder.ts} (100%) create mode 100644 src/agents/builtin-agents.ts create mode 100644 src/agents/builtin-agents/agent-overrides.ts create mode 100644 src/agents/builtin-agents/atlas-agent.ts create mode 100644 src/agents/builtin-agents/available-skills.ts create mode 100644 src/agents/builtin-agents/environment-context.ts create mode 100644 src/agents/builtin-agents/general-agents.ts create mode 100644 src/agents/builtin-agents/hephaestus-agent.ts create mode 100644 src/agents/builtin-agents/model-resolution.ts create mode 100644 src/agents/builtin-agents/sisyphus-agent.ts create mode 100644 src/agents/env-context.ts create mode 100644 src/agents/sisyphus-junior/agent.ts delete mode 100644 src/agents/utils.ts create mode 100644 src/cli/get-local-version/get-local-version.ts create mode 100644 src/features/tool-metadata-store/store.ts create mode 100644 src/hooks/agent-usage-reminder/hook.ts create mode 100644 src/hooks/anthropic-effort/hook.ts create mode 100644 src/hooks/auto-slash-command/hook.ts create mode 100644 src/hooks/background-notification/hook.ts create mode 100644 src/hooks/category-skill-reminder/formatter.ts create mode 100644 src/hooks/category-skill-reminder/hook.ts create mode 100644 src/hooks/comment-checker/cli-runner.ts create mode 100644 src/hooks/comment-checker/hook.ts create mode 100644 src/hooks/comment-checker/pending-calls.ts create mode 100644 src/hooks/compaction-context-injector/hook.ts create mode 100644 src/hooks/compaction-todo-preserver/hook.ts create mode 100644 src/hooks/delegate-task-retry/guidance.ts create mode 100644 src/hooks/delegate-task-retry/hook.ts create mode 100644 src/hooks/delegate-task-retry/patterns.ts create mode 100644 src/hooks/directory-agents-injector/finder.ts create mode 100644 src/hooks/directory-agents-injector/hook.ts create mode 100644 src/hooks/directory-agents-injector/injector.ts create mode 100644 src/hooks/directory-readme-injector/finder.ts create mode 100644 src/hooks/directory-readme-injector/hook.ts create mode 100644 src/hooks/directory-readme-injector/injector.ts create mode 100644 src/hooks/edit-error-recovery/hook.ts create mode 100644 src/hooks/keyword-detector/hook.ts rename src/hooks/keyword-detector/ultrawork/{utils.ts => source-detector.ts} (93%) create mode 100644 src/hooks/prometheus-md-only/agent-resolution.ts create mode 100644 src/hooks/prometheus-md-only/hook.ts create mode 100644 src/hooks/prometheus-md-only/path-policy.ts create mode 100644 src/hooks/question-label-truncator/hook.ts create mode 100644 src/hooks/rules-injector/cache.ts create mode 100644 src/hooks/rules-injector/hook.ts create mode 100644 src/hooks/rules-injector/injector.ts create mode 100644 src/hooks/sisyphus-junior-notepad/hook.ts create mode 100644 src/hooks/stop-continuation-guard/hook.ts create mode 100644 src/hooks/subagent-question-blocker/hook.ts create mode 100644 src/hooks/task-reminder/hook.ts create mode 100644 src/hooks/task-resume-info/hook.ts create mode 100644 src/hooks/tasks-todowrite-disabler/hook.ts create mode 100644 src/hooks/think-mode/hook.ts create mode 100644 src/hooks/thinking-block-validator/hook.ts create mode 100644 src/hooks/write-existing-file-guard/hook.ts create mode 100644 src/shared/model-resolution-types.ts create mode 100644 src/shared/opencode-config-dir-types.ts rename src/tools/ast-grep/{utils.ts => result-formatter.ts} (100%) create mode 100644 src/tools/delegate-task/background-continuation.ts create mode 100644 src/tools/delegate-task/background-task.ts create mode 100644 src/tools/delegate-task/category-resolver.ts create mode 100644 src/tools/delegate-task/error-formatting.ts create mode 100644 src/tools/delegate-task/executor-types.ts delete mode 100644 src/tools/delegate-task/helpers.ts create mode 100644 src/tools/delegate-task/model-string-parser.ts create mode 100644 src/tools/delegate-task/parent-context-resolver.ts create mode 100644 src/tools/delegate-task/sisyphus-junior-agent.ts create mode 100644 src/tools/delegate-task/skill-content-resolver.ts create mode 100644 src/tools/delegate-task/subagent-resolver.ts create mode 100644 src/tools/delegate-task/sync-continuation.ts create mode 100644 src/tools/delegate-task/sync-prompt-sender.ts create mode 100644 src/tools/delegate-task/sync-result-fetcher.ts create mode 100644 src/tools/delegate-task/sync-session-creator.ts create mode 100644 src/tools/delegate-task/sync-session-poller.ts create mode 100644 src/tools/delegate-task/sync-task.ts create mode 100644 src/tools/delegate-task/time-formatter.ts create mode 100644 src/tools/delegate-task/unstable-agent-task.ts rename src/tools/glob/{utils.ts => result-formatter.ts} (100%) rename src/tools/grep/{utils.ts => result-formatter.ts} (100%) rename src/tools/interactive-bash/{utils.ts => tmux-path-resolver.ts} (100%) create mode 100644 src/tools/lsp/diagnostics-tool.ts create mode 100644 src/tools/lsp/find-references-tool.ts create mode 100644 src/tools/lsp/goto-definition-tool.ts create mode 100644 src/tools/lsp/language-config.ts create mode 100644 src/tools/lsp/language-mappings.ts create mode 100644 src/tools/lsp/lsp-client-connection.ts create mode 100644 src/tools/lsp/lsp-client-transport.ts create mode 100644 src/tools/lsp/lsp-client-wrapper.ts create mode 100644 src/tools/lsp/lsp-client.ts create mode 100644 src/tools/lsp/lsp-formatters.ts create mode 100644 src/tools/lsp/lsp-process.ts create mode 100644 src/tools/lsp/lsp-server.ts create mode 100644 src/tools/lsp/rename-tools.ts create mode 100644 src/tools/lsp/server-config-loader.ts create mode 100644 src/tools/lsp/server-definitions.ts create mode 100644 src/tools/lsp/server-installation.ts create mode 100644 src/tools/lsp/server-resolution.ts create mode 100644 src/tools/lsp/symbols-tool.ts delete mode 100644 src/tools/lsp/utils.ts create mode 100644 src/tools/lsp/workspace-edit.ts rename src/tools/session-manager/{utils.ts => session-formatter.ts} (100%) diff --git a/bun.lock b/bun.lock index 7c5f969e..4a416c88 100644 --- a/bun.lock +++ b/bun.lock @@ -28,13 +28,13 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.3.0", - "oh-my-opencode-darwin-x64": "3.3.0", - "oh-my-opencode-linux-arm64": "3.3.0", - "oh-my-opencode-linux-arm64-musl": "3.3.0", - "oh-my-opencode-linux-x64": "3.3.0", - "oh-my-opencode-linux-x64-musl": "3.3.0", - "oh-my-opencode-windows-x64": "3.3.0", + "oh-my-opencode-darwin-arm64": "3.3.1", + "oh-my-opencode-darwin-x64": "3.3.1", + "oh-my-opencode-linux-arm64": "3.3.1", + "oh-my-opencode-linux-arm64-musl": "3.3.1", + "oh-my-opencode-linux-x64": "3.3.1", + "oh-my-opencode-linux-x64-musl": "3.3.1", + "oh-my-opencode-windows-x64": "3.3.1", }, }, }, @@ -226,19 +226,19 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.3.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-P2kZKJqZaA4j0qtGM3I8+ZeH204ai27ni/OXLjtFdOewRjJgrahxaC1XslgK7q/KU9fXz6BQfEqAjbvyPf/rgQ=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.3.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-R+o42Km6bsIaW6D3I8uu2HCF3BjIWqa/fg38W5y4hJEOw4mL0Q7uV4R+0vtrXRHo9crXTK9ag0fqVQUm+Y6iAQ=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.3.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-RopOorbW1WyhMQJ+ipuqiOA1GICS+3IkOwNyEe0KZlCLpoEDTyFopIL87HSns+gEQPMxnknroDp8lzxn1AKgjw=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.3.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7VTbpR1vH3OEkoJxBKtYuxFPX8M3IbJKoeHWME9iK6FpT11W1ASsjyuhvzB1jcxSeqF8ddMnjitlG5ub6h5EVw=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.3.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-297iEfuK+05g+q64crPW78Zbgm/j5PGjDDweSPkZ6rI6SEfHMvOIkGxMvN8gugM3zcH8FOCQXoY2nC8b6x3pwQ=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-BZ/r/CFlvbOxkdZZrRoT16xFOjibRZHuwQnaE4f0JvOzgK6/HWp3zJI1+2/aX/oK5GA6lZxNWRrJC/SKUi8LEg=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.3.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oVxP0+yn66HQYfrl9QT6I7TumRzciuPB4z24+PwKEVcDjPbWXQqLY1gwOGHZAQBPLf0vwewv9ybEDVD42RRH4g=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-U90Wruf21h+CJbtcrS7MeTAc/5VOF6RI+5jr7qj/cCxjXNJtjhyJdz/maehArjtgf304+lYCM/Mh1i+G2D3YFQ=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.3.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-k9LoLkisLJwJNR1J0Bh1bjGtGBkl5D9WzFPSdZCAlyiT6TgG9w5erPTlXqtl2Lt0We5tYUVYlkEIHRMK/ugNsQ=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-sYzohSNdwsAhivbXcbhPdF1qqQi2CCI7FSgbmvvfBOMyZ8HAgqOFqYW2r3GPdmtywzkjOTvCzTG56FZwEjx15w=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.3.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7asXCeae7wBxJrzoZ7J6Yo1oaOxwUN3bTO7jWurCTMs5TDHO+pEHysgv/nuF1jvj1T+r1vg1H5ZmopuKy1qvXg=="], + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-aG5pZ4eWS0YSGUicOnjMkUPrIqQV4poYF+d9SIvrfvlaMcK6WlQn7jXzgNCwJsfGn5lyhSmjshZBEU+v79Ua3w=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.3.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-ABvwfaXb2xdrpbivzlPPJzIm5vXp+QlVakkaHEQf3TU6Mi/+fehH6Qhq/KMh66FDO2gq3xmxbH7nktHRQp9kNA=="], + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.3.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-FGH7cnzBqNwjSkzCDglMsVttaq+MsykAxa7ehaFK+0dnBZArvllS3W13a3dGaANHMZzfK0vz8hNDUdVi7Z63cA=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], diff --git a/src/agents/AGENTS.md b/src/agents/AGENTS.md index 1cbf91d3..bd23df06 100644 --- a/src/agents/AGENTS.md +++ b/src/agents/AGENTS.md @@ -61,7 +61,7 @@ agents/ ## HOW TO ADD 1. Create `src/agents/my-agent.ts` exporting factory + metadata. -2. Add to `agentSources` in `src/agents/utils.ts`. +2. Add to `agentSources` in `src/agents/builtin-agents.ts`. 3. Update `AgentNameSchema` in `src/config/schema.ts`. 4. Register in `src/index.ts` initialization. diff --git a/src/agents/agent-builder.ts b/src/agents/agent-builder.ts new file mode 100644 index 00000000..459f18e0 --- /dev/null +++ b/src/agents/agent-builder.ts @@ -0,0 +1,52 @@ +import type { AgentConfig } from "@opencode-ai/sdk" +import type { AgentFactory } from "./types" +import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema" +import type { BrowserAutomationProvider } from "../config/schema" +import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants" +import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content" + +export type AgentSource = AgentFactory | AgentConfig + +export function isFactory(source: AgentSource): source is AgentFactory { + return typeof source === "function" +} + +export function buildAgent( + source: AgentSource, + model: string, + categories?: CategoriesConfig, + gitMasterConfig?: GitMasterConfig, + browserProvider?: BrowserAutomationProvider, + disabledSkills?: Set +): AgentConfig { + const base = isFactory(source) ? source(model) : source + const categoryConfigs: Record = categories + ? { ...DEFAULT_CATEGORIES, ...categories } + : DEFAULT_CATEGORIES + + const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string } + if (agentWithCategory.category) { + const categoryConfig = categoryConfigs[agentWithCategory.category] + if (categoryConfig) { + if (!base.model) { + base.model = categoryConfig.model + } + if (base.temperature === undefined && categoryConfig.temperature !== undefined) { + base.temperature = categoryConfig.temperature + } + if (base.variant === undefined && categoryConfig.variant !== undefined) { + base.variant = categoryConfig.variant + } + } + } + + if (agentWithCategory.skills?.length) { + const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { gitMasterConfig, browserProvider, disabledSkills }) + if (resolved.size > 0) { + const skillContent = Array.from(resolved.values()).join("\n\n") + base.prompt = skillContent + (base.prompt ? "\n\n" + base.prompt : "") + } + } + + return base +} diff --git a/src/agents/atlas/agent.ts b/src/agents/atlas/agent.ts new file mode 100644 index 00000000..7b2c73fc --- /dev/null +++ b/src/agents/atlas/agent.ts @@ -0,0 +1,142 @@ +/** + * Atlas - Master Orchestrator Agent + * + * Orchestrates work via task() to complete ALL tasks in a todo list until fully done. + * You are the conductor of a symphony of specialized agents. + * + * Routing: + * 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized) + * 2. Default (Claude, etc.) → default.ts (Claude-optimized) + */ + +import type { AgentConfig } from "@opencode-ai/sdk" +import type { AgentMode, AgentPromptMetadata } from "../types" +import { isGptModel } from "../types" +import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder" +import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder" +import type { CategoryConfig } from "../../config/schema" +import { DEFAULT_CATEGORIES } from "../../tools/delegate-task/constants" +import { createAgentToolRestrictions } from "../../shared/permission-compat" + +import { getDefaultAtlasPrompt } from "./default" +import { getGptAtlasPrompt } from "./gpt" +import { + getCategoryDescription, + buildAgentSelectionSection, + buildCategorySection, + buildSkillsSection, + buildDecisionMatrix, +} from "./prompt-section-builder" + +const MODE: AgentMode = "primary" + +export type AtlasPromptSource = "default" | "gpt" + +/** + * Determines which Atlas prompt to use based on model. + */ +export function getAtlasPromptSource(model?: string): AtlasPromptSource { + if (model && isGptModel(model)) { + return "gpt" + } + return "default" +} + +export interface OrchestratorContext { + model?: string + availableAgents?: AvailableAgent[] + availableSkills?: AvailableSkill[] + userCategories?: Record +} + +/** + * Gets the appropriate Atlas prompt based on model. + */ +export function getAtlasPrompt(model?: string): string { + const source = getAtlasPromptSource(model) + + switch (source) { + case "gpt": + return getGptAtlasPrompt() + case "default": + default: + return getDefaultAtlasPrompt() + } +} + +function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string { + const agents = ctx?.availableAgents ?? [] + const skills = ctx?.availableSkills ?? [] + const userCategories = ctx?.userCategories + const model = ctx?.model + + const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories } + const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({ + name, + description: getCategoryDescription(name, userCategories), + })) + + const categorySection = buildCategorySection(userCategories) + const agentSection = buildAgentSelectionSection(agents) + const decisionMatrix = buildDecisionMatrix(agents, userCategories) + const skillsSection = buildSkillsSection(skills) + const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, skills) + + const basePrompt = getAtlasPrompt(model) + + return basePrompt + .replace("{CATEGORY_SECTION}", categorySection) + .replace("{AGENT_SECTION}", agentSection) + .replace("{DECISION_MATRIX}", decisionMatrix) + .replace("{SKILLS_SECTION}", skillsSection) + .replace("{{CATEGORY_SKILLS_DELEGATION_GUIDE}}", categorySkillsGuide) +} + +export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig { + const restrictions = createAgentToolRestrictions([ + "task", + "call_omo_agent", + ]) + + const baseConfig = { + description: + "Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)", + mode: MODE, + ...(ctx.model ? { model: ctx.model } : {}), + temperature: 0.1, + prompt: buildDynamicOrchestratorPrompt(ctx), + color: "#10B981", + ...restrictions, + } + + return baseConfig as AgentConfig +} +createAtlasAgent.mode = MODE + +export const atlasPromptMetadata: AgentPromptMetadata = { + category: "advisor", + cost: "EXPENSIVE", + promptAlias: "Atlas", + triggers: [ + { + domain: "Todo list orchestration", + trigger: "Complete ALL tasks in a todo list with verification", + }, + { + domain: "Multi-agent coordination", + trigger: "Parallel task execution across specialized agents", + }, + ], + useWhen: [ + "User provides a todo list path (.sisyphus/plans/{name}.md)", + "Multiple tasks need to be completed in sequence or parallel", + "Work requires coordination across multiple specialized agents", + ], + avoidWhen: [ + "Single simple task that doesn't require orchestration", + "Tasks that can be handled directly by one agent", + "When user wants to execute tasks manually", + ], + keyTrigger: + "Todo list path provided OR multiple tasks requiring multi-agent orchestration", +} diff --git a/src/agents/atlas/index.ts b/src/agents/atlas/index.ts index 77cfdda8..c7719b41 100644 --- a/src/agents/atlas/index.ts +++ b/src/agents/atlas/index.ts @@ -1,33 +1,3 @@ -/** - * Atlas - Master Orchestrator Agent - * - * Orchestrates work via task() to complete ALL tasks in a todo list until fully done. - * You are the conductor of a symphony of specialized agents. - * - * Routing: - * 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized) - * 2. Default (Claude, etc.) → default.ts (Claude-optimized) - */ - -import type { AgentConfig } from "@opencode-ai/sdk" -import type { AgentMode, AgentPromptMetadata } from "../types" -import { isGptModel } from "../types" -import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder" -import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder" -import type { CategoryConfig } from "../../config/schema" -import { DEFAULT_CATEGORIES } from "../../tools/delegate-task/constants" -import { createAgentToolRestrictions } from "../../shared/permission-compat" - -import { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default" -import { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt" -import { - getCategoryDescription, - buildAgentSelectionSection, - buildCategorySection, - buildSkillsSection, - buildDecisionMatrix, -} from "./utils" - export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default" export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt" export { @@ -36,118 +6,9 @@ export { buildCategorySection, buildSkillsSection, buildDecisionMatrix, -} from "./utils" -export { isGptModel } +} from "./prompt-section-builder" -const MODE: AgentMode = "primary" +export { createAtlasAgent, getAtlasPromptSource, getAtlasPrompt, atlasPromptMetadata } from "./agent" +export type { AtlasPromptSource, OrchestratorContext } from "./agent" -export type AtlasPromptSource = "default" | "gpt" - -/** - * Determines which Atlas prompt to use based on model. - */ -export function getAtlasPromptSource(model?: string): AtlasPromptSource { - if (model && isGptModel(model)) { - return "gpt" - } - return "default" -} - -export interface OrchestratorContext { - model?: string - availableAgents?: AvailableAgent[] - availableSkills?: AvailableSkill[] - userCategories?: Record -} - -/** - * Gets the appropriate Atlas prompt based on model. - */ -export function getAtlasPrompt(model?: string): string { - const source = getAtlasPromptSource(model) - - switch (source) { - case "gpt": - return getGptAtlasPrompt() - case "default": - default: - return getDefaultAtlasPrompt() - } -} - -function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string { - const agents = ctx?.availableAgents ?? [] - const skills = ctx?.availableSkills ?? [] - const userCategories = ctx?.userCategories - const model = ctx?.model - - const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories } - const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({ - name, - description: getCategoryDescription(name, userCategories), - })) - - const categorySection = buildCategorySection(userCategories) - const agentSection = buildAgentSelectionSection(agents) - const decisionMatrix = buildDecisionMatrix(agents, userCategories) - const skillsSection = buildSkillsSection(skills) - const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, skills) - - const basePrompt = getAtlasPrompt(model) - - return basePrompt - .replace("{CATEGORY_SECTION}", categorySection) - .replace("{AGENT_SECTION}", agentSection) - .replace("{DECISION_MATRIX}", decisionMatrix) - .replace("{SKILLS_SECTION}", skillsSection) - .replace("{{CATEGORY_SKILLS_DELEGATION_GUIDE}}", categorySkillsGuide) -} - -export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig { - const restrictions = createAgentToolRestrictions([ - "task", - "call_omo_agent", - ]) - - const baseConfig = { - description: - "Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)", - mode: MODE, - ...(ctx.model ? { model: ctx.model } : {}), - temperature: 0.1, - prompt: buildDynamicOrchestratorPrompt(ctx), - color: "#10B981", - ...restrictions, - } - - return baseConfig as AgentConfig -} -createAtlasAgent.mode = MODE - -export const atlasPromptMetadata: AgentPromptMetadata = { - category: "advisor", - cost: "EXPENSIVE", - promptAlias: "Atlas", - triggers: [ - { - domain: "Todo list orchestration", - trigger: "Complete ALL tasks in a todo list with verification", - }, - { - domain: "Multi-agent coordination", - trigger: "Parallel task execution across specialized agents", - }, - ], - useWhen: [ - "User provides a todo list path (.sisyphus/plans/{name}.md)", - "Multiple tasks need to be completed in sequence or parallel", - "Work requires coordination across multiple specialized agents", - ], - avoidWhen: [ - "Single simple task that doesn't require orchestration", - "Tasks that can be handled directly by one agent", - "When user wants to execute tasks manually", - ], - keyTrigger: - "Todo list path provided OR multiple tasks requiring multi-agent orchestration", -} +export { isGptModel } from "../types" diff --git a/src/agents/atlas/utils.ts b/src/agents/atlas/prompt-section-builder.ts similarity index 100% rename from src/agents/atlas/utils.ts rename to src/agents/atlas/prompt-section-builder.ts diff --git a/src/agents/builtin-agents.ts b/src/agents/builtin-agents.ts new file mode 100644 index 00000000..8e74ca7a --- /dev/null +++ b/src/agents/builtin-agents.ts @@ -0,0 +1,163 @@ +import type { AgentConfig } from "@opencode-ai/sdk" +import type { BuiltinAgentName, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types" +import type { CategoriesConfig, GitMasterConfig } from "../config/schema" +import type { LoadedSkill } from "../features/opencode-skill-loader/types" +import type { BrowserAutomationProvider } from "../config/schema" +import { createSisyphusAgent } from "./sisyphus" +import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle" +import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian" +import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore" +import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker" +import { createMetisAgent, metisPromptMetadata } from "./metis" +import { createAtlasAgent, atlasPromptMetadata } from "./atlas" +import { createMomusAgent, momusPromptMetadata } from "./momus" +import { createHephaestusAgent } from "./hephaestus" +import type { AvailableCategory } from "./dynamic-agent-prompt-builder" +import { fetchAvailableModels, readConnectedProvidersCache } from "../shared" +import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants" +import { buildAvailableSkills } from "./builtin-agents/available-skills" +import { collectPendingBuiltinAgents } from "./builtin-agents/general-agents" +import { maybeCreateSisyphusConfig } from "./builtin-agents/sisyphus-agent" +import { maybeCreateHephaestusConfig } from "./builtin-agents/hephaestus-agent" +import { maybeCreateAtlasConfig } from "./builtin-agents/atlas-agent" + +type AgentSource = AgentFactory | AgentConfig + +const agentSources: Record = { + sisyphus: createSisyphusAgent, + hephaestus: createHephaestusAgent, + oracle: createOracleAgent, + librarian: createLibrarianAgent, + explore: createExploreAgent, + "multimodal-looker": createMultimodalLookerAgent, + metis: createMetisAgent, + momus: createMomusAgent, + // Note: Atlas is handled specially in createBuiltinAgents() + // because it needs OrchestratorContext, not just a model string + atlas: createAtlasAgent as AgentFactory, +} + +/** + * Metadata for each agent, used to build Sisyphus's dynamic prompt sections + * (Delegation Table, Tool Selection, Key Triggers, etc.) + */ +const agentMetadata: Partial> = { + oracle: ORACLE_PROMPT_METADATA, + librarian: LIBRARIAN_PROMPT_METADATA, + explore: EXPLORE_PROMPT_METADATA, + "multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA, + metis: metisPromptMetadata, + momus: momusPromptMetadata, + atlas: atlasPromptMetadata, +} + +export async function createBuiltinAgents( + disabledAgents: string[] = [], + agentOverrides: AgentOverrides = {}, + directory?: string, + systemDefaultModel?: string, + categories?: CategoriesConfig, + gitMasterConfig?: GitMasterConfig, + discoveredSkills: LoadedSkill[] = [], + client?: any, + browserProvider?: BrowserAutomationProvider, + uiSelectedModel?: string, + disabledSkills?: Set +): Promise> { + void client + + const connectedProviders = readConnectedProvidersCache() + // IMPORTANT: Do NOT pass client to fetchAvailableModels during plugin initialization. + // This function is called from config handler, and calling client API causes deadlock. + // See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301 + const availableModels = await fetchAvailableModels(undefined, { + connectedProviders: connectedProviders ?? undefined, + }) + const isFirstRunNoCache = + availableModels.size === 0 && (!connectedProviders || connectedProviders.length === 0) + + const result: Record = {} + + const mergedCategories = categories + ? { ...DEFAULT_CATEGORIES, ...categories } + : DEFAULT_CATEGORIES + + const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({ + name, + description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks", + })) + + const availableSkills = buildAvailableSkills(discoveredSkills, browserProvider, disabledSkills) + + // Collect general agents first (for availableAgents), but don't add to result yet + const { pendingAgentConfigs, availableAgents } = collectPendingBuiltinAgents({ + agentSources, + agentMetadata, + disabledAgents, + agentOverrides, + directory, + systemDefaultModel, + mergedCategories, + gitMasterConfig, + browserProvider, + uiSelectedModel, + availableModels, + disabledSkills, + }) + + const sisyphusConfig = maybeCreateSisyphusConfig({ + disabledAgents, + agentOverrides, + uiSelectedModel, + availableModels, + systemDefaultModel, + isFirstRunNoCache, + availableAgents, + availableSkills, + availableCategories, + mergedCategories, + directory, + userCategories: categories, + }) + if (sisyphusConfig) { + result["sisyphus"] = sisyphusConfig + } + + const hephaestusConfig = maybeCreateHephaestusConfig({ + disabledAgents, + agentOverrides, + availableModels, + systemDefaultModel, + isFirstRunNoCache, + availableAgents, + availableSkills, + availableCategories, + mergedCategories, + directory, + }) + if (hephaestusConfig) { + result["hephaestus"] = hephaestusConfig + } + + // Add pending agents after sisyphus and hephaestus to maintain order + for (const [name, config] of pendingAgentConfigs) { + result[name] = config + } + + const atlasConfig = maybeCreateAtlasConfig({ + disabledAgents, + agentOverrides, + uiSelectedModel, + availableModels, + systemDefaultModel, + availableAgents, + availableSkills, + mergedCategories, + userCategories: categories, + }) + if (atlasConfig) { + result["atlas"] = atlasConfig + } + + return result +} diff --git a/src/agents/builtin-agents/agent-overrides.ts b/src/agents/builtin-agents/agent-overrides.ts new file mode 100644 index 00000000..5e705b02 --- /dev/null +++ b/src/agents/builtin-agents/agent-overrides.ts @@ -0,0 +1,61 @@ +import type { AgentConfig } from "@opencode-ai/sdk" +import type { AgentOverrideConfig } from "../types" +import type { CategoryConfig } from "../../config/schema" +import { deepMerge, migrateAgentConfig } from "../../shared" + +/** + * Expands a category reference from an agent override into concrete config properties. + * Category properties are applied unconditionally (overwriting factory defaults), + * because the user's chosen category should take priority over factory base values. + * Direct override properties applied later via mergeAgentConfig() will supersede these. + */ +export function applyCategoryOverride( + config: AgentConfig, + categoryName: string, + mergedCategories: Record +): AgentConfig { + const categoryConfig = mergedCategories[categoryName] + if (!categoryConfig) return config + + const result = { ...config } as AgentConfig & Record + if (categoryConfig.model) result.model = categoryConfig.model + if (categoryConfig.variant !== undefined) result.variant = categoryConfig.variant + if (categoryConfig.temperature !== undefined) result.temperature = categoryConfig.temperature + if (categoryConfig.reasoningEffort !== undefined) result.reasoningEffort = categoryConfig.reasoningEffort + if (categoryConfig.textVerbosity !== undefined) result.textVerbosity = categoryConfig.textVerbosity + if (categoryConfig.thinking !== undefined) result.thinking = categoryConfig.thinking + if (categoryConfig.top_p !== undefined) result.top_p = categoryConfig.top_p + if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens + + return result as AgentConfig +} + +export function mergeAgentConfig(base: AgentConfig, override: AgentOverrideConfig): AgentConfig { + const migratedOverride = migrateAgentConfig(override as Record) as AgentOverrideConfig + const { prompt_append, ...rest } = migratedOverride + const merged = deepMerge(base, rest as Partial) + + if (prompt_append && merged.prompt) { + merged.prompt = merged.prompt + "\n" + prompt_append + } + + return merged +} + +export function applyOverrides( + config: AgentConfig, + override: AgentOverrideConfig | undefined, + mergedCategories: Record +): AgentConfig { + let result = config + const overrideCategory = (override as Record | undefined)?.category as string | undefined + if (overrideCategory) { + result = applyCategoryOverride(result, overrideCategory, mergedCategories) + } + + if (override) { + result = mergeAgentConfig(result, override) + } + + return result +} diff --git a/src/agents/builtin-agents/atlas-agent.ts b/src/agents/builtin-agents/atlas-agent.ts new file mode 100644 index 00000000..8d3e7004 --- /dev/null +++ b/src/agents/builtin-agents/atlas-agent.ts @@ -0,0 +1,63 @@ +import type { AgentConfig } from "@opencode-ai/sdk" +import type { AgentOverrides } from "../types" +import type { CategoriesConfig, CategoryConfig } from "../../config/schema" +import type { AvailableAgent, AvailableSkill } from "../dynamic-agent-prompt-builder" +import { AGENT_MODEL_REQUIREMENTS } from "../../shared" +import { applyOverrides } from "./agent-overrides" +import { applyModelResolution } from "./model-resolution" +import { createAtlasAgent } from "../atlas" + +export function maybeCreateAtlasConfig(input: { + disabledAgents: string[] + agentOverrides: AgentOverrides + uiSelectedModel?: string + availableModels: Set + systemDefaultModel?: string + availableAgents: AvailableAgent[] + availableSkills: AvailableSkill[] + mergedCategories: Record + userCategories?: CategoriesConfig +}): AgentConfig | undefined { + const { + disabledAgents, + agentOverrides, + uiSelectedModel, + availableModels, + systemDefaultModel, + availableAgents, + availableSkills, + mergedCategories, + userCategories, + } = input + + if (disabledAgents.includes("atlas")) return undefined + + const orchestratorOverride = agentOverrides["atlas"] + const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"] + + const atlasResolution = applyModelResolution({ + uiSelectedModel: orchestratorOverride?.model ? undefined : uiSelectedModel, + userModel: orchestratorOverride?.model, + requirement: atlasRequirement, + availableModels, + systemDefaultModel, + }) + + if (!atlasResolution) return undefined + const { model: atlasModel, variant: atlasResolvedVariant } = atlasResolution + + let orchestratorConfig = createAtlasAgent({ + model: atlasModel, + availableAgents, + availableSkills, + userCategories, + }) + + if (atlasResolvedVariant) { + orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant } + } + + orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories) + + return orchestratorConfig +} diff --git a/src/agents/builtin-agents/available-skills.ts b/src/agents/builtin-agents/available-skills.ts new file mode 100644 index 00000000..38a44801 --- /dev/null +++ b/src/agents/builtin-agents/available-skills.ts @@ -0,0 +1,35 @@ +import type { AvailableSkill } from "../dynamic-agent-prompt-builder" +import type { BrowserAutomationProvider } from "../../config/schema" +import type { LoadedSkill, SkillScope } from "../../features/opencode-skill-loader/types" +import { createBuiltinSkills } from "../../features/builtin-skills" + +function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] { + if (scope === "user" || scope === "opencode") return "user" + if (scope === "project" || scope === "opencode-project") return "project" + return "plugin" +} + +export function buildAvailableSkills( + discoveredSkills: LoadedSkill[], + browserProvider?: BrowserAutomationProvider, + disabledSkills?: Set +): AvailableSkill[] { + const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills }) + const builtinSkillNames = new Set(builtinSkills.map(s => s.name)) + + const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({ + name: skill.name, + description: skill.description, + location: "plugin" as const, + })) + + const discoveredAvailable: AvailableSkill[] = discoveredSkills + .filter(s => !builtinSkillNames.has(s.name)) + .map((skill) => ({ + name: skill.name, + description: skill.definition.description ?? "", + location: mapScopeToLocation(skill.scope), + })) + + return [...builtinAvailable, ...discoveredAvailable] +} diff --git a/src/agents/builtin-agents/environment-context.ts b/src/agents/builtin-agents/environment-context.ts new file mode 100644 index 00000000..cf309b85 --- /dev/null +++ b/src/agents/builtin-agents/environment-context.ts @@ -0,0 +1,8 @@ +import type { AgentConfig } from "@opencode-ai/sdk" +import { createEnvContext } from "../env-context" + +export function applyEnvironmentContext(config: AgentConfig, directory?: string): AgentConfig { + if (!directory || !config.prompt) return config + const envContext = createEnvContext() + return { ...config, prompt: config.prompt + envContext } +} diff --git a/src/agents/builtin-agents/general-agents.ts b/src/agents/builtin-agents/general-agents.ts new file mode 100644 index 00000000..0c88d56b --- /dev/null +++ b/src/agents/builtin-agents/general-agents.ts @@ -0,0 +1,108 @@ +import type { AgentConfig } from "@opencode-ai/sdk" +import type { BuiltinAgentName, AgentOverrides, AgentPromptMetadata } from "../types" +import type { CategoryConfig, GitMasterConfig } from "../../config/schema" +import type { BrowserAutomationProvider } from "../../config/schema" +import type { AvailableAgent } from "../dynamic-agent-prompt-builder" +import { AGENT_MODEL_REQUIREMENTS, isModelAvailable } from "../../shared" +import { buildAgent, isFactory } from "../agent-builder" +import { applyCategoryOverride, applyOverrides } from "./agent-overrides" +import { applyEnvironmentContext } from "./environment-context" +import { applyModelResolution } from "./model-resolution" + +export function collectPendingBuiltinAgents(input: { + agentSources: Record + agentMetadata: Partial> + disabledAgents: string[] + agentOverrides: AgentOverrides + directory?: string + systemDefaultModel?: string + mergedCategories: Record + gitMasterConfig?: GitMasterConfig + browserProvider?: BrowserAutomationProvider + uiSelectedModel?: string + availableModels: Set + disabledSkills?: Set +}): { pendingAgentConfigs: Map; availableAgents: AvailableAgent[] } { + const { + agentSources, + agentMetadata, + disabledAgents, + agentOverrides, + directory, + systemDefaultModel, + mergedCategories, + gitMasterConfig, + browserProvider, + uiSelectedModel, + availableModels, + disabledSkills, + } = input + + const availableAgents: AvailableAgent[] = [] + const pendingAgentConfigs: Map = new Map() + + for (const [name, source] of Object.entries(agentSources)) { + const agentName = name as BuiltinAgentName + + if (agentName === "sisyphus") continue + if (agentName === "hephaestus") continue + if (agentName === "atlas") continue + if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue + + const override = agentOverrides[agentName] + ?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] + const requirement = AGENT_MODEL_REQUIREMENTS[agentName] + + // Check if agent requires a specific model + if (requirement?.requiresModel && availableModels) { + if (!isModelAvailable(requirement.requiresModel, availableModels)) { + continue + } + } + + const isPrimaryAgent = isFactory(source) && source.mode === "primary" + + const resolution = applyModelResolution({ + uiSelectedModel: (isPrimaryAgent && !override?.model) ? uiSelectedModel : undefined, + userModel: override?.model, + requirement, + availableModels, + systemDefaultModel, + }) + if (!resolution) continue + const { model, variant: resolvedVariant } = resolution + + let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider, disabledSkills) + + // Apply resolved variant from model fallback chain + if (resolvedVariant) { + config = { ...config, variant: resolvedVariant } + } + + // Expand override.category into concrete properties (higher priority than factory/resolved) + const overrideCategory = (override as Record | undefined)?.category as string | undefined + if (overrideCategory) { + config = applyCategoryOverride(config, overrideCategory, mergedCategories) + } + + if (agentName === "librarian") { + config = applyEnvironmentContext(config, directory) + } + + config = applyOverrides(config, override, mergedCategories) + + // Store for later - will be added after sisyphus and hephaestus + pendingAgentConfigs.set(name, config) + + const metadata = agentMetadata[agentName] + if (metadata) { + availableAgents.push({ + name: agentName, + description: config.description ?? "", + metadata, + }) + } + } + + return { pendingAgentConfigs, availableAgents } +} diff --git a/src/agents/builtin-agents/hephaestus-agent.ts b/src/agents/builtin-agents/hephaestus-agent.ts new file mode 100644 index 00000000..5e803a03 --- /dev/null +++ b/src/agents/builtin-agents/hephaestus-agent.ts @@ -0,0 +1,88 @@ +import type { AgentConfig } from "@opencode-ai/sdk" +import type { AgentOverrides } from "../types" +import type { CategoryConfig } from "../../config/schema" +import type { AvailableAgent, AvailableCategory, AvailableSkill } from "../dynamic-agent-prompt-builder" +import { AGENT_MODEL_REQUIREMENTS, isAnyProviderConnected } from "../../shared" +import { createHephaestusAgent } from "../hephaestus" +import { createEnvContext } from "../env-context" +import { applyCategoryOverride, mergeAgentConfig } from "./agent-overrides" +import { applyModelResolution, getFirstFallbackModel } from "./model-resolution" + +export function maybeCreateHephaestusConfig(input: { + disabledAgents: string[] + agentOverrides: AgentOverrides + availableModels: Set + systemDefaultModel?: string + isFirstRunNoCache: boolean + availableAgents: AvailableAgent[] + availableSkills: AvailableSkill[] + availableCategories: AvailableCategory[] + mergedCategories: Record + directory?: string +}): AgentConfig | undefined { + const { + disabledAgents, + agentOverrides, + availableModels, + systemDefaultModel, + isFirstRunNoCache, + availableAgents, + availableSkills, + availableCategories, + mergedCategories, + directory, + } = input + + if (disabledAgents.includes("hephaestus")) return undefined + + const hephaestusOverride = agentOverrides["hephaestus"] + const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"] + const hasHephaestusExplicitConfig = hephaestusOverride !== undefined + + const hasRequiredProvider = + !hephaestusRequirement?.requiresProvider || + hasHephaestusExplicitConfig || + isFirstRunNoCache || + isAnyProviderConnected(hephaestusRequirement.requiresProvider, availableModels) + + if (!hasRequiredProvider) return undefined + + let hephaestusResolution = applyModelResolution({ + userModel: hephaestusOverride?.model, + requirement: hephaestusRequirement, + availableModels, + systemDefaultModel, + }) + + if (isFirstRunNoCache && !hephaestusOverride?.model) { + hephaestusResolution = getFirstFallbackModel(hephaestusRequirement) + } + + if (!hephaestusResolution) return undefined + const { model: hephaestusModel, variant: hephaestusResolvedVariant } = hephaestusResolution + + let hephaestusConfig = createHephaestusAgent( + hephaestusModel, + availableAgents, + undefined, + availableSkills, + availableCategories + ) + + hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" } + + const hepOverrideCategory = (hephaestusOverride as Record | undefined)?.category as string | undefined + if (hepOverrideCategory) { + hephaestusConfig = applyCategoryOverride(hephaestusConfig, hepOverrideCategory, mergedCategories) + } + + if (directory && hephaestusConfig.prompt) { + const envContext = createEnvContext() + hephaestusConfig = { ...hephaestusConfig, prompt: hephaestusConfig.prompt + envContext } + } + + if (hephaestusOverride) { + hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride) + } + return hephaestusConfig +} diff --git a/src/agents/builtin-agents/model-resolution.ts b/src/agents/builtin-agents/model-resolution.ts new file mode 100644 index 00000000..dd5f3266 --- /dev/null +++ b/src/agents/builtin-agents/model-resolution.ts @@ -0,0 +1,28 @@ +import { resolveModelPipeline } from "../../shared" + +export function applyModelResolution(input: { + uiSelectedModel?: string + userModel?: string + requirement?: { fallbackChain?: { providers: string[]; model: string; variant?: string }[] } + availableModels: Set + systemDefaultModel?: string +}) { + const { uiSelectedModel, userModel, requirement, availableModels, systemDefaultModel } = input + return resolveModelPipeline({ + intent: { uiSelectedModel, userModel }, + constraints: { availableModels }, + policy: { fallbackChain: requirement?.fallbackChain, systemDefaultModel }, + }) +} + +export function getFirstFallbackModel(requirement?: { + fallbackChain?: { providers: string[]; model: string; variant?: string }[] +}) { + const entry = requirement?.fallbackChain?.[0] + if (!entry || entry.providers.length === 0) return undefined + return { + model: `${entry.providers[0]}/${entry.model}`, + provenance: "provider-fallback" as const, + variant: entry.variant, + } +} diff --git a/src/agents/builtin-agents/sisyphus-agent.ts b/src/agents/builtin-agents/sisyphus-agent.ts new file mode 100644 index 00000000..ee31d0af --- /dev/null +++ b/src/agents/builtin-agents/sisyphus-agent.ts @@ -0,0 +1,81 @@ +import type { AgentConfig } from "@opencode-ai/sdk" +import type { AgentOverrides } from "../types" +import type { CategoriesConfig, CategoryConfig } from "../../config/schema" +import type { AvailableAgent, AvailableCategory, AvailableSkill } from "../dynamic-agent-prompt-builder" +import { AGENT_MODEL_REQUIREMENTS, isAnyFallbackModelAvailable } from "../../shared" +import { applyEnvironmentContext } from "./environment-context" +import { applyOverrides } from "./agent-overrides" +import { applyModelResolution, getFirstFallbackModel } from "./model-resolution" +import { createSisyphusAgent } from "../sisyphus" + +export function maybeCreateSisyphusConfig(input: { + disabledAgents: string[] + agentOverrides: AgentOverrides + uiSelectedModel?: string + availableModels: Set + systemDefaultModel?: string + isFirstRunNoCache: boolean + availableAgents: AvailableAgent[] + availableSkills: AvailableSkill[] + availableCategories: AvailableCategory[] + mergedCategories: Record + directory?: string + userCategories?: CategoriesConfig +}): AgentConfig | undefined { + const { + disabledAgents, + agentOverrides, + uiSelectedModel, + availableModels, + systemDefaultModel, + isFirstRunNoCache, + availableAgents, + availableSkills, + availableCategories, + mergedCategories, + directory, + } = input + + const sisyphusOverride = agentOverrides["sisyphus"] + const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"] + const hasSisyphusExplicitConfig = sisyphusOverride !== undefined + const meetsSisyphusAnyModelRequirement = + !sisyphusRequirement?.requiresAnyModel || + hasSisyphusExplicitConfig || + isFirstRunNoCache || + isAnyFallbackModelAvailable(sisyphusRequirement.fallbackChain, availableModels) + + if (disabledAgents.includes("sisyphus") || !meetsSisyphusAnyModelRequirement) return undefined + + let sisyphusResolution = applyModelResolution({ + uiSelectedModel: sisyphusOverride?.model ? undefined : uiSelectedModel, + userModel: sisyphusOverride?.model, + requirement: sisyphusRequirement, + availableModels, + systemDefaultModel, + }) + + if (isFirstRunNoCache && !sisyphusOverride?.model && !uiSelectedModel) { + sisyphusResolution = getFirstFallbackModel(sisyphusRequirement) + } + + if (!sisyphusResolution) return undefined + const { model: sisyphusModel, variant: sisyphusResolvedVariant } = sisyphusResolution + + let sisyphusConfig = createSisyphusAgent( + sisyphusModel, + availableAgents, + undefined, + availableSkills, + availableCategories + ) + + if (sisyphusResolvedVariant) { + sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant } + } + + sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories) + sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory) + + return sisyphusConfig +} diff --git a/src/agents/env-context.ts b/src/agents/env-context.ts new file mode 100644 index 00000000..262886ca --- /dev/null +++ b/src/agents/env-context.ts @@ -0,0 +1,33 @@ +/** + * Creates OmO-specific environment context (time, timezone, locale). + * Note: Working directory, platform, and date are already provided by OpenCode's system.ts, + * so we only include fields that OpenCode doesn't provide to avoid duplication. + * See: https://github.com/code-yeongyu/oh-my-opencode/issues/379 + */ +export function createEnvContext(): string { + const now = new Date() + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone + const locale = Intl.DateTimeFormat().resolvedOptions().locale + + const dateStr = now.toLocaleDateString(locale, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }) + + const timeStr = now.toLocaleTimeString(locale, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: true, + }) + + return ` + + Current date: ${dateStr} + Current time: ${timeStr} + Timezone: ${timezone} + Locale: ${locale} +` +} diff --git a/src/agents/index.ts b/src/agents/index.ts index 57b415fb..acf5fb6d 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,5 +1,5 @@ export * from "./types" -export { createBuiltinAgents } from "./utils" +export { createBuiltinAgents } from "./builtin-agents" export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder" export { createSisyphusAgent } from "./sisyphus" export { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle" diff --git a/src/agents/sisyphus-junior/agent.ts b/src/agents/sisyphus-junior/agent.ts new file mode 100644 index 00000000..88cabb30 --- /dev/null +++ b/src/agents/sisyphus-junior/agent.ts @@ -0,0 +1,119 @@ +/** + * Sisyphus-Junior - Focused Task Executor + * + * Executes delegated tasks directly without spawning other agents. + * Category-spawned executor with domain-specific configurations. + * + * Routing: + * 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized) + * 2. Default (Claude, etc.) -> default.ts (Claude-optimized) + */ + +import type { AgentConfig } from "@opencode-ai/sdk" +import type { AgentMode } from "../types" +import { isGptModel } from "../types" +import type { AgentOverrideConfig } from "../../config/schema" +import { + createAgentToolRestrictions, + type PermissionValue, +} from "../../shared/permission-compat" + +import { buildDefaultSisyphusJuniorPrompt } from "./default" +import { buildGptSisyphusJuniorPrompt } from "./gpt" + +const MODE: AgentMode = "subagent" + +// Core tools that Sisyphus-Junior must NEVER have access to +// Note: call_omo_agent is ALLOWED so subagents can spawn explore/librarian +const BLOCKED_TOOLS = ["task"] + +export const SISYPHUS_JUNIOR_DEFAULTS = { + model: "anthropic/claude-sonnet-4-5", + temperature: 0.1, +} as const + +export type SisyphusJuniorPromptSource = "default" | "gpt" + +/** + * Determines which Sisyphus-Junior prompt to use based on model. + */ +export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPromptSource { + if (model && isGptModel(model)) { + return "gpt" + } + return "default" +} + +/** + * Builds the appropriate Sisyphus-Junior prompt based on model. + */ +export function buildSisyphusJuniorPrompt( + model: string | undefined, + useTaskSystem: boolean, + promptAppend?: string +): string { + const source = getSisyphusJuniorPromptSource(model) + + switch (source) { + case "gpt": + return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend) + case "default": + default: + return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend) + } +} + +export function createSisyphusJuniorAgentWithOverrides( + override: AgentOverrideConfig | undefined, + systemDefaultModel?: string, + useTaskSystem = false +): AgentConfig { + if (override?.disable) { + override = undefined + } + + const overrideModel = (override as { model?: string } | undefined)?.model + const model = overrideModel ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model + const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature + + const promptAppend = override?.prompt_append + const prompt = buildSisyphusJuniorPrompt(model, useTaskSystem, promptAppend) + + const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS) + + const userPermission = (override?.permission ?? {}) as Record + const basePermission = baseRestrictions.permission + const merged: Record = { ...userPermission } + for (const tool of BLOCKED_TOOLS) { + merged[tool] = "deny" + } + merged.call_omo_agent = "allow" + const toolsConfig = { permission: { ...merged, ...basePermission } } + + const base: AgentConfig = { + description: override?.description ?? + "Focused task executor. Same discipline, no delegation. (Sisyphus-Junior - OhMyOpenCode)", + mode: MODE, + model, + temperature, + maxTokens: 64000, + prompt, + color: override?.color ?? "#20B2AA", + ...toolsConfig, + } + + if (override?.top_p !== undefined) { + base.top_p = override.top_p + } + + if (isGptModel(model)) { + return { ...base, reasoningEffort: "medium" } as AgentConfig + } + + return { + ...base, + thinking: { type: "enabled", budgetTokens: 32000 }, + } as AgentConfig +} + +createSisyphusJuniorAgentWithOverrides.mode = MODE diff --git a/src/agents/sisyphus-junior/index.ts b/src/agents/sisyphus-junior/index.ts index d9c56bc9..e2fd155f 100644 --- a/src/agents/sisyphus-junior/index.ts +++ b/src/agents/sisyphus-junior/index.ts @@ -1,121 +1,10 @@ -/** - * Sisyphus-Junior - Focused Task Executor - * - * Executes delegated tasks directly without spawning other agents. - * Category-spawned executor with domain-specific configurations. - * - * Routing: - * 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized) - * 2. Default (Claude, etc.) -> default.ts (Claude-optimized) - */ - -import type { AgentConfig } from "@opencode-ai/sdk" -import type { AgentMode } from "../types" -import { isGptModel } from "../types" -import type { AgentOverrideConfig } from "../../config/schema" -import { - createAgentToolRestrictions, - type PermissionValue, -} from "../../shared/permission-compat" - -import { buildDefaultSisyphusJuniorPrompt } from "./default" -import { buildGptSisyphusJuniorPrompt } from "./gpt" - export { buildDefaultSisyphusJuniorPrompt } from "./default" export { buildGptSisyphusJuniorPrompt } from "./gpt" -const MODE: AgentMode = "subagent" - -// Core tools that Sisyphus-Junior must NEVER have access to -// Note: call_omo_agent is ALLOWED so subagents can spawn explore/librarian -const BLOCKED_TOOLS = ["task"] - -export const SISYPHUS_JUNIOR_DEFAULTS = { - model: "anthropic/claude-sonnet-4-5", - temperature: 0.1, -} as const - -export type SisyphusJuniorPromptSource = "default" | "gpt" - -/** - * Determines which Sisyphus-Junior prompt to use based on model. - */ -export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPromptSource { - if (model && isGptModel(model)) { - return "gpt" - } - return "default" -} - -/** - * Builds the appropriate Sisyphus-Junior prompt based on model. - */ -export function buildSisyphusJuniorPrompt( - model: string | undefined, - useTaskSystem: boolean, - promptAppend?: string -): string { - const source = getSisyphusJuniorPromptSource(model) - - switch (source) { - case "gpt": - return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend) - case "default": - default: - return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend) - } -} - -export function createSisyphusJuniorAgentWithOverrides( - override: AgentOverrideConfig | undefined, - systemDefaultModel?: string, - useTaskSystem = false -): AgentConfig { - if (override?.disable) { - override = undefined - } - - const model = override?.model ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model - const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature - - const promptAppend = override?.prompt_append - const prompt = buildSisyphusJuniorPrompt(model, useTaskSystem, promptAppend) - - const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS) - - const userPermission = (override?.permission ?? {}) as Record - const basePermission = baseRestrictions.permission - const merged: Record = { ...userPermission } - for (const tool of BLOCKED_TOOLS) { - merged[tool] = "deny" - } - merged.call_omo_agent = "allow" - const toolsConfig = { permission: { ...merged, ...basePermission } } - - const base: AgentConfig = { - description: override?.description ?? - "Focused task executor. Same discipline, no delegation. (Sisyphus-Junior - OhMyOpenCode)", - mode: MODE, - model, - temperature, - maxTokens: 64000, - prompt, - color: override?.color ?? "#20B2AA", - ...toolsConfig, - } - - if (override?.top_p !== undefined) { - base.top_p = override.top_p - } - - if (isGptModel(model)) { - return { ...base, reasoningEffort: "medium" } as AgentConfig - } - - return { - ...base, - thinking: { type: "enabled", budgetTokens: 32000 }, - } as AgentConfig -} - -createSisyphusJuniorAgentWithOverrides.mode = MODE +export { + SISYPHUS_JUNIOR_DEFAULTS, + getSisyphusJuniorPromptSource, + buildSisyphusJuniorPrompt, + createSisyphusJuniorAgentWithOverrides, +} from "./agent" +export type { SisyphusJuniorPromptSource } from "./agent" diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 88883feb..2a0908fa 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -1,5 +1,7 @@ +/// + import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test" -import { createBuiltinAgents } from "./utils" +import { createBuiltinAgents } from "./builtin-agents" import type { AgentConfig } from "@opencode-ai/sdk" import { clearSkillCache } from "../features/opencode-skill-loader/skill-content" import * as connectedProvidersCache from "../shared/connected-providers-cache" @@ -543,7 +545,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => { }) describe("buildAgent with category and skills", () => { - const { buildAgent } = require("./utils") + const { buildAgent } = require("./agent-builder") const TEST_MODEL = "anthropic/claude-opus-4-6" beforeEach(() => { diff --git a/src/agents/utils.ts b/src/agents/utils.ts deleted file mode 100644 index 5aac0ebb..00000000 --- a/src/agents/utils.ts +++ /dev/null @@ -1,485 +0,0 @@ -import type { AgentConfig } from "@opencode-ai/sdk" -import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types" -import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema" -import { createSisyphusAgent } from "./sisyphus" -import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle" -import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian" -import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore" -import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker" -import { createMetisAgent, metisPromptMetadata } from "./metis" -import { createAtlasAgent, atlasPromptMetadata } from "./atlas" -import { createMomusAgent, momusPromptMetadata } from "./momus" -import { createHephaestusAgent } from "./hephaestus" -import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder" -import { deepMerge, fetchAvailableModels, resolveModelPipeline, AGENT_MODEL_REQUIREMENTS, readConnectedProvidersCache, isModelAvailable, isAnyFallbackModelAvailable, isAnyProviderConnected, migrateAgentConfig } from "../shared" -import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants" -import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content" -import { createBuiltinSkills } from "../features/builtin-skills" -import type { LoadedSkill, SkillScope } from "../features/opencode-skill-loader/types" -import type { BrowserAutomationProvider } from "../config/schema" - -type AgentSource = AgentFactory | AgentConfig - -const agentSources: Record = { - sisyphus: createSisyphusAgent, - hephaestus: createHephaestusAgent, - oracle: createOracleAgent, - librarian: createLibrarianAgent, - explore: createExploreAgent, - "multimodal-looker": createMultimodalLookerAgent, - metis: createMetisAgent, - momus: createMomusAgent, - // Note: Atlas is handled specially in createBuiltinAgents() - // because it needs OrchestratorContext, not just a model string - atlas: createAtlasAgent as unknown as AgentFactory, -} - -/** - * Metadata for each agent, used to build Sisyphus's dynamic prompt sections - * (Delegation Table, Tool Selection, Key Triggers, etc.) - */ -const agentMetadata: Partial> = { - oracle: ORACLE_PROMPT_METADATA, - librarian: LIBRARIAN_PROMPT_METADATA, - explore: EXPLORE_PROMPT_METADATA, - "multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA, - metis: metisPromptMetadata, - momus: momusPromptMetadata, - atlas: atlasPromptMetadata, -} - -function isFactory(source: AgentSource): source is AgentFactory { - return typeof source === "function" -} - -export function buildAgent( - source: AgentSource, - model: string, - categories?: CategoriesConfig, - gitMasterConfig?: GitMasterConfig, - browserProvider?: BrowserAutomationProvider, - disabledSkills?: Set -): AgentConfig { - const base = isFactory(source) ? source(model) : source - const categoryConfigs: Record = categories - ? { ...DEFAULT_CATEGORIES, ...categories } - : DEFAULT_CATEGORIES - - const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string } - if (agentWithCategory.category) { - const categoryConfig = categoryConfigs[agentWithCategory.category] - if (categoryConfig) { - if (!base.model) { - base.model = categoryConfig.model - } - if (base.temperature === undefined && categoryConfig.temperature !== undefined) { - base.temperature = categoryConfig.temperature - } - if (base.variant === undefined && categoryConfig.variant !== undefined) { - base.variant = categoryConfig.variant - } - } - } - - if (agentWithCategory.skills?.length) { - const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { gitMasterConfig, browserProvider, disabledSkills }) - if (resolved.size > 0) { - const skillContent = Array.from(resolved.values()).join("\n\n") - base.prompt = skillContent + (base.prompt ? "\n\n" + base.prompt : "") - } - } - - return base -} - -/** - * Creates OmO-specific environment context (time, timezone, locale). - * Note: Working directory, platform, and date are already provided by OpenCode's system.ts, - * so we only include fields that OpenCode doesn't provide to avoid duplication. - * See: https://github.com/code-yeongyu/oh-my-opencode/issues/379 - */ -export function createEnvContext(): string { - const now = new Date() - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone - const locale = Intl.DateTimeFormat().resolvedOptions().locale - - const dateStr = now.toLocaleDateString(locale, { - weekday: "short", - year: "numeric", - month: "short", - day: "numeric", - }) - - const timeStr = now.toLocaleTimeString(locale, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: true, - }) - - return ` - - Current date: ${dateStr} - Current time: ${timeStr} - Timezone: ${timezone} - Locale: ${locale} -` -} - -/** - * Expands a category reference from an agent override into concrete config properties. - * Category properties are applied unconditionally (overwriting factory defaults), - * because the user's chosen category should take priority over factory base values. - * Direct override properties applied later via mergeAgentConfig() will supersede these. - */ -function applyCategoryOverride( - config: AgentConfig, - categoryName: string, - mergedCategories: Record -): AgentConfig { - const categoryConfig = mergedCategories[categoryName] - if (!categoryConfig) return config - - const result = { ...config } as AgentConfig & Record - if (categoryConfig.model) result.model = categoryConfig.model - if (categoryConfig.variant !== undefined) result.variant = categoryConfig.variant - if (categoryConfig.temperature !== undefined) result.temperature = categoryConfig.temperature - if (categoryConfig.reasoningEffort !== undefined) result.reasoningEffort = categoryConfig.reasoningEffort - if (categoryConfig.textVerbosity !== undefined) result.textVerbosity = categoryConfig.textVerbosity - if (categoryConfig.thinking !== undefined) result.thinking = categoryConfig.thinking - if (categoryConfig.top_p !== undefined) result.top_p = categoryConfig.top_p - if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens - - return result as AgentConfig -} - -function applyModelResolution(input: { - uiSelectedModel?: string - userModel?: string - requirement?: { fallbackChain?: { providers: string[]; model: string; variant?: string }[] } - availableModels: Set - systemDefaultModel?: string -}) { - const { uiSelectedModel, userModel, requirement, availableModels, systemDefaultModel } = input - return resolveModelPipeline({ - intent: { uiSelectedModel, userModel }, - constraints: { availableModels }, - policy: { fallbackChain: requirement?.fallbackChain, systemDefaultModel }, - }) -} - -function getFirstFallbackModel(requirement?: { - fallbackChain?: { providers: string[]; model: string; variant?: string }[] -}) { - const entry = requirement?.fallbackChain?.[0] - if (!entry || entry.providers.length === 0) return undefined - return { - model: `${entry.providers[0]}/${entry.model}`, - provenance: "provider-fallback" as const, - variant: entry.variant, - } -} - -function applyEnvironmentContext(config: AgentConfig, directory?: string): AgentConfig { - if (!directory || !config.prompt) return config - const envContext = createEnvContext() - return { ...config, prompt: config.prompt + envContext } -} - -function applyOverrides( - config: AgentConfig, - override: AgentOverrideConfig | undefined, - mergedCategories: Record -): AgentConfig { - let result = config - const overrideCategory = (override as Record | undefined)?.category as string | undefined - if (overrideCategory) { - result = applyCategoryOverride(result, overrideCategory, mergedCategories) - } - - if (override) { - result = mergeAgentConfig(result, override) - } - - return result -} - -function mergeAgentConfig( - base: AgentConfig, - override: AgentOverrideConfig -): AgentConfig { - const migratedOverride = migrateAgentConfig(override as Record) as AgentOverrideConfig - const { prompt_append, ...rest } = migratedOverride - const merged = deepMerge(base, rest as Partial) - - if (prompt_append && merged.prompt) { - merged.prompt = merged.prompt + "\n" + prompt_append - } - - return merged -} - -function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] { - if (scope === "user" || scope === "opencode") return "user" - if (scope === "project" || scope === "opencode-project") return "project" - return "plugin" -} - -export async function createBuiltinAgents( - disabledAgents: string[] = [], - agentOverrides: AgentOverrides = {}, - directory?: string, - systemDefaultModel?: string, - categories?: CategoriesConfig, - gitMasterConfig?: GitMasterConfig, - discoveredSkills: LoadedSkill[] = [], - client?: any, - browserProvider?: BrowserAutomationProvider, - uiSelectedModel?: string, - disabledSkills?: Set -): Promise> { - const connectedProviders = readConnectedProvidersCache() - // IMPORTANT: Do NOT pass client to fetchAvailableModels during plugin initialization. - // This function is called from config handler, and calling client API causes deadlock. - // See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301 - const availableModels = await fetchAvailableModels(undefined, { - connectedProviders: connectedProviders ?? undefined, - }) - const isFirstRunNoCache = - availableModels.size === 0 && (!connectedProviders || connectedProviders.length === 0) - - const result: Record = {} - const availableAgents: AvailableAgent[] = [] - - const mergedCategories = categories - ? { ...DEFAULT_CATEGORIES, ...categories } - : DEFAULT_CATEGORIES - - const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({ - name, - description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks", - })) - - const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills }) - const builtinSkillNames = new Set(builtinSkills.map(s => s.name)) - - const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({ - name: skill.name, - description: skill.description, - location: "plugin" as const, - })) - - const discoveredAvailable: AvailableSkill[] = discoveredSkills - .filter(s => !builtinSkillNames.has(s.name)) - .map((skill) => ({ - name: skill.name, - description: skill.definition.description ?? "", - location: mapScopeToLocation(skill.scope), - })) - - const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable] - - // Collect general agents first (for availableAgents), but don't add to result yet - const pendingAgentConfigs: Map = new Map() - - for (const [name, source] of Object.entries(agentSources)) { - const agentName = name as BuiltinAgentName - - if (agentName === "sisyphus") continue - if (agentName === "hephaestus") continue - if (agentName === "atlas") continue - if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue - - const override = agentOverrides[agentName] - ?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] - const requirement = AGENT_MODEL_REQUIREMENTS[agentName] - - // Check if agent requires a specific model - if (requirement?.requiresModel && availableModels) { - if (!isModelAvailable(requirement.requiresModel, availableModels)) { - continue - } - } - - const isPrimaryAgent = isFactory(source) && source.mode === "primary" - - const resolution = applyModelResolution({ - uiSelectedModel: (isPrimaryAgent && !override?.model) ? uiSelectedModel : undefined, - userModel: override?.model, - requirement, - availableModels, - systemDefaultModel, - }) - if (!resolution) continue - const { model, variant: resolvedVariant } = resolution - - let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider, disabledSkills) - - // Apply resolved variant from model fallback chain - if (resolvedVariant) { - config = { ...config, variant: resolvedVariant } - } - - // Expand override.category into concrete properties (higher priority than factory/resolved) - const overrideCategory = (override as Record | undefined)?.category as string | undefined - if (overrideCategory) { - config = applyCategoryOverride(config, overrideCategory, mergedCategories) - } - - if (agentName === "librarian") { - config = applyEnvironmentContext(config, directory) - } - - config = applyOverrides(config, override, mergedCategories) - - // Store for later - will be added after sisyphus and hephaestus - pendingAgentConfigs.set(name, config) - - const metadata = agentMetadata[agentName] - if (metadata) { - availableAgents.push({ - name: agentName, - description: config.description ?? "", - metadata, - }) - } - } - - const sisyphusOverride = agentOverrides["sisyphus"] - const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"] - const hasSisyphusExplicitConfig = sisyphusOverride !== undefined - const meetsSisyphusAnyModelRequirement = - !sisyphusRequirement?.requiresAnyModel || - hasSisyphusExplicitConfig || - isFirstRunNoCache || - isAnyFallbackModelAvailable(sisyphusRequirement.fallbackChain, availableModels) - - if (!disabledAgents.includes("sisyphus") && meetsSisyphusAnyModelRequirement) { - let sisyphusResolution = applyModelResolution({ - uiSelectedModel: sisyphusOverride?.model ? undefined : uiSelectedModel, - userModel: sisyphusOverride?.model, - requirement: sisyphusRequirement, - availableModels, - systemDefaultModel, - }) - - if (isFirstRunNoCache && !sisyphusOverride?.model && !uiSelectedModel) { - sisyphusResolution = getFirstFallbackModel(sisyphusRequirement) - } - - if (sisyphusResolution) { - const { model: sisyphusModel, variant: sisyphusResolvedVariant } = sisyphusResolution - - let sisyphusConfig = createSisyphusAgent( - sisyphusModel, - availableAgents, - undefined, - availableSkills, - availableCategories - ) - - if (sisyphusResolvedVariant) { - sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant } - } - - sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories) - sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory) - - result["sisyphus"] = sisyphusConfig - } - } - - if (!disabledAgents.includes("hephaestus")) { - const hephaestusOverride = agentOverrides["hephaestus"] - const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"] - const hasHephaestusExplicitConfig = hephaestusOverride !== undefined - - const hasRequiredProvider = - !hephaestusRequirement?.requiresProvider || - hasHephaestusExplicitConfig || - isFirstRunNoCache || - isAnyProviderConnected(hephaestusRequirement.requiresProvider, availableModels) - - if (hasRequiredProvider) { - let hephaestusResolution = applyModelResolution({ - userModel: hephaestusOverride?.model, - requirement: hephaestusRequirement, - availableModels, - systemDefaultModel, - }) - - if (isFirstRunNoCache && !hephaestusOverride?.model) { - hephaestusResolution = getFirstFallbackModel(hephaestusRequirement) - } - - if (hephaestusResolution) { - const { model: hephaestusModel, variant: hephaestusResolvedVariant } = hephaestusResolution - - let hephaestusConfig = createHephaestusAgent( - hephaestusModel, - availableAgents, - undefined, - availableSkills, - availableCategories - ) - - hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" } - - const hepOverrideCategory = (hephaestusOverride as Record | undefined)?.category as string | undefined - if (hepOverrideCategory) { - hephaestusConfig = applyCategoryOverride(hephaestusConfig, hepOverrideCategory, mergedCategories) - } - - if (directory && hephaestusConfig.prompt) { - const envContext = createEnvContext() - hephaestusConfig = { ...hephaestusConfig, prompt: hephaestusConfig.prompt + envContext } - } - - if (hephaestusOverride) { - hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride) - } - - result["hephaestus"] = hephaestusConfig - } - } - } - - // Add pending agents after sisyphus and hephaestus to maintain order - for (const [name, config] of pendingAgentConfigs) { - result[name] = config - } - - if (!disabledAgents.includes("atlas")) { - const orchestratorOverride = agentOverrides["atlas"] - const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"] - - const atlasResolution = applyModelResolution({ - uiSelectedModel: orchestratorOverride?.model ? undefined : uiSelectedModel, - userModel: orchestratorOverride?.model, - requirement: atlasRequirement, - availableModels, - systemDefaultModel, - }) - - if (atlasResolution) { - const { model: atlasModel, variant: atlasResolvedVariant } = atlasResolution - - let orchestratorConfig = createAtlasAgent({ - model: atlasModel, - availableAgents, - availableSkills, - userCategories: categories, - }) - - if (atlasResolvedVariant) { - orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant } - } - - orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories) - - result["atlas"] = orchestratorConfig - } - } - - return result - } diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index 2807ba36..eac107bf 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -1,10 +1,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs" -import { - parseJsonc, - getOpenCodeConfigPaths, - type OpenCodeBinaryType, - type OpenCodeConfigPaths, -} from "../shared" +import { parseJsonc, getOpenCodeConfigPaths } from "../shared" +import type { + OpenCodeBinaryType, + OpenCodeConfigPaths, +} from "../shared/opencode-config-dir-types" import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types" import { generateModelConfig } from "./model-fallback" @@ -47,10 +46,6 @@ function getConfigJsonc(): string { return getConfigContext().paths.configJsonc } -function getPackageJson(): string { - return getConfigContext().paths.packageJson -} - function getOmoConfig(): string { return getConfigContext().paths.omoConfig } @@ -179,11 +174,6 @@ function isEmptyOrWhitespace(content: string): boolean { return content.trim().length === 0 } -function parseConfig(path: string, _isJsonc: boolean): OpenCodeConfig | null { - const result = parseConfigWithError(path) - return result.config -} - function parseConfigWithError(path: string): ParseConfigResult { try { const stat = statSync(path) diff --git a/src/cli/get-local-version/get-local-version.ts b/src/cli/get-local-version/get-local-version.ts new file mode 100644 index 00000000..4ce30e68 --- /dev/null +++ b/src/cli/get-local-version/get-local-version.ts @@ -0,0 +1,111 @@ +import { + findPluginEntry, + getCachedVersion, + getLatestVersion, + isLocalDevMode, +} from "../../hooks/auto-update-checker/checker" + +import type { GetLocalVersionOptions, VersionInfo } from "./types" +import { formatJsonOutput, formatVersionOutput } from "./formatter" + +export async function getLocalVersion( + options: GetLocalVersionOptions = {} +): Promise { + const directory = options.directory ?? process.cwd() + + try { + if (isLocalDevMode(directory)) { + const currentVersion = getCachedVersion() + const info: VersionInfo = { + currentVersion, + latestVersion: null, + isUpToDate: false, + isLocalDev: true, + isPinned: false, + pinnedVersion: null, + status: "local-dev", + } + + console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info)) + return 0 + } + + const pluginInfo = findPluginEntry(directory) + if (pluginInfo?.isPinned) { + const info: VersionInfo = { + currentVersion: pluginInfo.pinnedVersion, + latestVersion: null, + isUpToDate: false, + isLocalDev: false, + isPinned: true, + pinnedVersion: pluginInfo.pinnedVersion, + status: "pinned", + } + + console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info)) + return 0 + } + + const currentVersion = getCachedVersion() + if (!currentVersion) { + const info: VersionInfo = { + currentVersion: null, + latestVersion: null, + isUpToDate: false, + isLocalDev: false, + isPinned: false, + pinnedVersion: null, + status: "unknown", + } + + console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info)) + return 1 + } + + const { extractChannel } = await import("../../hooks/auto-update-checker/index") + const channel = extractChannel(pluginInfo?.pinnedVersion ?? currentVersion) + const latestVersion = await getLatestVersion(channel) + + if (!latestVersion) { + const info: VersionInfo = { + currentVersion, + latestVersion: null, + isUpToDate: false, + isLocalDev: false, + isPinned: false, + pinnedVersion: null, + status: "error", + } + + console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info)) + return 0 + } + + const isUpToDate = currentVersion === latestVersion + const info: VersionInfo = { + currentVersion, + latestVersion, + isUpToDate, + isLocalDev: false, + isPinned: false, + pinnedVersion: null, + status: isUpToDate ? "up-to-date" : "outdated", + } + + console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info)) + return 0 + } catch (error) { + const info: VersionInfo = { + currentVersion: null, + latestVersion: null, + isUpToDate: false, + isLocalDev: false, + isPinned: false, + pinnedVersion: null, + status: "error", + } + + console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info)) + return 1 + } +} diff --git a/src/cli/get-local-version/index.ts b/src/cli/get-local-version/index.ts index a0f80ace..c4ce80b7 100644 --- a/src/cli/get-local-version/index.ts +++ b/src/cli/get-local-version/index.ts @@ -1,106 +1,2 @@ -import { getCachedVersion, getLatestVersion, isLocalDevMode, findPluginEntry } from "../../hooks/auto-update-checker/checker" -import type { GetLocalVersionOptions, VersionInfo } from "./types" -import { formatVersionOutput, formatJsonOutput } from "./formatter" - -export async function getLocalVersion(options: GetLocalVersionOptions = {}): Promise { - const directory = options.directory ?? process.cwd() - - try { - if (isLocalDevMode(directory)) { - const currentVersion = getCachedVersion() - const info: VersionInfo = { - currentVersion, - latestVersion: null, - isUpToDate: false, - isLocalDev: true, - isPinned: false, - pinnedVersion: null, - status: "local-dev", - } - - console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info)) - return 0 - } - - const pluginInfo = findPluginEntry(directory) - if (pluginInfo?.isPinned) { - const info: VersionInfo = { - currentVersion: pluginInfo.pinnedVersion, - latestVersion: null, - isUpToDate: false, - isLocalDev: false, - isPinned: true, - pinnedVersion: pluginInfo.pinnedVersion, - status: "pinned", - } - - console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info)) - return 0 - } - - const currentVersion = getCachedVersion() - if (!currentVersion) { - const info: VersionInfo = { - currentVersion: null, - latestVersion: null, - isUpToDate: false, - isLocalDev: false, - isPinned: false, - pinnedVersion: null, - status: "unknown", - } - - console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info)) - return 1 - } - - const { extractChannel } = await import("../../hooks/auto-update-checker/index") - const channel = extractChannel(pluginInfo?.pinnedVersion ?? currentVersion) - const latestVersion = await getLatestVersion(channel) - - if (!latestVersion) { - const info: VersionInfo = { - currentVersion, - latestVersion: null, - isUpToDate: false, - isLocalDev: false, - isPinned: false, - pinnedVersion: null, - status: "error", - } - - console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info)) - return 0 - } - - const isUpToDate = currentVersion === latestVersion - const info: VersionInfo = { - currentVersion, - latestVersion, - isUpToDate, - isLocalDev: false, - isPinned: false, - pinnedVersion: null, - status: isUpToDate ? "up-to-date" : "outdated", - } - - console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info)) - return 0 - - } catch (error) { - const info: VersionInfo = { - currentVersion: null, - latestVersion: null, - isUpToDate: false, - isLocalDev: false, - isPinned: false, - pinnedVersion: null, - status: "error", - } - - console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info)) - return 1 - } -} - +export { getLocalVersion } from "./get-local-version" export * from "./types" diff --git a/src/features/tmux-subagent/pane-state-querier.ts b/src/features/tmux-subagent/pane-state-querier.ts index 568820ab..988cfaf0 100644 --- a/src/features/tmux-subagent/pane-state-querier.ts +++ b/src/features/tmux-subagent/pane-state-querier.ts @@ -1,6 +1,6 @@ import { spawn } from "bun" import type { WindowState, TmuxPaneInfo } from "./types" -import { getTmuxPath } from "../../tools/interactive-bash/utils" +import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver" import { log } from "../../shared" export async function queryWindowState(sourcePaneId: string): Promise { diff --git a/src/features/tool-metadata-store/index.ts b/src/features/tool-metadata-store/index.ts index 906e1c2f..f9c4e28a 100644 --- a/src/features/tool-metadata-store/index.ts +++ b/src/features/tool-metadata-store/index.ts @@ -1,84 +1,7 @@ -/** - * Pending tool metadata store. - * - * OpenCode's `fromPlugin()` wrapper always replaces the metadata returned by - * plugin tools with `{ truncated, outputPath }`, discarding any sessionId, - * title, or custom metadata set during `execute()`. - * - * This store captures metadata written via `ctx.metadata()` inside execute(), - * then the `tool.execute.after` hook consumes it and merges it back into the - * result *before* the processor writes the final part to the session store. - * - * Flow: - * execute() → storeToolMetadata(sessionID, callID, data) - * fromPlugin() → overwrites metadata with { truncated } - * tool.execute.after → consumeToolMetadata(sessionID, callID) → merges back - * processor → Session.updatePart(status:"completed", metadata: result.metadata) - */ - -export interface PendingToolMetadata { - title?: string - metadata?: Record -} - -const pendingStore = new Map() - -const STALE_TIMEOUT_MS = 15 * 60 * 1000 - -function makeKey(sessionID: string, callID: string): string { - return `${sessionID}:${callID}` -} - -function cleanupStaleEntries(): void { - const now = Date.now() - for (const [key, entry] of pendingStore) { - if (now - entry.storedAt > STALE_TIMEOUT_MS) { - pendingStore.delete(key) - } - } -} - -/** - * Store metadata to be restored after fromPlugin() overwrites it. - * Called from tool execute() functions alongside ctx.metadata(). - */ -export function storeToolMetadata( - sessionID: string, - callID: string, - data: PendingToolMetadata, -): void { - cleanupStaleEntries() - pendingStore.set(makeKey(sessionID, callID), { ...data, storedAt: Date.now() }) -} - -/** - * Consume stored metadata (one-time read, removes from store). - * Called from tool.execute.after hook. - */ -export function consumeToolMetadata( - sessionID: string, - callID: string, -): PendingToolMetadata | undefined { - const key = makeKey(sessionID, callID) - const stored = pendingStore.get(key) - if (stored) { - pendingStore.delete(key) - const { storedAt: _, ...data } = stored - return data - } - return undefined -} - -/** - * Get current store size (for testing/debugging). - */ -export function getPendingStoreSize(): number { - return pendingStore.size -} - -/** - * Clear all pending metadata (for testing). - */ -export function clearPendingStore(): void { - pendingStore.clear() -} +export { + clearPendingStore, + consumeToolMetadata, + getPendingStoreSize, + storeToolMetadata, +} from "./store" +export type { PendingToolMetadata } from "./store" diff --git a/src/features/tool-metadata-store/store.ts b/src/features/tool-metadata-store/store.ts new file mode 100644 index 00000000..14fc1e42 --- /dev/null +++ b/src/features/tool-metadata-store/store.ts @@ -0,0 +1,84 @@ +/** + * Pending tool metadata store. + * + * OpenCode's `fromPlugin()` wrapper always replaces the metadata returned by + * plugin tools with `{ truncated, outputPath }`, discarding any sessionId, + * title, or custom metadata set during `execute()`. + * + * This store captures metadata written via `ctx.metadata()` inside execute(), + * then the `tool.execute.after` hook consumes it and merges it back into the + * result *before* the processor writes the final part to the session store. + * + * Flow: + * execute() → storeToolMetadata(sessionID, callID, data) + * fromPlugin() → overwrites metadata with { truncated } + * tool.execute.after → consumeToolMetadata(sessionID, callID) → merges back + * processor → Session.updatePart(status:"completed", metadata: result.metadata) + */ + +export interface PendingToolMetadata { + title?: string + metadata?: Record +} + +const pendingStore = new Map() + +const STALE_TIMEOUT_MS = 15 * 60 * 1000 + +function makeKey(sessionID: string, callID: string): string { + return `${sessionID}:${callID}` +} + +function cleanupStaleEntries(): void { + const now = Date.now() + for (const [key, entry] of pendingStore) { + if (now - entry.storedAt > STALE_TIMEOUT_MS) { + pendingStore.delete(key) + } + } +} + +/** + * Store metadata to be restored after fromPlugin() overwrites it. + * Called from tool execute() functions alongside ctx.metadata(). + */ +export function storeToolMetadata( + sessionID: string, + callID: string, + data: PendingToolMetadata +): void { + cleanupStaleEntries() + pendingStore.set(makeKey(sessionID, callID), { ...data, storedAt: Date.now() }) +} + +/** + * Consume stored metadata (one-time read, removes from store). + * Called from tool.execute.after hook. + */ +export function consumeToolMetadata( + sessionID: string, + callID: string +): PendingToolMetadata | undefined { + const key = makeKey(sessionID, callID) + const stored = pendingStore.get(key) + if (stored) { + pendingStore.delete(key) + const { storedAt: _, ...data } = stored + return data + } + return undefined +} + +/** + * Get current store size (for testing/debugging). + */ +export function getPendingStoreSize(): number { + return pendingStore.size +} + +/** + * Clear all pending metadata (for testing). + */ +export function clearPendingStore(): void { + pendingStore.clear() +} diff --git a/src/hooks/agent-usage-reminder/hook.ts b/src/hooks/agent-usage-reminder/hook.ts new file mode 100644 index 00000000..bc7f3243 --- /dev/null +++ b/src/hooks/agent-usage-reminder/hook.ts @@ -0,0 +1,109 @@ +import type { PluginInput } from "@opencode-ai/plugin"; +import { + loadAgentUsageState, + saveAgentUsageState, + clearAgentUsageState, +} from "./storage"; +import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants"; +import type { AgentUsageState } from "./types"; + +interface ToolExecuteInput { + tool: string; + sessionID: string; + callID: string; +} + +interface ToolExecuteOutput { + title: string; + output: string; + metadata: unknown; +} + +interface EventInput { + event: { + type: string; + properties?: unknown; + }; +} + +export function createAgentUsageReminderHook(_ctx: PluginInput) { + const sessionStates = new Map(); + + function getOrCreateState(sessionID: string): AgentUsageState { + if (!sessionStates.has(sessionID)) { + const persisted = loadAgentUsageState(sessionID); + const state: AgentUsageState = persisted ?? { + sessionID, + agentUsed: false, + reminderCount: 0, + updatedAt: Date.now(), + }; + sessionStates.set(sessionID, state); + } + return sessionStates.get(sessionID)!; + } + + function markAgentUsed(sessionID: string): void { + const state = getOrCreateState(sessionID); + state.agentUsed = true; + state.updatedAt = Date.now(); + saveAgentUsageState(state); + } + + function resetState(sessionID: string): void { + sessionStates.delete(sessionID); + clearAgentUsageState(sessionID); + } + + const toolExecuteAfter = async ( + input: ToolExecuteInput, + output: ToolExecuteOutput, + ) => { + const { tool, sessionID } = input; + const toolLower = tool.toLowerCase(); + + if (AGENT_TOOLS.has(toolLower)) { + markAgentUsed(sessionID); + return; + } + + if (!TARGET_TOOLS.has(toolLower)) { + return; + } + + const state = getOrCreateState(sessionID); + + if (state.agentUsed) { + return; + } + + output.output += REMINDER_MESSAGE; + state.reminderCount++; + state.updatedAt = Date.now(); + saveAgentUsageState(state); + }; + + const eventHandler = async ({ event }: EventInput) => { + const props = event.properties as Record | undefined; + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined; + if (sessionInfo?.id) { + resetState(sessionInfo.id); + } + } + + if (event.type === "session.compacted") { + const sessionID = (props?.sessionID ?? + (props?.info as { id?: string } | undefined)?.id) as string | undefined; + if (sessionID) { + resetState(sessionID); + } + } + }; + + return { + "tool.execute.after": toolExecuteAfter, + event: eventHandler, + }; +} diff --git a/src/hooks/agent-usage-reminder/index.ts b/src/hooks/agent-usage-reminder/index.ts index bc7f3243..3b28bb76 100644 --- a/src/hooks/agent-usage-reminder/index.ts +++ b/src/hooks/agent-usage-reminder/index.ts @@ -1,109 +1 @@ -import type { PluginInput } from "@opencode-ai/plugin"; -import { - loadAgentUsageState, - saveAgentUsageState, - clearAgentUsageState, -} from "./storage"; -import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants"; -import type { AgentUsageState } from "./types"; - -interface ToolExecuteInput { - tool: string; - sessionID: string; - callID: string; -} - -interface ToolExecuteOutput { - title: string; - output: string; - metadata: unknown; -} - -interface EventInput { - event: { - type: string; - properties?: unknown; - }; -} - -export function createAgentUsageReminderHook(_ctx: PluginInput) { - const sessionStates = new Map(); - - function getOrCreateState(sessionID: string): AgentUsageState { - if (!sessionStates.has(sessionID)) { - const persisted = loadAgentUsageState(sessionID); - const state: AgentUsageState = persisted ?? { - sessionID, - agentUsed: false, - reminderCount: 0, - updatedAt: Date.now(), - }; - sessionStates.set(sessionID, state); - } - return sessionStates.get(sessionID)!; - } - - function markAgentUsed(sessionID: string): void { - const state = getOrCreateState(sessionID); - state.agentUsed = true; - state.updatedAt = Date.now(); - saveAgentUsageState(state); - } - - function resetState(sessionID: string): void { - sessionStates.delete(sessionID); - clearAgentUsageState(sessionID); - } - - const toolExecuteAfter = async ( - input: ToolExecuteInput, - output: ToolExecuteOutput, - ) => { - const { tool, sessionID } = input; - const toolLower = tool.toLowerCase(); - - if (AGENT_TOOLS.has(toolLower)) { - markAgentUsed(sessionID); - return; - } - - if (!TARGET_TOOLS.has(toolLower)) { - return; - } - - const state = getOrCreateState(sessionID); - - if (state.agentUsed) { - return; - } - - output.output += REMINDER_MESSAGE; - state.reminderCount++; - state.updatedAt = Date.now(); - saveAgentUsageState(state); - }; - - const eventHandler = async ({ event }: EventInput) => { - const props = event.properties as Record | undefined; - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - resetState(sessionInfo.id); - } - } - - if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined; - if (sessionID) { - resetState(sessionID); - } - } - }; - - return { - "tool.execute.after": toolExecuteAfter, - event: eventHandler, - }; -} +export { createAgentUsageReminderHook } from "./hook"; diff --git a/src/hooks/anthropic-effort/hook.ts b/src/hooks/anthropic-effort/hook.ts new file mode 100644 index 00000000..141933cb --- /dev/null +++ b/src/hooks/anthropic-effort/hook.ts @@ -0,0 +1,56 @@ +import { log } from "../../shared" + +const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i + +function normalizeModelID(modelID: string): string { + return modelID.replace(/\.(\d+)/g, "-$1") +} + +function isClaudeProvider(providerID: string, modelID: string): boolean { + if (["anthropic", "opencode"].includes(providerID)) return true + if (providerID === "github-copilot" && modelID.toLowerCase().includes("claude")) return true + return false +} + +function isOpus46(modelID: string): boolean { + const normalized = normalizeModelID(modelID) + return OPUS_4_6_PATTERN.test(normalized) +} + +interface ChatParamsInput { + sessionID: string + agent: { name?: string } + model: { providerID: string; modelID: string } + provider: { id: string } + message: { variant?: string } +} + +interface ChatParamsOutput { + temperature?: number + topP?: number + topK?: number + options: Record +} + +export function createAnthropicEffortHook() { + return { + "chat.params": async ( + input: ChatParamsInput, + output: ChatParamsOutput + ): Promise => { + const { model, message } = input + if (!model?.modelID || !model?.providerID) return + if (message.variant !== "max") return + if (!isClaudeProvider(model.providerID, model.modelID)) return + if (!isOpus46(model.modelID)) return + if (output.options.effort !== undefined) return + + output.options.effort = "max" + log("anthropic-effort: injected effort=max", { + sessionID: input.sessionID, + provider: model.providerID, + model: model.modelID, + }) + }, + } +} diff --git a/src/hooks/anthropic-effort/index.ts b/src/hooks/anthropic-effort/index.ts index 141933cb..cba54561 100644 --- a/src/hooks/anthropic-effort/index.ts +++ b/src/hooks/anthropic-effort/index.ts @@ -1,56 +1 @@ -import { log } from "../../shared" - -const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i - -function normalizeModelID(modelID: string): string { - return modelID.replace(/\.(\d+)/g, "-$1") -} - -function isClaudeProvider(providerID: string, modelID: string): boolean { - if (["anthropic", "opencode"].includes(providerID)) return true - if (providerID === "github-copilot" && modelID.toLowerCase().includes("claude")) return true - return false -} - -function isOpus46(modelID: string): boolean { - const normalized = normalizeModelID(modelID) - return OPUS_4_6_PATTERN.test(normalized) -} - -interface ChatParamsInput { - sessionID: string - agent: { name?: string } - model: { providerID: string; modelID: string } - provider: { id: string } - message: { variant?: string } -} - -interface ChatParamsOutput { - temperature?: number - topP?: number - topK?: number - options: Record -} - -export function createAnthropicEffortHook() { - return { - "chat.params": async ( - input: ChatParamsInput, - output: ChatParamsOutput - ): Promise => { - const { model, message } = input - if (!model?.modelID || !model?.providerID) return - if (message.variant !== "max") return - if (!isClaudeProvider(model.providerID, model.modelID)) return - if (!isOpus46(model.modelID)) return - if (output.options.effort !== undefined) return - - output.options.effort = "max" - log("anthropic-effort: injected effort=max", { - sessionID: input.sessionID, - provider: model.providerID, - model: model.modelID, - }) - }, - } -} +export { createAnthropicEffortHook } from "./hook"; diff --git a/src/hooks/auto-slash-command/hook.ts b/src/hooks/auto-slash-command/hook.ts new file mode 100644 index 00000000..a2f2a063 --- /dev/null +++ b/src/hooks/auto-slash-command/hook.ts @@ -0,0 +1,145 @@ +import { + detectSlashCommand, + extractPromptText, + findSlashCommandPartIndex, +} from "./detector" +import { executeSlashCommand, type ExecutorOptions } from "./executor" +import { log } from "../../shared" +import { + AUTO_SLASH_COMMAND_TAG_CLOSE, + AUTO_SLASH_COMMAND_TAG_OPEN, +} from "./constants" +import type { + AutoSlashCommandHookInput, + AutoSlashCommandHookOutput, + CommandExecuteBeforeInput, + CommandExecuteBeforeOutput, +} from "./types" +import type { LoadedSkill } from "../../features/opencode-skill-loader" + +const sessionProcessedCommands = new Set() +const sessionProcessedCommandExecutions = new Set() + +export interface AutoSlashCommandHookOptions { + skills?: LoadedSkill[] +} + +export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) { + const executorOptions: ExecutorOptions = { + skills: options?.skills, + } + + return { + "chat.message": async ( + input: AutoSlashCommandHookInput, + output: AutoSlashCommandHookOutput + ): Promise => { + const promptText = extractPromptText(output.parts) + + // Debug logging to diagnose slash command issues + if (promptText.startsWith("/")) { + log(`[auto-slash-command] chat.message hook received slash command`, { + sessionID: input.sessionID, + promptText: promptText.slice(0, 100), + }) + } + + if ( + promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) || + promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE) + ) { + return + } + + const parsed = detectSlashCommand(promptText) + + if (!parsed) { + return + } + + const commandKey = `${input.sessionID}:${input.messageID}:${parsed.command}` + if (sessionProcessedCommands.has(commandKey)) { + return + } + sessionProcessedCommands.add(commandKey) + + log(`[auto-slash-command] Detected: /${parsed.command}`, { + sessionID: input.sessionID, + args: parsed.args, + }) + + const result = await executeSlashCommand(parsed, executorOptions) + + const idx = findSlashCommandPartIndex(output.parts) + if (idx < 0) { + return + } + + if (!result.success || !result.replacementText) { + log(`[auto-slash-command] Command not found, skipping`, { + sessionID: input.sessionID, + command: parsed.command, + error: result.error, + }) + return + } + + const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}` + output.parts[idx].text = taggedContent + + log(`[auto-slash-command] Replaced message with command template`, { + sessionID: input.sessionID, + command: parsed.command, + }) + }, + + "command.execute.before": async ( + input: CommandExecuteBeforeInput, + output: CommandExecuteBeforeOutput + ): Promise => { + const commandKey = `${input.sessionID}:${input.command}:${Date.now()}` + if (sessionProcessedCommandExecutions.has(commandKey)) { + return + } + + log(`[auto-slash-command] command.execute.before received`, { + sessionID: input.sessionID, + command: input.command, + arguments: input.arguments, + }) + + const parsed = { + command: input.command, + args: input.arguments || "", + raw: `/${input.command}${input.arguments ? " " + input.arguments : ""}`, + } + + const result = await executeSlashCommand(parsed, executorOptions) + + if (!result.success || !result.replacementText) { + log(`[auto-slash-command] command.execute.before - command not found in our executor`, { + sessionID: input.sessionID, + command: input.command, + error: result.error, + }) + return + } + + sessionProcessedCommandExecutions.add(commandKey) + + const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}` + + const idx = findSlashCommandPartIndex(output.parts) + if (idx >= 0) { + output.parts[idx].text = taggedContent + } else { + output.parts.unshift({ type: "text", text: taggedContent }) + } + + log(`[auto-slash-command] command.execute.before - injected template`, { + sessionID: input.sessionID, + command: input.command, + }) + }, + } +} diff --git a/src/hooks/auto-slash-command/index.ts b/src/hooks/auto-slash-command/index.ts index e5e30d2e..bce2d42e 100644 --- a/src/hooks/auto-slash-command/index.ts +++ b/src/hooks/auto-slash-command/index.ts @@ -1,150 +1,7 @@ -import { - detectSlashCommand, - extractPromptText, - findSlashCommandPartIndex, -} from "./detector" -import { executeSlashCommand, type ExecutorOptions } from "./executor" -import { log } from "../../shared" -import { - AUTO_SLASH_COMMAND_TAG_OPEN, - AUTO_SLASH_COMMAND_TAG_CLOSE, -} from "./constants" -import type { - AutoSlashCommandHookInput, - AutoSlashCommandHookOutput, - CommandExecuteBeforeInput, - CommandExecuteBeforeOutput, -} from "./types" -import type { LoadedSkill } from "../../features/opencode-skill-loader" - export * from "./detector" export * from "./executor" export * from "./constants" export * from "./types" -const sessionProcessedCommands = new Set() -const sessionProcessedCommandExecutions = new Set() - -export interface AutoSlashCommandHookOptions { - skills?: LoadedSkill[] -} - -export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) { - const executorOptions: ExecutorOptions = { - skills: options?.skills, - } - - return { - "chat.message": async ( - input: AutoSlashCommandHookInput, - output: AutoSlashCommandHookOutput - ): Promise => { - const promptText = extractPromptText(output.parts) - - // Debug logging to diagnose slash command issues - if (promptText.startsWith("/")) { - log(`[auto-slash-command] chat.message hook received slash command`, { - sessionID: input.sessionID, - promptText: promptText.slice(0, 100), - }) - } - - if ( - promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) || - promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE) - ) { - return - } - - const parsed = detectSlashCommand(promptText) - - if (!parsed) { - return - } - - const commandKey = `${input.sessionID}:${input.messageID}:${parsed.command}` - if (sessionProcessedCommands.has(commandKey)) { - return - } - sessionProcessedCommands.add(commandKey) - - log(`[auto-slash-command] Detected: /${parsed.command}`, { - sessionID: input.sessionID, - args: parsed.args, - }) - - const result = await executeSlashCommand(parsed, executorOptions) - - const idx = findSlashCommandPartIndex(output.parts) - if (idx < 0) { - return - } - - if (!result.success || !result.replacementText) { - log(`[auto-slash-command] Command not found, skipping`, { - sessionID: input.sessionID, - command: parsed.command, - error: result.error, - }) - return - } - - const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}` - output.parts[idx].text = taggedContent - - log(`[auto-slash-command] Replaced message with command template`, { - sessionID: input.sessionID, - command: parsed.command, - }) - }, - - "command.execute.before": async ( - input: CommandExecuteBeforeInput, - output: CommandExecuteBeforeOutput - ): Promise => { - const commandKey = `${input.sessionID}:${input.command}:${Date.now()}` - if (sessionProcessedCommandExecutions.has(commandKey)) { - return - } - - log(`[auto-slash-command] command.execute.before received`, { - sessionID: input.sessionID, - command: input.command, - arguments: input.arguments, - }) - - const parsed = { - command: input.command, - args: input.arguments || "", - raw: `/${input.command}${input.arguments ? " " + input.arguments : ""}`, - } - - const result = await executeSlashCommand(parsed, executorOptions) - - if (!result.success || !result.replacementText) { - log(`[auto-slash-command] command.execute.before - command not found in our executor`, { - sessionID: input.sessionID, - command: input.command, - error: result.error, - }) - return - } - - sessionProcessedCommandExecutions.add(commandKey) - - const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}` - - const idx = findSlashCommandPartIndex(output.parts) - if (idx >= 0) { - output.parts[idx].text = taggedContent - } else { - output.parts.unshift({ type: "text", text: taggedContent }) - } - - log(`[auto-slash-command] command.execute.before - injected template`, { - sessionID: input.sessionID, - command: input.command, - }) - }, - } -} +export { createAutoSlashCommandHook } from "./hook" +export type { AutoSlashCommandHookOptions } from "./hook" diff --git a/src/hooks/background-notification/hook.ts b/src/hooks/background-notification/hook.ts new file mode 100644 index 00000000..f417bdba --- /dev/null +++ b/src/hooks/background-notification/hook.ts @@ -0,0 +1,26 @@ +import type { BackgroundManager } from "../../features/background-agent" + +interface Event { + type: string + properties?: Record +} + +interface EventInput { + event: Event +} + +/** + * Background notification hook - handles event routing to BackgroundManager. + * + * Notifications are now delivered directly via session.prompt({ noReply }) + * from the manager, so this hook only needs to handle event routing. + */ +export function createBackgroundNotificationHook(manager: BackgroundManager) { + const eventHandler = async ({ event }: EventInput) => { + manager.handleEvent(event) + } + + return { + event: eventHandler, + } +} diff --git a/src/hooks/background-notification/index.ts b/src/hooks/background-notification/index.ts index 9fcf562f..bb24af2b 100644 --- a/src/hooks/background-notification/index.ts +++ b/src/hooks/background-notification/index.ts @@ -1,28 +1,2 @@ -import type { BackgroundManager } from "../../features/background-agent" - -interface Event { - type: string - properties?: Record -} - -interface EventInput { - event: Event -} - -/** - * Background notification hook - handles event routing to BackgroundManager. - * - * Notifications are now delivered directly via session.prompt({ noReply }) - * from the manager, so this hook only needs to handle event routing. - */ -export function createBackgroundNotificationHook(manager: BackgroundManager) { - const eventHandler = async ({ event }: EventInput) => { - manager.handleEvent(event) - } - - return { - event: eventHandler, - } -} - +export { createBackgroundNotificationHook } from "./hook" export type { BackgroundNotificationHookConfig } from "./types" diff --git a/src/hooks/category-skill-reminder/formatter.ts b/src/hooks/category-skill-reminder/formatter.ts new file mode 100644 index 00000000..8b6e31e5 --- /dev/null +++ b/src/hooks/category-skill-reminder/formatter.ts @@ -0,0 +1,37 @@ +import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" + +function formatSkillNames(skills: AvailableSkill[], limit: number): string { + if (skills.length === 0) return "(none)" + const shown = skills.slice(0, limit).map((s) => s.name) + const remaining = skills.length - shown.length + const suffix = remaining > 0 ? ` (+${remaining} more)` : "" + return shown.join(", ") + suffix +} + +export function buildReminderMessage(availableSkills: AvailableSkill[]): string { + const builtinSkills = availableSkills.filter((s) => s.location === "plugin") + const customSkills = availableSkills.filter((s) => s.location !== "plugin") + + const builtinText = formatSkillNames(builtinSkills, 8) + const customText = formatSkillNames(customSkills, 8) + + const exampleSkillName = customSkills[0]?.name ?? builtinSkills[0]?.name + const loadSkills = exampleSkillName ? `["${exampleSkillName}"]` : "[]" + + const lines = [ + "", + "[Category+Skill Reminder]", + "", + `**Built-in**: ${builtinText}`, + `**⚡ YOUR SKILLS (PRIORITY)**: ${customText}`, + "", + "> User-installed skills OVERRIDE built-in defaults. ALWAYS prefer YOUR SKILLS when domain matches.", + "", + "```typescript", + `task(category=\"visual-engineering\", load_skills=${loadSkills}, run_in_background=true)`, + "```", + "", + ] + + return lines.join("\n") +} diff --git a/src/hooks/category-skill-reminder/hook.ts b/src/hooks/category-skill-reminder/hook.ts new file mode 100644 index 00000000..b15715cd --- /dev/null +++ b/src/hooks/category-skill-reminder/hook.ts @@ -0,0 +1,141 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" +import { getSessionAgent } from "../../features/claude-code-session-state" +import { log } from "../../shared" +import { buildReminderMessage } from "./formatter" + +/** + * Target agents that should receive category+skill reminders. + * These are orchestrator agents that delegate work to specialized agents. + */ +const TARGET_AGENTS = new Set([ + "sisyphus", + "sisyphus-junior", + "atlas", +]) + +/** + * Tools that indicate the agent is doing work that could potentially be delegated. + * When these tools are used, we remind the agent about the category+skill system. + */ +const DELEGATABLE_WORK_TOOLS = new Set([ + "edit", + "write", + "bash", + "read", + "grep", + "glob", +]) + +/** + * Tools that indicate the agent is already using delegation properly. + */ +const DELEGATION_TOOLS = new Set([ + "task", + "call_omo_agent", +]) + +interface ToolExecuteInput { + tool: string + sessionID: string + callID: string + agent?: string +} + +interface ToolExecuteOutput { + title: string + output: string + metadata: unknown +} + +interface SessionState { + delegationUsed: boolean + reminderShown: boolean + toolCallCount: number +} + +export function createCategorySkillReminderHook( + _ctx: PluginInput, + availableSkills: AvailableSkill[] = [] +) { + const sessionStates = new Map() + const reminderMessage = buildReminderMessage(availableSkills) + + function getOrCreateState(sessionID: string): SessionState { + if (!sessionStates.has(sessionID)) { + sessionStates.set(sessionID, { + delegationUsed: false, + reminderShown: false, + toolCallCount: 0, + }) + } + return sessionStates.get(sessionID)! + } + + function isTargetAgent(sessionID: string, inputAgent?: string): boolean { + const agent = getSessionAgent(sessionID) ?? inputAgent + if (!agent) return false + const agentLower = agent.toLowerCase() + return ( + TARGET_AGENTS.has(agentLower) || + agentLower.includes("sisyphus") || + agentLower.includes("atlas") + ) + } + + const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => { + const { tool, sessionID } = input + const toolLower = tool.toLowerCase() + + if (!isTargetAgent(sessionID, input.agent)) { + return + } + + const state = getOrCreateState(sessionID) + + if (DELEGATION_TOOLS.has(toolLower)) { + state.delegationUsed = true + log("[category-skill-reminder] Delegation tool used", { sessionID, tool }) + return + } + + if (!DELEGATABLE_WORK_TOOLS.has(toolLower)) { + return + } + + state.toolCallCount++ + + if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) { + output.output += reminderMessage + state.reminderShown = true + log("[category-skill-reminder] Reminder injected", { + sessionID, + toolCallCount: state.toolCallCount, + }) + } + } + + const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { + const props = event.properties as Record | undefined + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + sessionStates.delete(sessionInfo.id) + } + } + + if (event.type === "session.compacted") { + const sessionID = (props?.sessionID ?? + (props?.info as { id?: string } | undefined)?.id) as string | undefined + if (sessionID) { + sessionStates.delete(sessionID) + } + } + } + + return { + "tool.execute.after": toolExecuteAfter, + event: eventHandler, + } +} diff --git a/src/hooks/category-skill-reminder/index.ts b/src/hooks/category-skill-reminder/index.ts index 93221915..5169cd8a 100644 --- a/src/hooks/category-skill-reminder/index.ts +++ b/src/hooks/category-skill-reminder/index.ts @@ -1,177 +1 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" -import { getSessionAgent } from "../../features/claude-code-session-state" -import { log } from "../../shared" - -/** - * Target agents that should receive category+skill reminders. - * These are orchestrator agents that delegate work to specialized agents. - */ -const TARGET_AGENTS = new Set([ - "sisyphus", - "sisyphus-junior", - "atlas", -]) - -/** - * Tools that indicate the agent is doing work that could potentially be delegated. - * When these tools are used, we remind the agent about the category+skill system. - */ -const DELEGATABLE_WORK_TOOLS = new Set([ - "edit", - "write", - "bash", - "read", - "grep", - "glob", -]) - -/** - * Tools that indicate the agent is already using delegation properly. - */ -const DELEGATION_TOOLS = new Set([ - "task", - "call_omo_agent", -]) - -function formatSkillNames(skills: AvailableSkill[], limit: number): string { - if (skills.length === 0) return "(none)" - const shown = skills.slice(0, limit).map((s) => s.name) - const remaining = skills.length - shown.length - const suffix = remaining > 0 ? ` (+${remaining} more)` : "" - return shown.join(", ") + suffix -} - -function buildReminderMessage(availableSkills: AvailableSkill[]): string { - const builtinSkills = availableSkills.filter((s) => s.location === "plugin") - const customSkills = availableSkills.filter((s) => s.location !== "plugin") - - const builtinText = formatSkillNames(builtinSkills, 8) - const customText = formatSkillNames(customSkills, 8) - - const exampleSkillName = customSkills[0]?.name ?? builtinSkills[0]?.name - const loadSkills = exampleSkillName ? `["${exampleSkillName}"]` : "[]" - - const lines = [ - "", - "[Category+Skill Reminder]", - "", - `**Built-in**: ${builtinText}`, - `**⚡ YOUR SKILLS (PRIORITY)**: ${customText}`, - "", - "> User-installed skills OVERRIDE built-in defaults. ALWAYS prefer YOUR SKILLS when domain matches.", - "", - "```typescript", - `task(category=\"visual-engineering\", load_skills=${loadSkills}, run_in_background=true)`, - "```", - "", - ] - - return lines.join("\n") -} - -interface ToolExecuteInput { - tool: string - sessionID: string - callID: string - agent?: string -} - -interface ToolExecuteOutput { - title: string - output: string - metadata: unknown -} - -interface SessionState { - delegationUsed: boolean - reminderShown: boolean - toolCallCount: number -} - -export function createCategorySkillReminderHook( - _ctx: PluginInput, - availableSkills: AvailableSkill[] = [] -) { - const sessionStates = new Map() - const reminderMessage = buildReminderMessage(availableSkills) - - function getOrCreateState(sessionID: string): SessionState { - if (!sessionStates.has(sessionID)) { - sessionStates.set(sessionID, { - delegationUsed: false, - reminderShown: false, - toolCallCount: 0, - }) - } - return sessionStates.get(sessionID)! - } - - function isTargetAgent(sessionID: string, inputAgent?: string): boolean { - const agent = getSessionAgent(sessionID) ?? inputAgent - if (!agent) return false - const agentLower = agent.toLowerCase() - return TARGET_AGENTS.has(agentLower) || - agentLower.includes("sisyphus") || - agentLower.includes("atlas") - } - - const toolExecuteAfter = async ( - input: ToolExecuteInput, - output: ToolExecuteOutput, - ) => { - const { tool, sessionID } = input - const toolLower = tool.toLowerCase() - - if (!isTargetAgent(sessionID, input.agent)) { - return - } - - const state = getOrCreateState(sessionID) - - if (DELEGATION_TOOLS.has(toolLower)) { - state.delegationUsed = true - log("[category-skill-reminder] Delegation tool used", { sessionID, tool }) - return - } - - if (!DELEGATABLE_WORK_TOOLS.has(toolLower)) { - return - } - - state.toolCallCount++ - - if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) { - output.output += reminderMessage - state.reminderShown = true - log("[category-skill-reminder] Reminder injected", { - sessionID, - toolCallCount: state.toolCallCount - }) - } - } - - const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { - const props = event.properties as Record | undefined - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - sessionStates.delete(sessionInfo.id) - } - } - - if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined - if (sessionID) { - sessionStates.delete(sessionID) - } - } - } - - return { - "tool.execute.after": toolExecuteAfter, - event: eventHandler, - } -} +export { createCategorySkillReminderHook } from "./hook" diff --git a/src/hooks/comment-checker/cli-runner.ts b/src/hooks/comment-checker/cli-runner.ts new file mode 100644 index 00000000..8721836e --- /dev/null +++ b/src/hooks/comment-checker/cli-runner.ts @@ -0,0 +1,63 @@ +import type { PendingCall } from "./types" +import { existsSync } from "fs" + +import { runCommentChecker, getCommentCheckerPath, startBackgroundInit, type HookInput } from "./cli" + +let cliPathPromise: Promise | null = null + +export function initializeCommentCheckerCli(debugLog: (...args: unknown[]) => void): void { + // Start background CLI initialization (may trigger lazy download) + startBackgroundInit() + cliPathPromise = getCommentCheckerPath() + cliPathPromise + .then((path) => { + debugLog("CLI path resolved:", path || "disabled (no binary)") + }) + .catch((err) => { + debugLog("CLI path resolution error:", err) + }) +} + +export function getCommentCheckerCliPathPromise(): Promise | null { + return cliPathPromise +} + +export async function processWithCli( + input: { tool: string; sessionID: string; callID: string }, + pendingCall: PendingCall, + output: { output: string }, + cliPath: string, + customPrompt: string | undefined, + debugLog: (...args: unknown[]) => void, +): Promise { + void input + debugLog("using CLI mode with path:", cliPath) + + const hookInput: HookInput = { + session_id: pendingCall.sessionID, + tool_name: pendingCall.tool.charAt(0).toUpperCase() + pendingCall.tool.slice(1), + transcript_path: "", + cwd: process.cwd(), + hook_event_name: "PostToolUse", + tool_input: { + file_path: pendingCall.filePath, + content: pendingCall.content, + old_string: pendingCall.oldString, + new_string: pendingCall.newString, + edits: pendingCall.edits, + }, + } + + const result = await runCommentChecker(hookInput, cliPath, customPrompt) + + if (result.hasComments && result.message) { + debugLog("CLI detected comments, appending message") + output.output += `\n\n${result.message}` + } else { + debugLog("CLI: no comments detected") + } +} + +export function isCliPathUsable(cliPath: string | null): cliPath is string { + return Boolean(cliPath && existsSync(cliPath)) +} diff --git a/src/hooks/comment-checker/hook.ts b/src/hooks/comment-checker/hook.ts new file mode 100644 index 00000000..79d147b3 --- /dev/null +++ b/src/hooks/comment-checker/hook.ts @@ -0,0 +1,123 @@ +import type { PendingCall } from "./types" +import type { CommentCheckerConfig } from "../../config/schema" + +import { initializeCommentCheckerCli, getCommentCheckerCliPathPromise, isCliPathUsable, processWithCli } from "./cli-runner" +import { registerPendingCall, startPendingCallCleanup, takePendingCall } from "./pending-calls" + +import * as fs from "fs" +import { tmpdir } from "os" +import { join } from "path" + +const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1" +const DEBUG_FILE = join(tmpdir(), "comment-checker-debug.log") + +function debugLog(...args: unknown[]) { + if (DEBUG) { + const msg = `[${new Date().toISOString()}] [comment-checker:hook] ${args + .map((a) => (typeof a === "object" ? JSON.stringify(a, null, 2) : String(a))) + .join(" ")}\n` + fs.appendFileSync(DEBUG_FILE, msg) + } +} + +export function createCommentCheckerHooks(config?: CommentCheckerConfig) { + debugLog("createCommentCheckerHooks called", { config }) + + startPendingCallCleanup() + initializeCommentCheckerCli(debugLog) + + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record }, + ): Promise => { + debugLog("tool.execute.before:", { + tool: input.tool, + callID: input.callID, + args: output.args, + }) + + const toolLower = input.tool.toLowerCase() + if (toolLower !== "write" && toolLower !== "edit" && toolLower !== "multiedit") { + debugLog("skipping non-write/edit tool:", toolLower) + return + } + + const filePath = (output.args.filePath ?? + output.args.file_path ?? + output.args.path) as string | undefined + const content = output.args.content as string | undefined + const oldString = (output.args.oldString ?? output.args.old_string) as string | undefined + const newString = (output.args.newString ?? output.args.new_string) as string | undefined + const edits = output.args.edits as Array<{ old_string: string; new_string: string }> | undefined + + debugLog("extracted filePath:", filePath) + + if (!filePath) { + debugLog("no filePath found") + return + } + + debugLog("registering pendingCall:", { + callID: input.callID, + filePath, + tool: toolLower, + }) + registerPendingCall(input.callID, { + filePath, + content, + oldString: oldString as string | undefined, + newString: newString as string | undefined, + edits, + tool: toolLower as PendingCall["tool"], + sessionID: input.sessionID, + timestamp: Date.now(), + }) + }, + + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown }, + ): Promise => { + debugLog("tool.execute.after:", { tool: input.tool, callID: input.callID }) + + const pendingCall = takePendingCall(input.callID) + if (!pendingCall) { + debugLog("no pendingCall found for:", input.callID) + return + } + + debugLog("processing pendingCall:", pendingCall) + + // Only skip if the output indicates a tool execution failure + const outputLower = output.output.toLowerCase() + const isToolFailure = + outputLower.includes("error:") || + outputLower.includes("failed to") || + outputLower.includes("could not") || + outputLower.startsWith("error") + + if (isToolFailure) { + debugLog("skipping due to tool failure in output") + return + } + + try { + // Wait for CLI path resolution + const cliPath = await getCommentCheckerCliPathPromise() + + if (!isCliPathUsable(cliPath)) { + // CLI not available - silently skip comment checking + debugLog("CLI not available, skipping comment check") + return + } + + // CLI mode only + debugLog("using CLI:", cliPath) + await processWithCli(input, pendingCall, output, cliPath, config?.custom_prompt, debugLog) + } catch (err) { + debugLog("tool.execute.after failed:", err) + } + }, + } +} diff --git a/src/hooks/comment-checker/index.ts b/src/hooks/comment-checker/index.ts index 8fdf8748..b4eb4ec7 100644 --- a/src/hooks/comment-checker/index.ts +++ b/src/hooks/comment-checker/index.ts @@ -1,171 +1 @@ -import type { PendingCall } from "./types" -import { runCommentChecker, getCommentCheckerPath, startBackgroundInit, type HookInput } from "./cli" -import type { CommentCheckerConfig } from "../../config/schema" - -import * as fs from "fs" -import { existsSync } from "fs" -import { tmpdir } from "os" -import { join } from "path" - -const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1" -const DEBUG_FILE = join(tmpdir(), "comment-checker-debug.log") - -function debugLog(...args: unknown[]) { - if (DEBUG) { - const msg = `[${new Date().toISOString()}] [comment-checker:hook] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n` - fs.appendFileSync(DEBUG_FILE, msg) - } -} - -const pendingCalls = new Map() -const PENDING_CALL_TTL = 60_000 - -let cliPathPromise: Promise | null = null -let cleanupIntervalStarted = false - -function cleanupOldPendingCalls(): void { - const now = Date.now() - for (const [callID, call] of pendingCalls) { - if (now - call.timestamp > PENDING_CALL_TTL) { - pendingCalls.delete(callID) - } - } -} - -export function createCommentCheckerHooks(config?: CommentCheckerConfig) { - debugLog("createCommentCheckerHooks called", { config }) - - if (!cleanupIntervalStarted) { - cleanupIntervalStarted = true - setInterval(cleanupOldPendingCalls, 10_000) - } - - // Start background CLI initialization (may trigger lazy download) - startBackgroundInit() - cliPathPromise = getCommentCheckerPath() - cliPathPromise.then(path => { - debugLog("CLI path resolved:", path || "disabled (no binary)") - }).catch(err => { - debugLog("CLI path resolution error:", err) - }) - - return { - "tool.execute.before": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: Record } - ): Promise => { - debugLog("tool.execute.before:", { tool: input.tool, callID: input.callID, args: output.args }) - - const toolLower = input.tool.toLowerCase() - if (toolLower !== "write" && toolLower !== "edit" && toolLower !== "multiedit") { - debugLog("skipping non-write/edit tool:", toolLower) - return - } - - const filePath = (output.args.filePath ?? output.args.file_path ?? output.args.path) as string | undefined - const content = output.args.content as string | undefined - const oldString = output.args.oldString ?? output.args.old_string as string | undefined - const newString = output.args.newString ?? output.args.new_string as string | undefined - const edits = output.args.edits as Array<{ old_string: string; new_string: string }> | undefined - - debugLog("extracted filePath:", filePath) - - if (!filePath) { - debugLog("no filePath found") - return - } - - debugLog("registering pendingCall:", { callID: input.callID, filePath, tool: toolLower }) - pendingCalls.set(input.callID, { - filePath, - content, - oldString: oldString as string | undefined, - newString: newString as string | undefined, - edits, - tool: toolLower as "write" | "edit" | "multiedit", - sessionID: input.sessionID, - timestamp: Date.now(), - }) - }, - - "tool.execute.after": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { title: string; output: string; metadata: unknown } - ): Promise => { - debugLog("tool.execute.after:", { tool: input.tool, callID: input.callID }) - - const pendingCall = pendingCalls.get(input.callID) - if (!pendingCall) { - debugLog("no pendingCall found for:", input.callID) - return - } - - pendingCalls.delete(input.callID) - debugLog("processing pendingCall:", pendingCall) - - // Only skip if the output indicates a tool execution failure - const outputLower = output.output.toLowerCase() - const isToolFailure = - outputLower.includes("error:") || - outputLower.includes("failed to") || - outputLower.includes("could not") || - outputLower.startsWith("error") - - if (isToolFailure) { - debugLog("skipping due to tool failure in output") - return - } - - try { - // Wait for CLI path resolution - const cliPath = await cliPathPromise - - if (!cliPath || !existsSync(cliPath)) { - // CLI not available - silently skip comment checking - debugLog("CLI not available, skipping comment check") - return - } - - // CLI mode only - debugLog("using CLI:", cliPath) - await processWithCli(input, pendingCall, output, cliPath, config?.custom_prompt) - } catch (err) { - debugLog("tool.execute.after failed:", err) - } - }, - } -} - -async function processWithCli( - input: { tool: string; sessionID: string; callID: string }, - pendingCall: PendingCall, - output: { output: string }, - cliPath: string, - customPrompt?: string -): Promise { - debugLog("using CLI mode with path:", cliPath) - - const hookInput: HookInput = { - session_id: pendingCall.sessionID, - tool_name: pendingCall.tool.charAt(0).toUpperCase() + pendingCall.tool.slice(1), - transcript_path: "", - cwd: process.cwd(), - hook_event_name: "PostToolUse", - tool_input: { - file_path: pendingCall.filePath, - content: pendingCall.content, - old_string: pendingCall.oldString, - new_string: pendingCall.newString, - edits: pendingCall.edits, - }, - } - - const result = await runCommentChecker(hookInput, cliPath, customPrompt) - - if (result.hasComments && result.message) { - debugLog("CLI detected comments, appending message") - output.output += `\n\n${result.message}` - } else { - debugLog("CLI: no comments detected") - } -} +export { createCommentCheckerHooks } from "./hook" diff --git a/src/hooks/comment-checker/pending-calls.ts b/src/hooks/comment-checker/pending-calls.ts new file mode 100644 index 00000000..0cda2fc4 --- /dev/null +++ b/src/hooks/comment-checker/pending-calls.ts @@ -0,0 +1,32 @@ +import type { PendingCall } from "./types" + +const pendingCalls = new Map() +const PENDING_CALL_TTL = 60_000 + +let cleanupIntervalStarted = false + +function cleanupOldPendingCalls(): void { + const now = Date.now() + for (const [callID, call] of pendingCalls) { + if (now - call.timestamp > PENDING_CALL_TTL) { + pendingCalls.delete(callID) + } + } +} + +export function startPendingCallCleanup(): void { + if (cleanupIntervalStarted) return + cleanupIntervalStarted = true + setInterval(cleanupOldPendingCalls, 10_000) +} + +export function registerPendingCall(callID: string, pendingCall: PendingCall): void { + pendingCalls.set(callID, pendingCall) +} + +export function takePendingCall(callID: string): PendingCall | undefined { + const pendingCall = pendingCalls.get(callID) + if (!pendingCall) return undefined + pendingCalls.delete(callID) + return pendingCall +} diff --git a/src/hooks/compaction-context-injector/hook.ts b/src/hooks/compaction-context-injector/hook.ts new file mode 100644 index 00000000..639f3e96 --- /dev/null +++ b/src/hooks/compaction-context-injector/hook.ts @@ -0,0 +1,55 @@ +import { + createSystemDirective, + SystemDirectiveTypes, +} from "../../shared/system-directive" + +const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)} + +When summarizing this session, you MUST include the following sections in your summary: + +## 1. User Requests (As-Is) +- List all original user requests exactly as they were stated +- Preserve the user's exact wording and intent + +## 2. Final Goal +- What the user ultimately wanted to achieve +- The end result or deliverable expected + +## 3. Work Completed +- What has been done so far +- Files created/modified +- Features implemented +- Problems solved + +## 4. Remaining Tasks +- What still needs to be done +- Pending items from the original request +- Follow-up tasks identified during the work + +## 5. Active Working Context (For Seamless Continuation) +- **Files**: Paths of files currently being edited or frequently referenced +- **Code in Progress**: Key code snippets, function signatures, or data structures under active development +- **External References**: Documentation URLs, library APIs, or external resources being consulted +- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work + +## 6. Explicit Constraints (Verbatim Only) +- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context +- Quote constraints verbatim (do not paraphrase) +- Do NOT invent, add, or modify constraints +- If no explicit constraints exist, write "None" + +## 7. Agent Verification State (Critical for Reviewers) +- **Current Agent**: What agent is running (momus, oracle, etc.) +- **Verification Progress**: Files already verified/validated +- **Pending Verifications**: Files still needing verification +- **Previous Rejections**: If reviewer agent, what was rejected and why +- **Acceptance Status**: Current state of review process + +This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity. + +This context is critical for maintaining continuity after compaction. +` + +export function createCompactionContextInjector() { + return (): string => COMPACTION_CONTEXT_PROMPT +} diff --git a/src/hooks/compaction-context-injector/index.ts b/src/hooks/compaction-context-injector/index.ts index d9fed61d..e2ea8f89 100644 --- a/src/hooks/compaction-context-injector/index.ts +++ b/src/hooks/compaction-context-injector/index.ts @@ -1,52 +1 @@ -import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive" - -const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)} - -When summarizing this session, you MUST include the following sections in your summary: - -## 1. User Requests (As-Is) -- List all original user requests exactly as they were stated -- Preserve the user's exact wording and intent - -## 2. Final Goal -- What the user ultimately wanted to achieve -- The end result or deliverable expected - -## 3. Work Completed -- What has been done so far -- Files created/modified -- Features implemented -- Problems solved - -## 4. Remaining Tasks -- What still needs to be done -- Pending items from the original request -- Follow-up tasks identified during the work - -## 5. Active Working Context (For Seamless Continuation) -- **Files**: Paths of files currently being edited or frequently referenced -- **Code in Progress**: Key code snippets, function signatures, or data structures under active development -- **External References**: Documentation URLs, library APIs, or external resources being consulted -- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work - -## 6. Explicit Constraints (Verbatim Only) -- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context -- Quote constraints verbatim (do not paraphrase) -- Do NOT invent, add, or modify constraints -- If no explicit constraints exist, write "None" - -## 7. Agent Verification State (Critical for Reviewers) -- **Current Agent**: What agent is running (momus, oracle, etc.) -- **Verification Progress**: Files already verified/validated -- **Pending Verifications**: Files still needing verification -- **Previous Rejections**: If reviewer agent, what was rejected and why -- **Acceptance Status**: Current state of review process - -This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity. - -This context is critical for maintaining continuity after compaction. -` - -export function createCompactionContextInjector() { - return (): string => COMPACTION_CONTEXT_PROMPT -} +export { createCompactionContextInjector } from "./hook" diff --git a/src/hooks/compaction-todo-preserver/hook.ts b/src/hooks/compaction-todo-preserver/hook.ts new file mode 100644 index 00000000..dc1a8721 --- /dev/null +++ b/src/hooks/compaction-todo-preserver/hook.ts @@ -0,0 +1,127 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared/logger" + +interface TodoSnapshot { + id: string + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + priority?: "low" | "medium" | "high" +} + +type TodoWriter = (input: { sessionID: string; todos: TodoSnapshot[] }) => Promise + +const HOOK_NAME = "compaction-todo-preserver" + +function extractTodos(response: unknown): TodoSnapshot[] { + const payload = response as { data?: unknown } + if (Array.isArray(payload?.data)) { + return payload.data as TodoSnapshot[] + } + if (Array.isArray(response)) { + return response as TodoSnapshot[] + } + return [] +} + +async function resolveTodoWriter(): Promise { + try { + const loader = "opencode/session/todo" + const mod = (await import(loader)) as { + Todo?: { update?: TodoWriter } + } + const update = mod.Todo?.update + if (typeof update === "function") { + return update + } + } catch (err) { + log(`[${HOOK_NAME}] Failed to resolve Todo.update`, { error: String(err) }) + } + return null +} + +function resolveSessionID(props?: Record): string | undefined { + return (props?.sessionID ?? + (props?.info as { id?: string } | undefined)?.id) as string | undefined +} + +export interface CompactionTodoPreserver { + capture: (sessionID: string) => Promise + event: (input: { event: { type: string; properties?: unknown } }) => Promise +} + +export function createCompactionTodoPreserverHook( + ctx: PluginInput, +): CompactionTodoPreserver { + const snapshots = new Map() + + const capture = async (sessionID: string): Promise => { + if (!sessionID) return + try { + const response = await ctx.client.session.todo({ path: { id: sessionID } }) + const todos = extractTodos(response) + if (todos.length === 0) return + snapshots.set(sessionID, todos) + log(`[${HOOK_NAME}] Captured todo snapshot`, { sessionID, count: todos.length }) + } catch (err) { + log(`[${HOOK_NAME}] Failed to capture todos`, { sessionID, error: String(err) }) + } + } + + const restore = async (sessionID: string): Promise => { + const snapshot = snapshots.get(sessionID) + if (!snapshot || snapshot.length === 0) return + + let hasCurrent = false + let currentTodos: TodoSnapshot[] = [] + try { + const response = await ctx.client.session.todo({ path: { id: sessionID } }) + currentTodos = extractTodos(response) + hasCurrent = true + } catch (err) { + log(`[${HOOK_NAME}] Failed to fetch todos post-compaction`, { sessionID, error: String(err) }) + } + + if (hasCurrent && currentTodos.length > 0) { + snapshots.delete(sessionID) + log(`[${HOOK_NAME}] Skipped restore (todos already present)`, { sessionID, count: currentTodos.length }) + return + } + + const writer = await resolveTodoWriter() + if (!writer) { + log(`[${HOOK_NAME}] Skipped restore (Todo.update unavailable)`, { sessionID }) + return + } + + try { + await writer({ sessionID, todos: snapshot }) + log(`[${HOOK_NAME}] Restored todos after compaction`, { sessionID, count: snapshot.length }) + } catch (err) { + log(`[${HOOK_NAME}] Failed to restore todos`, { sessionID, error: String(err) }) + } finally { + snapshots.delete(sessionID) + } + } + + const event = async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { + const props = event.properties as Record | undefined + + if (event.type === "session.deleted") { + const sessionID = resolveSessionID(props) + if (sessionID) { + snapshots.delete(sessionID) + } + return + } + + if (event.type === "session.compacted") { + const sessionID = resolveSessionID(props) + if (sessionID) { + await restore(sessionID) + } + return + } + } + + return { capture, event } +} diff --git a/src/hooks/compaction-todo-preserver/index.ts b/src/hooks/compaction-todo-preserver/index.ts index dc1a8721..f5a7a6e7 100644 --- a/src/hooks/compaction-todo-preserver/index.ts +++ b/src/hooks/compaction-todo-preserver/index.ts @@ -1,127 +1,2 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { log } from "../../shared/logger" - -interface TodoSnapshot { - id: string - content: string - status: "pending" | "in_progress" | "completed" | "cancelled" - priority?: "low" | "medium" | "high" -} - -type TodoWriter = (input: { sessionID: string; todos: TodoSnapshot[] }) => Promise - -const HOOK_NAME = "compaction-todo-preserver" - -function extractTodos(response: unknown): TodoSnapshot[] { - const payload = response as { data?: unknown } - if (Array.isArray(payload?.data)) { - return payload.data as TodoSnapshot[] - } - if (Array.isArray(response)) { - return response as TodoSnapshot[] - } - return [] -} - -async function resolveTodoWriter(): Promise { - try { - const loader = "opencode/session/todo" - const mod = (await import(loader)) as { - Todo?: { update?: TodoWriter } - } - const update = mod.Todo?.update - if (typeof update === "function") { - return update - } - } catch (err) { - log(`[${HOOK_NAME}] Failed to resolve Todo.update`, { error: String(err) }) - } - return null -} - -function resolveSessionID(props?: Record): string | undefined { - return (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined -} - -export interface CompactionTodoPreserver { - capture: (sessionID: string) => Promise - event: (input: { event: { type: string; properties?: unknown } }) => Promise -} - -export function createCompactionTodoPreserverHook( - ctx: PluginInput, -): CompactionTodoPreserver { - const snapshots = new Map() - - const capture = async (sessionID: string): Promise => { - if (!sessionID) return - try { - const response = await ctx.client.session.todo({ path: { id: sessionID } }) - const todos = extractTodos(response) - if (todos.length === 0) return - snapshots.set(sessionID, todos) - log(`[${HOOK_NAME}] Captured todo snapshot`, { sessionID, count: todos.length }) - } catch (err) { - log(`[${HOOK_NAME}] Failed to capture todos`, { sessionID, error: String(err) }) - } - } - - const restore = async (sessionID: string): Promise => { - const snapshot = snapshots.get(sessionID) - if (!snapshot || snapshot.length === 0) return - - let hasCurrent = false - let currentTodos: TodoSnapshot[] = [] - try { - const response = await ctx.client.session.todo({ path: { id: sessionID } }) - currentTodos = extractTodos(response) - hasCurrent = true - } catch (err) { - log(`[${HOOK_NAME}] Failed to fetch todos post-compaction`, { sessionID, error: String(err) }) - } - - if (hasCurrent && currentTodos.length > 0) { - snapshots.delete(sessionID) - log(`[${HOOK_NAME}] Skipped restore (todos already present)`, { sessionID, count: currentTodos.length }) - return - } - - const writer = await resolveTodoWriter() - if (!writer) { - log(`[${HOOK_NAME}] Skipped restore (Todo.update unavailable)`, { sessionID }) - return - } - - try { - await writer({ sessionID, todos: snapshot }) - log(`[${HOOK_NAME}] Restored todos after compaction`, { sessionID, count: snapshot.length }) - } catch (err) { - log(`[${HOOK_NAME}] Failed to restore todos`, { sessionID, error: String(err) }) - } finally { - snapshots.delete(sessionID) - } - } - - const event = async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { - const props = event.properties as Record | undefined - - if (event.type === "session.deleted") { - const sessionID = resolveSessionID(props) - if (sessionID) { - snapshots.delete(sessionID) - } - return - } - - if (event.type === "session.compacted") { - const sessionID = resolveSessionID(props) - if (sessionID) { - await restore(sessionID) - } - return - } - } - - return { capture, event } -} +export type { CompactionTodoPreserver } from "./hook" +export { createCompactionTodoPreserverHook } from "./hook" diff --git a/src/hooks/delegate-task-retry/guidance.ts b/src/hooks/delegate-task-retry/guidance.ts new file mode 100644 index 00000000..7acc6e71 --- /dev/null +++ b/src/hooks/delegate-task-retry/guidance.ts @@ -0,0 +1,45 @@ +import { DELEGATE_TASK_ERROR_PATTERNS, type DetectedError } from "./patterns" + +function extractAvailableList(output: string): string | null { + const availableMatch = output.match(/Available[^:]*:\s*(.+)$/m) + return availableMatch ? availableMatch[1].trim() : null +} + +export function buildRetryGuidance(errorInfo: DetectedError): string { + const pattern = DELEGATE_TASK_ERROR_PATTERNS.find( + (p) => p.errorType === errorInfo.errorType + ) + + if (!pattern) { + return `[task ERROR] Fix the error and retry with correct parameters.` + } + + let guidance = ` + [task CALL FAILED - IMMEDIATE RETRY REQUIRED] + + **Error Type**: ${errorInfo.errorType} + **Fix**: ${pattern.fixHint} + ` + + const availableList = extractAvailableList(errorInfo.originalOutput) + if (availableList) { + guidance += `\n**Available Options**: ${availableList}\n` + } + + guidance += ` + **Action**: Retry task NOW with corrected parameters. + + Example of CORRECT call: + \`\`\` + task( + description="Task description", + prompt="Detailed prompt...", + category="unspecified-low", // OR subagent_type="explore" + run_in_background=false, + load_skills=[] + ) + \`\`\` + ` + + return guidance +} diff --git a/src/hooks/delegate-task-retry/hook.ts b/src/hooks/delegate-task-retry/hook.ts new file mode 100644 index 00000000..915da323 --- /dev/null +++ b/src/hooks/delegate-task-retry/hook.ts @@ -0,0 +1,21 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { buildRetryGuidance } from "./guidance" +import { detectDelegateTaskError } from "./patterns" + +export function createDelegateTaskRetryHook(_ctx: PluginInput) { + return { + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown } + ) => { + if (input.tool.toLowerCase() !== "task") return + + const errorInfo = detectDelegateTaskError(output.output) + if (errorInfo) { + const guidance = buildRetryGuidance(errorInfo) + output.output += `\n${guidance}` + } + }, + } +} diff --git a/src/hooks/delegate-task-retry/index.ts b/src/hooks/delegate-task-retry/index.ts index 75927033..c597ff26 100644 --- a/src/hooks/delegate-task-retry/index.ts +++ b/src/hooks/delegate-task-retry/index.ts @@ -1,136 +1,4 @@ -import type { PluginInput } from "@opencode-ai/plugin" - -export interface DelegateTaskErrorPattern { - pattern: string - errorType: string - fixHint: string -} - -export const DELEGATE_TASK_ERROR_PATTERNS: DelegateTaskErrorPattern[] = [ - { - pattern: "run_in_background", - errorType: "missing_run_in_background", - fixHint: "Add run_in_background=false (for delegation) or run_in_background=true (for parallel exploration)", - }, - { - pattern: "load_skills", - errorType: "missing_load_skills", - fixHint: "Add load_skills=[] parameter (empty array if no skills needed). Note: Calling Skill tool does NOT populate this.", - }, - { - pattern: "category OR subagent_type", - errorType: "mutual_exclusion", - fixHint: "Provide ONLY one of: category (e.g., 'general', 'quick') OR subagent_type (e.g., 'oracle', 'explore')", - }, - { - pattern: "Must provide either category or subagent_type", - errorType: "missing_category_or_agent", - fixHint: "Add either category='general' OR subagent_type='explore'", - }, - { - pattern: "Unknown category", - errorType: "unknown_category", - fixHint: "Use a valid category from the Available list in the error message", - }, - { - pattern: "Agent name cannot be empty", - errorType: "empty_agent", - fixHint: "Provide a non-empty subagent_type value", - }, - { - pattern: "Unknown agent", - errorType: "unknown_agent", - fixHint: "Use a valid agent from the Available agents list in the error message", - }, - { - pattern: "Cannot call primary agent", - errorType: "primary_agent", - fixHint: "Primary agents cannot be called via task. Use a subagent like 'explore', 'oracle', or 'librarian'", - }, - { - pattern: "Skills not found", - errorType: "unknown_skills", - fixHint: "Use valid skill names from the Available list in the error message", - }, -] - -export interface DetectedError { - errorType: string - originalOutput: string -} - -export function detectDelegateTaskError(output: string): DetectedError | null { - if (!output.includes("[ERROR]") && !output.includes("Invalid arguments")) return null - - for (const errorPattern of DELEGATE_TASK_ERROR_PATTERNS) { - if (output.includes(errorPattern.pattern)) { - return { - errorType: errorPattern.errorType, - originalOutput: output, - } - } - } - - return null -} - -function extractAvailableList(output: string): string | null { - const availableMatch = output.match(/Available[^:]*:\s*(.+)$/m) - return availableMatch ? availableMatch[1].trim() : null -} - -export function buildRetryGuidance(errorInfo: DetectedError): string { - const pattern = DELEGATE_TASK_ERROR_PATTERNS.find( - (p) => p.errorType === errorInfo.errorType - ) - - if (!pattern) { - return `[task ERROR] Fix the error and retry with correct parameters.` - } - - let guidance = ` -[task CALL FAILED - IMMEDIATE RETRY REQUIRED] - -**Error Type**: ${errorInfo.errorType} -**Fix**: ${pattern.fixHint} -` - - const availableList = extractAvailableList(errorInfo.originalOutput) - if (availableList) { - guidance += `\n**Available Options**: ${availableList}\n` - } - - guidance += ` -**Action**: Retry task NOW with corrected parameters. - -Example of CORRECT call: -\`\`\` -task( - description="Task description", - prompt="Detailed prompt...", - category="unspecified-low", // OR subagent_type="explore" - run_in_background=false, - load_skills=[] -) -\`\`\` -` - - return guidance -} - -export function createDelegateTaskRetryHook(_ctx: PluginInput) { - return { - "tool.execute.after": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { title: string; output: string; metadata: unknown } - ) => { - if (input.tool.toLowerCase() !== "task") return - - const errorInfo = detectDelegateTaskError(output.output) - if (errorInfo) { - const guidance = buildRetryGuidance(errorInfo) - output.output += `\n${guidance}` - } - }, - } -} +export type { DelegateTaskErrorPattern, DetectedError } from "./patterns" +export { DELEGATE_TASK_ERROR_PATTERNS, detectDelegateTaskError } from "./patterns" +export { buildRetryGuidance } from "./guidance" +export { createDelegateTaskRetryHook } from "./hook" diff --git a/src/hooks/delegate-task-retry/patterns.ts b/src/hooks/delegate-task-retry/patterns.ts new file mode 100644 index 00000000..2d982804 --- /dev/null +++ b/src/hooks/delegate-task-retry/patterns.ts @@ -0,0 +1,77 @@ +export interface DelegateTaskErrorPattern { + pattern: string + errorType: string + fixHint: string +} + +export const DELEGATE_TASK_ERROR_PATTERNS: DelegateTaskErrorPattern[] = [ + { + pattern: "run_in_background", + errorType: "missing_run_in_background", + fixHint: + "Add run_in_background=false (for delegation) or run_in_background=true (for parallel exploration)", + }, + { + pattern: "load_skills", + errorType: "missing_load_skills", + fixHint: + "Add load_skills=[] parameter (empty array if no skills needed). Note: Calling Skill tool does NOT populate this.", + }, + { + pattern: "category OR subagent_type", + errorType: "mutual_exclusion", + fixHint: + "Provide ONLY one of: category (e.g., 'general', 'quick') OR subagent_type (e.g., 'oracle', 'explore')", + }, + { + pattern: "Must provide either category or subagent_type", + errorType: "missing_category_or_agent", + fixHint: "Add either category='general' OR subagent_type='explore'", + }, + { + pattern: "Unknown category", + errorType: "unknown_category", + fixHint: "Use a valid category from the Available list in the error message", + }, + { + pattern: "Agent name cannot be empty", + errorType: "empty_agent", + fixHint: "Provide a non-empty subagent_type value", + }, + { + pattern: "Unknown agent", + errorType: "unknown_agent", + fixHint: "Use a valid agent from the Available agents list in the error message", + }, + { + pattern: "Cannot call primary agent", + errorType: "primary_agent", + fixHint: + "Primary agents cannot be called via task. Use a subagent like 'explore', 'oracle', or 'librarian'", + }, + { + pattern: "Skills not found", + errorType: "unknown_skills", + fixHint: "Use valid skill names from the Available list in the error message", + }, +] + +export interface DetectedError { + errorType: string + originalOutput: string +} + +export function detectDelegateTaskError(output: string): DetectedError | null { + if (!output.includes("[ERROR]") && !output.includes("Invalid arguments")) return null + + for (const errorPattern of DELEGATE_TASK_ERROR_PATTERNS) { + if (output.includes(errorPattern.pattern)) { + return { + errorType: errorPattern.errorType, + originalOutput: output, + } + } + } + + return null +} diff --git a/src/hooks/directory-agents-injector/finder.ts b/src/hooks/directory-agents-injector/finder.ts new file mode 100644 index 00000000..b54d7e50 --- /dev/null +++ b/src/hooks/directory-agents-injector/finder.ts @@ -0,0 +1,38 @@ +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +import { AGENTS_FILENAME } from "./constants"; + +export function resolveFilePath(rootDirectory: string, path: string): string | null { + if (!path) return null; + if (path.startsWith("/")) return path; + return resolve(rootDirectory, path); +} + +export function findAgentsMdUp(input: { + startDir: string; + rootDir: string; +}): string[] { + const found: string[] = []; + let current = input.startDir; + + while (true) { + // Skip root AGENTS.md - OpenCode's system.ts already loads it via custom() + // See: https://github.com/code-yeongyu/oh-my-opencode/issues/379 + const isRootDir = current === input.rootDir; + if (!isRootDir) { + const agentsPath = join(current, AGENTS_FILENAME); + if (existsSync(agentsPath)) { + found.push(agentsPath); + } + } + + if (isRootDir) break; + const parent = dirname(current); + if (parent === current) break; + if (!parent.startsWith(input.rootDir)) break; + current = parent; + } + + return found.reverse(); +} diff --git a/src/hooks/directory-agents-injector/hook.ts b/src/hooks/directory-agents-injector/hook.ts new file mode 100644 index 00000000..a510301a --- /dev/null +++ b/src/hooks/directory-agents-injector/hook.ts @@ -0,0 +1,84 @@ +import type { PluginInput } from "@opencode-ai/plugin"; + +import { createDynamicTruncator } from "../../shared/dynamic-truncator"; +import { processFilePathForAgentsInjection } from "./injector"; +import { clearInjectedPaths } from "./storage"; + +interface ToolExecuteInput { + tool: string; + sessionID: string; + callID: string; +} + +interface ToolExecuteOutput { + title: string; + output: string; + metadata: unknown; +} + +interface ToolExecuteBeforeOutput { + args: unknown; +} + +interface EventInput { + event: { + type: string; + properties?: unknown; + }; +} + +export function createDirectoryAgentsInjectorHook(ctx: PluginInput) { + const sessionCaches = new Map>(); + const truncator = createDynamicTruncator(ctx); + + const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => { + const toolName = input.tool.toLowerCase(); + + if (toolName === "read") { + await processFilePathForAgentsInjection({ + ctx, + truncator, + sessionCaches, + filePath: output.title, + sessionID: input.sessionID, + output, + }); + return; + } + }; + + const toolExecuteBefore = async ( + input: ToolExecuteInput, + output: ToolExecuteBeforeOutput, + ): Promise => { + void input; + void output; + }; + + const eventHandler = async ({ event }: EventInput) => { + const props = event.properties as Record | undefined; + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined; + if (sessionInfo?.id) { + sessionCaches.delete(sessionInfo.id); + clearInjectedPaths(sessionInfo.id); + } + } + + if (event.type === "session.compacted") { + const sessionID = (props?.sessionID ?? + (props?.info as { id?: string } | undefined)?.id) as string | undefined; + if (sessionID) { + sessionCaches.delete(sessionID); + clearInjectedPaths(sessionID); + } + } + }; + + return { + "tool.execute.before": toolExecuteBefore, + "tool.execute.after": toolExecuteAfter, + event: eventHandler, + }; +} diff --git a/src/hooks/directory-agents-injector/index.ts b/src/hooks/directory-agents-injector/index.ts index b1f29e04..e18d91c4 100644 --- a/src/hooks/directory-agents-injector/index.ts +++ b/src/hooks/directory-agents-injector/index.ts @@ -1,153 +1 @@ -import type { PluginInput } from "@opencode-ai/plugin"; -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { - loadInjectedPaths, - saveInjectedPaths, - clearInjectedPaths, -} from "./storage"; -import { AGENTS_FILENAME } from "./constants"; -import { createDynamicTruncator } from "../../shared/dynamic-truncator"; - -interface ToolExecuteInput { - tool: string; - sessionID: string; - callID: string; -} - -interface ToolExecuteOutput { - title: string; - output: string; - metadata: unknown; -} - -interface ToolExecuteBeforeOutput { - args: unknown; -} - -interface EventInput { - event: { - type: string; - properties?: unknown; - }; -} - -export function createDirectoryAgentsInjectorHook(ctx: PluginInput) { - const sessionCaches = new Map>(); - const truncator = createDynamicTruncator(ctx); - - function getSessionCache(sessionID: string): Set { - if (!sessionCaches.has(sessionID)) { - sessionCaches.set(sessionID, loadInjectedPaths(sessionID)); - } - return sessionCaches.get(sessionID)!; - } - - function resolveFilePath(path: string): string | null { - if (!path) return null; - if (path.startsWith("/")) return path; - return resolve(ctx.directory, path); - } - - function findAgentsMdUp(startDir: string): string[] { - const found: string[] = []; - let current = startDir; - - while (true) { - // Skip root AGENTS.md - OpenCode's system.ts already loads it via custom() - // See: https://github.com/code-yeongyu/oh-my-opencode/issues/379 - const isRootDir = current === ctx.directory; - if (!isRootDir) { - const agentsPath = join(current, AGENTS_FILENAME); - if (existsSync(agentsPath)) { - found.push(agentsPath); - } - } - - if (isRootDir) break; - const parent = dirname(current); - if (parent === current) break; - if (!parent.startsWith(ctx.directory)) break; - current = parent; - } - - return found.reverse(); - } - - async function processFilePathForInjection( - filePath: string, - sessionID: string, - output: ToolExecuteOutput, - ): Promise { - const resolved = resolveFilePath(filePath); - if (!resolved) return; - - const dir = dirname(resolved); - const cache = getSessionCache(sessionID); - const agentsPaths = findAgentsMdUp(dir); - - for (const agentsPath of agentsPaths) { - const agentsDir = dirname(agentsPath); - if (cache.has(agentsDir)) continue; - - try { - const content = readFileSync(agentsPath, "utf-8"); - const { result, truncated } = await truncator.truncate(sessionID, content); - const truncationNotice = truncated - ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]` - : ""; - output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`; - cache.add(agentsDir); - } catch {} - } - - saveInjectedPaths(sessionID, cache); - } - - const toolExecuteAfter = async ( - input: ToolExecuteInput, - output: ToolExecuteOutput, - ) => { - const toolName = input.tool.toLowerCase(); - - if (toolName === "read") { - await processFilePathForInjection(output.title, input.sessionID, output); - return; - } - }; - - const toolExecuteBefore = async ( - input: ToolExecuteInput, - output: ToolExecuteBeforeOutput, - ): Promise => { - void input; - void output; - }; - - const eventHandler = async ({ event }: EventInput) => { - const props = event.properties as Record | undefined; - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - sessionCaches.delete(sessionInfo.id); - clearInjectedPaths(sessionInfo.id); - } - } - - if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined; - if (sessionID) { - sessionCaches.delete(sessionID); - clearInjectedPaths(sessionID); - } - } - }; - - return { - "tool.execute.before": toolExecuteBefore, - "tool.execute.after": toolExecuteAfter, - event: eventHandler, - }; -} +export { createDirectoryAgentsInjectorHook } from "./hook"; diff --git a/src/hooks/directory-agents-injector/injector.ts b/src/hooks/directory-agents-injector/injector.ts new file mode 100644 index 00000000..dc6ca081 --- /dev/null +++ b/src/hooks/directory-agents-injector/injector.ts @@ -0,0 +1,55 @@ +import type { PluginInput } from "@opencode-ai/plugin"; +import { readFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import type { createDynamicTruncator } from "../../shared/dynamic-truncator"; +import { findAgentsMdUp, resolveFilePath } from "./finder"; +import { loadInjectedPaths, saveInjectedPaths } from "./storage"; + +type DynamicTruncator = ReturnType; + +function getSessionCache( + sessionCaches: Map>, + sessionID: string, +): Set { + if (!sessionCaches.has(sessionID)) { + sessionCaches.set(sessionID, loadInjectedPaths(sessionID)); + } + return sessionCaches.get(sessionID)!; +} + +export async function processFilePathForAgentsInjection(input: { + ctx: PluginInput; + truncator: DynamicTruncator; + sessionCaches: Map>; + filePath: string; + sessionID: string; + output: { title: string; output: string; metadata: unknown }; +}): Promise { + const resolved = resolveFilePath(input.ctx.directory, input.filePath); + if (!resolved) return; + + const dir = dirname(resolved); + const cache = getSessionCache(input.sessionCaches, input.sessionID); + const agentsPaths = findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory }); + + for (const agentsPath of agentsPaths) { + const agentsDir = dirname(agentsPath); + if (cache.has(agentsDir)) continue; + + try { + const content = readFileSync(agentsPath, "utf-8"); + const { result, truncated } = await input.truncator.truncate( + input.sessionID, + content, + ); + const truncationNotice = truncated + ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]` + : ""; + input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`; + cache.add(agentsDir); + } catch {} + } + + saveInjectedPaths(input.sessionID, cache); +} diff --git a/src/hooks/directory-readme-injector/finder.ts b/src/hooks/directory-readme-injector/finder.ts new file mode 100644 index 00000000..bc671bff --- /dev/null +++ b/src/hooks/directory-readme-injector/finder.ts @@ -0,0 +1,33 @@ +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +import { README_FILENAME } from "./constants"; + +export function resolveFilePath(rootDirectory: string, path: string): string | null { + if (!path) return null; + if (path.startsWith("/")) return path; + return resolve(rootDirectory, path); +} + +export function findReadmeMdUp(input: { + startDir: string; + rootDir: string; +}): string[] { + const found: string[] = []; + let current = input.startDir; + + while (true) { + const readmePath = join(current, README_FILENAME); + if (existsSync(readmePath)) { + found.push(readmePath); + } + + if (current === input.rootDir) break; + const parent = dirname(current); + if (parent === current) break; + if (!parent.startsWith(input.rootDir)) break; + current = parent; + } + + return found.reverse(); +} diff --git a/src/hooks/directory-readme-injector/hook.ts b/src/hooks/directory-readme-injector/hook.ts new file mode 100644 index 00000000..33c50bc7 --- /dev/null +++ b/src/hooks/directory-readme-injector/hook.ts @@ -0,0 +1,84 @@ +import type { PluginInput } from "@opencode-ai/plugin"; + +import { createDynamicTruncator } from "../../shared/dynamic-truncator"; +import { processFilePathForReadmeInjection } from "./injector"; +import { clearInjectedPaths } from "./storage"; + +interface ToolExecuteInput { + tool: string; + sessionID: string; + callID: string; +} + +interface ToolExecuteOutput { + title: string; + output: string; + metadata: unknown; +} + +interface ToolExecuteBeforeOutput { + args: unknown; +} + +interface EventInput { + event: { + type: string; + properties?: unknown; + }; +} + +export function createDirectoryReadmeInjectorHook(ctx: PluginInput) { + const sessionCaches = new Map>(); + const truncator = createDynamicTruncator(ctx); + + const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => { + const toolName = input.tool.toLowerCase(); + + if (toolName === "read") { + await processFilePathForReadmeInjection({ + ctx, + truncator, + sessionCaches, + filePath: output.title, + sessionID: input.sessionID, + output, + }); + return; + } + }; + + const toolExecuteBefore = async ( + input: ToolExecuteInput, + output: ToolExecuteBeforeOutput, + ): Promise => { + void input; + void output; + }; + + const eventHandler = async ({ event }: EventInput) => { + const props = event.properties as Record | undefined; + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined; + if (sessionInfo?.id) { + sessionCaches.delete(sessionInfo.id); + clearInjectedPaths(sessionInfo.id); + } + } + + if (event.type === "session.compacted") { + const sessionID = (props?.sessionID ?? + (props?.info as { id?: string } | undefined)?.id) as string | undefined; + if (sessionID) { + sessionCaches.delete(sessionID); + clearInjectedPaths(sessionID); + } + } + }; + + return { + "tool.execute.before": toolExecuteBefore, + "tool.execute.after": toolExecuteAfter, + event: eventHandler, + }; +} diff --git a/src/hooks/directory-readme-injector/index.ts b/src/hooks/directory-readme-injector/index.ts index 7487743c..7a223145 100644 --- a/src/hooks/directory-readme-injector/index.ts +++ b/src/hooks/directory-readme-injector/index.ts @@ -1,148 +1 @@ -import type { PluginInput } from "@opencode-ai/plugin"; -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { - loadInjectedPaths, - saveInjectedPaths, - clearInjectedPaths, -} from "./storage"; -import { README_FILENAME } from "./constants"; -import { createDynamicTruncator } from "../../shared/dynamic-truncator"; - -interface ToolExecuteInput { - tool: string; - sessionID: string; - callID: string; -} - -interface ToolExecuteOutput { - title: string; - output: string; - metadata: unknown; -} - -interface ToolExecuteBeforeOutput { - args: unknown; -} - -interface EventInput { - event: { - type: string; - properties?: unknown; - }; -} - -export function createDirectoryReadmeInjectorHook(ctx: PluginInput) { - const sessionCaches = new Map>(); - const truncator = createDynamicTruncator(ctx); - - function getSessionCache(sessionID: string): Set { - if (!sessionCaches.has(sessionID)) { - sessionCaches.set(sessionID, loadInjectedPaths(sessionID)); - } - return sessionCaches.get(sessionID)!; - } - - function resolveFilePath(path: string): string | null { - if (!path) return null; - if (path.startsWith("/")) return path; - return resolve(ctx.directory, path); - } - - function findReadmeMdUp(startDir: string): string[] { - const found: string[] = []; - let current = startDir; - - while (true) { - const readmePath = join(current, README_FILENAME); - if (existsSync(readmePath)) { - found.push(readmePath); - } - - if (current === ctx.directory) break; - const parent = dirname(current); - if (parent === current) break; - if (!parent.startsWith(ctx.directory)) break; - current = parent; - } - - return found.reverse(); - } - - async function processFilePathForInjection( - filePath: string, - sessionID: string, - output: ToolExecuteOutput, - ): Promise { - const resolved = resolveFilePath(filePath); - if (!resolved) return; - - const dir = dirname(resolved); - const cache = getSessionCache(sessionID); - const readmePaths = findReadmeMdUp(dir); - - for (const readmePath of readmePaths) { - const readmeDir = dirname(readmePath); - if (cache.has(readmeDir)) continue; - - try { - const content = readFileSync(readmePath, "utf-8"); - const { result, truncated } = await truncator.truncate(sessionID, content); - const truncationNotice = truncated - ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${readmePath}]` - : ""; - output.output += `\n\n[Project README: ${readmePath}]\n${result}${truncationNotice}`; - cache.add(readmeDir); - } catch {} - } - - saveInjectedPaths(sessionID, cache); - } - - const toolExecuteAfter = async ( - input: ToolExecuteInput, - output: ToolExecuteOutput, - ) => { - const toolName = input.tool.toLowerCase(); - - if (toolName === "read") { - await processFilePathForInjection(output.title, input.sessionID, output); - return; - } - }; - - const toolExecuteBefore = async ( - input: ToolExecuteInput, - output: ToolExecuteBeforeOutput, - ): Promise => { - void input; - void output; - }; - - const eventHandler = async ({ event }: EventInput) => { - const props = event.properties as Record | undefined; - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - sessionCaches.delete(sessionInfo.id); - clearInjectedPaths(sessionInfo.id); - } - } - - if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined; - if (sessionID) { - sessionCaches.delete(sessionID); - clearInjectedPaths(sessionID); - } - } - }; - - return { - "tool.execute.before": toolExecuteBefore, - "tool.execute.after": toolExecuteAfter, - event: eventHandler, - }; -} +export { createDirectoryReadmeInjectorHook } from "./hook"; diff --git a/src/hooks/directory-readme-injector/injector.ts b/src/hooks/directory-readme-injector/injector.ts new file mode 100644 index 00000000..08216584 --- /dev/null +++ b/src/hooks/directory-readme-injector/injector.ts @@ -0,0 +1,55 @@ +import type { PluginInput } from "@opencode-ai/plugin"; +import { readFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import type { createDynamicTruncator } from "../../shared/dynamic-truncator"; +import { findReadmeMdUp, resolveFilePath } from "./finder"; +import { loadInjectedPaths, saveInjectedPaths } from "./storage"; + +type DynamicTruncator = ReturnType; + +function getSessionCache( + sessionCaches: Map>, + sessionID: string, +): Set { + if (!sessionCaches.has(sessionID)) { + sessionCaches.set(sessionID, loadInjectedPaths(sessionID)); + } + return sessionCaches.get(sessionID)!; +} + +export async function processFilePathForReadmeInjection(input: { + ctx: PluginInput; + truncator: DynamicTruncator; + sessionCaches: Map>; + filePath: string; + sessionID: string; + output: { title: string; output: string; metadata: unknown }; +}): Promise { + const resolved = resolveFilePath(input.ctx.directory, input.filePath); + if (!resolved) return; + + const dir = dirname(resolved); + const cache = getSessionCache(input.sessionCaches, input.sessionID); + const readmePaths = findReadmeMdUp({ startDir: dir, rootDir: input.ctx.directory }); + + for (const readmePath of readmePaths) { + const readmeDir = dirname(readmePath); + if (cache.has(readmeDir)) continue; + + try { + const content = readFileSync(readmePath, "utf-8"); + const { result, truncated } = await input.truncator.truncate( + input.sessionID, + content, + ); + const truncationNotice = truncated + ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${readmePath}]` + : ""; + input.output.output += `\n\n[Project README: ${readmePath}]\n${result}${truncationNotice}`; + cache.add(readmeDir); + } catch {} + } + + saveInjectedPaths(input.sessionID, cache); +} diff --git a/src/hooks/edit-error-recovery/hook.ts b/src/hooks/edit-error-recovery/hook.ts new file mode 100644 index 00000000..84ac9e9d --- /dev/null +++ b/src/hooks/edit-error-recovery/hook.ts @@ -0,0 +1,57 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +/** + * Known Edit tool error patterns that indicate the AI made a mistake + */ +export const EDIT_ERROR_PATTERNS = [ + "oldString and newString must be different", + "oldString not found", + "oldString found multiple times", +] as const + +/** + * System reminder injected when Edit tool fails due to AI mistake + * Short, direct, and commanding - forces immediate corrective action + */ +export const EDIT_ERROR_REMINDER = ` +[EDIT ERROR - IMMEDIATE ACTION REQUIRED] + +You made an Edit mistake. STOP and do this NOW: + +1. READ the file immediately to see its ACTUAL current state +2. VERIFY what the content really looks like (your assumption was wrong) +3. APOLOGIZE briefly to the user for the error +4. CONTINUE with corrected action based on the real file content + +DO NOT attempt another edit until you've read and verified the file state. +` + +/** + * Detects Edit tool errors caused by AI mistakes and injects a recovery reminder + * + * This hook catches common Edit tool failures: + * - oldString and newString must be different (trying to "edit" to same content) + * - oldString not found (wrong assumption about file content) + * - oldString found multiple times (ambiguous match, need more context) + * + * @see https://github.com/sst/opencode/issues/4718 + */ +export function createEditErrorRecoveryHook(_ctx: PluginInput) { + return { + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown } + ) => { + if (input.tool.toLowerCase() !== "edit") return + + const outputLower = output.output.toLowerCase() + const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) => + outputLower.includes(pattern.toLowerCase()) + ) + + if (hasEditError) { + output.output += `\n${EDIT_ERROR_REMINDER}` + } + }, + } +} diff --git a/src/hooks/edit-error-recovery/index.ts b/src/hooks/edit-error-recovery/index.ts index 84ac9e9d..64682bf2 100644 --- a/src/hooks/edit-error-recovery/index.ts +++ b/src/hooks/edit-error-recovery/index.ts @@ -1,57 +1,5 @@ -import type { PluginInput } from "@opencode-ai/plugin" - -/** - * Known Edit tool error patterns that indicate the AI made a mistake - */ -export const EDIT_ERROR_PATTERNS = [ - "oldString and newString must be different", - "oldString not found", - "oldString found multiple times", -] as const - -/** - * System reminder injected when Edit tool fails due to AI mistake - * Short, direct, and commanding - forces immediate corrective action - */ -export const EDIT_ERROR_REMINDER = ` -[EDIT ERROR - IMMEDIATE ACTION REQUIRED] - -You made an Edit mistake. STOP and do this NOW: - -1. READ the file immediately to see its ACTUAL current state -2. VERIFY what the content really looks like (your assumption was wrong) -3. APOLOGIZE briefly to the user for the error -4. CONTINUE with corrected action based on the real file content - -DO NOT attempt another edit until you've read and verified the file state. -` - -/** - * Detects Edit tool errors caused by AI mistakes and injects a recovery reminder - * - * This hook catches common Edit tool failures: - * - oldString and newString must be different (trying to "edit" to same content) - * - oldString not found (wrong assumption about file content) - * - oldString found multiple times (ambiguous match, need more context) - * - * @see https://github.com/sst/opencode/issues/4718 - */ -export function createEditErrorRecoveryHook(_ctx: PluginInput) { - return { - "tool.execute.after": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { title: string; output: string; metadata: unknown } - ) => { - if (input.tool.toLowerCase() !== "edit") return - - const outputLower = output.output.toLowerCase() - const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) => - outputLower.includes(pattern.toLowerCase()) - ) - - if (hasEditError) { - output.output += `\n${EDIT_ERROR_REMINDER}` - } - }, - } -} +export { + createEditErrorRecoveryHook, + EDIT_ERROR_PATTERNS, + EDIT_ERROR_REMINDER, +} from "./hook"; diff --git a/src/hooks/keyword-detector/hook.ts b/src/hooks/keyword-detector/hook.ts new file mode 100644 index 00000000..c6b7e9df --- /dev/null +++ b/src/hooks/keyword-detector/hook.ts @@ -0,0 +1,115 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { detectKeywordsWithType, extractPromptText } from "./detector" +import { isPlannerAgent } from "./constants" +import { log } from "../../shared" +import { + isSystemDirective, + removeSystemReminders, +} from "../../shared/system-directive" +import { + getMainSessionID, + getSessionAgent, + subagentSessions, +} from "../../features/claude-code-session-state" +import type { ContextCollector } from "../../features/context-injector" + +export function createKeywordDetectorHook(ctx: PluginInput, _collector?: ContextCollector) { + return { + "chat.message": async ( + input: { + sessionID: string + agent?: string + model?: { providerID: string; modelID: string } + messageID?: string + }, + output: { + message: Record + parts: Array<{ type: string; text?: string; [key: string]: unknown }> + } + ): Promise => { + const promptText = extractPromptText(output.parts) + + if (isSystemDirective(promptText)) { + log(`[keyword-detector] Skipping system directive message`, { sessionID: input.sessionID }) + return + } + + const currentAgent = getSessionAgent(input.sessionID) ?? input.agent + + // Remove system-reminder content to prevent automated system messages from triggering mode keywords + const cleanText = removeSystemReminders(promptText) + const modelID = input.model?.modelID + let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID) + + if (isPlannerAgent(currentAgent)) { + detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork") + } + + if (detectedKeywords.length === 0) { + return + } + + // Skip keyword detection for background task sessions to prevent mode injection + // (e.g., [analyze-mode]) which incorrectly triggers Prometheus restrictions + const isBackgroundTaskSession = subagentSessions.has(input.sessionID) + if (isBackgroundTaskSession) { + return + } + + const mainSessionID = getMainSessionID() + const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID + + if (isNonMainSession) { + detectedKeywords = detectedKeywords.filter((k) => k.type === "ultrawork") + if (detectedKeywords.length === 0) { + log(`[keyword-detector] Skipping non-ultrawork keywords in non-main session`, { + sessionID: input.sessionID, + mainSessionID, + }) + return + } + } + + const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork") + if (hasUltrawork) { + log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID }) + + if (output.message.variant === undefined) { + output.message.variant = "max" + } + + ctx.client.tui + .showToast({ + body: { + title: "Ultrawork Mode Activated", + message: "Maximum precision engaged. All agents at your disposal.", + variant: "success" as const, + duration: 3000, + }, + }) + .catch((err) => + log(`[keyword-detector] Failed to show toast`, { + error: err, + sessionID: input.sessionID, + }) + ) + } + + const textPartIndex = output.parts.findIndex((p) => p.type === "text" && p.text !== undefined) + if (textPartIndex === -1) { + log(`[keyword-detector] No text part found, skipping injection`, { sessionID: input.sessionID }) + return + } + + const allMessages = detectedKeywords.map((k) => k.message).join("\n\n") + const originalText = output.parts[textPartIndex].text ?? "" + + output.parts[textPartIndex].text = `${allMessages}\n\n---\n\n${originalText}` + + log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, { + sessionID: input.sessionID, + types: detectedKeywords.map((k) => k.type), + }) + }, + } +} diff --git a/src/hooks/keyword-detector/index.ts b/src/hooks/keyword-detector/index.ts index cad5aef0..45bf43d0 100644 --- a/src/hooks/keyword-detector/index.ts +++ b/src/hooks/keyword-detector/index.ts @@ -1,109 +1,5 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector" -import { isPlannerAgent } from "./constants" -import { log } from "../../shared" -import { hasSystemReminder, isSystemDirective, removeSystemReminders } from "../../shared/system-directive" -import { getMainSessionID, getSessionAgent, subagentSessions } from "../../features/claude-code-session-state" -import type { ContextCollector } from "../../features/context-injector" - export * from "./detector" export * from "./constants" export * from "./types" -export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextCollector) { - return { - "chat.message": async ( - input: { - sessionID: string - agent?: string - model?: { providerID: string; modelID: string } - messageID?: string - }, - output: { - message: Record - parts: Array<{ type: string; text?: string; [key: string]: unknown }> - } - ): Promise => { - const promptText = extractPromptText(output.parts) - - if (isSystemDirective(promptText)) { - log(`[keyword-detector] Skipping system directive message`, { sessionID: input.sessionID }) - return - } - - const currentAgent = getSessionAgent(input.sessionID) ?? input.agent - - // Remove system-reminder content to prevent automated system messages from triggering mode keywords - const cleanText = removeSystemReminders(promptText) - const modelID = input.model?.modelID - let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID) - - if (isPlannerAgent(currentAgent)) { - detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork") - } - - if (detectedKeywords.length === 0) { - return - } - - // Skip keyword detection for background task sessions to prevent mode injection - // (e.g., [analyze-mode]) which incorrectly triggers Prometheus restrictions - const isBackgroundTaskSession = subagentSessions.has(input.sessionID) - if (isBackgroundTaskSession) { - return - } - - const mainSessionID = getMainSessionID() - const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID - - if (isNonMainSession) { - detectedKeywords = detectedKeywords.filter((k) => k.type === "ultrawork") - if (detectedKeywords.length === 0) { - log(`[keyword-detector] Skipping non-ultrawork keywords in non-main session`, { - sessionID: input.sessionID, - mainSessionID, - }) - return - } - } - - const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork") - if (hasUltrawork) { - log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID }) - - if (output.message.variant === undefined) { - output.message.variant = "max" - } - - ctx.client.tui - .showToast({ - body: { - title: "Ultrawork Mode Activated", - message: "Maximum precision engaged. All agents at your disposal.", - variant: "success" as const, - duration: 3000, - }, - }) - .catch((err) => - log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID }) - ) - } - - const textPartIndex = output.parts.findIndex((p) => p.type === "text" && p.text !== undefined) - if (textPartIndex === -1) { - log(`[keyword-detector] No text part found, skipping injection`, { sessionID: input.sessionID }) - return - } - - const allMessages = detectedKeywords.map((k) => k.message).join("\n\n") - const originalText = output.parts[textPartIndex].text ?? "" - - output.parts[textPartIndex].text = `${allMessages}\n\n---\n\n${originalText}` - - log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, { - sessionID: input.sessionID, - types: detectedKeywords.map((k) => k.type), - }) - }, - } -} +export { createKeywordDetectorHook } from "./hook" diff --git a/src/hooks/keyword-detector/ultrawork/index.ts b/src/hooks/keyword-detector/ultrawork/index.ts index a9dec912..b7ad2087 100644 --- a/src/hooks/keyword-detector/ultrawork/index.ts +++ b/src/hooks/keyword-detector/ultrawork/index.ts @@ -7,13 +7,13 @@ * 3. Default (Claude, etc.) → default.ts (optimized for Claude series) */ -export { isPlannerAgent, isGptModel, getUltraworkSource } from "./utils" -export type { UltraworkSource } from "./utils" +export { isPlannerAgent, isGptModel, getUltraworkSource } from "./source-detector" +export type { UltraworkSource } from "./source-detector" export { ULTRAWORK_PLANNER_SECTION, getPlannerUltraworkMessage } from "./planner" export { ULTRAWORK_GPT_MESSAGE, getGptUltraworkMessage } from "./gpt5.2" export { ULTRAWORK_DEFAULT_MESSAGE, getDefaultUltraworkMessage } from "./default" -import { getUltraworkSource } from "./utils" +import { getUltraworkSource } from "./source-detector" import { getPlannerUltraworkMessage } from "./planner" import { getGptUltraworkMessage } from "./gpt5.2" import { getDefaultUltraworkMessage } from "./default" diff --git a/src/hooks/keyword-detector/ultrawork/utils.ts b/src/hooks/keyword-detector/ultrawork/source-detector.ts similarity index 93% rename from src/hooks/keyword-detector/ultrawork/utils.ts rename to src/hooks/keyword-detector/ultrawork/source-detector.ts index d95f9cc6..2f0a897e 100644 --- a/src/hooks/keyword-detector/ultrawork/utils.ts +++ b/src/hooks/keyword-detector/ultrawork/source-detector.ts @@ -36,7 +36,10 @@ export type UltraworkSource = "planner" | "gpt" | "default" /** * Determines which ultrawork message source to use. */ -export function getUltraworkSource(agentName?: string, modelID?: string): UltraworkSource { +export function getUltraworkSource( + agentName?: string, + modelID?: string +): UltraworkSource { // Priority 1: Planner agents if (isPlannerAgent(agentName)) { return "planner" diff --git a/src/hooks/prometheus-md-only/agent-resolution.ts b/src/hooks/prometheus-md-only/agent-resolution.ts new file mode 100644 index 00000000..6035209b --- /dev/null +++ b/src/hooks/prometheus-md-only/agent-resolution.ts @@ -0,0 +1,52 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" +import { getSessionAgent } from "../../features/claude-code-session-state" +import { readBoulderState } from "../../features/boulder-state" + +function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) return directPath + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) return sessionPath + } + + return null +} + +function getAgentFromMessageFiles(sessionID: string): string | undefined { + const messageDir = getMessageDir(sessionID) + if (!messageDir) return undefined + return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent +} + +/** + * Get the effective agent for the session. + * Priority order: + * 1. In-memory session agent (most recent, set by /start-work) + * 2. Boulder state agent (persisted across restarts, fixes #927) + * 3. Message files (fallback for sessions without boulder state) + * + * This fixes issue #927 where after interruption: + * - In-memory map is cleared (process restart) + * - Message files return "prometheus" (oldest message from /plan) + * - But boulder.json has agent: "atlas" (set by /start-work) + */ +export function getAgentFromSession(sessionID: string, directory: string): string | undefined { + // Check in-memory first (current session) + const memoryAgent = getSessionAgent(sessionID) + if (memoryAgent) return memoryAgent + + // Check boulder state (persisted across restarts) - fixes #927 + const boulderState = readBoulderState(directory) + if (boulderState?.session_ids.includes(sessionID) && boulderState.agent) { + return boulderState.agent + } + + // Fallback to message files + return getAgentFromMessageFiles(sessionID) +} diff --git a/src/hooks/prometheus-md-only/hook.ts b/src/hooks/prometheus-md-only/hook.ts new file mode 100644 index 00000000..e44549c2 --- /dev/null +++ b/src/hooks/prometheus-md-only/hook.ts @@ -0,0 +1,96 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { HOOK_NAME, PROMETHEUS_AGENT, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants" +import { log } from "../../shared/logger" +import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" +import { getAgentDisplayName } from "../../shared/agent-display-names" +import { getAgentFromSession } from "./agent-resolution" +import { isAllowedFile } from "./path-policy" + +const TASK_TOOLS = ["task", "call_omo_agent"] + +export function createPrometheusMdOnlyHook(ctx: PluginInput) { + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record; message?: string } + ): Promise => { + const agentName = getAgentFromSession(input.sessionID, ctx.directory) + + if (agentName !== PROMETHEUS_AGENT) { + return + } + + const toolName = input.tool + + // Inject read-only warning for task tools called by Prometheus + if (TASK_TOOLS.includes(toolName)) { + const prompt = output.args.prompt as string | undefined + if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) { + output.args.prompt = PLANNING_CONSULT_WARNING + prompt + log(`[${HOOK_NAME}] Injected read-only planning warning to ${toolName}`, { + sessionID: input.sessionID, + tool: toolName, + agent: agentName, + }) + } + return + } + + if (!BLOCKED_TOOLS.includes(toolName)) { + return + } + + // Block bash commands completely - Prometheus is read-only + if (toolName === "bash") { + log(`[${HOOK_NAME}] Blocked: Prometheus cannot execute bash commands`, { + sessionID: input.sessionID, + tool: toolName, + agent: agentName, + }) + throw new Error( + `[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} cannot execute bash commands. ` + + `${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` + + `APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN` + ) + } + + const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined + if (!filePath) { + return + } + + if (!isAllowedFile(filePath, ctx.directory)) { + log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, { + sessionID: input.sessionID, + tool: toolName, + filePath, + agent: agentName, + }) + throw new Error( + `[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} can only write/edit .md files inside .sisyphus/ directory. ` + + `Attempted to modify: ${filePath}. ` + + `${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` + + `APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN` + ) + } + + const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/") + if (normalizedPath.includes(".sisyphus/plans/") || normalizedPath.includes(".sisyphus\\plans\\")) { + log(`[${HOOK_NAME}] Injecting workflow reminder for plan write`, { + sessionID: input.sessionID, + tool: toolName, + filePath, + agent: agentName, + }) + output.message = (output.message || "") + PROMETHEUS_WORKFLOW_REMINDER + } + + log(`[${HOOK_NAME}] Allowed: .sisyphus/*.md write permitted`, { + sessionID: input.sessionID, + tool: toolName, + filePath, + agent: agentName, + }) + }, + } +} diff --git a/src/hooks/prometheus-md-only/index.ts b/src/hooks/prometheus-md-only/index.ts index 410cc4a0..34a79b4a 100644 --- a/src/hooks/prometheus-md-only/index.ts +++ b/src/hooks/prometheus-md-only/index.ts @@ -1,186 +1,2 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { existsSync, readdirSync } from "node:fs" -import { join, resolve, relative, isAbsolute } from "node:path" -import { HOOK_NAME, PROMETHEUS_AGENT, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants" -import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" -import { getSessionAgent } from "../../features/claude-code-session-state" -import { readBoulderState } from "../../features/boulder-state" -import { log } from "../../shared/logger" -import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" -import { getAgentDisplayName } from "../../shared/agent-display-names" - export * from "./constants" - -/** - * Cross-platform path validator for Prometheus file writes. - * Uses path.resolve/relative instead of string matching to handle: - * - Windows backslashes (e.g., .sisyphus\\plans\\x.md) - * - Mixed separators (e.g., .sisyphus\\plans/x.md) - * - Case-insensitive directory/extension matching - * - Workspace confinement (blocks paths outside root or via traversal) - * - Nested project paths (e.g., parent/.sisyphus/... when ctx.directory is parent) - */ -function isAllowedFile(filePath: string, workspaceRoot: string): boolean { - // 1. Resolve to absolute path - const resolved = resolve(workspaceRoot, filePath) - - // 2. Get relative path from workspace root - const rel = relative(workspaceRoot, resolved) - - // 3. Reject if escapes root (starts with ".." or is absolute) - if (rel.startsWith("..") || isAbsolute(rel)) { - return false - } - - // 4. Check if .sisyphus/ or .sisyphus\ exists anywhere in the path (case-insensitive) - // This handles both direct paths (.sisyphus/x.md) and nested paths (project/.sisyphus/x.md) - if (!/\.sisyphus[/\\]/i.test(rel)) { - return false - } - - // 5. Check extension matches one of ALLOWED_EXTENSIONS (case-insensitive) - const hasAllowedExtension = ALLOWED_EXTENSIONS.some( - ext => resolved.toLowerCase().endsWith(ext.toLowerCase()) - ) - if (!hasAllowedExtension) { - return false - } - - return true -} - -function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} - -const TASK_TOOLS = ["task", "call_omo_agent"] - -function getAgentFromMessageFiles(sessionID: string): string | undefined { - const messageDir = getMessageDir(sessionID) - if (!messageDir) return undefined - return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent -} - -/** - * Get the effective agent for the session. - * Priority order: - * 1. In-memory session agent (most recent, set by /start-work) - * 2. Boulder state agent (persisted across restarts, fixes #927) - * 3. Message files (fallback for sessions without boulder state) - * - * This fixes issue #927 where after interruption: - * - In-memory map is cleared (process restart) - * - Message files return "prometheus" (oldest message from /plan) - * - But boulder.json has agent: "atlas" (set by /start-work) - */ -function getAgentFromSession(sessionID: string, directory: string): string | undefined { - // Check in-memory first (current session) - const memoryAgent = getSessionAgent(sessionID) - if (memoryAgent) return memoryAgent - - // Check boulder state (persisted across restarts) - fixes #927 - const boulderState = readBoulderState(directory) - if (boulderState?.session_ids.includes(sessionID) && boulderState.agent) { - return boulderState.agent - } - - // Fallback to message files - return getAgentFromMessageFiles(sessionID) -} - -export function createPrometheusMdOnlyHook(ctx: PluginInput) { - return { - "tool.execute.before": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: Record; message?: string } - ): Promise => { - const agentName = getAgentFromSession(input.sessionID, ctx.directory) - - if (agentName !== PROMETHEUS_AGENT) { - return - } - - const toolName = input.tool - - // Inject read-only warning for task tools called by Prometheus - if (TASK_TOOLS.includes(toolName)) { - const prompt = output.args.prompt as string | undefined - if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) { - output.args.prompt = PLANNING_CONSULT_WARNING + prompt - log(`[${HOOK_NAME}] Injected read-only planning warning to ${toolName}`, { - sessionID: input.sessionID, - tool: toolName, - agent: agentName, - }) - } - return - } - - if (!BLOCKED_TOOLS.includes(toolName)) { - return - } - - // Block bash commands completely - Prometheus is read-only - if (toolName === "bash") { - log(`[${HOOK_NAME}] Blocked: Prometheus cannot execute bash commands`, { - sessionID: input.sessionID, - tool: toolName, - agent: agentName, - }) - throw new Error( - `[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} cannot execute bash commands. ` + - `${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` + - `APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN` - ) - } - - const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined - if (!filePath) { - return - } - - if (!isAllowedFile(filePath, ctx.directory)) { - log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, { - sessionID: input.sessionID, - tool: toolName, - filePath, - agent: agentName, - }) - throw new Error( - `[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} can only write/edit .md files inside .sisyphus/ directory. ` + - `Attempted to modify: ${filePath}. ` + - `${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` + - `APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN` - ) - } - - const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/") - if (normalizedPath.includes(".sisyphus/plans/") || normalizedPath.includes(".sisyphus\\plans\\")) { - log(`[${HOOK_NAME}] Injecting workflow reminder for plan write`, { - sessionID: input.sessionID, - tool: toolName, - filePath, - agent: agentName, - }) - output.message = (output.message || "") + PROMETHEUS_WORKFLOW_REMINDER - } - - log(`[${HOOK_NAME}] Allowed: .sisyphus/*.md write permitted`, { - sessionID: input.sessionID, - tool: toolName, - filePath, - agent: agentName, - }) - }, - } -} +export { createPrometheusMdOnlyHook } from "./hook" diff --git a/src/hooks/prometheus-md-only/path-policy.ts b/src/hooks/prometheus-md-only/path-policy.ts new file mode 100644 index 00000000..ab3da318 --- /dev/null +++ b/src/hooks/prometheus-md-only/path-policy.ts @@ -0,0 +1,41 @@ +import { relative, resolve, isAbsolute } from "node:path" + +import { ALLOWED_EXTENSIONS } from "./constants" + +/** + * Cross-platform path validator for Prometheus file writes. + * Uses path.resolve/relative instead of string matching to handle: + * - Windows backslashes (e.g., .sisyphus\\plans\\x.md) + * - Mixed separators (e.g., .sisyphus\\plans/x.md) + * - Case-insensitive directory/extension matching + * - Workspace confinement (blocks paths outside root or via traversal) + * - Nested project paths (e.g., parent/.sisyphus/... when ctx.directory is parent) + */ +export function isAllowedFile(filePath: string, workspaceRoot: string): boolean { + // 1. Resolve to absolute path + const resolved = resolve(workspaceRoot, filePath) + + // 2. Get relative path from workspace root + const rel = relative(workspaceRoot, resolved) + + // 3. Reject if escapes root (starts with ".." or is absolute) + if (rel.startsWith("..") || isAbsolute(rel)) { + return false + } + + // 4. Check if .sisyphus/ or .sisyphus\ exists anywhere in the path (case-insensitive) + // This handles both direct paths (.sisyphus/x.md) and nested paths (project/.sisyphus/x.md) + if (!/\.sisyphus[/\\]/i.test(rel)) { + return false + } + + // 5. Check extension matches one of ALLOWED_EXTENSIONS (case-insensitive) + const hasAllowedExtension = ALLOWED_EXTENSIONS.some( + ext => resolved.toLowerCase().endsWith(ext.toLowerCase()) + ) + if (!hasAllowedExtension) { + return false + } + + return true +} diff --git a/src/hooks/question-label-truncator/hook.ts b/src/hooks/question-label-truncator/hook.ts new file mode 100644 index 00000000..03e72b23 --- /dev/null +++ b/src/hooks/question-label-truncator/hook.ts @@ -0,0 +1,62 @@ +const MAX_LABEL_LENGTH = 30; + +interface QuestionOption { + label: string; + description?: string; +} + +interface Question { + question: string; + header?: string; + options: QuestionOption[]; + multiSelect?: boolean; +} + +interface AskUserQuestionArgs { + questions: Question[]; +} + +function truncateLabel(label: string, maxLength: number = MAX_LABEL_LENGTH): string { + if (label.length <= maxLength) { + return label; + } + return label.substring(0, maxLength - 3) + "..."; +} + +function truncateQuestionLabels(args: AskUserQuestionArgs): AskUserQuestionArgs { + if (!args.questions || !Array.isArray(args.questions)) { + return args; + } + + return { + ...args, + questions: args.questions.map((question) => ({ + ...question, + options: + question.options?.map((option) => ({ + ...option, + label: truncateLabel(option.label), + })) ?? [], + })), + }; +} + +export function createQuestionLabelTruncatorHook() { + return { + "tool.execute.before": async ( + input: { tool: string }, + output: { args: Record } + ): Promise => { + const toolName = input.tool?.toLowerCase(); + + if (toolName === "askuserquestion" || toolName === "ask_user_question") { + const args = output.args as unknown as AskUserQuestionArgs | undefined; + + if (args?.questions) { + const truncatedArgs = truncateQuestionLabels(args); + Object.assign(output.args, truncatedArgs); + } + } + }, + }; +} diff --git a/src/hooks/question-label-truncator/index.ts b/src/hooks/question-label-truncator/index.ts index 9b3de8d2..25ffe06d 100644 --- a/src/hooks/question-label-truncator/index.ts +++ b/src/hooks/question-label-truncator/index.ts @@ -1,61 +1 @@ -const MAX_LABEL_LENGTH = 30; - -interface QuestionOption { - label: string; - description?: string; -} - -interface Question { - question: string; - header?: string; - options: QuestionOption[]; - multiSelect?: boolean; -} - -interface AskUserQuestionArgs { - questions: Question[]; -} - -function truncateLabel(label: string, maxLength: number = MAX_LABEL_LENGTH): string { - if (label.length <= maxLength) { - return label; - } - return label.substring(0, maxLength - 3) + "..."; -} - -function truncateQuestionLabels(args: AskUserQuestionArgs): AskUserQuestionArgs { - if (!args.questions || !Array.isArray(args.questions)) { - return args; - } - - return { - ...args, - questions: args.questions.map((question) => ({ - ...question, - options: question.options?.map((option) => ({ - ...option, - label: truncateLabel(option.label), - })) ?? [], - })), - }; -} - -export function createQuestionLabelTruncatorHook() { - return { - "tool.execute.before": async ( - input: { tool: string }, - output: { args: Record } - ): Promise => { - const toolName = input.tool?.toLowerCase(); - - if (toolName === "askuserquestion" || toolName === "ask_user_question") { - const args = output.args as unknown as AskUserQuestionArgs | undefined; - - if (args?.questions) { - const truncatedArgs = truncateQuestionLabels(args); - Object.assign(output.args, truncatedArgs); - } - } - }, - }; -} +export { createQuestionLabelTruncatorHook } from "./hook"; diff --git a/src/hooks/rules-injector/cache.ts b/src/hooks/rules-injector/cache.ts new file mode 100644 index 00000000..b2327314 --- /dev/null +++ b/src/hooks/rules-injector/cache.ts @@ -0,0 +1,27 @@ +import { clearInjectedRules, loadInjectedRules } from "./storage"; + +export type SessionInjectedRulesCache = { + contentHashes: Set; + realPaths: Set; +}; + +export function createSessionCacheStore(): { + getSessionCache: (sessionID: string) => SessionInjectedRulesCache; + clearSessionCache: (sessionID: string) => void; +} { + const sessionCaches = new Map(); + + function getSessionCache(sessionID: string): SessionInjectedRulesCache { + if (!sessionCaches.has(sessionID)) { + sessionCaches.set(sessionID, loadInjectedRules(sessionID)); + } + return sessionCaches.get(sessionID)!; + } + + function clearSessionCache(sessionID: string): void { + sessionCaches.delete(sessionID); + clearInjectedRules(sessionID); + } + + return { getSessionCache, clearSessionCache }; +} diff --git a/src/hooks/rules-injector/hook.ts b/src/hooks/rules-injector/hook.ts new file mode 100644 index 00000000..b556a8a7 --- /dev/null +++ b/src/hooks/rules-injector/hook.ts @@ -0,0 +1,87 @@ +import type { PluginInput } from "@opencode-ai/plugin"; +import { createDynamicTruncator } from "../../shared/dynamic-truncator"; +import { getRuleInjectionFilePath } from "./output-path"; +import { createSessionCacheStore } from "./cache"; +import { createRuleInjectionProcessor } from "./injector"; + +interface ToolExecuteInput { + tool: string; + sessionID: string; + callID: string; +} + +interface ToolExecuteOutput { + title: string; + output: string; + metadata: unknown; +} + +interface ToolExecuteBeforeOutput { + args: unknown; +} + +interface EventInput { + event: { + type: string; + properties?: unknown; + }; +} + +const TRACKED_TOOLS = ["read", "write", "edit", "multiedit"]; + +export function createRulesInjectorHook(ctx: PluginInput) { + const truncator = createDynamicTruncator(ctx); + const { getSessionCache, clearSessionCache } = createSessionCacheStore(); + const { processFilePathForInjection } = createRuleInjectionProcessor({ + workspaceDirectory: ctx.directory, + truncator, + getSessionCache, + }); + + const toolExecuteAfter = async ( + input: ToolExecuteInput, + output: ToolExecuteOutput + ) => { + const toolName = input.tool.toLowerCase(); + + if (TRACKED_TOOLS.includes(toolName)) { + const filePath = getRuleInjectionFilePath(output); + if (!filePath) return; + await processFilePathForInjection(filePath, input.sessionID, output); + return; + } + }; + + const toolExecuteBefore = async ( + input: ToolExecuteInput, + output: ToolExecuteBeforeOutput + ): Promise => { + void input; + void output; + }; + + const eventHandler = async ({ event }: EventInput) => { + const props = event.properties as Record | undefined; + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined; + if (sessionInfo?.id) { + clearSessionCache(sessionInfo.id); + } + } + + if (event.type === "session.compacted") { + const sessionID = (props?.sessionID ?? + (props?.info as { id?: string } | undefined)?.id) as string | undefined; + if (sessionID) { + clearSessionCache(sessionID); + } + } + }; + + return { + "tool.execute.before": toolExecuteBefore, + "tool.execute.after": toolExecuteAfter, + event: eventHandler, + }; +} diff --git a/src/hooks/rules-injector/index.ts b/src/hooks/rules-injector/index.ts index 866ee7eb..8bcd0bb0 100644 --- a/src/hooks/rules-injector/index.ts +++ b/src/hooks/rules-injector/index.ts @@ -1,190 +1 @@ -import type { PluginInput } from "@opencode-ai/plugin"; -import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { relative, resolve } from "node:path"; -import { findProjectRoot, findRuleFiles } from "./finder"; -import { - createContentHash, - isDuplicateByContentHash, - isDuplicateByRealPath, - shouldApplyRule, -} from "./matcher"; -import { parseRuleFrontmatter } from "./parser"; -import { - clearInjectedRules, - loadInjectedRules, - saveInjectedRules, -} from "./storage"; -import { createDynamicTruncator } from "../../shared/dynamic-truncator"; -import { getRuleInjectionFilePath } from "./output-path"; - -interface ToolExecuteInput { - tool: string; - sessionID: string; - callID: string; -} - -interface ToolExecuteOutput { - title: string; - output: string; - metadata: unknown; -} - -interface ToolExecuteBeforeOutput { - args: unknown; -} - -interface EventInput { - event: { - type: string; - properties?: unknown; - }; -} - -interface RuleToInject { - relativePath: string; - matchReason: string; - content: string; - distance: number; -} - -const TRACKED_TOOLS = ["read", "write", "edit", "multiedit"]; - -export function createRulesInjectorHook(ctx: PluginInput) { - const sessionCaches = new Map< - string, - { contentHashes: Set; realPaths: Set } - >(); - const truncator = createDynamicTruncator(ctx); - - function getSessionCache(sessionID: string): { - contentHashes: Set; - realPaths: Set; - } { - if (!sessionCaches.has(sessionID)) { - sessionCaches.set(sessionID, loadInjectedRules(sessionID)); - } - return sessionCaches.get(sessionID)!; - } - - function resolveFilePath(path: string): string | null { - if (!path) return null; - if (path.startsWith("/")) return path; - return resolve(ctx.directory, path); - } - - - async function processFilePathForInjection( - filePath: string, - sessionID: string, - output: ToolExecuteOutput - ): Promise { - const resolved = resolveFilePath(filePath); - if (!resolved) return; - - const projectRoot = findProjectRoot(resolved); - const cache = getSessionCache(sessionID); - const home = homedir(); - - const ruleFileCandidates = findRuleFiles(projectRoot, home, resolved); - const toInject: RuleToInject[] = []; - - for (const candidate of ruleFileCandidates) { - if (isDuplicateByRealPath(candidate.realPath, cache.realPaths)) continue; - - try { - const rawContent = readFileSync(candidate.path, "utf-8"); - const { metadata, body } = parseRuleFrontmatter(rawContent); - - let matchReason: string; - if (candidate.isSingleFile) { - matchReason = "copilot-instructions (always apply)"; - } else { - const matchResult = shouldApplyRule(metadata, resolved, projectRoot); - if (!matchResult.applies) continue; - matchReason = matchResult.reason ?? "matched"; - } - - const contentHash = createContentHash(body); - if (isDuplicateByContentHash(contentHash, cache.contentHashes)) continue; - - const relativePath = projectRoot - ? relative(projectRoot, candidate.path) - : candidate.path; - - toInject.push({ - relativePath, - matchReason, - content: body, - distance: candidate.distance, - }); - - cache.realPaths.add(candidate.realPath); - cache.contentHashes.add(contentHash); - } catch {} - } - - if (toInject.length === 0) return; - - toInject.sort((a, b) => a.distance - b.distance); - - for (const rule of toInject) { - const { result, truncated } = await truncator.truncate(sessionID, rule.content); - const truncationNotice = truncated - ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]` - : ""; - output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`; - } - - saveInjectedRules(sessionID, cache); - } - - const toolExecuteAfter = async ( - input: ToolExecuteInput, - output: ToolExecuteOutput - ) => { - const toolName = input.tool.toLowerCase(); - - if (TRACKED_TOOLS.includes(toolName)) { - const filePath = getRuleInjectionFilePath(output); - if (!filePath) return; - await processFilePathForInjection(filePath, input.sessionID, output); - return; - } - }; - - const toolExecuteBefore = async ( - input: ToolExecuteInput, - output: ToolExecuteBeforeOutput - ): Promise => { - void input; - void output; - }; - - const eventHandler = async ({ event }: EventInput) => { - const props = event.properties as Record | undefined; - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - sessionCaches.delete(sessionInfo.id); - clearInjectedRules(sessionInfo.id); - } - } - - if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined; - if (sessionID) { - sessionCaches.delete(sessionID); - clearInjectedRules(sessionID); - } - } - }; - - return { - "tool.execute.before": toolExecuteBefore, - "tool.execute.after": toolExecuteAfter, - event: eventHandler, - }; -} +export { createRulesInjectorHook } from "./hook"; diff --git a/src/hooks/rules-injector/injector.ts b/src/hooks/rules-injector/injector.ts new file mode 100644 index 00000000..9ba0324b --- /dev/null +++ b/src/hooks/rules-injector/injector.ts @@ -0,0 +1,126 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { relative, resolve } from "node:path"; +import { findProjectRoot, findRuleFiles } from "./finder"; +import { + createContentHash, + isDuplicateByContentHash, + isDuplicateByRealPath, + shouldApplyRule, +} from "./matcher"; +import { parseRuleFrontmatter } from "./parser"; +import { saveInjectedRules } from "./storage"; +import type { SessionInjectedRulesCache } from "./cache"; + +type ToolExecuteOutput = { + title: string; + output: string; + metadata: unknown; +}; + +type RuleToInject = { + relativePath: string; + matchReason: string; + content: string; + distance: number; +}; + +type DynamicTruncator = { + truncate: ( + sessionID: string, + content: string + ) => Promise<{ result: string; truncated: boolean }>; +}; + +function resolveFilePath( + workspaceDirectory: string, + path: string +): string | null { + if (!path) return null; + if (path.startsWith("/")) return path; + return resolve(workspaceDirectory, path); +} + +export function createRuleInjectionProcessor(deps: { + workspaceDirectory: string; + truncator: DynamicTruncator; + getSessionCache: (sessionID: string) => SessionInjectedRulesCache; +}): { + processFilePathForInjection: ( + filePath: string, + sessionID: string, + output: ToolExecuteOutput + ) => Promise; +} { + const { workspaceDirectory, truncator, getSessionCache } = deps; + + async function processFilePathForInjection( + filePath: string, + sessionID: string, + output: ToolExecuteOutput + ): Promise { + const resolved = resolveFilePath(workspaceDirectory, filePath); + if (!resolved) return; + + const projectRoot = findProjectRoot(resolved); + const cache = getSessionCache(sessionID); + const home = homedir(); + + const ruleFileCandidates = findRuleFiles(projectRoot, home, resolved); + const toInject: RuleToInject[] = []; + + for (const candidate of ruleFileCandidates) { + if (isDuplicateByRealPath(candidate.realPath, cache.realPaths)) continue; + + try { + const rawContent = readFileSync(candidate.path, "utf-8"); + const { metadata, body } = parseRuleFrontmatter(rawContent); + + let matchReason: string; + if (candidate.isSingleFile) { + matchReason = "copilot-instructions (always apply)"; + } else { + const matchResult = shouldApplyRule(metadata, resolved, projectRoot); + if (!matchResult.applies) continue; + matchReason = matchResult.reason ?? "matched"; + } + + const contentHash = createContentHash(body); + if (isDuplicateByContentHash(contentHash, cache.contentHashes)) continue; + + const relativePath = projectRoot + ? relative(projectRoot, candidate.path) + : candidate.path; + + toInject.push({ + relativePath, + matchReason, + content: body, + distance: candidate.distance, + }); + + cache.realPaths.add(candidate.realPath); + cache.contentHashes.add(contentHash); + } catch {} + } + + if (toInject.length === 0) return; + + toInject.sort((a, b) => a.distance - b.distance); + + for (const rule of toInject) { + const { result, truncated } = await truncator.truncate( + sessionID, + rule.content + ); + const truncationNotice = truncated + ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]` + : ""; + output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`; + } + + saveInjectedRules(sessionID, cache); + } + + return { processFilePathForInjection }; +} diff --git a/src/hooks/sisyphus-junior-notepad/hook.ts b/src/hooks/sisyphus-junior-notepad/hook.ts new file mode 100644 index 00000000..f80c0df0 --- /dev/null +++ b/src/hooks/sisyphus-junior-notepad/hook.ts @@ -0,0 +1,44 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { isCallerOrchestrator } from "../../shared/session-utils" +import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" +import { log } from "../../shared/logger" +import { HOOK_NAME, NOTEPAD_DIRECTIVE } from "./constants" + +export function createSisyphusJuniorNotepadHook(_ctx: PluginInput) { + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record; message?: string } + ): Promise => { + // 1. Check if tool is task + if (input.tool !== "task") { + return + } + + // 2. Check if caller is Atlas (orchestrator) + if (!isCallerOrchestrator(input.sessionID)) { + return + } + + // 3. Get prompt from output.args + const prompt = output.args.prompt as string | undefined + if (!prompt) { + return + } + + // 4. Check for double injection + if (prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) { + return + } + + // 5. Prepend directive + output.args.prompt = NOTEPAD_DIRECTIVE + prompt + + // 6. Log injection + log(`[${HOOK_NAME}] Injected notepad directive to task`, { + sessionID: input.sessionID, + }) + }, + } +} diff --git a/src/hooks/sisyphus-junior-notepad/index.ts b/src/hooks/sisyphus-junior-notepad/index.ts index 630de907..1652bf9c 100644 --- a/src/hooks/sisyphus-junior-notepad/index.ts +++ b/src/hooks/sisyphus-junior-notepad/index.ts @@ -1,45 +1,3 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { isCallerOrchestrator } from "../../shared/session-utils" -import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" -import { log } from "../../shared/logger" -import { HOOK_NAME, NOTEPAD_DIRECTIVE } from "./constants" - export * from "./constants" -export function createSisyphusJuniorNotepadHook(ctx: PluginInput) { - return { - "tool.execute.before": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: Record; message?: string } - ): Promise => { - // 1. Check if tool is task - if (input.tool !== "task") { - return - } - - // 2. Check if caller is Atlas (orchestrator) - if (!isCallerOrchestrator(input.sessionID)) { - return - } - - // 3. Get prompt from output.args - const prompt = output.args.prompt as string | undefined - if (!prompt) { - return - } - - // 4. Check for double injection - if (prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) { - return - } - - // 5. Prepend directive - output.args.prompt = NOTEPAD_DIRECTIVE + prompt - - // 6. Log injection - log(`[${HOOK_NAME}] Injected notepad directive to task`, { - sessionID: input.sessionID, - }) - }, - } -} +export { createSisyphusJuniorNotepadHook } from "./hook" diff --git a/src/hooks/stop-continuation-guard/hook.ts b/src/hooks/stop-continuation-guard/hook.ts new file mode 100644 index 00000000..40c475bc --- /dev/null +++ b/src/hooks/stop-continuation-guard/hook.ts @@ -0,0 +1,68 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { log } from "../../shared/logger" + +const HOOK_NAME = "stop-continuation-guard" + +export interface StopContinuationGuard { + event: (input: { event: { type: string; properties?: unknown } }) => Promise + "chat.message": (input: { sessionID?: string }) => Promise + stop: (sessionID: string) => void + isStopped: (sessionID: string) => boolean + clear: (sessionID: string) => void +} + +export function createStopContinuationGuardHook( + _ctx: PluginInput +): StopContinuationGuard { + const stoppedSessions = new Set() + + const stop = (sessionID: string): void => { + stoppedSessions.add(sessionID) + log(`[${HOOK_NAME}] Continuation stopped for session`, { sessionID }) + } + + const isStopped = (sessionID: string): boolean => { + return stoppedSessions.has(sessionID) + } + + const clear = (sessionID: string): void => { + stoppedSessions.delete(sessionID) + log(`[${HOOK_NAME}] Continuation guard cleared for session`, { sessionID }) + } + + const event = async ({ + event, + }: { + event: { type: string; properties?: unknown } + }): Promise => { + const props = event.properties as Record | undefined + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + clear(sessionInfo.id) + log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) + } + } + } + + const chatMessage = async ({ + sessionID, + }: { + sessionID?: string + }): Promise => { + if (sessionID && stoppedSessions.has(sessionID)) { + clear(sessionID) + log(`[${HOOK_NAME}] Cleared stop state on new user message`, { sessionID }) + } + } + + return { + event, + "chat.message": chatMessage, + stop, + isStopped, + clear, + } +} diff --git a/src/hooks/stop-continuation-guard/index.ts b/src/hooks/stop-continuation-guard/index.ts index 37ac304f..8dc9901c 100644 --- a/src/hooks/stop-continuation-guard/index.ts +++ b/src/hooks/stop-continuation-guard/index.ts @@ -1,67 +1,2 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { log } from "../../shared/logger" - -const HOOK_NAME = "stop-continuation-guard" - -export interface StopContinuationGuard { - event: (input: { event: { type: string; properties?: unknown } }) => Promise - "chat.message": (input: { sessionID?: string }) => Promise - stop: (sessionID: string) => void - isStopped: (sessionID: string) => boolean - clear: (sessionID: string) => void -} - -export function createStopContinuationGuardHook( - _ctx: PluginInput -): StopContinuationGuard { - const stoppedSessions = new Set() - - const stop = (sessionID: string): void => { - stoppedSessions.add(sessionID) - log(`[${HOOK_NAME}] Continuation stopped for session`, { sessionID }) - } - - const isStopped = (sessionID: string): boolean => { - return stoppedSessions.has(sessionID) - } - - const clear = (sessionID: string): void => { - stoppedSessions.delete(sessionID) - log(`[${HOOK_NAME}] Continuation guard cleared for session`, { sessionID }) - } - - const event = async ({ - event, - }: { - event: { type: string; properties?: unknown } - }): Promise => { - const props = event.properties as Record | undefined - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - clear(sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) - } - } - } - - const chatMessage = async ({ - sessionID, - }: { - sessionID?: string - }): Promise => { - if (sessionID && stoppedSessions.has(sessionID)) { - clear(sessionID) - log(`[${HOOK_NAME}] Cleared stop state on new user message`, { sessionID }) - } - } - - return { - event, - "chat.message": chatMessage, - stop, - isStopped, - clear, - } -} +export { createStopContinuationGuardHook } from "./hook" +export type { StopContinuationGuard } from "./hook" diff --git a/src/hooks/subagent-question-blocker/hook.ts b/src/hooks/subagent-question-blocker/hook.ts new file mode 100644 index 00000000..fc64fae7 --- /dev/null +++ b/src/hooks/subagent-question-blocker/hook.ts @@ -0,0 +1,29 @@ +import type { Hooks } from "@opencode-ai/plugin" +import { subagentSessions } from "../../features/claude-code-session-state" +import { log } from "../../shared" + +export function createSubagentQuestionBlockerHook(): Hooks { + return { + "tool.execute.before": async (input) => { + const toolName = input.tool?.toLowerCase() + if (toolName !== "question" && toolName !== "askuserquestion") { + return + } + + if (!subagentSessions.has(input.sessionID)) { + return + } + + log("[subagent-question-blocker] Blocking question tool call from subagent session", { + sessionID: input.sessionID, + tool: input.tool, + }) + + throw new Error( + "Question tool is disabled for subagent sessions. " + + "Subagents should complete their work autonomously without asking questions to users. " + + "If you need clarification, return to the parent agent with your findings and uncertainties." + ) + }, + } +} diff --git a/src/hooks/subagent-question-blocker/index.ts b/src/hooks/subagent-question-blocker/index.ts index b848d859..dbba20b8 100644 --- a/src/hooks/subagent-question-blocker/index.ts +++ b/src/hooks/subagent-question-blocker/index.ts @@ -1,29 +1 @@ -import type { Hooks } from "@opencode-ai/plugin" -import { subagentSessions } from "../../features/claude-code-session-state" -import { log } from "../../shared" - -export function createSubagentQuestionBlockerHook(): Hooks { - return { - "tool.execute.before": async (input) => { - const toolName = input.tool?.toLowerCase() - if (toolName !== "question" && toolName !== "askuserquestion") { - return - } - - if (!subagentSessions.has(input.sessionID)) { - return - } - - log("[subagent-question-blocker] Blocking question tool call from subagent session", { - sessionID: input.sessionID, - tool: input.tool, - }) - - throw new Error( - "Question tool is disabled for subagent sessions. " + - "Subagents should complete their work autonomously without asking questions to users. " + - "If you need clarification, return to the parent agent with your findings and uncertainties." - ) - }, - } -} +export { createSubagentQuestionBlockerHook } from "./hook"; diff --git a/src/hooks/task-reminder/hook.ts b/src/hooks/task-reminder/hook.ts new file mode 100644 index 00000000..4e795018 --- /dev/null +++ b/src/hooks/task-reminder/hook.ts @@ -0,0 +1,59 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +const TASK_TOOLS = new Set([ + "task", + "task_create", + "task_list", + "task_get", + "task_update", + "task_delete", +]) +const TURN_THRESHOLD = 10 +const REMINDER_MESSAGE = ` + +The task tools haven't been used recently. If you're tracking work, use task with action=create/update (or task_create/task_update) to record progress.` + +interface ToolExecuteInput { + tool: string + sessionID: string + callID: string +} + +interface ToolExecuteOutput { + output: string +} + +export function createTaskReminderHook(_ctx: PluginInput) { + const sessionCounters = new Map() + + const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => { + const { tool, sessionID } = input + const toolLower = tool.toLowerCase() + + if (TASK_TOOLS.has(toolLower)) { + sessionCounters.set(sessionID, 0) + return + } + + const currentCount = sessionCounters.get(sessionID) ?? 0 + const newCount = currentCount + 1 + + if (newCount >= TURN_THRESHOLD) { + output.output += REMINDER_MESSAGE + sessionCounters.set(sessionID, 0) + } else { + sessionCounters.set(sessionID, newCount) + } + } + + return { + "tool.execute.after": toolExecuteAfter, + event: async ({ event }: { event: { type: string; properties?: unknown } }) => { + if (event.type !== "session.deleted") return + const props = event.properties as { info?: { id?: string } } | undefined + const sessionId = props?.info?.id + if (!sessionId) return + sessionCounters.delete(sessionId) + }, + } +} diff --git a/src/hooks/task-reminder/index.ts b/src/hooks/task-reminder/index.ts index 4e795018..194a4261 100644 --- a/src/hooks/task-reminder/index.ts +++ b/src/hooks/task-reminder/index.ts @@ -1,59 +1 @@ -import type { PluginInput } from "@opencode-ai/plugin" - -const TASK_TOOLS = new Set([ - "task", - "task_create", - "task_list", - "task_get", - "task_update", - "task_delete", -]) -const TURN_THRESHOLD = 10 -const REMINDER_MESSAGE = ` - -The task tools haven't been used recently. If you're tracking work, use task with action=create/update (or task_create/task_update) to record progress.` - -interface ToolExecuteInput { - tool: string - sessionID: string - callID: string -} - -interface ToolExecuteOutput { - output: string -} - -export function createTaskReminderHook(_ctx: PluginInput) { - const sessionCounters = new Map() - - const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => { - const { tool, sessionID } = input - const toolLower = tool.toLowerCase() - - if (TASK_TOOLS.has(toolLower)) { - sessionCounters.set(sessionID, 0) - return - } - - const currentCount = sessionCounters.get(sessionID) ?? 0 - const newCount = currentCount + 1 - - if (newCount >= TURN_THRESHOLD) { - output.output += REMINDER_MESSAGE - sessionCounters.set(sessionID, 0) - } else { - sessionCounters.set(sessionID, newCount) - } - } - - return { - "tool.execute.after": toolExecuteAfter, - event: async ({ event }: { event: { type: string; properties?: unknown } }) => { - if (event.type !== "session.deleted") return - const props = event.properties as { info?: { id?: string } } | undefined - const sessionId = props?.info?.id - if (!sessionId) return - sessionCounters.delete(sessionId) - }, - } -} +export { createTaskReminderHook } from "./hook"; diff --git a/src/hooks/task-resume-info/hook.ts b/src/hooks/task-resume-info/hook.ts new file mode 100644 index 00000000..f5c0c518 --- /dev/null +++ b/src/hooks/task-resume-info/hook.ts @@ -0,0 +1,38 @@ +const TARGET_TOOLS = ["task", "Task", "task_tool", "call_omo_agent"] + +const SESSION_ID_PATTERNS = [ + /Session ID: (ses_[a-zA-Z0-9_-]+)/, + /session_id: (ses_[a-zA-Z0-9_-]+)/, + /\s*session_id: (ses_[a-zA-Z0-9_-]+)/, + /sessionId: (ses_[a-zA-Z0-9_-]+)/, +] + +function extractSessionId(output: string): string | null { + for (const pattern of SESSION_ID_PATTERNS) { + const match = output.match(pattern) + if (match) return match[1] ?? null + } + return null +} + +export function createTaskResumeInfoHook() { + const toolExecuteAfter = async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown } + ) => { + if (!TARGET_TOOLS.includes(input.tool)) return + if (output.output.startsWith("Error:") || output.output.startsWith("Failed")) return + if (output.output.includes("\nto continue:")) return + + const sessionId = extractSessionId(output.output) + if (!sessionId) return + + output.output = + output.output.trimEnd() + + `\n\nto continue: task(session_id="${sessionId}", prompt="...")` + } + + return { + "tool.execute.after": toolExecuteAfter, + } +} diff --git a/src/hooks/task-resume-info/index.ts b/src/hooks/task-resume-info/index.ts index 650624c7..7419cab2 100644 --- a/src/hooks/task-resume-info/index.ts +++ b/src/hooks/task-resume-info/index.ts @@ -1,36 +1 @@ -const TARGET_TOOLS = ["task", "Task", "task_tool", "call_omo_agent"] - -const SESSION_ID_PATTERNS = [ - /Session ID: (ses_[a-zA-Z0-9_-]+)/, - /session_id: (ses_[a-zA-Z0-9_-]+)/, - /\s*session_id: (ses_[a-zA-Z0-9_-]+)/, - /sessionId: (ses_[a-zA-Z0-9_-]+)/, -] - -function extractSessionId(output: string): string | null { - for (const pattern of SESSION_ID_PATTERNS) { - const match = output.match(pattern) - if (match) return match[1] - } - return null -} - -export function createTaskResumeInfoHook() { - const toolExecuteAfter = async ( - input: { tool: string; sessionID: string; callID: string }, - output: { title: string; output: string; metadata: unknown } - ) => { - if (!TARGET_TOOLS.includes(input.tool)) return - if (output.output.startsWith("Error:") || output.output.startsWith("Failed")) return - if (output.output.includes("\nto continue:")) return - - const sessionId = extractSessionId(output.output) - if (!sessionId) return - - output.output = output.output.trimEnd() + `\n\nto continue: task(session_id="${sessionId}", prompt="...")` - } - - return { - "tool.execute.after": toolExecuteAfter, - } -} +export { createTaskResumeInfoHook } from "./hook"; diff --git a/src/hooks/tasks-todowrite-disabler/hook.ts b/src/hooks/tasks-todowrite-disabler/hook.ts new file mode 100644 index 00000000..d9f7d1af --- /dev/null +++ b/src/hooks/tasks-todowrite-disabler/hook.ts @@ -0,0 +1,33 @@ +import { BLOCKED_TOOLS, REPLACEMENT_MESSAGE } from "./constants"; + +export interface TasksTodowriteDisablerConfig { + experimental?: { + task_system?: boolean; + }; +} + +export function createTasksTodowriteDisablerHook( + config: TasksTodowriteDisablerConfig, +) { + const isTaskSystemEnabled = config.experimental?.task_system ?? false; + + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + _output: { args: Record }, + ) => { + if (!isTaskSystemEnabled) { + return; + } + + const toolName = input.tool as string; + if ( + BLOCKED_TOOLS.some( + (blocked) => blocked.toLowerCase() === toolName.toLowerCase(), + ) + ) { + throw new Error(REPLACEMENT_MESSAGE); + } + }, + }; +} diff --git a/src/hooks/tasks-todowrite-disabler/index.ts b/src/hooks/tasks-todowrite-disabler/index.ts index 2fee8a18..a16effb5 100644 --- a/src/hooks/tasks-todowrite-disabler/index.ts +++ b/src/hooks/tasks-todowrite-disabler/index.ts @@ -1,29 +1,2 @@ -import { BLOCKED_TOOLS, REPLACEMENT_MESSAGE } from "./constants"; - -export interface TasksTodowriteDisablerConfig { - experimental?: { - task_system?: boolean; - }; -} - -export function createTasksTodowriteDisablerHook( - config: TasksTodowriteDisablerConfig, -) { - const isTaskSystemEnabled = config.experimental?.task_system ?? false; - - return { - "tool.execute.before": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: Record }, - ) => { - if (!isTaskSystemEnabled) { - return; - } - - const toolName = input.tool as string; - if (BLOCKED_TOOLS.some((blocked) => blocked.toLowerCase() === toolName.toLowerCase())) { - throw new Error(REPLACEMENT_MESSAGE); - } - }, - }; -} +export { createTasksTodowriteDisablerHook } from "./hook"; +export type { TasksTodowriteDisablerConfig } from "./hook"; diff --git a/src/hooks/think-mode/hook.ts b/src/hooks/think-mode/hook.ts new file mode 100644 index 00000000..83a55d3f --- /dev/null +++ b/src/hooks/think-mode/hook.ts @@ -0,0 +1,101 @@ +import { detectThinkKeyword, extractPromptText } from "./detector" +import { getHighVariant, getThinkingConfig, isAlreadyHighVariant } from "./switcher" +import type { ThinkModeInput, ThinkModeState } from "./types" +import { log } from "../../shared" + +const thinkModeState = new Map() + +export function clearThinkModeState(sessionID: string): void { + thinkModeState.delete(sessionID) +} + +export function createThinkModeHook() { + return { + "chat.params": async (output: ThinkModeInput, sessionID: string): Promise => { + const promptText = extractPromptText(output.parts) + + const state: ThinkModeState = { + requested: false, + modelSwitched: false, + thinkingConfigInjected: false, + } + + if (!detectThinkKeyword(promptText)) { + thinkModeState.set(sessionID, state) + return + } + + state.requested = true + + const currentModel = output.message.model + if (!currentModel) { + thinkModeState.set(sessionID, state) + return + } + + state.providerID = currentModel.providerID + state.modelID = currentModel.modelID + + if (isAlreadyHighVariant(currentModel.modelID)) { + thinkModeState.set(sessionID, state) + return + } + + const highVariant = getHighVariant(currentModel.modelID) + const thinkingConfig = getThinkingConfig(currentModel.providerID, currentModel.modelID) + + if (highVariant) { + output.message.model = { + providerID: currentModel.providerID, + modelID: highVariant, + } + state.modelSwitched = true + log("Think mode: model switched to high variant", { + sessionID, + from: currentModel.modelID, + to: highVariant, + }) + } + + if (thinkingConfig) { + const messageData = output.message as Record + const agentThinking = messageData.thinking as { type?: string } | undefined + const agentProviderOptions = messageData.providerOptions + + const agentDisabledThinking = agentThinking?.type === "disabled" + const agentHasCustomProviderOptions = Boolean(agentProviderOptions) + + if (agentDisabledThinking) { + log("Think mode: skipping - agent has thinking disabled", { + sessionID, + provider: currentModel.providerID, + }) + } else if (agentHasCustomProviderOptions) { + log("Think mode: skipping - agent has custom providerOptions", { + sessionID, + provider: currentModel.providerID, + }) + } else { + Object.assign(output.message, thinkingConfig) + state.thinkingConfigInjected = true + log("Think mode: thinking config injected", { + sessionID, + provider: currentModel.providerID, + config: thinkingConfig, + }) + } + } + + thinkModeState.set(sessionID, state) + }, + + event: async ({ event }: { event: { type: string; properties?: unknown } }) => { + if (event.type === "session.deleted") { + const props = event.properties as { info?: { id?: string } } | undefined + if (props?.info?.id) { + thinkModeState.delete(props.info.id) + } + } + }, + } +} diff --git a/src/hooks/think-mode/index.ts b/src/hooks/think-mode/index.ts index d8aafc25..9e1e0040 100644 --- a/src/hooks/think-mode/index.ts +++ b/src/hooks/think-mode/index.ts @@ -1,108 +1,5 @@ -import { detectThinkKeyword, extractPromptText } from "./detector" -import { getHighVariant, isAlreadyHighVariant, getThinkingConfig } from "./switcher" -import type { ThinkModeState, ThinkModeInput } from "./types" -import { log } from "../../shared" - export * from "./detector" export * from "./switcher" export * from "./types" -const thinkModeState = new Map() - -export function clearThinkModeState(sessionID: string): void { - thinkModeState.delete(sessionID) -} - -export function createThinkModeHook() { - return { - "chat.params": async ( - output: ThinkModeInput, - sessionID: string - ): Promise => { - const promptText = extractPromptText(output.parts) - - const state: ThinkModeState = { - requested: false, - modelSwitched: false, - thinkingConfigInjected: false, - } - - if (!detectThinkKeyword(promptText)) { - thinkModeState.set(sessionID, state) - return - } - - state.requested = true - - const currentModel = output.message.model - if (!currentModel) { - thinkModeState.set(sessionID, state) - return - } - - state.providerID = currentModel.providerID - state.modelID = currentModel.modelID - - if (isAlreadyHighVariant(currentModel.modelID)) { - thinkModeState.set(sessionID, state) - return - } - - const highVariant = getHighVariant(currentModel.modelID) - const thinkingConfig = getThinkingConfig(currentModel.providerID, currentModel.modelID) - - if (highVariant) { - output.message.model = { - providerID: currentModel.providerID, - modelID: highVariant, - } - state.modelSwitched = true - log("Think mode: model switched to high variant", { - sessionID, - from: currentModel.modelID, - to: highVariant, - }) - } - - if (thinkingConfig) { - const messageData = output.message as Record - const agentThinking = messageData.thinking as { type?: string } | undefined - const agentProviderOptions = messageData.providerOptions - - const agentDisabledThinking = agentThinking?.type === "disabled" - const agentHasCustomProviderOptions = Boolean(agentProviderOptions) - - if (agentDisabledThinking) { - log("Think mode: skipping - agent has thinking disabled", { - sessionID, - provider: currentModel.providerID, - }) - } else if (agentHasCustomProviderOptions) { - log("Think mode: skipping - agent has custom providerOptions", { - sessionID, - provider: currentModel.providerID, - }) - } else { - Object.assign(output.message, thinkingConfig) - state.thinkingConfigInjected = true - log("Think mode: thinking config injected", { - sessionID, - provider: currentModel.providerID, - config: thinkingConfig, - }) - } - } - - thinkModeState.set(sessionID, state) - }, - - event: async ({ event }: { event: { type: string; properties?: unknown } }) => { - if (event.type === "session.deleted") { - const props = event.properties as { info?: { id?: string } } | undefined - if (props?.info?.id) { - thinkModeState.delete(props.info.id) - } - } - }, - } -} +export { clearThinkModeState, createThinkModeHook } from "./hook" diff --git a/src/hooks/thinking-block-validator/hook.ts b/src/hooks/thinking-block-validator/hook.ts new file mode 100644 index 00000000..a4217b3e --- /dev/null +++ b/src/hooks/thinking-block-validator/hook.ts @@ -0,0 +1,168 @@ +/** + * Proactive Thinking Block Validator Hook + * + * Prevents "Expected thinking/redacted_thinking but found tool_use" errors + * by validating and fixing message structure BEFORE sending to Anthropic API. + * + * This hook runs on the "experimental.chat.messages.transform" hook point, + * which is called before messages are converted to ModelMessage format and + * sent to the API. + * + * Key differences from session-recovery hook: + * - PROACTIVE (prevents error) vs REACTIVE (fixes after error) + * - Runs BEFORE API call vs AFTER API error + * - User never sees the error vs User sees error then recovery + */ + +import type { Message, Part } from "@opencode-ai/sdk" + +interface MessageWithParts { + info: Message + parts: Part[] +} + +type MessagesTransformHook = { + "experimental.chat.messages.transform"?: ( + input: Record, + output: { messages: MessageWithParts[] } + ) => Promise +} + +/** + * Check if a model has extended thinking enabled + * Uses patterns from think-mode/switcher.ts for consistency + */ +function isExtendedThinkingModel(modelID: string): boolean { + if (!modelID) return false + const lower = modelID.toLowerCase() + + // Check for explicit thinking/high variants (always enabled) + if (lower.includes("thinking") || lower.endsWith("-high")) { + return true + } + + // Check for thinking-capable models (claude-4 family, claude-3) + // Aligns with THINKING_CAPABLE_MODELS in think-mode/switcher.ts + return ( + lower.includes("claude-sonnet-4") || + lower.includes("claude-opus-4") || + lower.includes("claude-3") + ) +} + +/** + * Check if a message has any content parts (tool_use, text, or other non-thinking content) + */ +function hasContentParts(parts: Part[]): boolean { + if (!parts || parts.length === 0) return false + + return parts.some((part: Part) => { + const type = part.type as string + // Include tool parts and text parts (anything that's not thinking/reasoning) + return type === "tool" || type === "tool_use" || type === "text" + }) +} + +/** + * Check if a message starts with a thinking/reasoning block + */ +function startsWithThinkingBlock(parts: Part[]): boolean { + if (!parts || parts.length === 0) return false + + const firstPart = parts[0] + const type = firstPart.type as string + return type === "thinking" || type === "reasoning" +} + +/** + * Find the most recent thinking content from previous assistant messages + */ +function findPreviousThinkingContent( + messages: MessageWithParts[], + currentIndex: number +): string { + // Search backwards from current message + for (let i = currentIndex - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info.role !== "assistant") continue + + // Look for thinking parts + if (!msg.parts) continue + for (const part of msg.parts) { + const type = part.type as string + if (type === "thinking" || type === "reasoning") { + const thinking = (part as any).thinking || (part as any).text + if (thinking && typeof thinking === "string" && thinking.trim().length > 0) { + return thinking + } + } + } + } + + return "" +} + +/** + * Prepend a thinking block to a message's parts array + */ +function prependThinkingBlock(message: MessageWithParts, thinkingContent: string): void { + if (!message.parts) { + message.parts = [] + } + + // Create synthetic thinking part + const thinkingPart = { + type: "thinking" as const, + id: `prt_0000000000_synthetic_thinking`, + sessionID: (message.info as any).sessionID || "", + messageID: message.info.id, + thinking: thinkingContent, + synthetic: true, + } + + // Prepend to parts array + message.parts.unshift(thinkingPart as unknown as Part) +} + +/** + * Validate and fix assistant messages that have tool_use but no thinking block + */ +export function createThinkingBlockValidatorHook(): MessagesTransformHook { + return { + "experimental.chat.messages.transform": async (_input, output) => { + const { messages } = output + + if (!messages || messages.length === 0) { + return + } + + // Get the model info from the last user message + const lastUserMessage = messages.findLast(m => m.info.role === "user") + const modelID = (lastUserMessage?.info as any)?.modelID || "" + + // Only process if extended thinking might be enabled + if (!isExtendedThinkingModel(modelID)) { + return + } + + // Process all assistant messages + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + + // Only check assistant messages + if (msg.info.role !== "assistant") continue + + // Check if message has content parts but doesn't start with thinking + if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) { + // Find thinking content from previous turns + const previousThinking = findPreviousThinkingContent(messages, i) + + // Prepend thinking block with content from previous turn or placeholder + const thinkingContent = previousThinking || "[Continuing from previous reasoning]" + + prependThinkingBlock(msg, thinkingContent) + } + } + }, + } +} diff --git a/src/hooks/thinking-block-validator/index.ts b/src/hooks/thinking-block-validator/index.ts index 8e927384..2a99b3c1 100644 --- a/src/hooks/thinking-block-validator/index.ts +++ b/src/hooks/thinking-block-validator/index.ts @@ -1,171 +1 @@ -/** - * Proactive Thinking Block Validator Hook - * - * Prevents "Expected thinking/redacted_thinking but found tool_use" errors - * by validating and fixing message structure BEFORE sending to Anthropic API. - * - * This hook runs on the "experimental.chat.messages.transform" hook point, - * which is called before messages are converted to ModelMessage format and - * sent to the API. - * - * Key differences from session-recovery hook: - * - PROACTIVE (prevents error) vs REACTIVE (fixes after error) - * - Runs BEFORE API call vs AFTER API error - * - User never sees the error vs User sees error then recovery - */ - -import type { Message, Part } from "@opencode-ai/sdk" - -interface MessageWithParts { - info: Message - parts: Part[] -} - -type MessagesTransformHook = { - "experimental.chat.messages.transform"?: ( - input: Record, - output: { messages: MessageWithParts[] } - ) => Promise -} - -/** - * Check if a model has extended thinking enabled - * Uses patterns from think-mode/switcher.ts for consistency - */ -function isExtendedThinkingModel(modelID: string): boolean { - if (!modelID) return false - const lower = modelID.toLowerCase() - - // Check for explicit thinking/high variants (always enabled) - if (lower.includes("thinking") || lower.endsWith("-high")) { - return true - } - - // Check for thinking-capable models (claude-4 family, claude-3) - // Aligns with THINKING_CAPABLE_MODELS in think-mode/switcher.ts - return ( - lower.includes("claude-sonnet-4") || - lower.includes("claude-opus-4") || - lower.includes("claude-3") - ) -} - -/** - * Check if a message has any content parts (tool_use, text, or other non-thinking content) - */ -function hasContentParts(parts: Part[]): boolean { - if (!parts || parts.length === 0) return false - - return parts.some((part: Part) => { - const type = part.type as string - // Include tool parts and text parts (anything that's not thinking/reasoning) - return type === "tool" || type === "tool_use" || type === "text" - }) -} - -/** - * Check if a message starts with a thinking/reasoning block - */ -function startsWithThinkingBlock(parts: Part[]): boolean { - if (!parts || parts.length === 0) return false - - const firstPart = parts[0] - const type = firstPart.type as string - return type === "thinking" || type === "reasoning" -} - -/** - * Find the most recent thinking content from previous assistant messages - */ -function findPreviousThinkingContent( - messages: MessageWithParts[], - currentIndex: number -): string { - // Search backwards from current message - for (let i = currentIndex - 1; i >= 0; i--) { - const msg = messages[i] - if (msg.info.role !== "assistant") continue - - // Look for thinking parts - if (!msg.parts) continue - for (const part of msg.parts) { - const type = part.type as string - if (type === "thinking" || type === "reasoning") { - const thinking = (part as any).thinking || (part as any).text - if (thinking && typeof thinking === "string" && thinking.trim().length > 0) { - return thinking - } - } - } - } - - return "" -} - -/** - * Prepend a thinking block to a message's parts array - */ -function prependThinkingBlock( - message: MessageWithParts, - thinkingContent: string -): void { - if (!message.parts) { - message.parts = [] - } - - // Create synthetic thinking part - const thinkingPart = { - type: "thinking" as const, - id: `prt_0000000000_synthetic_thinking`, - sessionID: (message.info as any).sessionID || "", - messageID: message.info.id, - thinking: thinkingContent, - synthetic: true, - } - - // Prepend to parts array - message.parts.unshift(thinkingPart as unknown as Part) -} - -/** - * Validate and fix assistant messages that have tool_use but no thinking block - */ -export function createThinkingBlockValidatorHook(): MessagesTransformHook { - return { - "experimental.chat.messages.transform": async (_input, output) => { - const { messages } = output - - if (!messages || messages.length === 0) { - return - } - - // Get the model info from the last user message - const lastUserMessage = messages.findLast(m => m.info.role === "user") - const modelID = (lastUserMessage?.info as any)?.modelID || "" - - // Only process if extended thinking might be enabled - if (!isExtendedThinkingModel(modelID)) { - return - } - - // Process all assistant messages - for (let i = 0; i < messages.length; i++) { - const msg = messages[i] - - // Only check assistant messages - if (msg.info.role !== "assistant") continue - - // Check if message has content parts but doesn't start with thinking - if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) { - // Find thinking content from previous turns - const previousThinking = findPreviousThinkingContent(messages, i) - - // Prepend thinking block with content from previous turn or placeholder - const thinkingContent = previousThinking || "[Continuing from previous reasoning]" - - prependThinkingBlock(msg, thinkingContent) - } - } - }, - } -} +export { createThinkingBlockValidatorHook } from "./hook" diff --git a/src/hooks/write-existing-file-guard/hook.ts b/src/hooks/write-existing-file-guard/hook.ts new file mode 100644 index 00000000..2def362d --- /dev/null +++ b/src/hooks/write-existing-file-guard/hook.ts @@ -0,0 +1,50 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" + +import { existsSync } from "fs" +import { resolve, isAbsolute, join, normalize, sep } from "path" + +import { log } from "../../shared" + +export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks { + return { + "tool.execute.before": async (input, output) => { + const toolName = input.tool?.toLowerCase() + if (toolName !== "write") { + return + } + + const args = output.args as + | { filePath?: string; path?: string; file_path?: string } + | undefined + const filePath = args?.filePath ?? args?.path ?? args?.file_path + if (!filePath) { + return + } + + const resolvedPath = normalize( + isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath) + ) + + if (existsSync(resolvedPath)) { + const sisyphusRoot = join(ctx.directory, ".sisyphus") + sep + const isSisyphusMarkdown = + resolvedPath.startsWith(sisyphusRoot) && resolvedPath.endsWith(".md") + if (isSisyphusMarkdown) { + log("[write-existing-file-guard] Allowing .sisyphus/*.md overwrite", { + sessionID: input.sessionID, + filePath, + }) + return + } + + log("[write-existing-file-guard] Blocking write to existing file", { + sessionID: input.sessionID, + filePath, + resolvedPath, + }) + + throw new Error("File already exists. Use edit tool instead.") + } + }, + } +} diff --git a/src/hooks/write-existing-file-guard/index.ts b/src/hooks/write-existing-file-guard/index.ts index 1e8ca751..00887990 100644 --- a/src/hooks/write-existing-file-guard/index.ts +++ b/src/hooks/write-existing-file-guard/index.ts @@ -1,43 +1 @@ -import type { Hooks, PluginInput } from "@opencode-ai/plugin" -import { existsSync } from "fs" -import { resolve, isAbsolute, join, normalize, sep } from "path" -import { log } from "../../shared" - -export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks { - return { - "tool.execute.before": async (input, output) => { - const toolName = input.tool?.toLowerCase() - if (toolName !== "write") { - return - } - - const args = output.args as { filePath?: string; path?: string; file_path?: string } | undefined - const filePath = args?.filePath ?? args?.path ?? args?.file_path - if (!filePath) { - return - } - - const resolvedPath = normalize(isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath)) - - if (existsSync(resolvedPath)) { - const sisyphusRoot = join(ctx.directory, ".sisyphus") + sep - const isSisyphusMarkdown = resolvedPath.startsWith(sisyphusRoot) && resolvedPath.endsWith(".md") - if (isSisyphusMarkdown) { - log("[write-existing-file-guard] Allowing .sisyphus/*.md overwrite", { - sessionID: input.sessionID, - filePath, - }) - return - } - - log("[write-existing-file-guard] Blocking write to existing file", { - sessionID: input.sessionID, - filePath, - resolvedPath, - }) - - throw new Error("File already exists. Use edit tool instead.") - } - }, - } -} +export { createWriteExistingFileGuardHook } from "./hook" diff --git a/src/shared/index.ts b/src/shared/index.ts index d42be5a7..e3262161 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -16,6 +16,11 @@ export * from "./claude-config-dir" export * from "./jsonc-parser" export * from "./migration" export * from "./opencode-config-dir" +export type { + OpenCodeBinaryType, + OpenCodeConfigDirOptions, + OpenCodeConfigPaths, +} from "./opencode-config-dir-types" export * from "./opencode-version" export * from "./permission-compat" export * from "./external-plugin-detector" @@ -28,12 +33,12 @@ export * from "./system-directive" export * from "./agent-tool-restrictions" export * from "./model-requirements" export * from "./model-resolver" -export { - resolveModelPipeline, - type ModelResolutionRequest, - type ModelResolutionResult as ModelResolutionPipelineResult, - type ModelResolutionProvenance, -} from "./model-resolution-pipeline" +export { resolveModelPipeline } from "./model-resolution-pipeline" +export type { + ModelResolutionRequest, + ModelResolutionProvenance, + ModelResolutionResult as ModelResolutionPipelineResult, +} from "./model-resolution-types" export * from "./model-availability" export * from "./connected-providers-cache" export * from "./session-utils" diff --git a/src/shared/model-resolution-pipeline.ts b/src/shared/model-resolution-pipeline.ts index 552746c8..1d27617e 100644 --- a/src/shared/model-resolution-pipeline.ts +++ b/src/shared/model-resolution-pipeline.ts @@ -1,36 +1,16 @@ import { log } from "./logger" import { readConnectedProvidersCache } from "./connected-providers-cache" import { fuzzyMatchModel } from "./model-availability" -import type { FallbackEntry } from "./model-requirements" +import type { + ModelResolutionRequest, + ModelResolutionResult, +} from "./model-resolution-types" -export type ModelResolutionRequest = { - intent?: { - uiSelectedModel?: string - userModel?: string - categoryDefaultModel?: string - } - constraints: { - availableModels: Set - } - policy?: { - fallbackChain?: FallbackEntry[] - systemDefaultModel?: string - } -} - -export type ModelResolutionProvenance = - | "override" - | "category-default" - | "provider-fallback" - | "system-default" - -export type ModelResolutionResult = { - model: string - provenance: ModelResolutionProvenance - variant?: string - attempted?: string[] - reason?: string -} +export type { + ModelResolutionProvenance, + ModelResolutionRequest, + ModelResolutionResult, +} from "./model-resolution-types" function normalizeModel(model?: string): string | undefined { const trimmed = model?.trim() diff --git a/src/shared/model-resolution-types.ts b/src/shared/model-resolution-types.ts new file mode 100644 index 00000000..6e77bb32 --- /dev/null +++ b/src/shared/model-resolution-types.ts @@ -0,0 +1,30 @@ +import type { FallbackEntry } from "./model-requirements" + +export type ModelResolutionRequest = { + intent?: { + uiSelectedModel?: string + userModel?: string + categoryDefaultModel?: string + } + constraints: { + availableModels: Set + } + policy?: { + fallbackChain?: FallbackEntry[] + systemDefaultModel?: string + } +} + +export type ModelResolutionProvenance = + | "override" + | "category-default" + | "provider-fallback" + | "system-default" + +export type ModelResolutionResult = { + model: string + provenance: ModelResolutionProvenance + variant?: string + attempted?: string[] + reason?: string +} diff --git a/src/shared/opencode-config-dir-types.ts b/src/shared/opencode-config-dir-types.ts new file mode 100644 index 00000000..bf2f222f --- /dev/null +++ b/src/shared/opencode-config-dir-types.ts @@ -0,0 +1,15 @@ +export type OpenCodeBinaryType = "opencode" | "opencode-desktop" + +export type OpenCodeConfigDirOptions = { + binary: OpenCodeBinaryType + version?: string | null + checkExisting?: boolean +} + +export type OpenCodeConfigPaths = { + configDir: string + configJson: string + configJsonc: string + packageJson: string + omoConfig: string +} diff --git a/src/shared/opencode-config-dir.ts b/src/shared/opencode-config-dir.ts index 6e469e46..9fe2c6b0 100644 --- a/src/shared/opencode-config-dir.ts +++ b/src/shared/opencode-config-dir.ts @@ -2,21 +2,17 @@ import { existsSync } from "node:fs" import { homedir } from "node:os" import { join, resolve } from "node:path" -export type OpenCodeBinaryType = "opencode" | "opencode-desktop" +import type { + OpenCodeBinaryType, + OpenCodeConfigDirOptions, + OpenCodeConfigPaths, +} from "./opencode-config-dir-types" -export interface OpenCodeConfigDirOptions { - binary: OpenCodeBinaryType - version?: string | null - checkExisting?: boolean -} - -export interface OpenCodeConfigPaths { - configDir: string - configJson: string - configJsonc: string - packageJson: string - omoConfig: string -} +export type { + OpenCodeBinaryType, + OpenCodeConfigDirOptions, + OpenCodeConfigPaths, +} from "./opencode-config-dir-types" export const TAURI_APP_IDENTIFIER = "ai.opencode.desktop" export const TAURI_APP_IDENTIFIER_DEV = "ai.opencode.desktop.dev" diff --git a/src/shared/tmux/tmux-utils.ts b/src/shared/tmux/tmux-utils.ts index 76abb737..9dd2d71b 100644 --- a/src/shared/tmux/tmux-utils.ts +++ b/src/shared/tmux/tmux-utils.ts @@ -1,7 +1,7 @@ import { spawn } from "bun" import type { TmuxConfig, TmuxLayout } from "../../config/schema" import type { SpawnPaneResult } from "./types" -import { getTmuxPath } from "../../tools/interactive-bash/utils" +import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver" let serverAvailable: boolean | null = null let serverCheckUrl: string | null = null diff --git a/src/tools/ast-grep/utils.ts b/src/tools/ast-grep/result-formatter.ts similarity index 100% rename from src/tools/ast-grep/utils.ts rename to src/tools/ast-grep/result-formatter.ts diff --git a/src/tools/ast-grep/tools.ts b/src/tools/ast-grep/tools.ts index 57c1d6a9..98b2d0c7 100644 --- a/src/tools/ast-grep/tools.ts +++ b/src/tools/ast-grep/tools.ts @@ -2,7 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { CLI_LANGUAGES } from "./constants" import { runSg } from "./cli" -import { formatSearchResult, formatReplaceResult } from "./utils" +import { formatSearchResult, formatReplaceResult } from "./result-formatter" import type { CliLanguage } from "./types" async function showOutputToUser(context: unknown, output: string): Promise { diff --git a/src/tools/delegate-task/background-continuation.ts b/src/tools/delegate-task/background-continuation.ts new file mode 100644 index 00000000..02ee25a1 --- /dev/null +++ b/src/tools/delegate-task/background-continuation.ts @@ -0,0 +1,61 @@ +import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types" +import type { ExecutorContext, ParentContext } from "./executor-types" +import { storeToolMetadata } from "../../features/tool-metadata-store" +import { formatDetailedError } from "./error-formatting" + +export async function executeBackgroundContinuation( + args: DelegateTaskArgs, + ctx: ToolContextWithMetadata, + executorCtx: ExecutorContext, + parentContext: ParentContext +): Promise { + const { manager } = executorCtx + + try { + const task = await manager.resume({ + sessionId: args.session_id!, + prompt: args.prompt, + parentSessionID: parentContext.sessionID, + parentMessageID: parentContext.messageID, + parentModel: parentContext.model, + parentAgent: parentContext.agent, + }) + + const bgContMeta = { + title: `Continue: ${task.description}`, + metadata: { + prompt: args.prompt, + agent: task.agent, + load_skills: args.load_skills, + description: args.description, + run_in_background: args.run_in_background, + sessionId: task.sessionID, + command: args.command, + }, + } + await ctx.metadata?.(bgContMeta) + if (ctx.callID) { + storeToolMetadata(ctx.sessionID, ctx.callID, bgContMeta) + } + + return `Background task continued. + +Task ID: ${task.id} +Description: ${task.description} +Agent: ${task.agent} +Status: ${task.status} + +Agent continues with full previous context preserved. +Use \`background_output\` with task_id="${task.id}" to check progress. + + +session_id: ${task.sessionID} +` + } catch (error) { + return formatDetailedError(error, { + operation: "Continue background task", + args, + sessionID: args.session_id, + }) + } +} diff --git a/src/tools/delegate-task/background-task.ts b/src/tools/delegate-task/background-task.ts new file mode 100644 index 00000000..35d9af9a --- /dev/null +++ b/src/tools/delegate-task/background-task.ts @@ -0,0 +1,87 @@ +import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types" +import type { ExecutorContext, ParentContext } from "./executor-types" +import { getTimingConfig } from "./timing" +import { storeToolMetadata } from "../../features/tool-metadata-store" +import { formatDetailedError } from "./error-formatting" + +export async function executeBackgroundTask( + args: DelegateTaskArgs, + ctx: ToolContextWithMetadata, + executorCtx: ExecutorContext, + parentContext: ParentContext, + agentToUse: string, + categoryModel: { providerID: string; modelID: string; variant?: string } | undefined, + systemContent: string | undefined +): Promise { + const { manager } = executorCtx + + try { + const task = await manager.launch({ + description: args.description, + prompt: args.prompt, + agent: agentToUse, + parentSessionID: parentContext.sessionID, + parentMessageID: parentContext.messageID, + parentModel: parentContext.model, + parentAgent: parentContext.agent, + model: categoryModel, + skills: args.load_skills.length > 0 ? args.load_skills : undefined, + skillContent: systemContent, + category: args.category, + }) + + // OpenCode TUI's `Task` tool UI calculates toolcalls by looking up + // `props.metadata.sessionId` and then counting tool parts in that session. + // BackgroundManager.launch() returns immediately (pending) before the session exists, + // so we must wait briefly for the session to be created to set metadata correctly. + const timing = getTimingConfig() + const waitStart = Date.now() + let sessionId = task.sessionID + while (!sessionId && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) { + if (ctx.abort?.aborted) { + return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}` + } + await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS)) + const updated = manager.getTask(task.id) + sessionId = updated?.sessionID + } + + const unstableMeta = { + title: args.description, + metadata: { + prompt: args.prompt, + agent: task.agent, + category: args.category, + load_skills: args.load_skills, + description: args.description, + run_in_background: args.run_in_background, + sessionId: sessionId ?? "pending", + command: args.command, + }, + } + await ctx.metadata?.(unstableMeta) + if (ctx.callID) { + storeToolMetadata(ctx.sessionID, ctx.callID, unstableMeta) + } + + return `Background task launched. + +Task ID: ${task.id} +Description: ${task.description} +Agent: ${task.agent}${args.category ? ` (category: ${args.category})` : ""} +Status: ${task.status} + +System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check. + + +session_id: ${sessionId} +` + } catch (error) { + return formatDetailedError(error, { + operation: "Launch background task", + args, + agent: agentToUse, + category: args.category, + }) + } +} diff --git a/src/tools/delegate-task/category-resolver.ts b/src/tools/delegate-task/category-resolver.ts new file mode 100644 index 00000000..3eba5c24 --- /dev/null +++ b/src/tools/delegate-task/category-resolver.ts @@ -0,0 +1,165 @@ +import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" +import type { DelegateTaskArgs } from "./types" +import type { ExecutorContext } from "./executor-types" +import { DEFAULT_CATEGORIES } from "./constants" +import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" +import { resolveCategoryConfig } from "./categories" +import { parseModelString } from "./model-string-parser" +import { fetchAvailableModels } from "../../shared/model-availability" +import { readConnectedProvidersCache } from "../../shared/connected-providers-cache" +import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" +import { resolveModelPipeline } from "../../shared" + +export interface CategoryResolutionResult { + agentToUse: string + categoryModel: { providerID: string; modelID: string; variant?: string } | undefined + categoryPromptAppend: string | undefined + modelInfo: ModelFallbackInfo | undefined + actualModel: string | undefined + isUnstableAgent: boolean + error?: string +} + +export async function resolveCategoryExecution( + args: DelegateTaskArgs, + executorCtx: ExecutorContext, + inheritedModel: string | undefined, + systemDefaultModel: string | undefined +): Promise { + const { client, userCategories, sisyphusJuniorModel } = executorCtx + + const connectedProviders = readConnectedProvidersCache() + const availableModels = await fetchAvailableModels(client, { + connectedProviders: connectedProviders ?? undefined, + }) + + const resolved = resolveCategoryConfig(args.category!, { + userCategories, + inheritedModel, + systemDefaultModel, + availableModels, + }) + + if (!resolved) { + return { + agentToUse: "", + categoryModel: undefined, + categoryPromptAppend: undefined, + modelInfo: undefined, + actualModel: undefined, + isUnstableAgent: false, + error: `Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`, + } + } + + const requirement = CATEGORY_MODEL_REQUIREMENTS[args.category!] + let actualModel: string | undefined + let modelInfo: ModelFallbackInfo | undefined + let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined + + const overrideModel = sisyphusJuniorModel + const explicitCategoryModel = userCategories?.[args.category!]?.model + + if (!requirement) { + // Precedence: explicit category model > sisyphus-junior default > category resolved model + // This keeps `sisyphus-junior.model` useful as a global default while allowing + // per-category overrides via `categories[category].model`. + actualModel = explicitCategoryModel ?? overrideModel ?? resolved.model + if (actualModel) { + modelInfo = explicitCategoryModel || overrideModel + ? { model: actualModel, type: "user-defined", source: "override" } + : { model: actualModel, type: "system-default", source: "system-default" } + } + } else { + const resolution = resolveModelPipeline({ + intent: { + userModel: explicitCategoryModel ?? overrideModel, + categoryDefaultModel: resolved.model, + }, + constraints: { availableModels }, + policy: { + fallbackChain: requirement.fallbackChain, + systemDefaultModel, + }, + }) + + if (resolution) { + const { model: resolvedModel, provenance, variant: resolvedVariant } = resolution + actualModel = resolvedModel + + if (!parseModelString(actualModel)) { + return { + agentToUse: "", + categoryModel: undefined, + categoryPromptAppend: undefined, + modelInfo: undefined, + actualModel: undefined, + isUnstableAgent: false, + error: `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`, + } + } + + let type: "user-defined" | "inherited" | "category-default" | "system-default" + const source = provenance + switch (provenance) { + case "override": + type = "user-defined" + break + case "category-default": + case "provider-fallback": + type = "category-default" + break + case "system-default": + type = "system-default" + break + } + + modelInfo = { model: actualModel, type, source } + + const parsedModel = parseModelString(actualModel) + const variantToUse = userCategories?.[args.category!]?.variant ?? resolvedVariant ?? resolved.config.variant + categoryModel = parsedModel + ? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel) + : undefined + } + } + + if (!categoryModel && actualModel) { + const parsedModel = parseModelString(actualModel) + categoryModel = parsedModel ?? undefined + } + const categoryPromptAppend = resolved.promptAppend || undefined + + if (!categoryModel && !actualModel) { + const categoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }) + return { + agentToUse: "", + categoryModel: undefined, + categoryPromptAppend: undefined, + modelInfo: undefined, + actualModel: undefined, + isUnstableAgent: false, + error: `Model not configured for category "${args.category}". + +Configure in one of: +1. OpenCode: Set "model" in opencode.json +2. Oh-My-OpenCode: Set category model in oh-my-opencode.json +3. Provider: Connect a provider with available models + +Current category: ${args.category} +Available categories: ${categoryNames.join(", ")}`, + } + } + + const unstableModel = actualModel?.toLowerCase() + const isUnstableAgent = resolved.config.is_unstable_agent === true || (unstableModel ? unstableModel.includes("gemini") || unstableModel.includes("minimax") : false) + + return { + agentToUse: SISYPHUS_JUNIOR_AGENT, + categoryModel, + categoryPromptAppend, + modelInfo, + actualModel, + isUnstableAgent, + } +} diff --git a/src/tools/delegate-task/error-formatting.ts b/src/tools/delegate-task/error-formatting.ts new file mode 100644 index 00000000..f2c24abc --- /dev/null +++ b/src/tools/delegate-task/error-formatting.ts @@ -0,0 +1,51 @@ +import type { DelegateTaskArgs } from "./types" + +/** + * Context for error formatting. + */ +export interface ErrorContext { + operation: string + args?: DelegateTaskArgs + sessionID?: string + agent?: string + category?: string +} + +/** + * Format an error with detailed context for debugging. + */ +export function formatDetailedError(error: unknown, ctx: ErrorContext): string { + const message = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : undefined + + const lines: string[] = [`${ctx.operation} failed`, "", `**Error**: ${message}`] + + if (ctx.sessionID) { + lines.push(`**Session ID**: ${ctx.sessionID}`) + } + + if (ctx.agent) { + lines.push(`**Agent**: ${ctx.agent}${ctx.category ? ` (category: ${ctx.category})` : ""}`) + } + + if (ctx.args) { + lines.push("", "**Arguments**:") + lines.push(`- description: "${ctx.args.description}"`) + lines.push(`- category: ${ctx.args.category ?? "(none)"}`) + lines.push(`- subagent_type: ${ctx.args.subagent_type ?? "(none)"}`) + lines.push(`- run_in_background: ${ctx.args.run_in_background}`) + lines.push(`- load_skills: [${ctx.args.load_skills?.join(", ") ?? ""}]`) + if (ctx.args.session_id) { + lines.push(`- session_id: ${ctx.args.session_id}`) + } + } + + if (stack) { + lines.push("", "**Stack Trace**:") + lines.push("```") + lines.push(stack.split("\n").slice(0, 10).join("\n")) + lines.push("```") + } + + return lines.join("\n") +} diff --git a/src/tools/delegate-task/executor-types.ts b/src/tools/delegate-task/executor-types.ts new file mode 100644 index 00000000..4a671613 --- /dev/null +++ b/src/tools/delegate-task/executor-types.ts @@ -0,0 +1,33 @@ +import type { BackgroundManager } from "../../features/background-agent" +import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider } from "../../config/schema" +import type { OpencodeClient } from "./types" + +export interface ExecutorContext { + manager: BackgroundManager + client: OpencodeClient + directory: string + userCategories?: CategoriesConfig + gitMasterConfig?: GitMasterConfig + sisyphusJuniorModel?: string + browserProvider?: BrowserAutomationProvider + onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise +} + +export interface ParentContext { + sessionID: string + messageID: string + agent?: string + model?: { providerID: string; modelID: string; variant?: string } +} + +export interface SessionMessage { + info?: { + role?: string + time?: { created?: number } + agent?: string + model?: { providerID: string; modelID: string } + modelID?: string + providerID?: string + } + parts?: Array<{ type?: string; text?: string }> +} diff --git a/src/tools/delegate-task/executor.ts b/src/tools/delegate-task/executor.ts index 2a3be2b2..ec63771c 100644 --- a/src/tools/delegate-task/executor.ts +++ b/src/tools/delegate-task/executor.ts @@ -1,1018 +1,16 @@ -import type { BackgroundManager } from "../../features/background-agent" -import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider } from "../../config/schema" -import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" -import type { DelegateTaskArgs, ToolContextWithMetadata, OpencodeClient } from "./types" -import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS, isPlanAgent } from "./constants" -import { getTimingConfig } from "./timing" -import { parseModelString, getMessageDir, formatDuration, formatDetailedError } from "./helpers" -import { resolveCategoryConfig } from "./categories" -import { buildSystemContent } from "./prompt-builder" -import { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../features/hook-message-injector" -import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content" -import { discoverSkills } from "../../features/opencode-skill-loader" -import { getTaskToastManager } from "../../features/task-toast-manager" -import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state" -import { log, getAgentToolRestrictions, resolveModelPipeline, promptWithModelSuggestionRetry } from "../../shared" -import { fetchAvailableModels, isModelAvailable } from "../../shared/model-availability" -import { readConnectedProvidersCache } from "../../shared/connected-providers-cache" -import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" -import { storeToolMetadata } from "../../features/tool-metadata-store" +export type { ExecutorContext, ParentContext } from "./executor-types" -const SISYPHUS_JUNIOR_AGENT = "sisyphus-junior" +export { resolveSkillContent } from "./skill-content-resolver" +export { resolveParentContext } from "./parent-context-resolver" -export interface ExecutorContext { - manager: BackgroundManager - client: OpencodeClient - directory: string - userCategories?: CategoriesConfig - gitMasterConfig?: GitMasterConfig - sisyphusJuniorModel?: string - browserProvider?: BrowserAutomationProvider - onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise -} +export { executeBackgroundContinuation } from "./background-continuation" +export { executeSyncContinuation } from "./sync-continuation" -export interface ParentContext { - sessionID: string - messageID: string - agent?: string - model?: { providerID: string; modelID: string; variant?: string } -} +export { executeUnstableAgentTask } from "./unstable-agent-task" +export { executeBackgroundTask } from "./background-task" +export { executeSyncTask } from "./sync-task" -interface SessionMessage { - info?: { role?: string; time?: { created?: number }; agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - parts?: Array<{ type?: string; text?: string }> -} +export { resolveCategoryExecution } from "./category-resolver" +export type { CategoryResolutionResult } from "./category-resolver" -export async function resolveSkillContent( - skills: string[], - options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set } -): Promise<{ content: string | undefined; error: string | null }> { - if (skills.length === 0) { - return { content: undefined, error: null } - } - - const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options) - if (notFound.length > 0) { - const allSkills = await discoverSkills({ includeClaudeCodePaths: true }) - const available = allSkills.map(s => s.name).join(", ") - return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` } - } - - return { content: Array.from(resolved.values()).join("\n\n"), error: null } -} - -export function resolveParentContext(ctx: ToolContextWithMetadata): ParentContext { - const messageDir = getMessageDir(ctx.sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null - const sessionAgent = getSessionAgent(ctx.sessionID) - const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent - - log("[task] parentAgent resolution", { - sessionID: ctx.sessionID, - messageDir, - ctxAgent: ctx.agent, - sessionAgent, - firstMessageAgent, - prevMessageAgent: prevMessage?.agent, - resolvedParentAgent: parentAgent, - }) - - const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID - ? { - providerID: prevMessage.model.providerID, - modelID: prevMessage.model.modelID, - ...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}), - } - : undefined - - return { - sessionID: ctx.sessionID, - messageID: ctx.messageID, - agent: parentAgent, - model: parentModel, - } -} - -export async function executeBackgroundContinuation( - args: DelegateTaskArgs, - ctx: ToolContextWithMetadata, - executorCtx: ExecutorContext, - parentContext: ParentContext -): Promise { - const { manager } = executorCtx - - try { - const task = await manager.resume({ - sessionId: args.session_id!, - prompt: args.prompt, - parentSessionID: parentContext.sessionID, - parentMessageID: parentContext.messageID, - parentModel: parentContext.model, - parentAgent: parentContext.agent, - }) - - const bgContMeta = { - title: `Continue: ${task.description}`, - metadata: { - prompt: args.prompt, - agent: task.agent, - load_skills: args.load_skills, - description: args.description, - run_in_background: args.run_in_background, - sessionId: task.sessionID, - command: args.command, - }, - } - await ctx.metadata?.(bgContMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, bgContMeta) - } - - return `Background task continued. - -Task ID: ${task.id} -Description: ${task.description} -Agent: ${task.agent} -Status: ${task.status} - -Agent continues with full previous context preserved. -Use \`background_output\` with task_id="${task.id}" to check progress. - - -session_id: ${task.sessionID} -` - } catch (error) { - return formatDetailedError(error, { - operation: "Continue background task", - args, - sessionID: args.session_id, - }) - } -} - -export async function executeSyncContinuation( - args: DelegateTaskArgs, - ctx: ToolContextWithMetadata, - executorCtx: ExecutorContext -): Promise { - const { client } = executorCtx - const toastManager = getTaskToastManager() - const taskId = `resume_sync_${args.session_id!.slice(0, 8)}` - const startTime = new Date() - - if (toastManager) { - toastManager.addTask({ - id: taskId, - description: args.description, - agent: "continue", - isBackground: false, - }) - } - - const syncContMeta = { - title: `Continue: ${args.description}`, - metadata: { - prompt: args.prompt, - load_skills: args.load_skills, - description: args.description, - run_in_background: args.run_in_background, - sessionId: args.session_id, - sync: true, - command: args.command, - }, - } - await ctx.metadata?.(syncContMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, syncContMeta) - } - - try { - let resumeAgent: string | undefined - let resumeModel: { providerID: string; modelID: string } | undefined - - try { - const messagesResp = await client.session.messages({ path: { id: args.session_id! } }) - const messages = (messagesResp.data ?? []) as SessionMessage[] - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - resumeAgent = info.agent - resumeModel = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) - break - } - } - } catch { - const resumeMessageDir = getMessageDir(args.session_id!) - const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null - resumeAgent = resumeMessage?.agent - resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID - ? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID } - : undefined - } - - await (client.session as any).promptAsync({ - path: { id: args.session_id! }, - body: { - ...(resumeAgent !== undefined ? { agent: resumeAgent } : {}), - ...(resumeModel !== undefined ? { model: resumeModel } : {}), - tools: { - ...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}), - task: false, - call_omo_agent: true, - question: false, - }, - parts: [{ type: "text", text: args.prompt }], - }, - }) - } catch (promptError) { - if (toastManager) { - toastManager.removeTask(taskId) - } - const errorMessage = promptError instanceof Error ? promptError.message : String(promptError) - return `Failed to send continuation prompt: ${errorMessage}\n\nSession ID: ${args.session_id}` - } - - const timing = getTimingConfig() - const pollStart = Date.now() - let lastMsgCount = 0 - let stablePolls = 0 - - while (Date.now() - pollStart < 60000) { - await new Promise(resolve => setTimeout(resolve, timing.POLL_INTERVAL_MS)) - - const elapsed = Date.now() - pollStart - if (elapsed < timing.SESSION_CONTINUATION_STABILITY_MS) continue - - const messagesCheck = await client.session.messages({ path: { id: args.session_id! } }) - const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array - const currentMsgCount = msgs.length - - if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) { - stablePolls++ - if (stablePolls >= timing.STABILITY_POLLS_REQUIRED) break - } else { - stablePolls = 0 - lastMsgCount = currentMsgCount - } - } - - const messagesResult = await client.session.messages({ - path: { id: args.session_id! }, - }) - - if (messagesResult.error) { - if (toastManager) { - toastManager.removeTask(taskId) - } - return `Error fetching result: ${messagesResult.error}\n\nSession ID: ${args.session_id}` - } - - const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[] - const assistantMessages = messages - .filter((m) => m.info?.role === "assistant") - .sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0)) - const lastMessage = assistantMessages[0] - - if (toastManager) { - toastManager.removeTask(taskId) - } - - if (!lastMessage) { - return `No assistant response found.\n\nSession ID: ${args.session_id}` - } - - const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] - const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n") - const duration = formatDuration(startTime) - - return `Task continued and completed in ${duration}. - ---- - -${textContent || "(No text output)"} - - -session_id: ${args.session_id} -` -} - -export async function executeUnstableAgentTask( - args: DelegateTaskArgs, - ctx: ToolContextWithMetadata, - executorCtx: ExecutorContext, - parentContext: ParentContext, - agentToUse: string, - categoryModel: { providerID: string; modelID: string; variant?: string } | undefined, - systemContent: string | undefined, - actualModel: string | undefined -): Promise { - const { manager, client } = executorCtx - - try { - const task = await manager.launch({ - description: args.description, - prompt: args.prompt, - agent: agentToUse, - parentSessionID: parentContext.sessionID, - parentMessageID: parentContext.messageID, - parentModel: parentContext.model, - parentAgent: parentContext.agent, - model: categoryModel, - skills: args.load_skills.length > 0 ? args.load_skills : undefined, - skillContent: systemContent, - category: args.category, - }) - - const timing = getTimingConfig() - const waitStart = Date.now() - let sessionID = task.sessionID - while (!sessionID && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) { - if (ctx.abort?.aborted) { - return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}` - } - await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS)) - const updated = manager.getTask(task.id) - sessionID = updated?.sessionID - } - if (!sessionID) { - return formatDetailedError(new Error(`Task failed to start within timeout (30s). Task ID: ${task.id}, Status: ${task.status}`), { - operation: "Launch monitored background task", - args, - agent: agentToUse, - category: args.category, - }) - } - - const bgTaskMeta = { - title: args.description, - metadata: { - prompt: args.prompt, - agent: agentToUse, - category: args.category, - load_skills: args.load_skills, - description: args.description, - run_in_background: args.run_in_background, - sessionId: sessionID, - command: args.command, - }, - } - await ctx.metadata?.(bgTaskMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, bgTaskMeta) - } - - const startTime = new Date() - const timingCfg = getTimingConfig() - const pollStart = Date.now() - let lastMsgCount = 0 - let stablePolls = 0 - - while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) { - if (ctx.abort?.aborted) { - return `Task aborted (was running in background mode).\n\nSession ID: ${sessionID}` - } - - await new Promise(resolve => setTimeout(resolve, timingCfg.POLL_INTERVAL_MS)) - - const statusResult = await client.session.status() - const allStatuses = (statusResult.data ?? {}) as Record - const sessionStatus = allStatuses[sessionID] - - if (sessionStatus && sessionStatus.type !== "idle") { - stablePolls = 0 - lastMsgCount = 0 - continue - } - - if (Date.now() - pollStart < timingCfg.MIN_STABILITY_TIME_MS) continue - - const messagesCheck = await client.session.messages({ path: { id: sessionID } }) - const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array - const currentMsgCount = msgs.length - - if (currentMsgCount === lastMsgCount) { - stablePolls++ - if (stablePolls >= timingCfg.STABILITY_POLLS_REQUIRED) break - } else { - stablePolls = 0 - lastMsgCount = currentMsgCount - } - } - - const messagesResult = await client.session.messages({ path: { id: sessionID } }) - const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[] - - const assistantMessages = messages - .filter((m) => m.info?.role === "assistant") - .sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0)) - const lastMessage = assistantMessages[0] - - if (!lastMessage) { - return `No assistant response found (task ran in background mode).\n\nSession ID: ${sessionID}` - } - - const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] - const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n") - const duration = formatDuration(startTime) - - return `SUPERVISED TASK COMPLETED SUCCESSFULLY - -IMPORTANT: This model (${actualModel}) is marked as unstable/experimental. -Your run_in_background=false was automatically converted to background mode for reliability monitoring. - -Duration: ${duration} -Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""} - -MONITORING INSTRUCTIONS: -- The task was monitored and completed successfully -- If you observe this agent behaving erratically in future calls, actively monitor its progress -- Use background_cancel(task_id="...") to abort if the agent seems stuck or producing garbage output -- Do NOT retry automatically if you see this message - the task already succeeded - ---- - -RESULT: - -${textContent || "(No text output)"} - - -session_id: ${sessionID} -` - } catch (error) { - return formatDetailedError(error, { - operation: "Launch monitored background task", - args, - agent: agentToUse, - category: args.category, - }) - } -} - -export async function executeBackgroundTask( - args: DelegateTaskArgs, - ctx: ToolContextWithMetadata, - executorCtx: ExecutorContext, - parentContext: ParentContext, - agentToUse: string, - categoryModel: { providerID: string; modelID: string; variant?: string } | undefined, - systemContent: string | undefined -): Promise { - const { manager } = executorCtx - - try { - const task = await manager.launch({ - description: args.description, - prompt: args.prompt, - agent: agentToUse, - parentSessionID: parentContext.sessionID, - parentMessageID: parentContext.messageID, - parentModel: parentContext.model, - parentAgent: parentContext.agent, - model: categoryModel, - skills: args.load_skills.length > 0 ? args.load_skills : undefined, - skillContent: systemContent, - category: args.category, - }) - - // OpenCode TUI's `Task` tool UI calculates toolcalls by looking up - // `props.metadata.sessionId` and then counting tool parts in that session. - // BackgroundManager.launch() returns immediately (pending) before the session exists, - // so we must wait briefly for the session to be created to set metadata correctly. - const timing = getTimingConfig() - const waitStart = Date.now() - let sessionId = task.sessionID - while (!sessionId && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) { - if (ctx.abort?.aborted) { - return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}` - } - await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS)) - const updated = manager.getTask(task.id) - sessionId = updated?.sessionID - } - - const unstableMeta = { - title: args.description, - metadata: { - prompt: args.prompt, - agent: task.agent, - category: args.category, - load_skills: args.load_skills, - description: args.description, - run_in_background: args.run_in_background, - sessionId: sessionId ?? "pending", - command: args.command, - }, - } - await ctx.metadata?.(unstableMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, unstableMeta) - } - - return `Background task launched. - -Task ID: ${task.id} -Description: ${task.description} -Agent: ${task.agent}${args.category ? ` (category: ${args.category})` : ""} -Status: ${task.status} - -System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check. - - -session_id: ${sessionId} -` - } catch (error) { - return formatDetailedError(error, { - operation: "Launch background task", - args, - agent: agentToUse, - category: args.category, - }) - } -} - -export async function executeSyncTask( - args: DelegateTaskArgs, - ctx: ToolContextWithMetadata, - executorCtx: ExecutorContext, - parentContext: ParentContext, - agentToUse: string, - categoryModel: { providerID: string; modelID: string; variant?: string } | undefined, - systemContent: string | undefined, - modelInfo?: ModelFallbackInfo -): Promise { - const { client, directory, onSyncSessionCreated } = executorCtx - const toastManager = getTaskToastManager() - let taskId: string | undefined - let syncSessionID: string | undefined - - try { - const parentSession = client.session.get - ? await client.session.get({ path: { id: parentContext.sessionID } }).catch(() => null) - : null - const parentDirectory = parentSession?.data?.directory ?? directory - - const createResult = await client.session.create({ - body: { - parentID: parentContext.sessionID, - title: `${args.description} (@${agentToUse} subagent)`, - permission: [ - { permission: "question", action: "deny" as const, pattern: "*" }, - ], - } as any, - query: { - directory: parentDirectory, - }, - }) - - if (createResult.error) { - return `Failed to create session: ${createResult.error}` - } - - const sessionID = createResult.data.id - syncSessionID = sessionID - subagentSessions.add(sessionID) - - if (onSyncSessionCreated) { - log("[task] Invoking onSyncSessionCreated callback", { sessionID, parentID: parentContext.sessionID }) - await onSyncSessionCreated({ - sessionID, - parentID: parentContext.sessionID, - title: args.description, - }).catch((err) => { - log("[task] onSyncSessionCreated callback failed", { error: String(err) }) - }) - await new Promise(r => setTimeout(r, 200)) - } - - taskId = `sync_${sessionID.slice(0, 8)}` - const startTime = new Date() - - if (toastManager) { - toastManager.addTask({ - id: taskId, - description: args.description, - agent: agentToUse, - isBackground: false, - category: args.category, - skills: args.load_skills, - modelInfo, - }) - } - - const syncTaskMeta = { - title: args.description, - metadata: { - prompt: args.prompt, - agent: agentToUse, - category: args.category, - load_skills: args.load_skills, - description: args.description, - run_in_background: args.run_in_background, - sessionId: sessionID, - sync: true, - command: args.command, - }, - } - await ctx.metadata?.(syncTaskMeta) - if (ctx.callID) { - storeToolMetadata(ctx.sessionID, ctx.callID, syncTaskMeta) - } - - try { - const allowTask = isPlanAgent(agentToUse) - await promptWithModelSuggestionRetry(client, { - path: { id: sessionID }, - body: { - agent: agentToUse, - system: systemContent, - tools: { - task: allowTask, - call_omo_agent: true, - question: false, - }, - parts: [{ type: "text", text: args.prompt }], - ...(categoryModel ? { model: { providerID: categoryModel.providerID, modelID: categoryModel.modelID } } : {}), - ...(categoryModel?.variant ? { variant: categoryModel.variant } : {}), - }, - }) - } catch (promptError) { - if (toastManager && taskId !== undefined) { - toastManager.removeTask(taskId) - } - const errorMessage = promptError instanceof Error ? promptError.message : String(promptError) - if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { - return formatDetailedError(new Error(`Agent "${agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`), { - operation: "Send prompt to agent", - args, - sessionID, - agent: agentToUse, - category: args.category, - }) - } - return formatDetailedError(promptError, { - operation: "Send prompt", - args, - sessionID, - agent: agentToUse, - category: args.category, - }) - } - - const syncTiming = getTimingConfig() - const pollStart = Date.now() - let lastMsgCount = 0 - let stablePolls = 0 - let pollCount = 0 - - log("[task] Starting poll loop", { sessionID, agentToUse }) - - while (Date.now() - pollStart < syncTiming.MAX_POLL_TIME_MS) { - if (ctx.abort?.aborted) { - log("[task] Aborted by user", { sessionID }) - if (toastManager && taskId) toastManager.removeTask(taskId) - return `Task aborted.\n\nSession ID: ${sessionID}` - } - - await new Promise(resolve => setTimeout(resolve, syncTiming.POLL_INTERVAL_MS)) - pollCount++ - - const statusResult = await client.session.status() - const allStatuses = (statusResult.data ?? {}) as Record - const sessionStatus = allStatuses[sessionID] - - if (pollCount % 10 === 0) { - log("[task] Poll status", { - sessionID, - pollCount, - elapsed: Math.floor((Date.now() - pollStart) / 1000) + "s", - sessionStatus: sessionStatus?.type ?? "not_in_status", - stablePolls, - lastMsgCount, - }) - } - - if (sessionStatus && sessionStatus.type !== "idle") { - stablePolls = 0 - lastMsgCount = 0 - continue - } - - const elapsed = Date.now() - pollStart - if (elapsed < syncTiming.MIN_STABILITY_TIME_MS) { - continue - } - - const messagesCheck = await client.session.messages({ path: { id: sessionID } }) - const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array - const currentMsgCount = msgs.length - - if (currentMsgCount === lastMsgCount) { - stablePolls++ - if (stablePolls >= syncTiming.STABILITY_POLLS_REQUIRED) { - log("[task] Poll complete - messages stable", { sessionID, pollCount, currentMsgCount }) - break - } - } else { - stablePolls = 0 - lastMsgCount = currentMsgCount - } - } - - if (Date.now() - pollStart >= syncTiming.MAX_POLL_TIME_MS) { - log("[task] Poll timeout reached", { sessionID, pollCount, lastMsgCount, stablePolls }) - } - - const messagesResult = await client.session.messages({ - path: { id: sessionID }, - }) - - if (messagesResult.error) { - return `Error fetching result: ${messagesResult.error}\n\nSession ID: ${sessionID}` - } - - const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[] - - const assistantMessages = messages - .filter((m) => m.info?.role === "assistant") - .sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0)) - const lastMessage = assistantMessages[0] - - if (!lastMessage) { - return `No assistant response found.\n\nSession ID: ${sessionID}` - } - - const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] - const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n") - - const duration = formatDuration(startTime) - - if (toastManager) { - toastManager.removeTask(taskId) - } - - subagentSessions.delete(sessionID) - - return `Task completed in ${duration}. - -Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""} - ---- - -${textContent || "(No text output)"} - - -session_id: ${sessionID} -` - } catch (error) { - if (toastManager && taskId !== undefined) { - toastManager.removeTask(taskId) - } - if (syncSessionID) { - subagentSessions.delete(syncSessionID) - } - return formatDetailedError(error, { - operation: "Execute task", - args, - sessionID: syncSessionID, - agent: agentToUse, - category: args.category, - }) - } -} - -export interface CategoryResolutionResult { - agentToUse: string - categoryModel: { providerID: string; modelID: string; variant?: string } | undefined - categoryPromptAppend: string | undefined - modelInfo: ModelFallbackInfo | undefined - actualModel: string | undefined - isUnstableAgent: boolean - error?: string -} - -export async function resolveCategoryExecution( - args: DelegateTaskArgs, - executorCtx: ExecutorContext, - inheritedModel: string | undefined, - systemDefaultModel: string | undefined -): Promise { - const { client, userCategories, sisyphusJuniorModel } = executorCtx - - const connectedProviders = readConnectedProvidersCache() - const availableModels = await fetchAvailableModels(client, { - connectedProviders: connectedProviders ?? undefined, - }) - - const resolved = resolveCategoryConfig(args.category!, { - userCategories, - inheritedModel, - systemDefaultModel, - availableModels, - }) - - if (!resolved) { - return { - agentToUse: "", - categoryModel: undefined, - categoryPromptAppend: undefined, - modelInfo: undefined, - actualModel: undefined, - isUnstableAgent: false, - error: `Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`, - } - } - - const requirement = CATEGORY_MODEL_REQUIREMENTS[args.category!] - let actualModel: string | undefined - let modelInfo: ModelFallbackInfo | undefined - let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined - - const overrideModel = sisyphusJuniorModel - const explicitCategoryModel = userCategories?.[args.category!]?.model - - if (!requirement) { - // Precedence: explicit category model > sisyphus-junior default > category resolved model - // This keeps `sisyphus-junior.model` useful as a global default while allowing - // per-category overrides via `categories[category].model`. - actualModel = explicitCategoryModel ?? overrideModel ?? resolved.model - if (actualModel) { - modelInfo = explicitCategoryModel || overrideModel - ? { model: actualModel, type: "user-defined", source: "override" } - : { model: actualModel, type: "system-default", source: "system-default" } - } - } else { - const resolution = resolveModelPipeline({ - intent: { - userModel: explicitCategoryModel ?? overrideModel, - categoryDefaultModel: resolved.model, - }, - constraints: { availableModels }, - policy: { - fallbackChain: requirement.fallbackChain, - systemDefaultModel, - }, - }) - - if (resolution) { - const { model: resolvedModel, provenance, variant: resolvedVariant } = resolution - actualModel = resolvedModel - - if (!parseModelString(actualModel)) { - return { - agentToUse: "", - categoryModel: undefined, - categoryPromptAppend: undefined, - modelInfo: undefined, - actualModel: undefined, - isUnstableAgent: false, - error: `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`, - } - } - - let type: "user-defined" | "inherited" | "category-default" | "system-default" - const source = provenance - switch (provenance) { - case "override": - type = "user-defined" - break - case "category-default": - case "provider-fallback": - type = "category-default" - break - case "system-default": - type = "system-default" - break - } - - modelInfo = { model: actualModel, type, source } - - const parsedModel = parseModelString(actualModel) - const variantToUse = userCategories?.[args.category!]?.variant ?? resolvedVariant ?? resolved.config.variant - categoryModel = parsedModel - ? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel) - : undefined - } - } - - if (!categoryModel && actualModel) { - const parsedModel = parseModelString(actualModel) - categoryModel = parsedModel ?? undefined - } - const categoryPromptAppend = resolved.promptAppend || undefined - - if (!categoryModel && !actualModel) { - const categoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }) - return { - agentToUse: "", - categoryModel: undefined, - categoryPromptAppend: undefined, - modelInfo: undefined, - actualModel: undefined, - isUnstableAgent: false, - error: `Model not configured for category "${args.category}". - -Configure in one of: -1. OpenCode: Set "model" in opencode.json -2. Oh-My-OpenCode: Set category model in oh-my-opencode.json -3. Provider: Connect a provider with available models - -Current category: ${args.category} -Available categories: ${categoryNames.join(", ")}`, - } - } - - const unstableModel = actualModel?.toLowerCase() - const isUnstableAgent = resolved.config.is_unstable_agent === true || (unstableModel ? unstableModel.includes("gemini") || unstableModel.includes("minimax") : false) - - return { - agentToUse: SISYPHUS_JUNIOR_AGENT, - categoryModel, - categoryPromptAppend, - modelInfo, - actualModel, - isUnstableAgent, - } -} - -export async function resolveSubagentExecution( - args: DelegateTaskArgs, - executorCtx: ExecutorContext, - parentAgent: string | undefined, - categoryExamples: string -): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string } | undefined; error?: string }> { - const { client } = executorCtx - - if (!args.subagent_type?.trim()) { - return { agentToUse: "", categoryModel: undefined, error: `Agent name cannot be empty.` } - } - - const agentName = args.subagent_type.trim() - - if (agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()) { - return { - agentToUse: "", - categoryModel: undefined, - error: `Cannot use subagent_type="${SISYPHUS_JUNIOR_AGENT}" directly. Use category parameter instead (e.g., ${categoryExamples}). - -Sisyphus-Junior is spawned automatically when you specify a category. Pick the appropriate category for your task domain.`, - } - } - - if (isPlanAgent(agentName) && isPlanAgent(parentAgent)) { - return { - agentToUse: "", - categoryModel: undefined, - error: `You are prometheus. You cannot delegate to prometheus via task. - -Create the work plan directly - that's your job as the planning agent.`, - } - } - - let agentToUse = agentName - let categoryModel: { providerID: string; modelID: string } | undefined - - try { - const agentsResult = await client.app.agents() - type AgentInfo = { name: string; mode?: "subagent" | "primary" | "all"; model?: { providerID: string; modelID: string } } - const agents = (agentsResult as { data?: AgentInfo[] }).data ?? agentsResult as unknown as AgentInfo[] - - const callableAgents = agents.filter((a) => a.mode !== "primary") - - const matchedAgent = callableAgents.find( - (agent) => agent.name.toLowerCase() === agentToUse.toLowerCase() - ) - if (!matchedAgent) { - const isPrimaryAgent = agents - .filter((a) => a.mode === "primary") - .find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()) - if (isPrimaryAgent) { - return { - agentToUse: "", - categoryModel: undefined, - error: `Cannot call primary agent "${isPrimaryAgent.name}" via task. Primary agents are top-level orchestrators.`, - } - } - - const availableAgents = callableAgents - .map((a) => a.name) - .sort() - .join(", ") - return { - agentToUse: "", - categoryModel: undefined, - error: `Unknown agent: "${agentToUse}". Available agents: ${availableAgents}`, - } - } - - agentToUse = matchedAgent.name - - if (matchedAgent.model) { - categoryModel = matchedAgent.model - } - } catch { - // Proceed anyway - session.prompt will fail with clearer error if agent doesn't exist - } - - return { agentToUse, categoryModel } -} +export { resolveSubagentExecution } from "./subagent-resolver" diff --git a/src/tools/delegate-task/helpers.ts b/src/tools/delegate-task/helpers.ts deleted file mode 100644 index 05a26e87..00000000 --- a/src/tools/delegate-task/helpers.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { MESSAGE_STORAGE } from "../../features/hook-message-injector" -import type { DelegateTaskArgs } from "./types" - -/** - * Parse a model string in "provider/model" format. - */ -export function parseModelString(model: string): { providerID: string; modelID: string } | undefined { - const parts = model.split("/") - if (parts.length >= 2) { - return { providerID: parts[0], modelID: parts.slice(1).join("/") } - } - return undefined -} - -/** - * Get the message directory for a session, checking both direct and nested paths. - */ -export function getMessageDir(sessionID: string): string | null { - if (!sessionID.startsWith("ses_")) return null - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} - -/** - * Format a duration between two dates as a human-readable string. - */ -export function formatDuration(start: Date, end?: Date): string { - const duration = (end ?? new Date()).getTime() - start.getTime() - const seconds = Math.floor(duration / 1000) - const minutes = Math.floor(seconds / 60) - const hours = Math.floor(minutes / 60) - - if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s` - if (minutes > 0) return `${minutes}m ${seconds % 60}s` - return `${seconds}s` -} - -/** - * Context for error formatting. - */ -export interface ErrorContext { - operation: string - args?: DelegateTaskArgs - sessionID?: string - agent?: string - category?: string -} - -/** - * Format an error with detailed context for debugging. - */ -export function formatDetailedError(error: unknown, ctx: ErrorContext): string { - const message = error instanceof Error ? error.message : String(error) - const stack = error instanceof Error ? error.stack : undefined - - const lines: string[] = [ - `${ctx.operation} failed`, - "", - `**Error**: ${message}`, - ] - - if (ctx.sessionID) { - lines.push(`**Session ID**: ${ctx.sessionID}`) - } - - if (ctx.agent) { - lines.push(`**Agent**: ${ctx.agent}${ctx.category ? ` (category: ${ctx.category})` : ""}`) - } - - if (ctx.args) { - lines.push("", "**Arguments**:") - lines.push(`- description: "${ctx.args.description}"`) - lines.push(`- category: ${ctx.args.category ?? "(none)"}`) - lines.push(`- subagent_type: ${ctx.args.subagent_type ?? "(none)"}`) - lines.push(`- run_in_background: ${ctx.args.run_in_background}`) - lines.push(`- load_skills: [${ctx.args.load_skills?.join(", ") ?? ""}]`) - if (ctx.args.session_id) { - lines.push(`- session_id: ${ctx.args.session_id}`) - } - } - - if (stack) { - lines.push("", "**Stack Trace**:") - lines.push("```") - lines.push(stack.split("\n").slice(0, 10).join("\n")) - lines.push("```") - } - - return lines.join("\n") -} diff --git a/src/tools/delegate-task/model-string-parser.ts b/src/tools/delegate-task/model-string-parser.ts new file mode 100644 index 00000000..97d4f331 --- /dev/null +++ b/src/tools/delegate-task/model-string-parser.ts @@ -0,0 +1,10 @@ +/** + * Parse a model string in "provider/model" format. + */ +export function parseModelString(model: string): { providerID: string; modelID: string } | undefined { + const parts = model.split("/") + if (parts.length >= 2) { + return { providerID: parts[0], modelID: parts.slice(1).join("/") } + } + return undefined +} diff --git a/src/tools/delegate-task/parent-context-resolver.ts b/src/tools/delegate-task/parent-context-resolver.ts new file mode 100644 index 00000000..664cb8d9 --- /dev/null +++ b/src/tools/delegate-task/parent-context-resolver.ts @@ -0,0 +1,38 @@ +import type { ToolContextWithMetadata } from "./types" +import type { ParentContext } from "./executor-types" +import { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../features/hook-message-injector" +import { getSessionAgent } from "../../features/claude-code-session-state" +import { log, getMessageDir } from "../../shared" + +export function resolveParentContext(ctx: ToolContextWithMetadata): ParentContext { + const messageDir = getMessageDir(ctx.sessionID) + const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null + const sessionAgent = getSessionAgent(ctx.sessionID) + const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent + + log("[task] parentAgent resolution", { + sessionID: ctx.sessionID, + messageDir, + ctxAgent: ctx.agent, + sessionAgent, + firstMessageAgent, + prevMessageAgent: prevMessage?.agent, + resolvedParentAgent: parentAgent, + }) + + const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID + ? { + providerID: prevMessage.model.providerID, + modelID: prevMessage.model.modelID, + ...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}), + } + : undefined + + return { + sessionID: ctx.sessionID, + messageID: ctx.messageID, + agent: parentAgent, + model: parentModel, + } +} diff --git a/src/tools/delegate-task/sisyphus-junior-agent.ts b/src/tools/delegate-task/sisyphus-junior-agent.ts new file mode 100644 index 00000000..1839932e --- /dev/null +++ b/src/tools/delegate-task/sisyphus-junior-agent.ts @@ -0,0 +1 @@ +export const SISYPHUS_JUNIOR_AGENT = "sisyphus-junior" diff --git a/src/tools/delegate-task/skill-content-resolver.ts b/src/tools/delegate-task/skill-content-resolver.ts new file mode 100644 index 00000000..db31c898 --- /dev/null +++ b/src/tools/delegate-task/skill-content-resolver.ts @@ -0,0 +1,21 @@ +import type { GitMasterConfig, BrowserAutomationProvider } from "../../config/schema" +import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content" +import { discoverSkills } from "../../features/opencode-skill-loader" + +export async function resolveSkillContent( + skills: string[], + options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set } +): Promise<{ content: string | undefined; error: string | null }> { + if (skills.length === 0) { + return { content: undefined, error: null } + } + + const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options) + if (notFound.length > 0) { + const allSkills = await discoverSkills({ includeClaudeCodePaths: true }) + const available = allSkills.map(s => s.name).join(", ") + return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` } + } + + return { content: Array.from(resolved.values()).join("\n\n"), error: null } +} diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts new file mode 100644 index 00000000..ebfaa448 --- /dev/null +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -0,0 +1,87 @@ +import type { DelegateTaskArgs } from "./types" +import type { ExecutorContext } from "./executor-types" +import { isPlanAgent } from "./constants" +import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" + +export async function resolveSubagentExecution( + args: DelegateTaskArgs, + executorCtx: ExecutorContext, + parentAgent: string | undefined, + categoryExamples: string +): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string } | undefined; error?: string }> { + const { client } = executorCtx + + if (!args.subagent_type?.trim()) { + return { agentToUse: "", categoryModel: undefined, error: `Agent name cannot be empty.` } + } + + const agentName = args.subagent_type.trim() + + if (agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()) { + return { + agentToUse: "", + categoryModel: undefined, + error: `Cannot use subagent_type="${SISYPHUS_JUNIOR_AGENT}" directly. Use category parameter instead (e.g., ${categoryExamples}). + +Sisyphus-Junior is spawned automatically when you specify a category. Pick the appropriate category for your task domain.`, + } + } + + if (isPlanAgent(agentName) && isPlanAgent(parentAgent)) { + return { + agentToUse: "", + categoryModel: undefined, + error: `You are prometheus. You cannot delegate to prometheus via task. + +Create the work plan directly - that's your job as the planning agent.`, + } + } + + let agentToUse = agentName + let categoryModel: { providerID: string; modelID: string } | undefined + + try { + const agentsResult = await client.app.agents() + type AgentInfo = { name: string; mode?: "subagent" | "primary" | "all"; model?: { providerID: string; modelID: string } } + const agents = (agentsResult as { data?: AgentInfo[] }).data ?? agentsResult as unknown as AgentInfo[] + + const callableAgents = agents.filter((a) => a.mode !== "primary") + + const matchedAgent = callableAgents.find( + (agent) => agent.name.toLowerCase() === agentToUse.toLowerCase() + ) + if (!matchedAgent) { + const isPrimaryAgent = agents + .filter((a) => a.mode === "primary") + .find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()) + + if (isPrimaryAgent) { + return { + agentToUse: "", + categoryModel: undefined, + error: `Cannot call primary agent "${isPrimaryAgent.name}" via task. Primary agents are top-level orchestrators.`, + } + } + + const availableAgents = callableAgents + .map((a) => a.name) + .sort() + .join(", ") + return { + agentToUse: "", + categoryModel: undefined, + error: `Unknown agent: "${agentToUse}". Available agents: ${availableAgents}`, + } + } + + agentToUse = matchedAgent.name + + if (matchedAgent.model) { + categoryModel = matchedAgent.model + } + } catch { + // Proceed anyway - session.prompt will fail with clearer error if agent doesn't exist + } + + return { agentToUse, categoryModel } +} diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts new file mode 100644 index 00000000..8b772bc7 --- /dev/null +++ b/src/tools/delegate-task/sync-continuation.ts @@ -0,0 +1,154 @@ +import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types" +import type { ExecutorContext, SessionMessage } from "./executor-types" +import { getTimingConfig } from "./timing" +import { storeToolMetadata } from "../../features/tool-metadata-store" +import { getTaskToastManager } from "../../features/task-toast-manager" +import { getAgentToolRestrictions, getMessageDir } from "../../shared" +import { findNearestMessageWithFields } from "../../features/hook-message-injector" +import { formatDuration } from "./time-formatter" + +export async function executeSyncContinuation( + args: DelegateTaskArgs, + ctx: ToolContextWithMetadata, + executorCtx: ExecutorContext +): Promise { + const { client } = executorCtx + const toastManager = getTaskToastManager() + const taskId = `resume_sync_${args.session_id!.slice(0, 8)}` + const startTime = new Date() + + if (toastManager) { + toastManager.addTask({ + id: taskId, + description: args.description, + agent: "continue", + isBackground: false, + }) + } + + const syncContMeta = { + title: `Continue: ${args.description}`, + metadata: { + prompt: args.prompt, + load_skills: args.load_skills, + description: args.description, + run_in_background: args.run_in_background, + sessionId: args.session_id, + sync: true, + command: args.command, + }, + } + await ctx.metadata?.(syncContMeta) + if (ctx.callID) { + storeToolMetadata(ctx.sessionID, ctx.callID, syncContMeta) + } + + try { + let resumeAgent: string | undefined + let resumeModel: { providerID: string; modelID: string } | undefined + + try { + const messagesResp = await client.session.messages({ path: { id: args.session_id! } }) + const messages = (messagesResp.data ?? []) as SessionMessage[] + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i].info + if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { + resumeAgent = info.agent + resumeModel = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) + break + } + } + } catch { + const resumeMessageDir = getMessageDir(args.session_id!) + const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null + resumeAgent = resumeMessage?.agent + resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID + ? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID } + : undefined + } + + await (client.session as any).promptAsync({ + path: { id: args.session_id! }, + body: { + ...(resumeAgent !== undefined ? { agent: resumeAgent } : {}), + ...(resumeModel !== undefined ? { model: resumeModel } : {}), + tools: { + ...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}), + task: false, + call_omo_agent: true, + question: false, + }, + parts: [{ type: "text", text: args.prompt }], + }, + }) + } catch (promptError) { + if (toastManager) { + toastManager.removeTask(taskId) + } + const errorMessage = promptError instanceof Error ? promptError.message : String(promptError) + return `Failed to send continuation prompt: ${errorMessage}\n\nSession ID: ${args.session_id}` + } + + const timing = getTimingConfig() + const pollStart = Date.now() + let lastMsgCount = 0 + let stablePolls = 0 + + while (Date.now() - pollStart < 60000) { + await new Promise(resolve => setTimeout(resolve, timing.POLL_INTERVAL_MS)) + + const elapsed = Date.now() - pollStart + if (elapsed < timing.SESSION_CONTINUATION_STABILITY_MS) continue + + const messagesCheck = await client.session.messages({ path: { id: args.session_id! } }) + const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array + const currentMsgCount = msgs.length + + if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) { + stablePolls++ + if (stablePolls >= timing.STABILITY_POLLS_REQUIRED) break + } else { + stablePolls = 0 + lastMsgCount = currentMsgCount + } + } + + const messagesResult = await client.session.messages({ + path: { id: args.session_id! }, + }) + + if (messagesResult.error) { + if (toastManager) { + toastManager.removeTask(taskId) + } + return `Error fetching result: ${messagesResult.error}\n\nSession ID: ${args.session_id}` + } + + const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[] + const assistantMessages = messages + .filter((m) => m.info?.role === "assistant") + .sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0)) + const lastMessage = assistantMessages[0] + + if (toastManager) { + toastManager.removeTask(taskId) + } + + if (!lastMessage) { + return `No assistant response found.\n\nSession ID: ${args.session_id}` + } + + const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] + const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n") + const duration = formatDuration(startTime) + + return `Task continued and completed in ${duration}. + +--- + +${textContent || "(No text output)"} + + +session_id: ${args.session_id} +` +} diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts new file mode 100644 index 00000000..d3a9f8e6 --- /dev/null +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -0,0 +1,59 @@ +import type { DelegateTaskArgs, OpencodeClient } from "./types" +import { isPlanAgent } from "./constants" +import { promptWithModelSuggestionRetry } from "../../shared" +import { formatDetailedError } from "./error-formatting" + +export async function sendSyncPrompt( + client: OpencodeClient, + input: { + sessionID: string + agentToUse: string + args: DelegateTaskArgs + systemContent: string | undefined + categoryModel: { providerID: string; modelID: string; variant?: string } | undefined + toastManager: { removeTask: (id: string) => void } | null | undefined + taskId: string | undefined + } +): Promise { + try { + const allowTask = isPlanAgent(input.agentToUse) + await promptWithModelSuggestionRetry(client, { + path: { id: input.sessionID }, + body: { + agent: input.agentToUse, + system: input.systemContent, + tools: { + task: allowTask, + call_omo_agent: true, + question: false, + }, + parts: [{ type: "text", text: input.args.prompt }], + ...(input.categoryModel ? { model: { providerID: input.categoryModel.providerID, modelID: input.categoryModel.modelID } } : {}), + ...(input.categoryModel?.variant ? { variant: input.categoryModel.variant } : {}), + }, + }) + } catch (promptError) { + if (input.toastManager && input.taskId !== undefined) { + input.toastManager.removeTask(input.taskId) + } + const errorMessage = promptError instanceof Error ? promptError.message : String(promptError) + if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { + return formatDetailedError(new Error(`Agent "${input.agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`), { + operation: "Send prompt to agent", + args: input.args, + sessionID: input.sessionID, + agent: input.agentToUse, + category: input.args.category, + }) + } + return formatDetailedError(promptError, { + operation: "Send prompt", + args: input.args, + sessionID: input.sessionID, + agent: input.agentToUse, + category: input.args.category, + }) + } + + return null +} diff --git a/src/tools/delegate-task/sync-result-fetcher.ts b/src/tools/delegate-task/sync-result-fetcher.ts new file mode 100644 index 00000000..977f93b7 --- /dev/null +++ b/src/tools/delegate-task/sync-result-fetcher.ts @@ -0,0 +1,31 @@ +import type { OpencodeClient } from "./types" +import type { SessionMessage } from "./executor-types" + +export async function fetchSyncResult( + client: OpencodeClient, + sessionID: string +): Promise<{ ok: true; textContent: string } | { ok: false; error: string }> { + const messagesResult = await client.session.messages({ + path: { id: sessionID }, + }) + + if ((messagesResult as { error?: unknown }).error) { + return { ok: false, error: `Error fetching result: ${(messagesResult as { error: unknown }).error}\n\nSession ID: ${sessionID}` } + } + + const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[] + + const assistantMessages = messages + .filter((m) => m.info?.role === "assistant") + .sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0)) + const lastMessage = assistantMessages[0] + + if (!lastMessage) { + return { ok: false, error: `No assistant response found.\n\nSession ID: ${sessionID}` } + } + + const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] + const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n") + + return { ok: true, textContent } +} diff --git a/src/tools/delegate-task/sync-session-creator.ts b/src/tools/delegate-task/sync-session-creator.ts new file mode 100644 index 00000000..400a6da0 --- /dev/null +++ b/src/tools/delegate-task/sync-session-creator.ts @@ -0,0 +1,30 @@ +import type { OpencodeClient } from "./types" + +export async function createSyncSession( + client: OpencodeClient, + input: { parentSessionID: string; agentToUse: string; description: string; defaultDirectory: string } +): Promise<{ ok: true; sessionID: string; parentDirectory: string } | { ok: false; error: string }> { + const parentSession = client.session.get + ? await client.session.get({ path: { id: input.parentSessionID } }).catch(() => null) + : null + const parentDirectory = parentSession?.data?.directory ?? input.defaultDirectory + + const createResult = await client.session.create({ + body: { + parentID: input.parentSessionID, + title: `${input.description} (@${input.agentToUse} subagent)`, + permission: [ + { permission: "question", action: "deny" as const, pattern: "*" }, + ], + } as any, + query: { + directory: parentDirectory, + }, + }) + + if (createResult.error) { + return { ok: false, error: `Failed to create session: ${createResult.error}` } + } + + return { ok: true, sessionID: createResult.data.id, parentDirectory } +} diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts new file mode 100644 index 00000000..42832d7e --- /dev/null +++ b/src/tools/delegate-task/sync-session-poller.ts @@ -0,0 +1,80 @@ +import type { ToolContextWithMetadata, OpencodeClient } from "./types" +import { getTimingConfig } from "./timing" +import { log } from "../../shared" + +export async function pollSyncSession( + ctx: ToolContextWithMetadata, + client: OpencodeClient, + input: { + sessionID: string + agentToUse: string + toastManager: { removeTask: (id: string) => void } | null | undefined + taskId: string | undefined + } +): Promise { + const syncTiming = getTimingConfig() + const pollStart = Date.now() + let lastMsgCount = 0 + let stablePolls = 0 + let pollCount = 0 + + log("[task] Starting poll loop", { sessionID: input.sessionID, agentToUse: input.agentToUse }) + + while (Date.now() - pollStart < syncTiming.MAX_POLL_TIME_MS) { + if (ctx.abort?.aborted) { + log("[task] Aborted by user", { sessionID: input.sessionID }) + if (input.toastManager && input.taskId) input.toastManager.removeTask(input.taskId) + return `Task aborted.\n\nSession ID: ${input.sessionID}` + } + + await new Promise(resolve => setTimeout(resolve, syncTiming.POLL_INTERVAL_MS)) + pollCount++ + + const statusResult = await client.session.status() + const allStatuses = (statusResult.data ?? {}) as Record + const sessionStatus = allStatuses[input.sessionID] + + if (pollCount % 10 === 0) { + log("[task] Poll status", { + sessionID: input.sessionID, + pollCount, + elapsed: Math.floor((Date.now() - pollStart) / 1000) + "s", + sessionStatus: sessionStatus?.type ?? "not_in_status", + stablePolls, + lastMsgCount, + }) + } + + if (sessionStatus && sessionStatus.type !== "idle") { + stablePolls = 0 + lastMsgCount = 0 + continue + } + + const elapsed = Date.now() - pollStart + if (elapsed < syncTiming.MIN_STABILITY_TIME_MS) { + continue + } + + const messagesCheck = await client.session.messages({ path: { id: input.sessionID } }) + const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array + const currentMsgCount = msgs.length + + if (currentMsgCount === lastMsgCount) { + stablePolls++ + if (stablePolls >= syncTiming.STABILITY_POLLS_REQUIRED) { + log("[task] Poll complete - messages stable", { sessionID: input.sessionID, pollCount, currentMsgCount }) + break + } + } else { + stablePolls = 0 + lastMsgCount = currentMsgCount + } + } + + if (Date.now() - pollStart >= syncTiming.MAX_POLL_TIME_MS) { + log("[task] Poll timeout reached", { sessionID: input.sessionID, pollCount, lastMsgCount, stablePolls }) + } + + return null +} diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts new file mode 100644 index 00000000..4d621d50 --- /dev/null +++ b/src/tools/delegate-task/sync-task.ts @@ -0,0 +1,154 @@ +import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" +import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types" +import type { ExecutorContext, ParentContext } from "./executor-types" +import { getTaskToastManager } from "../../features/task-toast-manager" +import { storeToolMetadata } from "../../features/tool-metadata-store" +import { subagentSessions } from "../../features/claude-code-session-state" +import { log } from "../../shared" +import { formatDuration } from "./time-formatter" +import { formatDetailedError } from "./error-formatting" +import { createSyncSession } from "./sync-session-creator" +import { sendSyncPrompt } from "./sync-prompt-sender" +import { pollSyncSession } from "./sync-session-poller" +import { fetchSyncResult } from "./sync-result-fetcher" + +export async function executeSyncTask( + args: DelegateTaskArgs, + ctx: ToolContextWithMetadata, + executorCtx: ExecutorContext, + parentContext: ParentContext, + agentToUse: string, + categoryModel: { providerID: string; modelID: string; variant?: string } | undefined, + systemContent: string | undefined, + modelInfo?: ModelFallbackInfo +): Promise { + const { client, directory, onSyncSessionCreated } = executorCtx + const toastManager = getTaskToastManager() + let taskId: string | undefined + let syncSessionID: string | undefined + + try { + const createSessionResult = await createSyncSession(client, { + parentSessionID: parentContext.sessionID, + agentToUse, + description: args.description, + defaultDirectory: directory, + }) + + if (!createSessionResult.ok) { + return createSessionResult.error + } + + const sessionID = createSessionResult.sessionID + syncSessionID = sessionID + subagentSessions.add(sessionID) + + if (onSyncSessionCreated) { + log("[task] Invoking onSyncSessionCreated callback", { sessionID, parentID: parentContext.sessionID }) + await onSyncSessionCreated({ + sessionID, + parentID: parentContext.sessionID, + title: args.description, + }).catch((err) => { + log("[task] onSyncSessionCreated callback failed", { error: String(err) }) + }) + await new Promise(r => setTimeout(r, 200)) + } + + taskId = `sync_${sessionID.slice(0, 8)}` + const startTime = new Date() + + if (toastManager) { + toastManager.addTask({ + id: taskId, + description: args.description, + agent: agentToUse, + isBackground: false, + category: args.category, + skills: args.load_skills, + modelInfo, + }) + } + + const syncTaskMeta = { + title: args.description, + metadata: { + prompt: args.prompt, + agent: agentToUse, + category: args.category, + load_skills: args.load_skills, + description: args.description, + run_in_background: args.run_in_background, + sessionId: sessionID, + sync: true, + command: args.command, + }, + } + await ctx.metadata?.(syncTaskMeta) + if (ctx.callID) { + storeToolMetadata(ctx.sessionID, ctx.callID, syncTaskMeta) + } + + const promptError = await sendSyncPrompt(client, { + sessionID, + agentToUse, + args, + systemContent, + categoryModel, + toastManager, + taskId, + }) + if (promptError) { + return promptError + } + + const pollError = await pollSyncSession(ctx, client, { + sessionID, + agentToUse, + toastManager, + taskId, + }) + if (pollError) { + return pollError + } + + const result = await fetchSyncResult(client, sessionID) + if (!result.ok) { + return result.error + } + + const duration = formatDuration(startTime) + + if (toastManager) { + toastManager.removeTask(taskId) + } + + subagentSessions.delete(sessionID) + + return `Task completed in ${duration}. + +Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""} + +--- + +${result.textContent || "(No text output)"} + + +session_id: ${sessionID} +` + } catch (error) { + if (toastManager && taskId !== undefined) { + toastManager.removeTask(taskId) + } + if (syncSessionID) { + subagentSessions.delete(syncSessionID) + } + return formatDetailedError(error, { + operation: "Execute task", + args, + sessionID: syncSessionID, + agent: agentToUse, + category: args.category, + }) + } +} diff --git a/src/tools/delegate-task/time-formatter.ts b/src/tools/delegate-task/time-formatter.ts new file mode 100644 index 00000000..4d994e22 --- /dev/null +++ b/src/tools/delegate-task/time-formatter.ts @@ -0,0 +1,13 @@ +/** + * Format a duration between two dates as a human-readable string. + */ +export function formatDuration(start: Date, end?: Date): string { + const duration = (end ?? new Date()).getTime() - start.getTime() + const seconds = Math.floor(duration / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + + if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s` + if (minutes > 0) return `${minutes}m ${seconds % 60}s` + return `${seconds}s` +} diff --git a/src/tools/delegate-task/unstable-agent-task.ts b/src/tools/delegate-task/unstable-agent-task.ts new file mode 100644 index 00000000..ae97153b --- /dev/null +++ b/src/tools/delegate-task/unstable-agent-task.ts @@ -0,0 +1,158 @@ +import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types" +import type { ExecutorContext, ParentContext, SessionMessage } from "./executor-types" +import { getTimingConfig } from "./timing" +import { storeToolMetadata } from "../../features/tool-metadata-store" +import { formatDuration } from "./time-formatter" +import { formatDetailedError } from "./error-formatting" + +export async function executeUnstableAgentTask( + args: DelegateTaskArgs, + ctx: ToolContextWithMetadata, + executorCtx: ExecutorContext, + parentContext: ParentContext, + agentToUse: string, + categoryModel: { providerID: string; modelID: string; variant?: string } | undefined, + systemContent: string | undefined, + actualModel: string | undefined +): Promise { + const { manager, client } = executorCtx + + try { + const task = await manager.launch({ + description: args.description, + prompt: args.prompt, + agent: agentToUse, + parentSessionID: parentContext.sessionID, + parentMessageID: parentContext.messageID, + parentModel: parentContext.model, + parentAgent: parentContext.agent, + model: categoryModel, + skills: args.load_skills.length > 0 ? args.load_skills : undefined, + skillContent: systemContent, + category: args.category, + }) + + const timing = getTimingConfig() + const waitStart = Date.now() + let sessionID = task.sessionID + while (!sessionID && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) { + if (ctx.abort?.aborted) { + return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}` + } + await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS)) + const updated = manager.getTask(task.id) + sessionID = updated?.sessionID + } + if (!sessionID) { + return formatDetailedError(new Error(`Task failed to start within timeout (30s). Task ID: ${task.id}, Status: ${task.status}`), { + operation: "Launch monitored background task", + args, + agent: agentToUse, + category: args.category, + }) + } + + const bgTaskMeta = { + title: args.description, + metadata: { + prompt: args.prompt, + agent: agentToUse, + category: args.category, + load_skills: args.load_skills, + description: args.description, + run_in_background: args.run_in_background, + sessionId: sessionID, + command: args.command, + }, + } + await ctx.metadata?.(bgTaskMeta) + if (ctx.callID) { + storeToolMetadata(ctx.sessionID, ctx.callID, bgTaskMeta) + } + + const startTime = new Date() + const timingCfg = getTimingConfig() + const pollStart = Date.now() + let lastMsgCount = 0 + let stablePolls = 0 + + while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) { + if (ctx.abort?.aborted) { + return `Task aborted (was running in background mode).\n\nSession ID: ${sessionID}` + } + + await new Promise(resolve => setTimeout(resolve, timingCfg.POLL_INTERVAL_MS)) + + const statusResult = await client.session.status() + const allStatuses = (statusResult.data ?? {}) as Record + const sessionStatus = allStatuses[sessionID] + + if (sessionStatus && sessionStatus.type !== "idle") { + stablePolls = 0 + lastMsgCount = 0 + continue + } + + if (Date.now() - pollStart < timingCfg.MIN_STABILITY_TIME_MS) continue + + const messagesCheck = await client.session.messages({ path: { id: sessionID } }) + const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array + const currentMsgCount = msgs.length + + if (currentMsgCount === lastMsgCount) { + stablePolls++ + if (stablePolls >= timingCfg.STABILITY_POLLS_REQUIRED) break + } else { + stablePolls = 0 + lastMsgCount = currentMsgCount + } + } + + const messagesResult = await client.session.messages({ path: { id: sessionID } }) + const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[] + + const assistantMessages = messages + .filter((m) => m.info?.role === "assistant") + .sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0)) + const lastMessage = assistantMessages[0] + + if (!lastMessage) { + return `No assistant response found (task ran in background mode).\n\nSession ID: ${sessionID}` + } + + const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] + const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n") + const duration = formatDuration(startTime) + + return `SUPERVISED TASK COMPLETED SUCCESSFULLY + +IMPORTANT: This model (${actualModel}) is marked as unstable/experimental. +Your run_in_background=false was automatically converted to background mode for reliability monitoring. + +Duration: ${duration} +Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""} + +MONITORING INSTRUCTIONS: +- The task was monitored and completed successfully +- If you observe this agent behaving erratically in future calls, actively monitor its progress +- Use background_cancel(task_id="...") to abort if the agent seems stuck or producing garbage output +- Do NOT retry automatically if you see this message - the task already succeeded + +--- + +RESULT: + +${textContent || "(No text output)"} + + +session_id: ${sessionID} +` + } catch (error) { + return formatDetailedError(error, { + operation: "Launch monitored background task", + args, + agent: agentToUse, + category: args.category, + }) + } +} diff --git a/src/tools/glob/utils.ts b/src/tools/glob/result-formatter.ts similarity index 100% rename from src/tools/glob/utils.ts rename to src/tools/glob/result-formatter.ts diff --git a/src/tools/glob/tools.ts b/src/tools/glob/tools.ts index 318d178d..361d43cd 100644 --- a/src/tools/glob/tools.ts +++ b/src/tools/glob/tools.ts @@ -2,7 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { runRgFiles } from "./cli" import { resolveGrepCliWithAutoInstall } from "./constants" -import { formatGlobResult } from "./utils" +import { formatGlobResult } from "./result-formatter" export function createGlobTools(ctx: PluginInput): Record { const glob: ToolDefinition = tool({ diff --git a/src/tools/grep/utils.ts b/src/tools/grep/result-formatter.ts similarity index 100% rename from src/tools/grep/utils.ts rename to src/tools/grep/result-formatter.ts diff --git a/src/tools/grep/tools.ts b/src/tools/grep/tools.ts index dd55e3c0..59ff2ec3 100644 --- a/src/tools/grep/tools.ts +++ b/src/tools/grep/tools.ts @@ -1,7 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { runRg } from "./cli" -import { formatGrepResult } from "./utils" +import { formatGrepResult } from "./result-formatter" export function createGrepTools(ctx: PluginInput): Record { const grep: ToolDefinition = tool({ diff --git a/src/tools/interactive-bash/index.ts b/src/tools/interactive-bash/index.ts index 72b101e4..57b4e4f4 100644 --- a/src/tools/interactive-bash/index.ts +++ b/src/tools/interactive-bash/index.ts @@ -1,4 +1,4 @@ import { interactive_bash } from "./tools" -import { startBackgroundCheck } from "./utils" +import { startBackgroundCheck } from "./tmux-path-resolver" export { interactive_bash, startBackgroundCheck } diff --git a/src/tools/interactive-bash/utils.ts b/src/tools/interactive-bash/tmux-path-resolver.ts similarity index 100% rename from src/tools/interactive-bash/utils.ts rename to src/tools/interactive-bash/tmux-path-resolver.ts diff --git a/src/tools/interactive-bash/tools.ts b/src/tools/interactive-bash/tools.ts index bca941b9..dac46bd6 100644 --- a/src/tools/interactive-bash/tools.ts +++ b/src/tools/interactive-bash/tools.ts @@ -1,6 +1,6 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants" -import { getCachedTmuxPath } from "./utils" +import { getCachedTmuxPath } from "./tmux-path-resolver" /** * Quote-aware command tokenizer with escape handling diff --git a/src/tools/lsp/client.ts b/src/tools/lsp/client.ts index 57f9a5c8..5e4651f1 100644 --- a/src/tools/lsp/client.ts +++ b/src/tools/lsp/client.ts @@ -1,803 +1,3 @@ -import { spawn as bunSpawn, type Subprocess } from "bun" -import { spawn as nodeSpawn, spawnSync, type ChildProcess } from "node:child_process" -import { Readable, Writable } from "node:stream" -import { existsSync, readFileSync, statSync } from "fs" -import { extname, resolve } from "path" -import { pathToFileURL } from "node:url" -import { - createMessageConnection, - StreamMessageReader, - StreamMessageWriter, - type MessageConnection, -} from "vscode-jsonrpc/node" -import { getLanguageId } from "./config" -import type { Diagnostic, ResolvedServer } from "./types" -import { log } from "../../shared/logger" - -// Bun spawn segfaults on Windows (oven-sh/bun#25798) — unfixed as of v1.3.8+ -function shouldUseNodeSpawn(): boolean { - return process.platform === "win32" -} - -// Prevents segfaults when libuv gets a non-existent cwd (oven-sh/bun#25798) -export function validateCwd(cwd: string): { valid: boolean; error?: string } { - try { - if (!existsSync(cwd)) { - return { valid: false, error: `Working directory does not exist: ${cwd}` } - } - const stats = statSync(cwd) - if (!stats.isDirectory()) { - return { valid: false, error: `Path is not a directory: ${cwd}` } - } - return { valid: true } - } catch (err) { - return { valid: false, error: `Cannot access working directory: ${cwd} (${err instanceof Error ? err.message : String(err)})` } - } -} - -function isBinaryAvailableOnWindows(command: string): boolean { - if (process.platform !== "win32") return true - - if (command.includes("/") || command.includes("\\")) { - return existsSync(command) - } - - try { - const result = spawnSync("where", [command], { - shell: true, - windowsHide: true, - timeout: 5000, - }) - return result.status === 0 - } catch { - return true - } -} - -interface StreamReader { - read(): Promise<{ done: boolean; value: Uint8Array | undefined }> -} - -// Bridges Bun Subprocess and Node.js ChildProcess under a common API -interface UnifiedProcess { - stdin: { write(chunk: Uint8Array | string): void } - stdout: { getReader(): StreamReader } - stderr: { getReader(): StreamReader } - exitCode: number | null - exited: Promise - kill(signal?: string): void -} - -function wrapNodeProcess(proc: ChildProcess): UnifiedProcess { - let resolveExited: (code: number) => void - let exitCode: number | null = null - - const exitedPromise = new Promise((resolve) => { - resolveExited = resolve - }) - - proc.on("exit", (code) => { - exitCode = code ?? 1 - resolveExited(exitCode) - }) - - proc.on("error", () => { - if (exitCode === null) { - exitCode = 1 - resolveExited(1) - } - }) - - const createStreamReader = (nodeStream: NodeJS.ReadableStream | null): StreamReader => { - const chunks: Uint8Array[] = [] - let streamEnded = false - type ReadResult = { done: boolean; value: Uint8Array | undefined } - let waitingResolve: ((result: ReadResult) => void) | null = null - - if (nodeStream) { - nodeStream.on("data", (chunk: Buffer) => { - const uint8 = new Uint8Array(chunk) - if (waitingResolve) { - const resolve = waitingResolve - waitingResolve = null - resolve({ done: false, value: uint8 }) - } else { - chunks.push(uint8) - } - }) - - nodeStream.on("end", () => { - streamEnded = true - if (waitingResolve) { - const resolve = waitingResolve - waitingResolve = null - resolve({ done: true, value: undefined }) - } - }) - - nodeStream.on("error", () => { - streamEnded = true - if (waitingResolve) { - const resolve = waitingResolve - waitingResolve = null - resolve({ done: true, value: undefined }) - } - }) - } else { - streamEnded = true - } - - return { - read(): Promise { - return new Promise((resolve) => { - if (chunks.length > 0) { - resolve({ done: false, value: chunks.shift()! }) - } else if (streamEnded) { - resolve({ done: true, value: undefined }) - } else { - waitingResolve = resolve - } - }) - }, - } - } - - return { - stdin: { - write(chunk: Uint8Array | string) { - if (proc.stdin) { - proc.stdin.write(chunk) - } - }, - }, - stdout: { - getReader: () => createStreamReader(proc.stdout), - }, - stderr: { - getReader: () => createStreamReader(proc.stderr), - }, - get exitCode() { - return exitCode - }, - exited: exitedPromise, - kill(signal?: string) { - try { - if (signal === "SIGKILL") { - proc.kill("SIGKILL") - } else { - proc.kill() - } - } catch {} - }, - } -} - -function spawnProcess( - command: string[], - options: { cwd: string; env: Record } -): UnifiedProcess { - const cwdValidation = validateCwd(options.cwd) - if (!cwdValidation.valid) { - throw new Error(`[LSP] ${cwdValidation.error}`) - } - - if (shouldUseNodeSpawn()) { - const [cmd, ...args] = command - - if (!isBinaryAvailableOnWindows(cmd)) { - throw new Error( - `[LSP] Binary '${cmd}' not found on Windows. ` + - `Ensure the LSP server is installed and available in PATH. ` + - `For npm packages, try: npm install -g ${cmd}` - ) - } - - log("[LSP] Using Node.js child_process on Windows to avoid Bun spawn segfault") - - const proc = nodeSpawn(cmd, args, { - cwd: options.cwd, - env: options.env as NodeJS.ProcessEnv, - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - shell: true, - }) - return wrapNodeProcess(proc) - } - - const proc = bunSpawn(command, { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - cwd: options.cwd, - env: options.env, - }) - - return proc as unknown as UnifiedProcess -} - -interface ManagedClient { - client: LSPClient - lastUsedAt: number - refCount: number - initPromise?: Promise - isInitializing: boolean -} - -class LSPServerManager { - private static instance: LSPServerManager - private clients = new Map() - private cleanupInterval: ReturnType | null = null - private readonly IDLE_TIMEOUT = 5 * 60 * 1000 - - private constructor() { - this.startCleanupTimer() - this.registerProcessCleanup() - } - - private registerProcessCleanup(): void { - // Synchronous cleanup for 'exit' event (cannot await) - const syncCleanup = () => { - for (const [, managed] of this.clients) { - try { - // Fire-and-forget during sync exit - process is terminating - void managed.client.stop().catch(() => {}) - } catch {} - } - this.clients.clear() - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval) - this.cleanupInterval = null - } - } - - // Async cleanup for signal handlers - properly await all stops - const asyncCleanup = async () => { - const stopPromises: Promise[] = [] - for (const [, managed] of this.clients) { - stopPromises.push(managed.client.stop().catch(() => {})) - } - await Promise.allSettled(stopPromises) - this.clients.clear() - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval) - this.cleanupInterval = null - } - } - - process.on("exit", syncCleanup) - - // Don't call process.exit() here - let other handlers complete their cleanup first - // The background-agent manager handles the final exit call - // Use async handlers to properly await LSP subprocess cleanup - process.on("SIGINT", () => void asyncCleanup().catch(() => {})) - process.on("SIGTERM", () => void asyncCleanup().catch(() => {})) - - if (process.platform === "win32") { - process.on("SIGBREAK", () => void asyncCleanup().catch(() => {})) - } - } - - static getInstance(): LSPServerManager { - if (!LSPServerManager.instance) { - LSPServerManager.instance = new LSPServerManager() - } - return LSPServerManager.instance - } - - private getKey(root: string, serverId: string): string { - return `${root}::${serverId}` - } - - private startCleanupTimer(): void { - if (this.cleanupInterval) return - this.cleanupInterval = setInterval(() => { - this.cleanupIdleClients() - }, 60000) - } - - private cleanupIdleClients(): void { - const now = Date.now() - for (const [key, managed] of this.clients) { - if (managed.refCount === 0 && now - managed.lastUsedAt > this.IDLE_TIMEOUT) { - managed.client.stop() - this.clients.delete(key) - } - } - } - - async getClient(root: string, server: ResolvedServer): Promise { - const key = this.getKey(root, server.id) - - let managed = this.clients.get(key) - if (managed) { - if (managed.initPromise) { - await managed.initPromise - } - if (managed.client.isAlive()) { - managed.refCount++ - managed.lastUsedAt = Date.now() - return managed.client - } - await managed.client.stop() - this.clients.delete(key) - } - - const client = new LSPClient(root, server) - const initPromise = (async () => { - await client.start() - await client.initialize() - })() - - this.clients.set(key, { - client, - lastUsedAt: Date.now(), - refCount: 1, - initPromise, - isInitializing: true, - }) - - await initPromise - const m = this.clients.get(key) - if (m) { - m.initPromise = undefined - m.isInitializing = false - } - - return client - } - - warmupClient(root: string, server: ResolvedServer): void { - const key = this.getKey(root, server.id) - if (this.clients.has(key)) return - - const client = new LSPClient(root, server) - const initPromise = (async () => { - await client.start() - await client.initialize() - })() - - this.clients.set(key, { - client, - lastUsedAt: Date.now(), - refCount: 0, - initPromise, - isInitializing: true, - }) - - initPromise.then(() => { - const m = this.clients.get(key) - if (m) { - m.initPromise = undefined - m.isInitializing = false - } - }) - } - - releaseClient(root: string, serverId: string): void { - const key = this.getKey(root, serverId) - const managed = this.clients.get(key) - if (managed && managed.refCount > 0) { - managed.refCount-- - managed.lastUsedAt = Date.now() - } - } - - isServerInitializing(root: string, serverId: string): boolean { - const key = this.getKey(root, serverId) - const managed = this.clients.get(key) - return managed?.isInitializing ?? false - } - - async stopAll(): Promise { - for (const [, managed] of this.clients) { - await managed.client.stop() - } - this.clients.clear() - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval) - this.cleanupInterval = null - } - } - - async cleanupTempDirectoryClients(): Promise { - const keysToRemove: string[] = [] - for (const [key, managed] of this.clients.entries()) { - const isTempDir = key.startsWith("/tmp/") || key.startsWith("/var/folders/") - const isIdle = managed.refCount === 0 - if (isTempDir && isIdle) { - keysToRemove.push(key) - } - } - for (const key of keysToRemove) { - const managed = this.clients.get(key) - if (managed) { - this.clients.delete(key) - try { - await managed.client.stop() - } catch {} - } - } - } -} - -export const lspManager = LSPServerManager.getInstance() - -export class LSPClient { - private proc: UnifiedProcess | null = null - private connection: MessageConnection | null = null - private openedFiles = new Set() - private documentVersions = new Map() - private lastSyncedText = new Map() - private stderrBuffer: string[] = [] - private processExited = false - private diagnosticsStore = new Map() - private readonly REQUEST_TIMEOUT = 15000 - - constructor( - private root: string, - private server: ResolvedServer - ) {} - - async start(): Promise { - this.proc = spawnProcess(this.server.command, { - cwd: this.root, - env: { - ...process.env, - ...this.server.env, - }, - }) - - if (!this.proc) { - throw new Error(`Failed to spawn LSP server: ${this.server.command.join(" ")}`) - } - - this.startStderrReading() - - await new Promise((resolve) => setTimeout(resolve, 100)) - - if (this.proc.exitCode !== null) { - const stderr = this.stderrBuffer.join("\n") - throw new Error( - `LSP server exited immediately with code ${this.proc.exitCode}` + (stderr ? `\nstderr: ${stderr}` : "") - ) - } - - const stdoutReader = this.proc.stdout.getReader() - const nodeReadable = new Readable({ - async read() { - try { - const { done, value } = await stdoutReader.read() - if (done || !value) { - this.push(null) - } else { - this.push(Buffer.from(value)) - } - } catch { - this.push(null) - } - }, - }) - - const stdin = this.proc.stdin - const nodeWritable = new Writable({ - write(chunk, _encoding, callback) { - try { - stdin.write(chunk) - callback() - } catch (err) { - callback(err as Error) - } - }, - }) - - this.connection = createMessageConnection( - new StreamMessageReader(nodeReadable), - new StreamMessageWriter(nodeWritable) - ) - - this.connection.onNotification("textDocument/publishDiagnostics", (params: { uri?: string; diagnostics?: Diagnostic[] }) => { - if (params.uri) { - this.diagnosticsStore.set(params.uri, params.diagnostics ?? []) - } - }) - - this.connection.onRequest("workspace/configuration", (params: { items?: Array<{ section?: string }> }) => { - const items = params?.items ?? [] - return items.map((item) => { - if (item.section === "json") return { validate: { enable: true } } - return {} - }) - }) - - this.connection.onRequest("client/registerCapability", () => null) - this.connection.onRequest("window/workDoneProgress/create", () => null) - - this.connection.onClose(() => { - this.processExited = true - }) - - this.connection.onError((error) => { - log("LSP connection error:", error) - }) - - this.connection.listen() - } - - private startStderrReading(): void { - if (!this.proc) return - - const reader = this.proc.stderr.getReader() - const read = async () => { - const decoder = new TextDecoder() - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - const text = decoder.decode(value) - this.stderrBuffer.push(text) - if (this.stderrBuffer.length > 100) { - this.stderrBuffer.shift() - } - } - } catch {} - } - read() - } - - private async sendRequest(method: string, params?: unknown): Promise { - if (!this.connection) throw new Error("LSP client not started") - - if (this.processExited || (this.proc && this.proc.exitCode !== null)) { - const stderr = this.stderrBuffer.slice(-10).join("\n") - throw new Error(`LSP server already exited (code: ${this.proc?.exitCode})` + (stderr ? `\nstderr: ${stderr}` : "")) - } - - let timeoutId: ReturnType - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - const stderr = this.stderrBuffer.slice(-5).join("\n") - reject(new Error(`LSP request timeout (method: ${method})` + (stderr ? `\nrecent stderr: ${stderr}` : ""))) - }, this.REQUEST_TIMEOUT) - }) - - const requestPromise = this.connection.sendRequest(method, params) as Promise - - try { - const result = await Promise.race([requestPromise, timeoutPromise]) - clearTimeout(timeoutId!) - return result - } catch (error) { - clearTimeout(timeoutId!) - throw error - } - } - - private sendNotification(method: string, params?: unknown): void { - if (!this.connection) return - if (this.processExited || (this.proc && this.proc.exitCode !== null)) return - - this.connection.sendNotification(method, params) - } - - async initialize(): Promise { - const rootUri = pathToFileURL(this.root).href - await this.sendRequest("initialize", { - processId: process.pid, - rootUri, - rootPath: this.root, - workspaceFolders: [{ uri: rootUri, name: "workspace" }], - capabilities: { - textDocument: { - hover: { contentFormat: ["markdown", "plaintext"] }, - definition: { linkSupport: true }, - references: {}, - documentSymbol: { hierarchicalDocumentSymbolSupport: true }, - publishDiagnostics: {}, - rename: { - prepareSupport: true, - prepareSupportDefaultBehavior: 1, - honorsChangeAnnotations: true, - }, - codeAction: { - codeActionLiteralSupport: { - codeActionKind: { - valueSet: [ - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports", - "source.fixAll", - ], - }, - }, - isPreferredSupport: true, - disabledSupport: true, - dataSupport: true, - resolveSupport: { - properties: ["edit", "command"], - }, - }, - }, - workspace: { - symbol: {}, - workspaceFolders: true, - configuration: true, - applyEdit: true, - workspaceEdit: { - documentChanges: true, - }, - }, - }, - ...this.server.initialization, - }) - this.sendNotification("initialized") - this.sendNotification("workspace/didChangeConfiguration", { - settings: { json: { validate: { enable: true } } }, - }) - await new Promise((r) => setTimeout(r, 300)) - } - - async openFile(filePath: string): Promise { - const absPath = resolve(filePath) - - const uri = pathToFileURL(absPath).href - const text = readFileSync(absPath, "utf-8") - - if (!this.openedFiles.has(absPath)) { - const ext = extname(absPath) - const languageId = getLanguageId(ext) - const version = 1 - - this.sendNotification("textDocument/didOpen", { - textDocument: { - uri, - languageId, - version, - text, - }, - }) - - this.openedFiles.add(absPath) - this.documentVersions.set(uri, version) - this.lastSyncedText.set(uri, text) - await new Promise((r) => setTimeout(r, 1000)) - return - } - - const prevText = this.lastSyncedText.get(uri) - if (prevText === text) { - return - } - - const nextVersion = (this.documentVersions.get(uri) ?? 1) + 1 - this.documentVersions.set(uri, nextVersion) - this.lastSyncedText.set(uri, text) - - this.sendNotification("textDocument/didChange", { - textDocument: { uri, version: nextVersion }, - contentChanges: [{ text }], - }) - - // Some servers update diagnostics only after save - this.sendNotification("textDocument/didSave", { - textDocument: { uri }, - text, - }) - } - - async definition(filePath: string, line: number, character: number): Promise { - const absPath = resolve(filePath) - await this.openFile(absPath) - return this.sendRequest("textDocument/definition", { - textDocument: { uri: pathToFileURL(absPath).href }, - position: { line: line - 1, character }, - }) - } - - async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise { - const absPath = resolve(filePath) - await this.openFile(absPath) - return this.sendRequest("textDocument/references", { - textDocument: { uri: pathToFileURL(absPath).href }, - position: { line: line - 1, character }, - context: { includeDeclaration }, - }) - } - - async documentSymbols(filePath: string): Promise { - const absPath = resolve(filePath) - await this.openFile(absPath) - return this.sendRequest("textDocument/documentSymbol", { - textDocument: { uri: pathToFileURL(absPath).href }, - }) - } - - async workspaceSymbols(query: string): Promise { - return this.sendRequest("workspace/symbol", { query }) - } - - async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> { - const absPath = resolve(filePath) - const uri = pathToFileURL(absPath).href - await this.openFile(absPath) - await new Promise((r) => setTimeout(r, 500)) - - try { - const result = await this.sendRequest<{ items?: Diagnostic[] }>("textDocument/diagnostic", { - textDocument: { uri }, - }) - if (result && typeof result === "object" && "items" in result) { - return result as { items: Diagnostic[] } - } - } catch {} - - return { items: this.diagnosticsStore.get(uri) ?? [] } - } - - async prepareRename(filePath: string, line: number, character: number): Promise { - const absPath = resolve(filePath) - await this.openFile(absPath) - return this.sendRequest("textDocument/prepareRename", { - textDocument: { uri: pathToFileURL(absPath).href }, - position: { line: line - 1, character }, - }) - } - - async rename(filePath: string, line: number, character: number, newName: string): Promise { - const absPath = resolve(filePath) - await this.openFile(absPath) - return this.sendRequest("textDocument/rename", { - textDocument: { uri: pathToFileURL(absPath).href }, - position: { line: line - 1, character }, - newName, - }) - } - - isAlive(): boolean { - return this.proc !== null && !this.processExited && this.proc.exitCode === null - } - - async stop(): Promise { - if (this.connection) { - try { - this.sendNotification("shutdown", {}) - this.sendNotification("exit") - } catch {} - this.connection.dispose() - this.connection = null - } - const proc = this.proc - if (proc) { - this.proc = null - let exitedBeforeTimeout = false - try { - proc.kill() - // Wait for exit with timeout to prevent indefinite hang - let timeoutId: ReturnType | undefined - const timeoutPromise = new Promise((resolve) => { - timeoutId = setTimeout(resolve, 5000) - }) - await Promise.race([ - proc.exited.then(() => { exitedBeforeTimeout = true }).finally(() => timeoutId && clearTimeout(timeoutId)), - timeoutPromise, - ]) - if (!exitedBeforeTimeout) { - log("[LSPClient] Process did not exit within timeout, escalating to SIGKILL") - try { - proc.kill("SIGKILL") - // Wait briefly for SIGKILL to take effect - await Promise.race([ - proc.exited, - new Promise((resolve) => setTimeout(resolve, 1000)), - ]) - } catch {} - } - } catch {} - } - this.processExited = true - this.diagnosticsStore.clear() - } -} +export { validateCwd } from "./lsp-process" +export { lspManager } from "./lsp-server" +export { LSPClient } from "./lsp-client" diff --git a/src/tools/lsp/config.ts b/src/tools/lsp/config.ts index ac97c90d..2d36aa0f 100644 --- a/src/tools/lsp/config.ts +++ b/src/tools/lsp/config.ts @@ -1,289 +1,3 @@ -import { existsSync, readFileSync } from "fs" -import { join } from "path" -import { BUILTIN_SERVERS, EXT_TO_LANG, LSP_INSTALL_HINTS } from "./constants" -import type { ResolvedServer, ServerLookupResult } from "./types" -import { getOpenCodeConfigDir, getDataDir } from "../../shared" - -interface LspEntry { - disabled?: boolean - command?: string[] - extensions?: string[] - priority?: number - env?: Record - initialization?: Record -} - -interface ConfigJson { - lsp?: Record -} - -type ConfigSource = "project" | "user" | "opencode" - -interface ServerWithSource extends ResolvedServer { - source: ConfigSource -} - -function loadJsonFile(path: string): T | null { - if (!existsSync(path)) return null - try { - return JSON.parse(readFileSync(path, "utf-8")) as T - } catch { - return null - } -} - -function getConfigPaths(): { project: string; user: string; opencode: string } { - const cwd = process.cwd() - const configDir = getOpenCodeConfigDir({ binary: "opencode" }) - return { - project: join(cwd, ".opencode", "oh-my-opencode.json"), - user: join(configDir, "oh-my-opencode.json"), - opencode: join(configDir, "opencode.json"), - } -} - -function loadAllConfigs(): Map { - const paths = getConfigPaths() - const configs = new Map() - - const project = loadJsonFile(paths.project) - if (project) configs.set("project", project) - - const user = loadJsonFile(paths.user) - if (user) configs.set("user", user) - - const opencode = loadJsonFile(paths.opencode) - if (opencode) configs.set("opencode", opencode) - - return configs -} - -function getMergedServers(): ServerWithSource[] { - const configs = loadAllConfigs() - const servers: ServerWithSource[] = [] - const disabled = new Set() - const seen = new Set() - - const sources: ConfigSource[] = ["project", "user", "opencode"] - - for (const source of sources) { - const config = configs.get(source) - if (!config?.lsp) continue - - for (const [id, entry] of Object.entries(config.lsp)) { - if (entry.disabled) { - disabled.add(id) - continue - } - - if (seen.has(id)) continue - if (!entry.command || !entry.extensions) continue - - servers.push({ - id, - command: entry.command, - extensions: entry.extensions, - priority: entry.priority ?? 0, - env: entry.env, - initialization: entry.initialization, - source, - }) - seen.add(id) - } - } - - for (const [id, config] of Object.entries(BUILTIN_SERVERS)) { - if (disabled.has(id) || seen.has(id)) continue - - servers.push({ - id, - command: config.command, - extensions: config.extensions, - priority: -100, - source: "opencode", - }) - } - - return servers.sort((a, b) => { - if (a.source !== b.source) { - const order: Record = { project: 0, user: 1, opencode: 2 } - return order[a.source] - order[b.source] - } - return b.priority - a.priority - }) -} - -export function findServerForExtension(ext: string): ServerLookupResult { - const servers = getMergedServers() - - for (const server of servers) { - if (server.extensions.includes(ext) && isServerInstalled(server.command)) { - return { - status: "found", - server: { - id: server.id, - command: server.command, - extensions: server.extensions, - priority: server.priority, - env: server.env, - initialization: server.initialization, - }, - } - } - } - - for (const server of servers) { - if (server.extensions.includes(ext)) { - const installHint = - LSP_INSTALL_HINTS[server.id] || `Install '${server.command[0]}' and ensure it's in your PATH` - return { - status: "not_installed", - server: { - id: server.id, - command: server.command, - extensions: server.extensions, - }, - installHint, - } - } - } - - const availableServers = [...new Set(servers.map((s) => s.id))] - return { - status: "not_configured", - extension: ext, - availableServers, - } -} - -export function getLanguageId(ext: string): string { - return EXT_TO_LANG[ext] || "plaintext" -} - -export function isServerInstalled(command: string[]): boolean { - if (command.length === 0) return false - - const cmd = command[0] - - // Support absolute paths (e.g., C:\Users\...\server.exe or /usr/local/bin/server) - if (cmd.includes("/") || cmd.includes("\\")) { - if (existsSync(cmd)) return true - } - - const isWindows = process.platform === "win32" - - let exts = [""] - if (isWindows) { - const pathExt = process.env.PATHEXT || "" - if (pathExt) { - const systemExts = pathExt.split(";").filter(Boolean) - exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])] - } else { - exts = ["", ".exe", ".cmd", ".bat", ".ps1"] - } - } - - let pathEnv = process.env.PATH || "" - if (isWindows && !pathEnv) { - pathEnv = process.env.Path || "" - } - - const pathSeparator = isWindows ? ";" : ":" - const paths = pathEnv.split(pathSeparator) - - for (const p of paths) { - for (const suffix of exts) { - if (existsSync(join(p, cmd + suffix))) { - return true - } - } - } - - const cwd = process.cwd() - const configDir = getOpenCodeConfigDir({ binary: "opencode" }) - const dataDir = join(getDataDir(), "opencode") - const additionalBases = [ - join(cwd, "node_modules", ".bin"), - join(configDir, "bin"), - join(configDir, "node_modules", ".bin"), - join(dataDir, "bin"), - ] - - for (const base of additionalBases) { - for (const suffix of exts) { - if (existsSync(join(base, cmd + suffix))) { - return true - } - } - } - - // Runtime wrappers (bun/node) are always available in oh-my-opencode context - if (cmd === "bun" || cmd === "node") { - return true - } - - return false -} - -export function getAllServers(): Array<{ - id: string - installed: boolean - extensions: string[] - disabled: boolean - source: string - priority: number -}> { - const configs = loadAllConfigs() - const servers = getMergedServers() - const disabled = new Set() - - for (const config of configs.values()) { - if (!config.lsp) continue - for (const [id, entry] of Object.entries(config.lsp)) { - if (entry.disabled) disabled.add(id) - } - } - - const result: Array<{ - id: string - installed: boolean - extensions: string[] - disabled: boolean - source: string - priority: number - }> = [] - - const seen = new Set() - - for (const server of servers) { - if (seen.has(server.id)) continue - result.push({ - id: server.id, - installed: isServerInstalled(server.command), - extensions: server.extensions, - disabled: false, - source: server.source, - priority: server.priority, - }) - seen.add(server.id) - } - - for (const id of disabled) { - if (seen.has(id)) continue - const builtin = BUILTIN_SERVERS[id] - result.push({ - id, - installed: builtin ? isServerInstalled(builtin.command) : false, - extensions: builtin?.extensions || [], - disabled: true, - source: "disabled", - priority: 0, - }) - } - - return result -} - -export function getConfigPaths_(): { project: string; user: string; opencode: string } { - return getConfigPaths() -} +export { findServerForExtension, getAllServers, getConfigPaths_ } from "./server-resolution" +export { getLanguageId } from "./language-config" +export { isServerInstalled } from "./server-installation" diff --git a/src/tools/lsp/constants.ts b/src/tools/lsp/constants.ts index d5aada38..5997b01b 100644 --- a/src/tools/lsp/constants.ts +++ b/src/tools/lsp/constants.ts @@ -1,390 +1,6 @@ -import type { LSPServerConfig } from "./types" - -export const SYMBOL_KIND_MAP: Record = { - 1: "File", - 2: "Module", - 3: "Namespace", - 4: "Package", - 5: "Class", - 6: "Method", - 7: "Property", - 8: "Field", - 9: "Constructor", - 10: "Enum", - 11: "Interface", - 12: "Function", - 13: "Variable", - 14: "Constant", - 15: "String", - 16: "Number", - 17: "Boolean", - 18: "Array", - 19: "Object", - 20: "Key", - 21: "Null", - 22: "EnumMember", - 23: "Struct", - 24: "Event", - 25: "Operator", - 26: "TypeParameter", -} - -export const SEVERITY_MAP: Record = { - 1: "error", - 2: "warning", - 3: "information", - 4: "hint", -} - export const DEFAULT_MAX_REFERENCES = 200 export const DEFAULT_MAX_SYMBOLS = 200 export const DEFAULT_MAX_DIAGNOSTICS = 200 -export const LSP_INSTALL_HINTS: Record = { - typescript: "npm install -g typescript-language-server typescript", - deno: "Install Deno from https://deno.land", - vue: "npm install -g @vue/language-server", - eslint: "npm install -g vscode-langservers-extracted", - oxlint: "npm install -g oxlint", - biome: "npm install -g @biomejs/biome", - gopls: "go install golang.org/x/tools/gopls@latest", - "ruby-lsp": "gem install ruby-lsp", - basedpyright: "pip install basedpyright", - pyright: "pip install pyright", - ty: "pip install ty", - ruff: "pip install ruff", - "elixir-ls": "See https://github.com/elixir-lsp/elixir-ls", - zls: "See https://github.com/zigtools/zls", - csharp: "dotnet tool install -g csharp-ls", - fsharp: "dotnet tool install -g fsautocomplete", - "sourcekit-lsp": "Included with Xcode or Swift toolchain", - rust: "rustup component add rust-analyzer", - clangd: "See https://clangd.llvm.org/installation", - svelte: "npm install -g svelte-language-server", - astro: "npm install -g @astrojs/language-server", - "bash-ls": "npm install -g bash-language-server", - jdtls: "See https://github.com/eclipse-jdtls/eclipse.jdt.ls", - "yaml-ls": "npm install -g yaml-language-server", - "lua-ls": "See https://github.com/LuaLS/lua-language-server", - php: "npm install -g intelephense", - dart: "Included with Dart SDK", - "terraform-ls": "See https://github.com/hashicorp/terraform-ls", - terraform: "See https://github.com/hashicorp/terraform-ls", - prisma: "npm install -g prisma", - "ocaml-lsp": "opam install ocaml-lsp-server", - texlab: "See https://github.com/latex-lsp/texlab", - dockerfile: "npm install -g dockerfile-language-server-nodejs", - gleam: "See https://gleam.run/getting-started/installing/", - "clojure-lsp": "See https://clojure-lsp.io/installation/", - nixd: "nix profile install nixpkgs#nixd", - tinymist: "See https://github.com/Myriad-Dreamin/tinymist", - "haskell-language-server": "ghcup install hls", - bash: "npm install -g bash-language-server", - "kotlin-ls": "See https://github.com/Kotlin/kotlin-lsp", -} - -// Synced with OpenCode's server.ts -// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/server.ts -export const BUILTIN_SERVERS: Record> = { - typescript: { - command: ["typescript-language-server", "--stdio"], - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], - }, - deno: { - command: ["deno", "lsp"], - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], - }, - vue: { - command: ["vue-language-server", "--stdio"], - extensions: [".vue"], - }, - eslint: { - command: ["vscode-eslint-language-server", "--stdio"], - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], - }, - oxlint: { - command: ["oxlint", "--lsp"], - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"], - }, - biome: { - command: ["biome", "lsp-proxy", "--stdio"], - extensions: [ - ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", - ".json", ".jsonc", ".vue", ".astro", ".svelte", ".css", ".graphql", ".gql", ".html", - ], - }, - gopls: { - command: ["gopls"], - extensions: [".go"], - }, - "ruby-lsp": { - command: ["rubocop", "--lsp"], - extensions: [".rb", ".rake", ".gemspec", ".ru"], - }, - basedpyright: { - command: ["basedpyright-langserver", "--stdio"], - extensions: [".py", ".pyi"], - }, - pyright: { - command: ["pyright-langserver", "--stdio"], - extensions: [".py", ".pyi"], - }, - ty: { - command: ["ty", "server"], - extensions: [".py", ".pyi"], - }, - ruff: { - command: ["ruff", "server"], - extensions: [".py", ".pyi"], - }, - "elixir-ls": { - command: ["elixir-ls"], - extensions: [".ex", ".exs"], - }, - zls: { - command: ["zls"], - extensions: [".zig", ".zon"], - }, - csharp: { - command: ["csharp-ls"], - extensions: [".cs"], - }, - fsharp: { - command: ["fsautocomplete"], - extensions: [".fs", ".fsi", ".fsx", ".fsscript"], - }, - "sourcekit-lsp": { - command: ["sourcekit-lsp"], - extensions: [".swift", ".objc", ".objcpp"], - }, - rust: { - command: ["rust-analyzer"], - extensions: [".rs"], - }, - clangd: { - command: ["clangd", "--background-index", "--clang-tidy"], - extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], - }, - svelte: { - command: ["svelteserver", "--stdio"], - extensions: [".svelte"], - }, - astro: { - command: ["astro-ls", "--stdio"], - extensions: [".astro"], - }, - bash: { - command: ["bash-language-server", "start"], - extensions: [".sh", ".bash", ".zsh", ".ksh"], - }, - // Keep legacy alias for backward compatibility - "bash-ls": { - command: ["bash-language-server", "start"], - extensions: [".sh", ".bash", ".zsh", ".ksh"], - }, - jdtls: { - command: ["jdtls"], - extensions: [".java"], - }, - "yaml-ls": { - command: ["yaml-language-server", "--stdio"], - extensions: [".yaml", ".yml"], - }, - "lua-ls": { - command: ["lua-language-server"], - extensions: [".lua"], - }, - php: { - command: ["intelephense", "--stdio"], - extensions: [".php"], - }, - dart: { - command: ["dart", "language-server", "--lsp"], - extensions: [".dart"], - }, - terraform: { - command: ["terraform-ls", "serve"], - extensions: [".tf", ".tfvars"], - }, - // Legacy alias for backward compatibility - "terraform-ls": { - command: ["terraform-ls", "serve"], - extensions: [".tf", ".tfvars"], - }, - prisma: { - command: ["prisma", "language-server"], - extensions: [".prisma"], - }, - "ocaml-lsp": { - command: ["ocamllsp"], - extensions: [".ml", ".mli"], - }, - texlab: { - command: ["texlab"], - extensions: [".tex", ".bib"], - }, - dockerfile: { - command: ["docker-langserver", "--stdio"], - extensions: [".dockerfile"], - }, - gleam: { - command: ["gleam", "lsp"], - extensions: [".gleam"], - }, - "clojure-lsp": { - command: ["clojure-lsp", "listen"], - extensions: [".clj", ".cljs", ".cljc", ".edn"], - }, - nixd: { - command: ["nixd"], - extensions: [".nix"], - }, - tinymist: { - command: ["tinymist"], - extensions: [".typ", ".typc"], - }, - "haskell-language-server": { - command: ["haskell-language-server-wrapper", "--lsp"], - extensions: [".hs", ".lhs"], - }, - "kotlin-ls": { - command: ["kotlin-lsp"], - extensions: [".kt", ".kts"], - }, -} - -// Synced with OpenCode's language.ts -// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/language.ts -export const EXT_TO_LANG: Record = { - ".abap": "abap", - ".bat": "bat", - ".bib": "bibtex", - ".bibtex": "bibtex", - ".clj": "clojure", - ".cljs": "clojure", - ".cljc": "clojure", - ".edn": "clojure", - ".coffee": "coffeescript", - ".c": "c", - ".cpp": "cpp", - ".cxx": "cpp", - ".cc": "cpp", - ".c++": "cpp", - ".cs": "csharp", - ".css": "css", - ".d": "d", - ".pas": "pascal", - ".pascal": "pascal", - ".diff": "diff", - ".patch": "diff", - ".dart": "dart", - ".dockerfile": "dockerfile", - ".ex": "elixir", - ".exs": "elixir", - ".erl": "erlang", - ".hrl": "erlang", - ".fs": "fsharp", - ".fsi": "fsharp", - ".fsx": "fsharp", - ".fsscript": "fsharp", - ".gitcommit": "git-commit", - ".gitrebase": "git-rebase", - ".go": "go", - ".groovy": "groovy", - ".gleam": "gleam", - ".hbs": "handlebars", - ".handlebars": "handlebars", - ".hs": "haskell", - ".html": "html", - ".htm": "html", - ".ini": "ini", - ".java": "java", - ".js": "javascript", - ".jsx": "javascriptreact", - ".json": "json", - ".jsonc": "jsonc", - ".tex": "latex", - ".latex": "latex", - ".less": "less", - ".lua": "lua", - ".makefile": "makefile", - makefile: "makefile", - ".md": "markdown", - ".markdown": "markdown", - ".m": "objective-c", - ".mm": "objective-cpp", - ".pl": "perl", - ".pm": "perl", - ".pm6": "perl6", - ".php": "php", - ".ps1": "powershell", - ".psm1": "powershell", - ".pug": "jade", - ".jade": "jade", - ".py": "python", - ".pyi": "python", - ".r": "r", - ".cshtml": "razor", - ".razor": "razor", - ".rb": "ruby", - ".rake": "ruby", - ".gemspec": "ruby", - ".ru": "ruby", - ".erb": "erb", - ".html.erb": "erb", - ".js.erb": "erb", - ".css.erb": "erb", - ".json.erb": "erb", - ".rs": "rust", - ".scss": "scss", - ".sass": "sass", - ".scala": "scala", - ".shader": "shaderlab", - ".sh": "shellscript", - ".bash": "shellscript", - ".zsh": "shellscript", - ".ksh": "shellscript", - ".sql": "sql", - ".svelte": "svelte", - ".swift": "swift", - ".ts": "typescript", - ".tsx": "typescriptreact", - ".mts": "typescript", - ".cts": "typescript", - ".mtsx": "typescriptreact", - ".ctsx": "typescriptreact", - ".xml": "xml", - ".xsl": "xsl", - ".yaml": "yaml", - ".yml": "yaml", - ".mjs": "javascript", - ".cjs": "javascript", - ".vue": "vue", - ".zig": "zig", - ".zon": "zig", - ".astro": "astro", - ".ml": "ocaml", - ".mli": "ocaml", - ".tf": "terraform", - ".tfvars": "terraform-vars", - ".hcl": "hcl", - ".nix": "nix", - ".typ": "typst", - ".typc": "typst", - ".ets": "typescript", - ".lhs": "haskell", - ".kt": "kotlin", - ".kts": "kotlin", - ".prisma": "prisma", - // Additional extensions not in OpenCode - ".h": "c", - ".hpp": "cpp", - ".hh": "cpp", - ".hxx": "cpp", - ".h++": "cpp", - ".objc": "objective-c", - ".objcpp": "objective-cpp", - ".fish": "fish", - ".graphql": "graphql", - ".gql": "graphql", -} +export { SYMBOL_KIND_MAP, SEVERITY_MAP, EXT_TO_LANG } from "./language-mappings" +export { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./server-definitions" diff --git a/src/tools/lsp/diagnostics-tool.ts b/src/tools/lsp/diagnostics-tool.ts new file mode 100644 index 00000000..b9d944e4 --- /dev/null +++ b/src/tools/lsp/diagnostics-tool.ts @@ -0,0 +1,53 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" + +import { DEFAULT_MAX_DIAGNOSTICS } from "./constants" +import { filterDiagnosticsBySeverity, formatDiagnostic } from "./lsp-formatters" +import { withLspClient } from "./lsp-client-wrapper" +import type { Diagnostic } from "./types" + +export const lsp_diagnostics: ToolDefinition = tool({ + description: "Get errors, warnings, hints from language server BEFORE running build.", + args: { + filePath: tool.schema.string(), + severity: tool.schema + .enum(["error", "warning", "information", "hint", "all"]) + .optional() + .describe("Filter by severity level"), + }, + execute: async (args, context) => { + try { + const result = await withLspClient(args.filePath, async (client) => { + return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null + }) + + let diagnostics: Diagnostic[] = [] + if (result) { + if (Array.isArray(result)) { + diagnostics = result + } else if (result.items) { + diagnostics = result.items + } + } + + diagnostics = filterDiagnosticsBySeverity(diagnostics, args.severity) + + if (diagnostics.length === 0) { + const output = "No diagnostics found" + return output + } + + const total = diagnostics.length + const truncated = total > DEFAULT_MAX_DIAGNOSTICS + const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics + const lines = limited.map(formatDiagnostic) + if (truncated) { + lines.unshift(`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`) + } + const output = lines.join("\n") + return output + } catch (e) { + const output = `Error: ${e instanceof Error ? e.message : String(e)}` + throw new Error(output) + } + }, +}) diff --git a/src/tools/lsp/find-references-tool.ts b/src/tools/lsp/find-references-tool.ts new file mode 100644 index 00000000..b9b93556 --- /dev/null +++ b/src/tools/lsp/find-references-tool.ts @@ -0,0 +1,43 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" + +import { DEFAULT_MAX_REFERENCES } from "./constants" +import { formatLocation } from "./lsp-formatters" +import { withLspClient } from "./lsp-client-wrapper" +import type { Location } from "./types" + +export const lsp_find_references: ToolDefinition = tool({ + description: "Find ALL usages/references of a symbol across the entire workspace.", + args: { + filePath: tool.schema.string(), + line: tool.schema.number().min(1).describe("1-based"), + character: tool.schema.number().min(0).describe("0-based"), + includeDeclaration: tool.schema.boolean().optional().describe("Include the declaration itself"), + }, + execute: async (args, context) => { + try { + const result = await withLspClient(args.filePath, async (client) => { + return (await client.references(args.filePath, args.line, args.character, args.includeDeclaration ?? true)) as + | Location[] + | null + }) + + if (!result || result.length === 0) { + const output = "No references found" + return output + } + + const total = result.length + const truncated = total > DEFAULT_MAX_REFERENCES + const limited = truncated ? result.slice(0, DEFAULT_MAX_REFERENCES) : result + const lines = limited.map(formatLocation) + if (truncated) { + lines.unshift(`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`) + } + const output = lines.join("\n") + return output + } catch (e) { + const output = `Error: ${e instanceof Error ? e.message : String(e)}` + return output + } + }, +}) diff --git a/src/tools/lsp/goto-definition-tool.ts b/src/tools/lsp/goto-definition-tool.ts new file mode 100644 index 00000000..2cdf76fb --- /dev/null +++ b/src/tools/lsp/goto-definition-tool.ts @@ -0,0 +1,42 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" + +import { formatLocation } from "./lsp-formatters" +import { withLspClient } from "./lsp-client-wrapper" +import type { Location, LocationLink } from "./types" + +export const lsp_goto_definition: ToolDefinition = tool({ + description: "Jump to symbol definition. Find WHERE something is defined.", + args: { + filePath: tool.schema.string(), + line: tool.schema.number().min(1).describe("1-based"), + character: tool.schema.number().min(0).describe("0-based"), + }, + execute: async (args, context) => { + try { + const result = await withLspClient(args.filePath, async (client) => { + return (await client.definition(args.filePath, args.line, args.character)) as + | Location + | Location[] + | LocationLink[] + | null + }) + + if (!result) { + const output = "No definition found" + return output + } + + const locations = Array.isArray(result) ? result : [result] + if (locations.length === 0) { + const output = "No definition found" + return output + } + + const output = locations.map(formatLocation).join("\n") + return output + } catch (e) { + const output = `Error: ${e instanceof Error ? e.message : String(e)}` + return output + } + }, +}) diff --git a/src/tools/lsp/index.ts b/src/tools/lsp/index.ts index f149bec3..92421765 100644 --- a/src/tools/lsp/index.ts +++ b/src/tools/lsp/index.ts @@ -2,6 +2,8 @@ export * from "./types" export * from "./constants" export * from "./config" export * from "./client" -export * from "./utils" +export * from "./lsp-client-wrapper" +export * from "./lsp-formatters" +export * from "./workspace-edit" // NOTE: lsp_servers removed - duplicates OpenCode's built-in LspServers export { lsp_goto_definition, lsp_find_references, lsp_symbols, lsp_diagnostics, lsp_prepare_rename, lsp_rename } from "./tools" diff --git a/src/tools/lsp/language-config.ts b/src/tools/lsp/language-config.ts new file mode 100644 index 00000000..75b84f8f --- /dev/null +++ b/src/tools/lsp/language-config.ts @@ -0,0 +1,5 @@ +import { EXT_TO_LANG } from "./constants" + +export function getLanguageId(ext: string): string { + return EXT_TO_LANG[ext] || "plaintext" +} diff --git a/src/tools/lsp/language-mappings.ts b/src/tools/lsp/language-mappings.ts new file mode 100644 index 00000000..136c6821 --- /dev/null +++ b/src/tools/lsp/language-mappings.ts @@ -0,0 +1,171 @@ +export const SYMBOL_KIND_MAP: Record = { + 1: "File", + 2: "Module", + 3: "Namespace", + 4: "Package", + 5: "Class", + 6: "Method", + 7: "Property", + 8: "Field", + 9: "Constructor", + 10: "Enum", + 11: "Interface", + 12: "Function", + 13: "Variable", + 14: "Constant", + 15: "String", + 16: "Number", + 17: "Boolean", + 18: "Array", + 19: "Object", + 20: "Key", + 21: "Null", + 22: "EnumMember", + 23: "Struct", + 24: "Event", + 25: "Operator", + 26: "TypeParameter", +} + +export const SEVERITY_MAP: Record = { + 1: "error", + 2: "warning", + 3: "information", + 4: "hint", +} + +// Synced with OpenCode's language.ts +// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/language.ts +export const EXT_TO_LANG: Record = { + ".abap": "abap", + ".bat": "bat", + ".bib": "bibtex", + ".bibtex": "bibtex", + ".clj": "clojure", + ".cljs": "clojure", + ".cljc": "clojure", + ".edn": "clojure", + ".coffee": "coffeescript", + ".c": "c", + ".cpp": "cpp", + ".cxx": "cpp", + ".cc": "cpp", + ".c++": "cpp", + ".cs": "csharp", + ".css": "css", + ".d": "d", + ".pas": "pascal", + ".pascal": "pascal", + ".diff": "diff", + ".patch": "diff", + ".dart": "dart", + ".dockerfile": "dockerfile", + ".ex": "elixir", + ".exs": "elixir", + ".erl": "erlang", + ".hrl": "erlang", + ".fs": "fsharp", + ".fsi": "fsharp", + ".fsx": "fsharp", + ".fsscript": "fsharp", + ".gitcommit": "git-commit", + ".gitrebase": "git-rebase", + ".go": "go", + ".groovy": "groovy", + ".gleam": "gleam", + ".hbs": "handlebars", + ".handlebars": "handlebars", + ".hs": "haskell", + ".html": "html", + ".htm": "html", + ".ini": "ini", + ".java": "java", + ".js": "javascript", + ".jsx": "javascriptreact", + ".json": "json", + ".jsonc": "jsonc", + ".tex": "latex", + ".latex": "latex", + ".less": "less", + ".lua": "lua", + ".makefile": "makefile", + makefile: "makefile", + ".md": "markdown", + ".markdown": "markdown", + ".m": "objective-c", + ".mm": "objective-cpp", + ".pl": "perl", + ".pm": "perl", + ".pm6": "perl6", + ".php": "php", + ".ps1": "powershell", + ".psm1": "powershell", + ".pug": "jade", + ".jade": "jade", + ".py": "python", + ".pyi": "python", + ".r": "r", + ".cshtml": "razor", + ".razor": "razor", + ".rb": "ruby", + ".rake": "ruby", + ".gemspec": "ruby", + ".ru": "ruby", + ".erb": "erb", + ".html.erb": "erb", + ".js.erb": "erb", + ".css.erb": "erb", + ".json.erb": "erb", + ".rs": "rust", + ".scss": "scss", + ".sass": "sass", + ".scala": "scala", + ".shader": "shaderlab", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".ksh": "shellscript", + ".sql": "sql", + ".svelte": "svelte", + ".swift": "swift", + ".ts": "typescript", + ".tsx": "typescriptreact", + ".mts": "typescript", + ".cts": "typescript", + ".mtsx": "typescriptreact", + ".ctsx": "typescriptreact", + ".xml": "xml", + ".xsl": "xsl", + ".yaml": "yaml", + ".yml": "yaml", + ".mjs": "javascript", + ".cjs": "javascript", + ".vue": "vue", + ".zig": "zig", + ".zon": "zig", + ".astro": "astro", + ".ml": "ocaml", + ".mli": "ocaml", + ".tf": "terraform", + ".tfvars": "terraform-vars", + ".hcl": "hcl", + ".nix": "nix", + ".typ": "typst", + ".typc": "typst", + ".ets": "typescript", + ".lhs": "haskell", + ".kt": "kotlin", + ".kts": "kotlin", + ".prisma": "prisma", + // Additional extensions not in OpenCode + ".h": "c", + ".hpp": "cpp", + ".hh": "cpp", + ".hxx": "cpp", + ".h++": "cpp", + ".objc": "objective-c", + ".objcpp": "objective-cpp", + ".fish": "fish", + ".graphql": "graphql", + ".gql": "graphql", +} diff --git a/src/tools/lsp/lsp-client-connection.ts b/src/tools/lsp/lsp-client-connection.ts new file mode 100644 index 00000000..b9eec9de --- /dev/null +++ b/src/tools/lsp/lsp-client-connection.ts @@ -0,0 +1,66 @@ +import { pathToFileURL } from "node:url" + +import { LSPClientTransport } from "./lsp-client-transport" + +export class LSPClientConnection extends LSPClientTransport { + async initialize(): Promise { + const rootUri = pathToFileURL(this.root).href + await this.sendRequest("initialize", { + processId: process.pid, + rootUri, + rootPath: this.root, + workspaceFolders: [{ uri: rootUri, name: "workspace" }], + capabilities: { + textDocument: { + hover: { contentFormat: ["markdown", "plaintext"] }, + definition: { linkSupport: true }, + references: {}, + documentSymbol: { hierarchicalDocumentSymbolSupport: true }, + publishDiagnostics: {}, + rename: { + prepareSupport: true, + prepareSupportDefaultBehavior: 1, + honorsChangeAnnotations: true, + }, + codeAction: { + codeActionLiteralSupport: { + codeActionKind: { + valueSet: [ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + "source.fixAll", + ], + }, + }, + isPreferredSupport: true, + disabledSupport: true, + dataSupport: true, + resolveSupport: { + properties: ["edit", "command"], + }, + }, + }, + workspace: { + symbol: {}, + workspaceFolders: true, + configuration: true, + applyEdit: true, + workspaceEdit: { + documentChanges: true, + }, + }, + }, + ...this.server.initialization, + }) + this.sendNotification("initialized") + this.sendNotification("workspace/didChangeConfiguration", { + settings: { json: { validate: { enable: true } } }, + }) + await new Promise((r) => setTimeout(r, 300)) + } +} diff --git a/src/tools/lsp/lsp-client-transport.ts b/src/tools/lsp/lsp-client-transport.ts new file mode 100644 index 00000000..d4590262 --- /dev/null +++ b/src/tools/lsp/lsp-client-transport.ts @@ -0,0 +1,194 @@ +import { Readable, Writable } from "node:stream" +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, + type MessageConnection, +} from "vscode-jsonrpc/node" +import type { Diagnostic, ResolvedServer } from "./types" +import { spawnProcess, type UnifiedProcess } from "./lsp-process" +import { log } from "../../shared/logger" +export class LSPClientTransport { + protected proc: UnifiedProcess | null = null + protected connection: MessageConnection | null = null + protected readonly stderrBuffer: string[] = [] + protected processExited = false + protected readonly diagnosticsStore = new Map() + protected readonly REQUEST_TIMEOUT = 15000 + + constructor(protected root: string, protected server: ResolvedServer) {} + async start(): Promise { + this.proc = spawnProcess(this.server.command, { + cwd: this.root, + env: { + ...process.env, + ...this.server.env, + }, + }) + if (!this.proc) { + throw new Error(`Failed to spawn LSP server: ${this.server.command.join(" ")}`) + } + this.startStderrReading() + await new Promise((resolve) => setTimeout(resolve, 100)) + + if (this.proc.exitCode !== null) { + const stderr = this.stderrBuffer.join("\n") + throw new Error(`LSP server exited immediately with code ${this.proc.exitCode}` + (stderr ? `\nstderr: ${stderr}` : "")) + } + + const stdoutReader = this.proc.stdout.getReader() + const nodeReadable = new Readable({ + async read() { + try { + const { done, value } = await stdoutReader.read() + if (done || !value) { + this.push(null) + } else { + this.push(Buffer.from(value)) + } + } catch { + this.push(null) + } + }, + }) + + const stdin = this.proc.stdin + const nodeWritable = new Writable({ + write(chunk, _encoding, callback) { + try { + stdin.write(chunk) + callback() + } catch (err) { + callback(err as Error) + } + }, + }) + + this.connection = createMessageConnection(new StreamMessageReader(nodeReadable), new StreamMessageWriter(nodeWritable)) + + this.connection.onNotification("textDocument/publishDiagnostics", (params: { uri?: string; diagnostics?: Diagnostic[] }) => { + if (params.uri) { + this.diagnosticsStore.set(params.uri, params.diagnostics ?? []) + } + }) + + this.connection.onRequest("workspace/configuration", (params: { items?: Array<{ section?: string }> }) => { + const items = params?.items ?? [] + return items.map((item) => { + if (item.section === "json") return { validate: { enable: true } } + return {} + }) + }) + + this.connection.onRequest("client/registerCapability", () => null) + this.connection.onRequest("window/workDoneProgress/create", () => null) + + this.connection.onClose(() => { + this.processExited = true + }) + + this.connection.onError((error) => { + log("LSP connection error:", error) + }) + + this.connection.listen() + } + + protected startStderrReading(): void { + if (!this.proc) return + const reader = this.proc.stderr.getReader() + const read = async () => { + const decoder = new TextDecoder() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + const text = decoder.decode(value) + this.stderrBuffer.push(text) + if (this.stderrBuffer.length > 100) { + this.stderrBuffer.shift() + } + } + } catch {} + } + read() + } + + protected async sendRequest(method: string, params?: unknown): Promise { + if (!this.connection) throw new Error("LSP client not started") + + if (this.processExited || (this.proc && this.proc.exitCode !== null)) { + const stderr = this.stderrBuffer.slice(-10).join("\n") + throw new Error(`LSP server already exited (code: ${this.proc?.exitCode})` + (stderr ? `\nstderr: ${stderr}` : "")) + } + + let timeoutId: ReturnType + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + const stderr = this.stderrBuffer.slice(-5).join("\n") + reject(new Error(`LSP request timeout (method: ${method})` + (stderr ? `\nrecent stderr: ${stderr}` : ""))) + }, this.REQUEST_TIMEOUT) + }) + + const requestPromise = this.connection.sendRequest(method, params) as Promise + + try { + const result = await Promise.race([requestPromise, timeoutPromise]) + clearTimeout(timeoutId!) + return result + } catch (error) { + clearTimeout(timeoutId!) + throw error + } + } + + protected sendNotification(method: string, params?: unknown): void { + if (!this.connection) return + if (this.processExited || (this.proc && this.proc.exitCode !== null)) return + this.connection.sendNotification(method, params) + } + + isAlive(): boolean { + return this.proc !== null && !this.processExited && this.proc.exitCode === null + } + + async stop(): Promise { + if (this.connection) { + try { + this.sendNotification("shutdown", {}) + this.sendNotification("exit") + } catch {} + this.connection.dispose() + this.connection = null + } + const proc = this.proc + if (proc) { + this.proc = null + let exitedBeforeTimeout = false + try { + proc.kill() + // Wait for exit with timeout to prevent indefinite hang + let timeoutId: ReturnType | undefined + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(resolve, 5000) + }) + await Promise.race([ + proc.exited.then(() => { + exitedBeforeTimeout = true + }).finally(() => timeoutId && clearTimeout(timeoutId)), + timeoutPromise, + ]) + if (!exitedBeforeTimeout) { + log("[LSPClient] Process did not exit within timeout, escalating to SIGKILL") + try { + proc.kill("SIGKILL") + // Wait briefly for SIGKILL to take effect + await Promise.race([proc.exited, new Promise((resolve) => setTimeout(resolve, 1000))]) + } catch {} + } + } catch {} + } + this.processExited = true + this.diagnosticsStore.clear() + } +} diff --git a/src/tools/lsp/lsp-client-wrapper.ts b/src/tools/lsp/lsp-client-wrapper.ts new file mode 100644 index 00000000..7ec33847 --- /dev/null +++ b/src/tools/lsp/lsp-client-wrapper.ts @@ -0,0 +1,100 @@ +import { extname, resolve } from "path" +import { fileURLToPath } from "node:url" +import { existsSync } from "fs" + +import { LSPClient, lspManager } from "./client" +import { findServerForExtension } from "./config" +import type { ServerLookupResult } from "./types" + +export function findWorkspaceRoot(filePath: string): string { + let dir = resolve(filePath) + + if (!existsSync(dir) || !require("fs").statSync(dir).isDirectory()) { + dir = require("path").dirname(dir) + } + + const markers = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "build.gradle"] + + let prevDir = "" + while (dir !== prevDir) { + for (const marker of markers) { + if (existsSync(require("path").join(dir, marker))) { + return dir + } + } + prevDir = dir + dir = require("path").dirname(dir) + } + + return require("path").dirname(resolve(filePath)) +} + +export function uriToPath(uri: string): string { + return fileURLToPath(uri) +} + +export function formatServerLookupError(result: Exclude): string { + if (result.status === "not_installed") { + const { server, installHint } = result + return [ + `LSP server '${server.id}' is configured but NOT INSTALLED.`, + ``, + `Command not found: ${server.command[0]}`, + ``, + `To install:`, + ` ${installHint}`, + ``, + `Supported extensions: ${server.extensions.join(", ")}`, + ``, + `After installation, the server will be available automatically.`, + `Run 'LspServers' tool to verify installation status.`, + ].join("\n") + } + + return [ + `No LSP server configured for extension: ${result.extension}`, + ``, + `Available servers: ${result.availableServers.slice(0, 10).join(", ")}${result.availableServers.length > 10 ? "..." : ""}`, + ``, + `To add a custom server, configure 'lsp' in oh-my-opencode.json:`, + ` {`, + ` "lsp": {`, + ` "my-server": {`, + ` "command": ["my-lsp", "--stdio"],`, + ` "extensions": ["${result.extension}"]`, + ` }`, + ` }`, + ` }`, + ].join("\n") +} + +export async function withLspClient(filePath: string, fn: (client: LSPClient) => Promise): Promise { + const absPath = resolve(filePath) + const ext = extname(absPath) + const result = findServerForExtension(ext) + + if (result.status !== "found") { + throw new Error(formatServerLookupError(result)) + } + + const server = result.server + const root = findWorkspaceRoot(absPath) + const client = await lspManager.getClient(root, server) + + try { + return await fn(client) + } catch (e) { + if (e instanceof Error && e.message.includes("timeout")) { + const isInitializing = lspManager.isServerInitializing(root, server.id) + if (isInitializing) { + throw new Error( + `LSP server is still initializing. Please retry in a few seconds. ` + + `Original error: ${e.message}` + ) + } + } + throw e + } finally { + lspManager.releaseClient(root, server.id) + } +} diff --git a/src/tools/lsp/lsp-client.ts b/src/tools/lsp/lsp-client.ts new file mode 100644 index 00000000..4785909c --- /dev/null +++ b/src/tools/lsp/lsp-client.ts @@ -0,0 +1,129 @@ +import { readFileSync } from "fs" +import { extname, resolve } from "path" +import { pathToFileURL } from "node:url" + +import { getLanguageId } from "./config" +import { LSPClientConnection } from "./lsp-client-connection" +import type { Diagnostic } from "./types" + +export class LSPClient extends LSPClientConnection { + private openedFiles = new Set() + private documentVersions = new Map() + private lastSyncedText = new Map() + + async openFile(filePath: string): Promise { + const absPath = resolve(filePath) + + const uri = pathToFileURL(absPath).href + const text = readFileSync(absPath, "utf-8") + + if (!this.openedFiles.has(absPath)) { + const ext = extname(absPath) + const languageId = getLanguageId(ext) + const version = 1 + + this.sendNotification("textDocument/didOpen", { + textDocument: { + uri, + languageId, + version, + text, + }, + }) + + this.openedFiles.add(absPath) + this.documentVersions.set(uri, version) + this.lastSyncedText.set(uri, text) + await new Promise((r) => setTimeout(r, 1000)) + return + } + + const prevText = this.lastSyncedText.get(uri) + if (prevText === text) { + return + } + + const nextVersion = (this.documentVersions.get(uri) ?? 1) + 1 + this.documentVersions.set(uri, nextVersion) + this.lastSyncedText.set(uri, text) + + this.sendNotification("textDocument/didChange", { + textDocument: { uri, version: nextVersion }, + contentChanges: [{ text }], + }) + + // Some servers update diagnostics only after save + this.sendNotification("textDocument/didSave", { + textDocument: { uri }, + text, + }) + } + + async definition(filePath: string, line: number, character: number): Promise { + const absPath = resolve(filePath) + await this.openFile(absPath) + return this.sendRequest("textDocument/definition", { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + }) + } + + async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise { + const absPath = resolve(filePath) + await this.openFile(absPath) + return this.sendRequest("textDocument/references", { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + context: { includeDeclaration }, + }) + } + + async documentSymbols(filePath: string): Promise { + const absPath = resolve(filePath) + await this.openFile(absPath) + return this.sendRequest("textDocument/documentSymbol", { + textDocument: { uri: pathToFileURL(absPath).href }, + }) + } + + async workspaceSymbols(query: string): Promise { + return this.sendRequest("workspace/symbol", { query }) + } + + async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> { + const absPath = resolve(filePath) + const uri = pathToFileURL(absPath).href + await this.openFile(absPath) + await new Promise((r) => setTimeout(r, 500)) + + try { + const result = await this.sendRequest<{ items?: Diagnostic[] }>("textDocument/diagnostic", { + textDocument: { uri }, + }) + if (result && typeof result === "object" && "items" in result) { + return result as { items: Diagnostic[] } + } + } catch {} + + return { items: this.diagnosticsStore.get(uri) ?? [] } + } + + async prepareRename(filePath: string, line: number, character: number): Promise { + const absPath = resolve(filePath) + await this.openFile(absPath) + return this.sendRequest("textDocument/prepareRename", { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + }) + } + + async rename(filePath: string, line: number, character: number, newName: string): Promise { + const absPath = resolve(filePath) + await this.openFile(absPath) + return this.sendRequest("textDocument/rename", { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + newName, + }) + } +} diff --git a/src/tools/lsp/lsp-formatters.ts b/src/tools/lsp/lsp-formatters.ts new file mode 100644 index 00000000..0633d55f --- /dev/null +++ b/src/tools/lsp/lsp-formatters.ts @@ -0,0 +1,193 @@ +import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants" +import { uriToPath } from "./lsp-client-wrapper" +import type { + Diagnostic, + DocumentSymbol, + Location, + LocationLink, + PrepareRenameDefaultBehavior, + PrepareRenameResult, + Range, + SymbolInfo, + TextEdit, + WorkspaceEdit, +} from "./types" +import type { ApplyResult } from "./workspace-edit" + +export function formatLocation(loc: Location | LocationLink): string { + if ("targetUri" in loc) { + const uri = uriToPath(loc.targetUri) + const line = loc.targetRange.start.line + 1 + const char = loc.targetRange.start.character + return `${uri}:${line}:${char}` + } + + const uri = uriToPath(loc.uri) + const line = loc.range.start.line + 1 + const char = loc.range.start.character + return `${uri}:${line}:${char}` +} + +export function formatSymbolKind(kind: number): string { + return SYMBOL_KIND_MAP[kind] || `Unknown(${kind})` +} + +export function formatSeverity(severity: number | undefined): string { + if (!severity) return "unknown" + return SEVERITY_MAP[severity] || `unknown(${severity})` +} + +export function formatDocumentSymbol(symbol: DocumentSymbol, indent = 0): string { + const prefix = " ".repeat(indent) + const kind = formatSymbolKind(symbol.kind) + const line = symbol.range.start.line + 1 + let result = `${prefix}${symbol.name} (${kind}) - line ${line}` + + if (symbol.children && symbol.children.length > 0) { + for (const child of symbol.children) { + result += "\n" + formatDocumentSymbol(child, indent + 1) + } + } + + return result +} + +export function formatSymbolInfo(symbol: SymbolInfo): string { + const kind = formatSymbolKind(symbol.kind) + const loc = formatLocation(symbol.location) + const container = symbol.containerName ? ` (in ${symbol.containerName})` : "" + return `${symbol.name} (${kind})${container} - ${loc}` +} + +export function formatDiagnostic(diag: Diagnostic): string { + const severity = formatSeverity(diag.severity) + const line = diag.range.start.line + 1 + const char = diag.range.start.character + const source = diag.source ? `[${diag.source}]` : "" + const code = diag.code ? ` (${diag.code})` : "" + return `${severity}${source}${code} at ${line}:${char}: ${diag.message}` +} + +export function filterDiagnosticsBySeverity( + diagnostics: Diagnostic[], + severityFilter?: "error" | "warning" | "information" | "hint" | "all" +): Diagnostic[] { + if (!severityFilter || severityFilter === "all") { + return diagnostics + } + + const severityMap: Record = { + error: 1, + warning: 2, + information: 3, + hint: 4, + } + + const targetSeverity = severityMap[severityFilter] + return diagnostics.filter((d) => d.severity === targetSeverity) +} + +export function formatPrepareRenameResult( + result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null +): string { + if (!result) return "Cannot rename at this position" + + // Case 1: { defaultBehavior: boolean } + if ("defaultBehavior" in result) { + return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position" + } + + // Case 2: { range: Range, placeholder?: string } + if ("range" in result && result.range) { + const startLine = result.range.start.line + 1 + const startChar = result.range.start.character + const endLine = result.range.end.line + 1 + const endChar = result.range.end.character + const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : "" + return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}` + } + + // Case 3: Range directly (has start/end but no range property) + if ("start" in result && "end" in result) { + const startLine = result.start.line + 1 + const startChar = result.start.character + const endLine = result.end.line + 1 + const endChar = result.end.character + return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}` + } + + return "Cannot rename at this position" +} + +export function formatTextEdit(edit: TextEdit): string { + const startLine = edit.range.start.line + 1 + const startChar = edit.range.start.character + const endLine = edit.range.end.line + 1 + const endChar = edit.range.end.character + + const rangeStr = `${startLine}:${startChar}-${endLine}:${endChar}` + const preview = edit.newText.length > 50 ? edit.newText.substring(0, 50) + "..." : edit.newText + + return ` ${rangeStr}: "${preview}"` +} + +export function formatWorkspaceEdit(edit: WorkspaceEdit | null): string { + if (!edit) return "No changes" + + const lines: string[] = [] + + if (edit.changes) { + for (const [uri, edits] of Object.entries(edit.changes)) { + const filePath = uriToPath(uri) + lines.push(`File: ${filePath}`) + for (const textEdit of edits) { + lines.push(formatTextEdit(textEdit)) + } + } + } + + if (edit.documentChanges) { + for (const change of edit.documentChanges) { + if ("kind" in change) { + if (change.kind === "create") { + lines.push(`Create: ${change.uri}`) + } else if (change.kind === "rename") { + lines.push(`Rename: ${change.oldUri} -> ${change.newUri}`) + } else if (change.kind === "delete") { + lines.push(`Delete: ${change.uri}`) + } + } else { + const filePath = uriToPath(change.textDocument.uri) + lines.push(`File: ${filePath}`) + for (const textEdit of change.edits) { + lines.push(formatTextEdit(textEdit)) + } + } + } + } + + if (lines.length === 0) return "No changes" + + return lines.join("\n") +} + +export function formatApplyResult(result: ApplyResult): string { + const lines: string[] = [] + + if (result.success) { + lines.push(`Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`) + for (const file of result.filesModified) { + lines.push(` - ${file}`) + } + } else { + lines.push("Failed to apply some changes:") + for (const err of result.errors) { + lines.push(` Error: ${err}`) + } + if (result.filesModified.length > 0) { + lines.push(`Successfully modified: ${result.filesModified.join(", ")}`) + } + } + + return lines.join("\n") +} diff --git a/src/tools/lsp/lsp-process.ts b/src/tools/lsp/lsp-process.ts new file mode 100644 index 00000000..a193aa96 --- /dev/null +++ b/src/tools/lsp/lsp-process.ts @@ -0,0 +1,186 @@ +import { spawn as bunSpawn } from "bun" +import { spawn as nodeSpawn, spawnSync, type ChildProcess } from "node:child_process" +import { existsSync, statSync } from "fs" +import { log } from "../../shared/logger" +// Bun spawn segfaults on Windows (oven-sh/bun#25798) — unfixed as of v1.3.8+ +function shouldUseNodeSpawn(): boolean { + return process.platform === "win32" +} +// Prevents segfaults when libuv gets a non-existent cwd (oven-sh/bun#25798) +export function validateCwd(cwd: string): { valid: boolean; error?: string } { + try { + if (!existsSync(cwd)) { + return { valid: false, error: `Working directory does not exist: ${cwd}` } + } + const stats = statSync(cwd) + if (!stats.isDirectory()) { + return { valid: false, error: `Path is not a directory: ${cwd}` } + } + return { valid: true } + } catch (err) { + return { valid: false, error: `Cannot access working directory: ${cwd} (${err instanceof Error ? err.message : String(err)})` } + } +} +function isBinaryAvailableOnWindows(command: string): boolean { + if (process.platform !== "win32") return true + + if (command.includes("/") || command.includes("\\")) { + return existsSync(command) + } + + try { + const result = spawnSync("where", [command], { + shell: true, + windowsHide: true, + timeout: 5000, + }) + return result.status === 0 + } catch { + return true + } +} +interface StreamReader { + read(): Promise<{ done: boolean; value: Uint8Array | undefined }> +} +// Bridges Bun Subprocess and Node.js ChildProcess under a common API +export interface UnifiedProcess { + stdin: { write(chunk: Uint8Array | string): void } + stdout: { getReader(): StreamReader } + stderr: { getReader(): StreamReader } + exitCode: number | null + exited: Promise + kill(signal?: string): void +} +function wrapNodeProcess(proc: ChildProcess): UnifiedProcess { + let resolveExited: (code: number) => void + let exitCode: number | null = null + const exitedPromise = new Promise((resolve) => { + resolveExited = resolve + }) + proc.on("exit", (code) => { + exitCode = code ?? 1 + resolveExited(exitCode) + }) + proc.on("error", () => { + if (exitCode === null) { + exitCode = 1 + resolveExited(1) + } + }) + const createStreamReader = (nodeStream: NodeJS.ReadableStream | null): StreamReader => { + const chunks: Uint8Array[] = [] + let streamEnded = false + type ReadResult = { done: boolean; value: Uint8Array | undefined } + let waitingResolve: ((result: ReadResult) => void) | null = null + + if (nodeStream) { + nodeStream.on("data", (chunk: Buffer) => { + const uint8 = new Uint8Array(chunk) + if (waitingResolve) { + const resolve = waitingResolve + waitingResolve = null + resolve({ done: false, value: uint8 }) + } else { + chunks.push(uint8) + } + }) + + nodeStream.on("end", () => { + streamEnded = true + if (waitingResolve) { + const resolve = waitingResolve + waitingResolve = null + resolve({ done: true, value: undefined }) + } + }) + + nodeStream.on("error", () => { + streamEnded = true + if (waitingResolve) { + const resolve = waitingResolve + waitingResolve = null + resolve({ done: true, value: undefined }) + } + }) + } else { + streamEnded = true + } + return { + read(): Promise { + return new Promise((resolve) => { + if (chunks.length > 0) { + resolve({ done: false, value: chunks.shift()! }) + } else if (streamEnded) { + resolve({ done: true, value: undefined }) + } else { + waitingResolve = resolve + } + }) + }, + } + } + return { + stdin: { + write(chunk: Uint8Array | string) { + if (proc.stdin) { + proc.stdin.write(chunk) + } + }, + }, + stdout: { + getReader: () => createStreamReader(proc.stdout), + }, + stderr: { + getReader: () => createStreamReader(proc.stderr), + }, + get exitCode() { + return exitCode + }, + exited: exitedPromise, + kill(signal?: string) { + try { + if (signal === "SIGKILL") { + proc.kill("SIGKILL") + } else { + proc.kill() + } + } catch {} + }, + } +} +export function spawnProcess( + command: string[], + options: { cwd: string; env: Record } +): UnifiedProcess { + const cwdValidation = validateCwd(options.cwd) + if (!cwdValidation.valid) { + throw new Error(`[LSP] ${cwdValidation.error}`) + } + if (shouldUseNodeSpawn()) { + const [cmd, ...args] = command + if (!isBinaryAvailableOnWindows(cmd)) { + throw new Error( + `[LSP] Binary '${cmd}' not found on Windows. ` + + `Ensure the LSP server is installed and available in PATH. ` + + `For npm packages, try: npm install -g ${cmd}` + ) + } + log("[LSP] Using Node.js child_process on Windows to avoid Bun spawn segfault") + const proc = nodeSpawn(cmd, args, { + cwd: options.cwd, + env: options.env as NodeJS.ProcessEnv, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + shell: true, + }) + return wrapNodeProcess(proc) + } + const proc = bunSpawn(command, { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + cwd: options.cwd, + env: options.env, + }) + return proc as unknown as UnifiedProcess +} diff --git a/src/tools/lsp/lsp-server.ts b/src/tools/lsp/lsp-server.ts new file mode 100644 index 00000000..6d4ee4b6 --- /dev/null +++ b/src/tools/lsp/lsp-server.ts @@ -0,0 +1,197 @@ +import type { ResolvedServer } from "./types" +import { LSPClient } from "./lsp-client" +interface ManagedClient { + client: LSPClient + lastUsedAt: number + refCount: number + initPromise?: Promise + isInitializing: boolean +} +class LSPServerManager { + private static instance: LSPServerManager + private clients = new Map() + private cleanupInterval: ReturnType | null = null + private readonly IDLE_TIMEOUT = 5 * 60 * 1000 + private constructor() { + this.startCleanupTimer() + this.registerProcessCleanup() + } + private registerProcessCleanup(): void { + // Synchronous cleanup for 'exit' event (cannot await) + const syncCleanup = () => { + for (const [, managed] of this.clients) { + try { + // Fire-and-forget during sync exit - process is terminating + void managed.client.stop().catch(() => {}) + } catch {} + } + this.clients.clear() + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval) + this.cleanupInterval = null + } + } + // Async cleanup for signal handlers - properly await all stops + const asyncCleanup = async () => { + const stopPromises: Promise[] = [] + for (const [, managed] of this.clients) { + stopPromises.push(managed.client.stop().catch(() => {})) + } + await Promise.allSettled(stopPromises) + this.clients.clear() + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval) + this.cleanupInterval = null + } + } + process.on("exit", syncCleanup) + + // Don't call process.exit() here; other handlers (background-agent manager) handle final exit. + process.on("SIGINT", () => void asyncCleanup().catch(() => {})) + process.on("SIGTERM", () => void asyncCleanup().catch(() => {})) + if (process.platform === "win32") { + process.on("SIGBREAK", () => void asyncCleanup().catch(() => {})) + } + } + + static getInstance(): LSPServerManager { + if (!LSPServerManager.instance) { + LSPServerManager.instance = new LSPServerManager() + } + return LSPServerManager.instance + } + + private getKey(root: string, serverId: string): string { + return `${root}::${serverId}` + } + + private startCleanupTimer(): void { + if (this.cleanupInterval) return + this.cleanupInterval = setInterval(() => { + this.cleanupIdleClients() + }, 60000) + } + + private cleanupIdleClients(): void { + const now = Date.now() + for (const [key, managed] of this.clients) { + if (managed.refCount === 0 && now - managed.lastUsedAt > this.IDLE_TIMEOUT) { + managed.client.stop() + this.clients.delete(key) + } + } + } + + async getClient(root: string, server: ResolvedServer): Promise { + const key = this.getKey(root, server.id) + let managed = this.clients.get(key) + if (managed) { + if (managed.initPromise) { + await managed.initPromise + } + if (managed.client.isAlive()) { + managed.refCount++ + managed.lastUsedAt = Date.now() + return managed.client + } + await managed.client.stop() + this.clients.delete(key) + } + + const client = new LSPClient(root, server) + const initPromise = (async () => { + await client.start() + await client.initialize() + })() + this.clients.set(key, { + client, + lastUsedAt: Date.now(), + refCount: 1, + initPromise, + isInitializing: true, + }) + + await initPromise + const m = this.clients.get(key) + if (m) { + m.initPromise = undefined + m.isInitializing = false + } + + return client + } + + warmupClient(root: string, server: ResolvedServer): void { + const key = this.getKey(root, server.id) + if (this.clients.has(key)) return + const client = new LSPClient(root, server) + const initPromise = (async () => { + await client.start() + await client.initialize() + })() + + this.clients.set(key, { + client, + lastUsedAt: Date.now(), + refCount: 0, + initPromise, + isInitializing: true, + }) + + initPromise.then(() => { + const m = this.clients.get(key) + if (m) { + m.initPromise = undefined + m.isInitializing = false + } + }) + } + + releaseClient(root: string, serverId: string): void { + const key = this.getKey(root, serverId) + const managed = this.clients.get(key) + if (managed && managed.refCount > 0) { + managed.refCount-- + managed.lastUsedAt = Date.now() + } + } + + isServerInitializing(root: string, serverId: string): boolean { + const key = this.getKey(root, serverId) + const managed = this.clients.get(key) + return managed?.isInitializing ?? false + } + + async stopAll(): Promise { + for (const [, managed] of this.clients) { + await managed.client.stop() + } + this.clients.clear() + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval) + this.cleanupInterval = null + } + } + + async cleanupTempDirectoryClients(): Promise { + const keysToRemove: string[] = [] + for (const [key, managed] of this.clients.entries()) { + const isTempDir = key.startsWith("/tmp/") || key.startsWith("/var/folders/") + const isIdle = managed.refCount === 0 + if (isTempDir && isIdle) { + keysToRemove.push(key) + } + } + for (const key of keysToRemove) { + const managed = this.clients.get(key) + if (managed) { + this.clients.delete(key) + try { + await managed.client.stop() + } catch {} + } + } + } +} + +export const lspManager = LSPServerManager.getInstance() diff --git a/src/tools/lsp/rename-tools.ts b/src/tools/lsp/rename-tools.ts new file mode 100644 index 00000000..d29ce205 --- /dev/null +++ b/src/tools/lsp/rename-tools.ts @@ -0,0 +1,53 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" + +import { formatApplyResult, formatPrepareRenameResult } from "./lsp-formatters" +import { withLspClient } from "./lsp-client-wrapper" +import { applyWorkspaceEdit } from "./workspace-edit" +import type { PrepareRenameDefaultBehavior, PrepareRenameResult, WorkspaceEdit } from "./types" + +export const lsp_prepare_rename: ToolDefinition = tool({ + description: "Check if rename is valid. Use BEFORE lsp_rename.", + args: { + filePath: tool.schema.string(), + line: tool.schema.number().min(1).describe("1-based"), + character: tool.schema.number().min(0).describe("0-based"), + }, + execute: async (args, context) => { + try { + const result = await withLspClient(args.filePath, async (client) => { + return (await client.prepareRename(args.filePath, args.line, args.character)) as + | PrepareRenameResult + | PrepareRenameDefaultBehavior + | null + }) + const output = formatPrepareRenameResult(result) + return output + } catch (e) { + const output = `Error: ${e instanceof Error ? e.message : String(e)}` + return output + } + }, +}) + +export const lsp_rename: ToolDefinition = tool({ + description: "Rename symbol across entire workspace. APPLIES changes to all files.", + args: { + filePath: tool.schema.string(), + line: tool.schema.number().min(1).describe("1-based"), + character: tool.schema.number().min(0).describe("0-based"), + newName: tool.schema.string().describe("New symbol name"), + }, + execute: async (args, context) => { + try { + const edit = await withLspClient(args.filePath, async (client) => { + return (await client.rename(args.filePath, args.line, args.character, args.newName)) as WorkspaceEdit | null + }) + const result = applyWorkspaceEdit(edit) + const output = formatApplyResult(result) + return output + } catch (e) { + const output = `Error: ${e instanceof Error ? e.message : String(e)}` + return output + } + }, +}) diff --git a/src/tools/lsp/server-config-loader.ts b/src/tools/lsp/server-config-loader.ts new file mode 100644 index 00000000..ec8bd183 --- /dev/null +++ b/src/tools/lsp/server-config-loader.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync } from "fs" +import { join } from "path" + +import { BUILTIN_SERVERS } from "./constants" +import type { ResolvedServer } from "./types" +import { getOpenCodeConfigDir } from "../../shared" + +interface LspEntry { + disabled?: boolean + command?: string[] + extensions?: string[] + priority?: number + env?: Record + initialization?: Record +} + +interface ConfigJson { + lsp?: Record +} + +type ConfigSource = "project" | "user" | "opencode" + +interface ServerWithSource extends ResolvedServer { + source: ConfigSource +} + +function loadJsonFile(path: string): T | null { + if (!existsSync(path)) return null + try { + return JSON.parse(readFileSync(path, "utf-8")) as T + } catch { + return null + } +} + +export function getConfigPaths(): { project: string; user: string; opencode: string } { + const cwd = process.cwd() + const configDir = getOpenCodeConfigDir({ binary: "opencode" }) + return { + project: join(cwd, ".opencode", "oh-my-opencode.json"), + user: join(configDir, "oh-my-opencode.json"), + opencode: join(configDir, "opencode.json"), + } +} + +export function loadAllConfigs(): Map { + const paths = getConfigPaths() + const configs = new Map() + + const project = loadJsonFile(paths.project) + if (project) configs.set("project", project) + + const user = loadJsonFile(paths.user) + if (user) configs.set("user", user) + + const opencode = loadJsonFile(paths.opencode) + if (opencode) configs.set("opencode", opencode) + + return configs +} + +export function getMergedServers(): ServerWithSource[] { + const configs = loadAllConfigs() + const servers: ServerWithSource[] = [] + const disabled = new Set() + const seen = new Set() + + const sources: ConfigSource[] = ["project", "user", "opencode"] + + for (const source of sources) { + const config = configs.get(source) + if (!config?.lsp) continue + + for (const [id, entry] of Object.entries(config.lsp)) { + if (entry.disabled) { + disabled.add(id) + continue + } + + if (seen.has(id)) continue + if (!entry.command || !entry.extensions) continue + + servers.push({ + id, + command: entry.command, + extensions: entry.extensions, + priority: entry.priority ?? 0, + env: entry.env, + initialization: entry.initialization, + source, + }) + seen.add(id) + } + } + + for (const [id, config] of Object.entries(BUILTIN_SERVERS)) { + if (disabled.has(id) || seen.has(id)) continue + + servers.push({ + id, + command: config.command, + extensions: config.extensions, + priority: -100, + source: "opencode", + }) + } + + return servers.sort((a, b) => { + if (a.source !== b.source) { + const order: Record = { project: 0, user: 1, opencode: 2 } + return order[a.source] - order[b.source] + } + return b.priority - a.priority + }) +} diff --git a/src/tools/lsp/server-definitions.ts b/src/tools/lsp/server-definitions.ts new file mode 100644 index 00000000..0e00f139 --- /dev/null +++ b/src/tools/lsp/server-definitions.ts @@ -0,0 +1,91 @@ +import type { LSPServerConfig } from "./types" + +export const LSP_INSTALL_HINTS: Record = { + typescript: "npm install -g typescript-language-server typescript", + deno: "Install Deno from https://deno.land", + vue: "npm install -g @vue/language-server", + eslint: "npm install -g vscode-langservers-extracted", + oxlint: "npm install -g oxlint", + biome: "npm install -g @biomejs/biome", + gopls: "go install golang.org/x/tools/gopls@latest", + "ruby-lsp": "gem install ruby-lsp", + basedpyright: "pip install basedpyright", + pyright: "pip install pyright", + ty: "pip install ty", + ruff: "pip install ruff", + "elixir-ls": "See https://github.com/elixir-lsp/elixir-ls", + zls: "See https://github.com/zigtools/zls", + csharp: "dotnet tool install -g csharp-ls", + fsharp: "dotnet tool install -g fsautocomplete", + "sourcekit-lsp": "Included with Xcode or Swift toolchain", + rust: "rustup component add rust-analyzer", + clangd: "See https://clangd.llvm.org/installation", + svelte: "npm install -g svelte-language-server", + astro: "npm install -g @astrojs/language-server", + "bash-ls": "npm install -g bash-language-server", + jdtls: "See https://github.com/eclipse-jdtls/eclipse.jdt.ls", + "yaml-ls": "npm install -g yaml-language-server", + "lua-ls": "See https://github.com/LuaLS/lua-language-server", + php: "npm install -g intelephense", + dart: "Included with Dart SDK", + "terraform-ls": "See https://github.com/hashicorp/terraform-ls", + terraform: "See https://github.com/hashicorp/terraform-ls", + prisma: "npm install -g prisma", + "ocaml-lsp": "opam install ocaml-lsp-server", + texlab: "See https://github.com/latex-lsp/texlab", + dockerfile: "npm install -g dockerfile-language-server-nodejs", + gleam: "See https://gleam.run/getting-started/installing/", + "clojure-lsp": "See https://clojure-lsp.io/installation/", + nixd: "nix profile install nixpkgs#nixd", + tinymist: "See https://github.com/Myriad-Dreamin/tinymist", + "haskell-language-server": "ghcup install hls", + bash: "npm install -g bash-language-server", + "kotlin-ls": "See https://github.com/Kotlin/kotlin-lsp", +} + +// Synced with OpenCode's server.ts +// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/server.ts +export const BUILTIN_SERVERS: Record> = { + typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"] }, + deno: { command: ["deno", "lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"] }, + vue: { command: ["vue-language-server", "--stdio"], extensions: [".vue"] }, + eslint: { command: ["vscode-eslint-language-server", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"] }, + oxlint: { command: ["oxlint", "--lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"] }, + biome: { command: ["biome", "lsp-proxy", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".json", ".jsonc", ".vue", ".astro", ".svelte", ".css", ".graphql", ".gql", ".html"] }, + gopls: { command: ["gopls"], extensions: [".go"] }, + "ruby-lsp": { command: ["rubocop", "--lsp"], extensions: [".rb", ".rake", ".gemspec", ".ru"] }, + basedpyright: { command: ["basedpyright-langserver", "--stdio"], extensions: [".py", ".pyi"] }, + pyright: { command: ["pyright-langserver", "--stdio"], extensions: [".py", ".pyi"] }, + ty: { command: ["ty", "server"], extensions: [".py", ".pyi"] }, + ruff: { command: ["ruff", "server"], extensions: [".py", ".pyi"] }, + "elixir-ls": { command: ["elixir-ls"], extensions: [".ex", ".exs"] }, + zls: { command: ["zls"], extensions: [".zig", ".zon"] }, + csharp: { command: ["csharp-ls"], extensions: [".cs"] }, + fsharp: { command: ["fsautocomplete"], extensions: [".fs", ".fsi", ".fsx", ".fsscript"] }, + "sourcekit-lsp": { command: ["sourcekit-lsp"], extensions: [".swift", ".objc", ".objcpp"] }, + rust: { command: ["rust-analyzer"], extensions: [".rs"] }, + clangd: { command: ["clangd", "--background-index", "--clang-tidy"], extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"] }, + svelte: { command: ["svelteserver", "--stdio"], extensions: [".svelte"] }, + astro: { command: ["astro-ls", "--stdio"], extensions: [".astro"] }, + bash: { command: ["bash-language-server", "start"], extensions: [".sh", ".bash", ".zsh", ".ksh"] }, + // Keep legacy alias for backward compatibility + "bash-ls": { command: ["bash-language-server", "start"], extensions: [".sh", ".bash", ".zsh", ".ksh"] }, + jdtls: { command: ["jdtls"], extensions: [".java"] }, + "yaml-ls": { command: ["yaml-language-server", "--stdio"], extensions: [".yaml", ".yml"] }, + "lua-ls": { command: ["lua-language-server"], extensions: [".lua"] }, + php: { command: ["intelephense", "--stdio"], extensions: [".php"] }, + dart: { command: ["dart", "language-server", "--lsp"], extensions: [".dart"] }, + terraform: { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] }, + // Legacy alias for backward compatibility + "terraform-ls": { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] }, + prisma: { command: ["prisma", "language-server"], extensions: [".prisma"] }, + "ocaml-lsp": { command: ["ocamllsp"], extensions: [".ml", ".mli"] }, + texlab: { command: ["texlab"], extensions: [".tex", ".bib"] }, + dockerfile: { command: ["docker-langserver", "--stdio"], extensions: [".dockerfile"] }, + gleam: { command: ["gleam", "lsp"], extensions: [".gleam"] }, + "clojure-lsp": { command: ["clojure-lsp", "listen"], extensions: [".clj", ".cljs", ".cljc", ".edn"] }, + nixd: { command: ["nixd"], extensions: [".nix"] }, + tinymist: { command: ["tinymist"], extensions: [".typ", ".typc"] }, + "haskell-language-server": { command: ["haskell-language-server-wrapper", "--lsp"], extensions: [".hs", ".lhs"] }, + "kotlin-ls": { command: ["kotlin-lsp"], extensions: [".kt", ".kts"] }, +} diff --git a/src/tools/lsp/server-installation.ts b/src/tools/lsp/server-installation.ts new file mode 100644 index 00000000..e3a834c8 --- /dev/null +++ b/src/tools/lsp/server-installation.ts @@ -0,0 +1,69 @@ +import { existsSync } from "fs" +import { join } from "path" + +import { getOpenCodeConfigDir, getDataDir } from "../../shared" + +export function isServerInstalled(command: string[]): boolean { + if (command.length === 0) return false + + const cmd = command[0] + + // Support absolute paths (e.g., C:\Users\...\server.exe or /usr/local/bin/server) + if (cmd.includes("/") || cmd.includes("\\")) { + if (existsSync(cmd)) return true + } + + const isWindows = process.platform === "win32" + + let exts = [""] + if (isWindows) { + const pathExt = process.env.PATHEXT || "" + if (pathExt) { + const systemExts = pathExt.split(";").filter(Boolean) + exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])] + } else { + exts = ["", ".exe", ".cmd", ".bat", ".ps1"] + } + } + + let pathEnv = process.env.PATH || "" + if (isWindows && !pathEnv) { + pathEnv = process.env.Path || "" + } + + const pathSeparator = isWindows ? ";" : ":" + const paths = pathEnv.split(pathSeparator) + + for (const p of paths) { + for (const suffix of exts) { + if (existsSync(join(p, cmd + suffix))) { + return true + } + } + } + + const cwd = process.cwd() + const configDir = getOpenCodeConfigDir({ binary: "opencode" }) + const dataDir = join(getDataDir(), "opencode") + const additionalBases = [ + join(cwd, "node_modules", ".bin"), + join(configDir, "bin"), + join(configDir, "node_modules", ".bin"), + join(dataDir, "bin"), + ] + + for (const base of additionalBases) { + for (const suffix of exts) { + if (existsSync(join(base, cmd + suffix))) { + return true + } + } + } + + // Runtime wrappers (bun/node) are always available in oh-my-opencode context + if (cmd === "bun" || cmd === "node") { + return true + } + + return false +} diff --git a/src/tools/lsp/server-resolution.ts b/src/tools/lsp/server-resolution.ts new file mode 100644 index 00000000..4279110e --- /dev/null +++ b/src/tools/lsp/server-resolution.ts @@ -0,0 +1,109 @@ +import { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./constants" +import { getConfigPaths, getMergedServers, loadAllConfigs } from "./server-config-loader" +import { isServerInstalled } from "./server-installation" +import type { ServerLookupResult } from "./types" + +export function findServerForExtension(ext: string): ServerLookupResult { + const servers = getMergedServers() + + for (const server of servers) { + if (server.extensions.includes(ext) && isServerInstalled(server.command)) { + return { + status: "found", + server: { + id: server.id, + command: server.command, + extensions: server.extensions, + priority: server.priority, + env: server.env, + initialization: server.initialization, + }, + } + } + } + + for (const server of servers) { + if (server.extensions.includes(ext)) { + const installHint = LSP_INSTALL_HINTS[server.id] || `Install '${server.command[0]}' and ensure it's in your PATH` + return { + status: "not_installed", + server: { + id: server.id, + command: server.command, + extensions: server.extensions, + }, + installHint, + } + } + } + + const availableServers = [...new Set(servers.map((s) => s.id))] + return { + status: "not_configured", + extension: ext, + availableServers, + } +} + +export function getAllServers(): Array<{ + id: string + installed: boolean + extensions: string[] + disabled: boolean + source: string + priority: number +}> { + const configs = loadAllConfigs() + const servers = getMergedServers() + const disabled = new Set() + + for (const config of configs.values()) { + if (!config.lsp) continue + for (const [id, entry] of Object.entries(config.lsp)) { + if (entry.disabled) disabled.add(id) + } + } + + const result: Array<{ + id: string + installed: boolean + extensions: string[] + disabled: boolean + source: string + priority: number + }> = [] + + const seen = new Set() + + for (const server of servers) { + if (seen.has(server.id)) continue + result.push({ + id: server.id, + installed: isServerInstalled(server.command), + extensions: server.extensions, + disabled: false, + source: server.source, + priority: server.priority, + }) + seen.add(server.id) + } + + for (const id of disabled) { + if (seen.has(id)) continue + const builtin = BUILTIN_SERVERS[id] + result.push({ + id, + installed: builtin ? isServerInstalled(builtin.command) : false, + extensions: builtin?.extensions || [], + disabled: true, + source: "disabled", + priority: 0, + }) + } + + return result +} + +export function getConfigPaths_(): { project: string; user: string; opencode: string } { + return getConfigPaths() +} diff --git a/src/tools/lsp/symbols-tool.ts b/src/tools/lsp/symbols-tool.ts new file mode 100644 index 00000000..eba177ef --- /dev/null +++ b/src/tools/lsp/symbols-tool.ts @@ -0,0 +1,76 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" + +import { DEFAULT_MAX_SYMBOLS } from "./constants" +import { formatDocumentSymbol, formatSymbolInfo } from "./lsp-formatters" +import { withLspClient } from "./lsp-client-wrapper" +import type { DocumentSymbol, SymbolInfo } from "./types" + +export const lsp_symbols: ToolDefinition = tool({ + description: + "Get symbols from file (document) or search across workspace. Use scope='document' for file outline, scope='workspace' for project-wide symbol search.", + args: { + filePath: tool.schema.string().describe("File path for LSP context"), + scope: tool.schema + .enum(["document", "workspace"]) + .default("document") + .describe("'document' for file symbols, 'workspace' for project-wide search"), + query: tool.schema.string().optional().describe("Symbol name to search (required for workspace scope)"), + limit: tool.schema.number().optional().describe("Max results (default 50)"), + }, + execute: async (args, context) => { + try { + const scope = args.scope ?? "document" + + if (scope === "workspace") { + if (!args.query) { + return "Error: 'query' is required for workspace scope" + } + + const result = await withLspClient(args.filePath, async (client) => { + return (await client.workspaceSymbols(args.query!)) as SymbolInfo[] | null + }) + + if (!result || result.length === 0) { + return "No symbols found" + } + + const total = result.length + const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS) + const truncated = total > limit + const limited = result.slice(0, limit) + const lines = limited.map(formatSymbolInfo) + if (truncated) { + lines.unshift(`Found ${total} symbols (showing first ${limit}):`) + } + return lines.join("\n") + } else { + const result = await withLspClient(args.filePath, async (client) => { + return (await client.documentSymbols(args.filePath)) as DocumentSymbol[] | SymbolInfo[] | null + }) + + if (!result || result.length === 0) { + return "No symbols found" + } + + const total = result.length + const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS) + const truncated = total > limit + const limited = truncated ? result.slice(0, limit) : result + + const lines: string[] = [] + if (truncated) { + lines.push(`Found ${total} symbols (showing first ${limit}):`) + } + + if ("range" in limited[0]) { + lines.push(...(limited as DocumentSymbol[]).map((s) => formatDocumentSymbol(s))) + } else { + lines.push(...(limited as SymbolInfo[]).map(formatSymbolInfo)) + } + return lines.join("\n") + } + } catch (e) { + return `Error: ${e instanceof Error ? e.message : String(e)}` + } + }, +}) diff --git a/src/tools/lsp/tools.ts b/src/tools/lsp/tools.ts index 802604f4..9ed6ff7b 100644 --- a/src/tools/lsp/tools.ts +++ b/src/tools/lsp/tools.ts @@ -1,261 +1,5 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" -import { - DEFAULT_MAX_REFERENCES, - DEFAULT_MAX_SYMBOLS, - DEFAULT_MAX_DIAGNOSTICS, -} from "./constants" -import { - withLspClient, - formatLocation, - formatDocumentSymbol, - formatSymbolInfo, - formatDiagnostic, - filterDiagnosticsBySeverity, - formatPrepareRenameResult, - applyWorkspaceEdit, - formatApplyResult, -} from "./utils" -import type { - Location, - LocationLink, - DocumentSymbol, - SymbolInfo, - Diagnostic, - PrepareRenameResult, - PrepareRenameDefaultBehavior, - WorkspaceEdit, -} from "./types" - -export const lsp_goto_definition: ToolDefinition = tool({ - description: "Jump to symbol definition. Find WHERE something is defined.", - args: { - filePath: tool.schema.string(), - line: tool.schema.number().min(1).describe("1-based"), - character: tool.schema.number().min(0).describe("0-based"), - }, - execute: async (args, context) => { - try { - const result = await withLspClient(args.filePath, async (client) => { - return (await client.definition(args.filePath, args.line, args.character)) as - | Location - | Location[] - | LocationLink[] - | null - }) - - if (!result) { - const output = "No definition found" - return output - } - - const locations = Array.isArray(result) ? result : [result] - if (locations.length === 0) { - const output = "No definition found" - return output - } - - const output = locations.map(formatLocation).join("\n") - return output - } catch (e) { - const output = `Error: ${e instanceof Error ? e.message : String(e)}` - return output - } - }, -}) - -export const lsp_find_references: ToolDefinition = tool({ - description: "Find ALL usages/references of a symbol across the entire workspace.", - args: { - filePath: tool.schema.string(), - line: tool.schema.number().min(1).describe("1-based"), - character: tool.schema.number().min(0).describe("0-based"), - includeDeclaration: tool.schema.boolean().optional().describe("Include the declaration itself"), - }, - execute: async (args, context) => { - try { - const result = await withLspClient(args.filePath, async (client) => { - return (await client.references(args.filePath, args.line, args.character, args.includeDeclaration ?? true)) as - | Location[] - | null - }) - - if (!result || result.length === 0) { - const output = "No references found" - return output - } - - const total = result.length - const truncated = total > DEFAULT_MAX_REFERENCES - const limited = truncated ? result.slice(0, DEFAULT_MAX_REFERENCES) : result - const lines = limited.map(formatLocation) - if (truncated) { - lines.unshift(`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`) - } - const output = lines.join("\n") - return output - } catch (e) { - const output = `Error: ${e instanceof Error ? e.message : String(e)}` - return output - } - }, -}) - -export const lsp_symbols: ToolDefinition = tool({ - description: "Get symbols from file (document) or search across workspace. Use scope='document' for file outline, scope='workspace' for project-wide symbol search.", - args: { - filePath: tool.schema.string().describe("File path for LSP context"), - scope: tool.schema.enum(["document", "workspace"]).default("document").describe("'document' for file symbols, 'workspace' for project-wide search"), - query: tool.schema.string().optional().describe("Symbol name to search (required for workspace scope)"), - limit: tool.schema.number().optional().describe("Max results (default 50)"), - }, - execute: async (args, context) => { - try { - const scope = args.scope ?? "document" - - if (scope === "workspace") { - if (!args.query) { - return "Error: 'query' is required for workspace scope" - } - - const result = await withLspClient(args.filePath, async (client) => { - return (await client.workspaceSymbols(args.query!)) as SymbolInfo[] | null - }) - - if (!result || result.length === 0) { - return "No symbols found" - } - - const total = result.length - const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS) - const truncated = total > limit - const limited = result.slice(0, limit) - const lines = limited.map(formatSymbolInfo) - if (truncated) { - lines.unshift(`Found ${total} symbols (showing first ${limit}):`) - } - return lines.join("\n") - } else { - const result = await withLspClient(args.filePath, async (client) => { - return (await client.documentSymbols(args.filePath)) as DocumentSymbol[] | SymbolInfo[] | null - }) - - if (!result || result.length === 0) { - return "No symbols found" - } - - const total = result.length - const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS) - const truncated = total > limit - const limited = truncated ? result.slice(0, limit) : result - - const lines: string[] = [] - if (truncated) { - lines.push(`Found ${total} symbols (showing first ${limit}):`) - } - - if ("range" in limited[0]) { - lines.push(...(limited as DocumentSymbol[]).map((s) => formatDocumentSymbol(s))) - } else { - lines.push(...(limited as SymbolInfo[]).map(formatSymbolInfo)) - } - return lines.join("\n") - } - } catch (e) { - return `Error: ${e instanceof Error ? e.message : String(e)}` - } - }, -}) - -export const lsp_diagnostics: ToolDefinition = tool({ - description: "Get errors, warnings, hints from language server BEFORE running build.", - args: { - filePath: tool.schema.string(), - severity: tool.schema - .enum(["error", "warning", "information", "hint", "all"]) - .optional() - .describe("Filter by severity level"), - }, - execute: async (args, context) => { - try { - const result = await withLspClient(args.filePath, async (client) => { - return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null - }) - - let diagnostics: Diagnostic[] = [] - if (result) { - if (Array.isArray(result)) { - diagnostics = result - } else if (result.items) { - diagnostics = result.items - } - } - - diagnostics = filterDiagnosticsBySeverity(diagnostics, args.severity) - - if (diagnostics.length === 0) { - const output = "No diagnostics found" - return output - } - - const total = diagnostics.length - const truncated = total > DEFAULT_MAX_DIAGNOSTICS - const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics - const lines = limited.map(formatDiagnostic) - if (truncated) { - lines.unshift(`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`) - } - const output = lines.join("\n") - return output - } catch (e) { - const output = `Error: ${e instanceof Error ? e.message : String(e)}` - throw new Error(output) - } - }, -}) - -export const lsp_prepare_rename: ToolDefinition = tool({ - description: "Check if rename is valid. Use BEFORE lsp_rename.", - args: { - filePath: tool.schema.string(), - line: tool.schema.number().min(1).describe("1-based"), - character: tool.schema.number().min(0).describe("0-based"), - }, - execute: async (args, context) => { - try { - const result = await withLspClient(args.filePath, async (client) => { - return (await client.prepareRename(args.filePath, args.line, args.character)) as - | PrepareRenameResult - | PrepareRenameDefaultBehavior - | null - }) - const output = formatPrepareRenameResult(result) - return output - } catch (e) { - const output = `Error: ${e instanceof Error ? e.message : String(e)}` - return output - } - }, -}) - -export const lsp_rename: ToolDefinition = tool({ - description: "Rename symbol across entire workspace. APPLIES changes to all files.", - args: { - filePath: tool.schema.string(), - line: tool.schema.number().min(1).describe("1-based"), - character: tool.schema.number().min(0).describe("0-based"), - newName: tool.schema.string().describe("New symbol name"), - }, - execute: async (args, context) => { - try { - const edit = await withLspClient(args.filePath, async (client) => { - return (await client.rename(args.filePath, args.line, args.character, args.newName)) as WorkspaceEdit | null - }) - const result = applyWorkspaceEdit(edit) - const output = formatApplyResult(result) - return output - } catch (e) { - const output = `Error: ${e instanceof Error ? e.message : String(e)}` - return output - } - }, -}) +export { lsp_goto_definition } from "./goto-definition-tool" +export { lsp_find_references } from "./find-references-tool" +export { lsp_symbols } from "./symbols-tool" +export { lsp_diagnostics } from "./diagnostics-tool" +export { lsp_prepare_rename, lsp_rename } from "./rename-tools" diff --git a/src/tools/lsp/utils.test.ts b/src/tools/lsp/utils.test.ts index ecd2f90a..50788f9f 100644 --- a/src/tools/lsp/utils.test.ts +++ b/src/tools/lsp/utils.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" import { join } from "path" import os from "os" -import { findWorkspaceRoot } from "./utils" +import { findWorkspaceRoot } from "./lsp-client-wrapper" describe("lsp utils", () => { describe("findWorkspaceRoot", () => { diff --git a/src/tools/lsp/utils.ts b/src/tools/lsp/utils.ts deleted file mode 100644 index 51087150..00000000 --- a/src/tools/lsp/utils.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { extname, resolve } from "path" -import { fileURLToPath } from "node:url" -import { existsSync, readFileSync, writeFileSync } from "fs" -import { LSPClient, lspManager } from "./client" -import { findServerForExtension } from "./config" -import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants" -import type { - Location, - LocationLink, - DocumentSymbol, - SymbolInfo, - Diagnostic, - PrepareRenameResult, - PrepareRenameDefaultBehavior, - Range, - WorkspaceEdit, - TextEdit, - ServerLookupResult, -} from "./types" - -export function findWorkspaceRoot(filePath: string): string { - let dir = resolve(filePath) - - if (!existsSync(dir) || !require("fs").statSync(dir).isDirectory()) { - dir = require("path").dirname(dir) - } - - const markers = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "build.gradle"] - - let prevDir = "" - while (dir !== prevDir) { - for (const marker of markers) { - if (existsSync(require("path").join(dir, marker))) { - return dir - } - } - prevDir = dir - dir = require("path").dirname(dir) - } - - return require("path").dirname(resolve(filePath)) -} - -export function uriToPath(uri: string): string { - return fileURLToPath(uri) -} - -export function formatServerLookupError(result: Exclude): string { - if (result.status === "not_installed") { - const { server, installHint } = result - return [ - `LSP server '${server.id}' is configured but NOT INSTALLED.`, - ``, - `Command not found: ${server.command[0]}`, - ``, - `To install:`, - ` ${installHint}`, - ``, - `Supported extensions: ${server.extensions.join(", ")}`, - ``, - `After installation, the server will be available automatically.`, - `Run 'LspServers' tool to verify installation status.`, - ].join("\n") - } - - return [ - `No LSP server configured for extension: ${result.extension}`, - ``, - `Available servers: ${result.availableServers.slice(0, 10).join(", ")}${result.availableServers.length > 10 ? "..." : ""}`, - ``, - `To add a custom server, configure 'lsp' in oh-my-opencode.json:`, - ` {`, - ` "lsp": {`, - ` "my-server": {`, - ` "command": ["my-lsp", "--stdio"],`, - ` "extensions": ["${result.extension}"]`, - ` }`, - ` }`, - ].join("\n") -} - -export async function withLspClient(filePath: string, fn: (client: LSPClient) => Promise): Promise { - const absPath = resolve(filePath) - const ext = extname(absPath) - const result = findServerForExtension(ext) - - if (result.status !== "found") { - throw new Error(formatServerLookupError(result)) - } - - const server = result.server - const root = findWorkspaceRoot(absPath) - const client = await lspManager.getClient(root, server) - - try { - return await fn(client) - } catch (e) { - if (e instanceof Error && e.message.includes("timeout")) { - const isInitializing = lspManager.isServerInitializing(root, server.id) - if (isInitializing) { - throw new Error( - `LSP server is still initializing. Please retry in a few seconds. ` + - `Original error: ${e.message}` - ) - } - } - throw e - } finally { - lspManager.releaseClient(root, server.id) - } -} - -export function formatLocation(loc: Location | LocationLink): string { - if ("targetUri" in loc) { - const uri = uriToPath(loc.targetUri) - const line = loc.targetRange.start.line + 1 - const char = loc.targetRange.start.character - return `${uri}:${line}:${char}` - } - - const uri = uriToPath(loc.uri) - const line = loc.range.start.line + 1 - const char = loc.range.start.character - return `${uri}:${line}:${char}` -} - -export function formatSymbolKind(kind: number): string { - return SYMBOL_KIND_MAP[kind] || `Unknown(${kind})` -} - -export function formatSeverity(severity: number | undefined): string { - if (!severity) return "unknown" - return SEVERITY_MAP[severity] || `unknown(${severity})` -} - -export function formatDocumentSymbol(symbol: DocumentSymbol, indent = 0): string { - const prefix = " ".repeat(indent) - const kind = formatSymbolKind(symbol.kind) - const line = symbol.range.start.line + 1 - let result = `${prefix}${symbol.name} (${kind}) - line ${line}` - - if (symbol.children && symbol.children.length > 0) { - for (const child of symbol.children) { - result += "\n" + formatDocumentSymbol(child, indent + 1) - } - } - - return result -} - -export function formatSymbolInfo(symbol: SymbolInfo): string { - const kind = formatSymbolKind(symbol.kind) - const loc = formatLocation(symbol.location) - const container = symbol.containerName ? ` (in ${symbol.containerName})` : "" - return `${symbol.name} (${kind})${container} - ${loc}` -} - -export function formatDiagnostic(diag: Diagnostic): string { - const severity = formatSeverity(diag.severity) - const line = diag.range.start.line + 1 - const char = diag.range.start.character - const source = diag.source ? `[${diag.source}]` : "" - const code = diag.code ? ` (${diag.code})` : "" - return `${severity}${source}${code} at ${line}:${char}: ${diag.message}` -} - -export function filterDiagnosticsBySeverity( - diagnostics: Diagnostic[], - severityFilter?: "error" | "warning" | "information" | "hint" | "all" -): Diagnostic[] { - if (!severityFilter || severityFilter === "all") { - return diagnostics - } - - const severityMap: Record = { - error: 1, - warning: 2, - information: 3, - hint: 4, - } - - const targetSeverity = severityMap[severityFilter] - return diagnostics.filter((d) => d.severity === targetSeverity) -} - -export function formatPrepareRenameResult( - result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null -): string { - if (!result) return "Cannot rename at this position" - - // Case 1: { defaultBehavior: boolean } - if ("defaultBehavior" in result) { - return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position" - } - - // Case 2: { range: Range, placeholder?: string } - if ("range" in result && result.range) { - const startLine = result.range.start.line + 1 - const startChar = result.range.start.character - const endLine = result.range.end.line + 1 - const endChar = result.range.end.character - const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : "" - return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}` - } - - // Case 3: Range directly (has start/end but no range property) - if ("start" in result && "end" in result) { - const startLine = result.start.line + 1 - const startChar = result.start.character - const endLine = result.end.line + 1 - const endChar = result.end.character - return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}` - } - - return "Cannot rename at this position" -} - -export function formatTextEdit(edit: TextEdit): string { - const startLine = edit.range.start.line + 1 - const startChar = edit.range.start.character - const endLine = edit.range.end.line + 1 - const endChar = edit.range.end.character - - const rangeStr = `${startLine}:${startChar}-${endLine}:${endChar}` - const preview = edit.newText.length > 50 ? edit.newText.substring(0, 50) + "..." : edit.newText - - return ` ${rangeStr}: "${preview}"` -} - -export function formatWorkspaceEdit(edit: WorkspaceEdit | null): string { - if (!edit) return "No changes" - - const lines: string[] = [] - - if (edit.changes) { - for (const [uri, edits] of Object.entries(edit.changes)) { - const filePath = uriToPath(uri) - lines.push(`File: ${filePath}`) - for (const textEdit of edits) { - lines.push(formatTextEdit(textEdit)) - } - } - } - - if (edit.documentChanges) { - for (const change of edit.documentChanges) { - if ("kind" in change) { - if (change.kind === "create") { - lines.push(`Create: ${change.uri}`) - } else if (change.kind === "rename") { - lines.push(`Rename: ${change.oldUri} -> ${change.newUri}`) - } else if (change.kind === "delete") { - lines.push(`Delete: ${change.uri}`) - } - } else { - const filePath = uriToPath(change.textDocument.uri) - lines.push(`File: ${filePath}`) - for (const textEdit of change.edits) { - lines.push(formatTextEdit(textEdit)) - } - } - } - } - - if (lines.length === 0) return "No changes" - - return lines.join("\n") -} - -export interface ApplyResult { - success: boolean - filesModified: string[] - totalEdits: number - errors: string[] -} - -function applyTextEditsToFile(filePath: string, edits: TextEdit[]): { success: boolean; editCount: number; error?: string } { - try { - let content = readFileSync(filePath, "utf-8") - const lines = content.split("\n") - - const sortedEdits = [...edits].sort((a, b) => { - if (b.range.start.line !== a.range.start.line) { - return b.range.start.line - a.range.start.line - } - return b.range.start.character - a.range.start.character - }) - - for (const edit of sortedEdits) { - const startLine = edit.range.start.line - const startChar = edit.range.start.character - const endLine = edit.range.end.line - const endChar = edit.range.end.character - - if (startLine === endLine) { - const line = lines[startLine] || "" - lines[startLine] = line.substring(0, startChar) + edit.newText + line.substring(endChar) - } else { - const firstLine = lines[startLine] || "" - const lastLine = lines[endLine] || "" - const newContent = firstLine.substring(0, startChar) + edit.newText + lastLine.substring(endChar) - lines.splice(startLine, endLine - startLine + 1, ...newContent.split("\n")) - } - } - - writeFileSync(filePath, lines.join("\n"), "utf-8") - return { success: true, editCount: edits.length } - } catch (err) { - return { success: false, editCount: 0, error: err instanceof Error ? err.message : String(err) } - } -} - -export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult { - if (!edit) { - return { success: false, filesModified: [], totalEdits: 0, errors: ["No edit provided"] } - } - - const result: ApplyResult = { success: true, filesModified: [], totalEdits: 0, errors: [] } - - if (edit.changes) { - for (const [uri, edits] of Object.entries(edit.changes)) { - const filePath = uriToPath(uri) - const applyResult = applyTextEditsToFile(filePath, edits) - - if (applyResult.success) { - result.filesModified.push(filePath) - result.totalEdits += applyResult.editCount - } else { - result.success = false - result.errors.push(`${filePath}: ${applyResult.error}`) - } - } - } - - if (edit.documentChanges) { - for (const change of edit.documentChanges) { - if ("kind" in change) { - if (change.kind === "create") { - try { - const filePath = uriToPath(change.uri) - writeFileSync(filePath, "", "utf-8") - result.filesModified.push(filePath) - } catch (err) { - result.success = false - result.errors.push(`Create ${change.uri}: ${err}`) - } - } else if (change.kind === "rename") { - try { - const oldPath = uriToPath(change.oldUri) - const newPath = uriToPath(change.newUri) - const content = readFileSync(oldPath, "utf-8") - writeFileSync(newPath, content, "utf-8") - require("fs").unlinkSync(oldPath) - result.filesModified.push(newPath) - } catch (err) { - result.success = false - result.errors.push(`Rename ${change.oldUri}: ${err}`) - } - } else if (change.kind === "delete") { - try { - const filePath = uriToPath(change.uri) - require("fs").unlinkSync(filePath) - result.filesModified.push(filePath) - } catch (err) { - result.success = false - result.errors.push(`Delete ${change.uri}: ${err}`) - } - } - } else { - const filePath = uriToPath(change.textDocument.uri) - const applyResult = applyTextEditsToFile(filePath, change.edits) - - if (applyResult.success) { - result.filesModified.push(filePath) - result.totalEdits += applyResult.editCount - } else { - result.success = false - result.errors.push(`${filePath}: ${applyResult.error}`) - } - } - } - } - - return result -} - -export function formatApplyResult(result: ApplyResult): string { - const lines: string[] = [] - - if (result.success) { - lines.push(`Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`) - for (const file of result.filesModified) { - lines.push(` - ${file}`) - } - } else { - lines.push("Failed to apply some changes:") - for (const err of result.errors) { - lines.push(` Error: ${err}`) - } - if (result.filesModified.length > 0) { - lines.push(`Successfully modified: ${result.filesModified.join(", ")}`) - } - } - - return lines.join("\n") -} diff --git a/src/tools/lsp/workspace-edit.ts b/src/tools/lsp/workspace-edit.ts new file mode 100644 index 00000000..e0a836dc --- /dev/null +++ b/src/tools/lsp/workspace-edit.ts @@ -0,0 +1,121 @@ +import { readFileSync, writeFileSync } from "fs" + +import { uriToPath } from "./lsp-client-wrapper" +import type { TextEdit, WorkspaceEdit } from "./types" + +export interface ApplyResult { + success: boolean + filesModified: string[] + totalEdits: number + errors: string[] +} + +function applyTextEditsToFile(filePath: string, edits: TextEdit[]): { success: boolean; editCount: number; error?: string } { + try { + let content = readFileSync(filePath, "utf-8") + const lines = content.split("\n") + + const sortedEdits = [...edits].sort((a, b) => { + if (b.range.start.line !== a.range.start.line) { + return b.range.start.line - a.range.start.line + } + return b.range.start.character - a.range.start.character + }) + + for (const edit of sortedEdits) { + const startLine = edit.range.start.line + const startChar = edit.range.start.character + const endLine = edit.range.end.line + const endChar = edit.range.end.character + + if (startLine === endLine) { + const line = lines[startLine] || "" + lines[startLine] = line.substring(0, startChar) + edit.newText + line.substring(endChar) + } else { + const firstLine = lines[startLine] || "" + const lastLine = lines[endLine] || "" + const newContent = firstLine.substring(0, startChar) + edit.newText + lastLine.substring(endChar) + lines.splice(startLine, endLine - startLine + 1, ...newContent.split("\n")) + } + } + + writeFileSync(filePath, lines.join("\n"), "utf-8") + return { success: true, editCount: edits.length } + } catch (err) { + return { success: false, editCount: 0, error: err instanceof Error ? err.message : String(err) } + } +} + +export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult { + if (!edit) { + return { success: false, filesModified: [], totalEdits: 0, errors: ["No edit provided"] } + } + + const result: ApplyResult = { success: true, filesModified: [], totalEdits: 0, errors: [] } + + if (edit.changes) { + for (const [uri, edits] of Object.entries(edit.changes)) { + const filePath = uriToPath(uri) + const applyResult = applyTextEditsToFile(filePath, edits) + + if (applyResult.success) { + result.filesModified.push(filePath) + result.totalEdits += applyResult.editCount + } else { + result.success = false + result.errors.push(`${filePath}: ${applyResult.error}`) + } + } + } + + if (edit.documentChanges) { + for (const change of edit.documentChanges) { + if ("kind" in change) { + if (change.kind === "create") { + try { + const filePath = uriToPath(change.uri) + writeFileSync(filePath, "", "utf-8") + result.filesModified.push(filePath) + } catch (err) { + result.success = false + result.errors.push(`Create ${change.uri}: ${err}`) + } + } else if (change.kind === "rename") { + try { + const oldPath = uriToPath(change.oldUri) + const newPath = uriToPath(change.newUri) + const content = readFileSync(oldPath, "utf-8") + writeFileSync(newPath, content, "utf-8") + require("fs").unlinkSync(oldPath) + result.filesModified.push(newPath) + } catch (err) { + result.success = false + result.errors.push(`Rename ${change.oldUri}: ${err}`) + } + } else if (change.kind === "delete") { + try { + const filePath = uriToPath(change.uri) + require("fs").unlinkSync(filePath) + result.filesModified.push(filePath) + } catch (err) { + result.success = false + result.errors.push(`Delete ${change.uri}: ${err}`) + } + } + } else { + const filePath = uriToPath(change.textDocument.uri) + const applyResult = applyTextEditsToFile(filePath, change.edits) + + if (applyResult.success) { + result.filesModified.push(filePath) + result.totalEdits += applyResult.editCount + } else { + result.success = false + result.errors.push(`${filePath}: ${applyResult.error}`) + } + } + } + } + + return result +} diff --git a/src/tools/session-manager/utils.ts b/src/tools/session-manager/session-formatter.ts similarity index 100% rename from src/tools/session-manager/utils.ts rename to src/tools/session-manager/session-formatter.ts diff --git a/src/tools/session-manager/tools.ts b/src/tools/session-manager/tools.ts index 5da95a1a..7650013c 100644 --- a/src/tools/session-manager/tools.ts +++ b/src/tools/session-manager/tools.ts @@ -14,7 +14,7 @@ import { formatSessionMessages, formatSearchResults, searchInSession, -} from "./utils" +} from "./session-formatter" import type { SessionListArgs, SessionReadArgs, SessionSearchArgs, SessionInfoArgs, SearchResult } from "./types" const SEARCH_TIMEOUT_MS = 60_000 diff --git a/src/tools/session-manager/utils.test.ts b/src/tools/session-manager/utils.test.ts index 78392a3d..3a0e8ce7 100644 --- a/src/tools/session-manager/utils.test.ts +++ b/src/tools/session-manager/utils.test.ts @@ -6,7 +6,7 @@ import { formatSearchResults, filterSessionsByDate, searchInSession, -} from "./utils" +} from "./session-formatter" import type { SessionInfo, SessionMessage, SearchResult } from "./types" describe("session-manager utils", () => {