From 29155ec7bc362835070328c4f0502e13e1ab8198 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 13:57:26 +0900 Subject: [PATCH 01/51] 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 7c5f969e3..4a416c88d 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 1cbf91d37..bd23df061 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 000000000..459f18e07 --- /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 000000000..7b2c73fcc --- /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 77cfdda86..c7719b414 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 000000000..8e74ca7a9 --- /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 000000000..5e705b025 --- /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 000000000..8d3e70041 --- /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 000000000..38a44801b --- /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 000000000..cf309b85d --- /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 000000000..0c88d56b3 --- /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 000000000..5e803a036 --- /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 000000000..dd5f32662 --- /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 000000000..ee31d0af3 --- /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 000000000..262886ca3 --- /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 57b415fb1..acf5fb6d1 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 000000000..88cabb302 --- /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 d9c56bc9d..e2fd155fe 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 88883feba..2a0908fa3 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 5aac0ebb4..000000000 --- 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 2807ba368..eac107bf5 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 000000000..4ce30e688 --- /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 a0f80acec..c4ce80b71 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 568820abc..988cfaf02 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 906e1c2f5..f9c4e28ad 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 000000000..14fc1e423 --- /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 000000000..bc7f3243f --- /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 bc7f3243f..3b28bb765 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 000000000..141933cb2 --- /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 141933cb2..cba545619 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 000000000..a2f2a063b --- /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 e5e30d2e9..bce2d42e5 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 000000000..f417bdbad --- /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 9fcf562f2..bb24af2ba 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 000000000..8b6e31e5b --- /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 000000000..b15715cda --- /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 932219154..5169cd8a8 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 000000000..8721836ed --- /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 000000000..79d147b30 --- /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 8fdf87485..b4eb4ec75 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 000000000..0cda2fc46 --- /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 000000000..639f3e969 --- /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 d9fed61d1..e2ea8f893 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 000000000..dc1a87211 --- /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 dc1a87211..f5a7a6e7a 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 000000000..7acc6e718 --- /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 000000000..915da323d --- /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 75927033d..c597ff263 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 000000000..2d9828048 --- /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 000000000..b54d7e50c --- /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 000000000..a510301a9 --- /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 b1f29e046..e18d91c4c 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 000000000..dc6ca0810 --- /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 000000000..bc671bff0 --- /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 000000000..33c50bc71 --- /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 7487743cc..7a2231456 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 000000000..082165844 --- /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 000000000..84ac9e9dc --- /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 84ac9e9dc..64682bf23 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 000000000..c6b7e9dfb --- /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 cad5aef05..45bf43d04 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 a9dec9120..b7ad20876 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 d95f9cc66..2f0a897e2 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 000000000..6035209b1 --- /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 000000000..e44549c25 --- /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 410cc4a0b..34a79b4aa 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 000000000..ab3da318b --- /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 000000000..03e72b23c --- /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 9b3de8d21..25ffe06de 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 000000000..b23273144 --- /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 000000000..b556a8a7a --- /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 866ee7ebf..8bcd0bb0f 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 000000000..9ba0324ba --- /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 000000000..f80c0df00 --- /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 630de9070..1652bf9c3 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 000000000..40c475bcf --- /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 37ac304fd..8dc9901cb 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 000000000..fc64fae77 --- /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 b848d859a..dbba20b8b 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 000000000..4e795018d --- /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 4e795018d..194a4261a 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 000000000..f5c0c5185 --- /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 650624c77..7419cab24 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 000000000..d9f7d1afb --- /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 2fee8a18e..a16effb5e 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 000000000..83a55d3fd --- /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 d8aafc253..9e1e0040a 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 000000000..a4217b3e8 --- /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 8e9273848..2a99b3c16 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 000000000..2def362d2 --- /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 1e8ca7519..008879903 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 d42be5a75..e3262161c 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 552746c87..1d27617ec 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 000000000..6e77bb324 --- /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 000000000..bf2f222fe --- /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 6e469e462..9fe2c6b0a 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 76abb7371..9dd2d71ba 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 57c1d6a9f..98b2d0c7e 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 000000000..02ee25a15 --- /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 000000000..35d9af9af --- /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 000000000..3eba5c24f --- /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 000000000..f2c24abc5 --- /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 000000000..4a6716137 --- /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 2a3be2b2d..ec63771cb 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 05a26e875..000000000 --- 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 000000000..97d4f331f --- /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 000000000..664cb8d95 --- /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 000000000..1839932e6 --- /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 000000000..db31c8988 --- /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 000000000..ebfaa4483 --- /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 000000000..8b772bc7e --- /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 000000000..d3a9f8e63 --- /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 000000000..977f93b73 --- /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 000000000..400a6da03 --- /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 000000000..42832d7e1 --- /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 000000000..4d621d50d --- /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 000000000..4d994e224 --- /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 000000000..ae97153bd --- /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 318d178de..361d43cde 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 dd55e3c0c..59ff2ec3d 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 72b101e4b..57b4e4f45 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 bca941b90..dac46bd60 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 57f9a5c8c..5e4651f1b 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 ac97c90d3..2d36aa0f5 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 d5aada383..5997b01ba 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 000000000..b9d944e45 --- /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 000000000..b9b935569 --- /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 000000000..2cdf76fb8 --- /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 f149bec3c..924217654 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 000000000..75b84f8fb --- /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 000000000..136c68214 --- /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 000000000..b9eec9ded --- /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 000000000..d4590262b --- /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 000000000..7ec33847b --- /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 000000000..4785909cc --- /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 000000000..0633d55f0 --- /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 000000000..a193aa968 --- /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 000000000..6d4ee4b6f --- /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 000000000..d29ce2054 --- /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 000000000..ec8bd1838 --- /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 000000000..0e00f1395 --- /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 000000000..e3a834c8a --- /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 000000000..4279110e6 --- /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 000000000..eba177efb --- /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 802604f47..9ed6ff7b3 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 ecd2f90a7..50788f9fe 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 510871509..000000000 --- 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 000000000..e0a836dc2 --- /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 5da95a1ab..7650013cf 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 78392a3d2..3a0e8ce7a 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", () => { From 119e18c8102dcd5c22e9098ccf9c83aa9e443a5e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 15:01:42 +0900 Subject: [PATCH 02/51] refactor: wave 2 - split atlas, auto-update-checker, session-recovery, todo-enforcer, background-task hooks - Extract atlas/ into 15 focused modules (hook, event handler, tool policies, types, etc.) - Split auto-update-checker into checker/ and hook/ subdirectories with single-purpose files - Decompose session-recovery into separate recovery strategy files per error type - Extract todo-continuation-enforcer from monolith to directory with dedicated modules - Split background-task/tools.ts into individual tool creator files - Extract command-executor, tmux-utils into focused sub-modules - Split config/schema.ts into domain-specific schema files - Decompose cli/config-manager.ts into focused modules - Rollback skill-mcp-manager, model-availability, index.ts splits that broke tests - Fix all import path depths for moved files (../../ -> ../../../) - Add explicit type annotations to resolve TS7006 implicit any errors Typecheck: 0 errors Tests: 2359 pass, 5 fail (all pre-existing) --- src/agents/prometheus/index.ts | 48 +- src/agents/prometheus/system-prompt.ts | 29 + src/cli/cli-program.ts | 191 +++++ src/cli/config-manager.ts | 670 +-------------- .../add-plugin-to-opencode-config.ts | 82 ++ src/cli/config-manager/add-provider-config.ts | 54 ++ .../antigravity-provider-configuration.ts | 64 ++ src/cli/config-manager/auth-plugins.ts | 64 ++ src/cli/config-manager/bun-install.ts | 60 ++ src/cli/config-manager/config-context.ts | 46 + src/cli/config-manager/deep-merge-record.ts | 29 + .../config-manager/detect-current-config.ts | 78 ++ .../ensure-config-directory-exists.ts | 9 + .../format-error-with-suggestion.ts | 39 + src/cli/config-manager/generate-omo-config.ts | 6 + src/cli/config-manager/npm-dist-tags.ts | 21 + src/cli/config-manager/opencode-binary.ts | 40 + .../config-manager/opencode-config-format.ts | 17 + .../parse-opencode-config-file.ts | 48 ++ .../plugin-name-with-version.ts | 19 + src/cli/config-manager/write-omo-config.ts | 67 ++ src/cli/index.ts | 190 +---- src/config/schema.ts | 483 +---------- src/config/schema/agent-names.ts | 44 + src/config/schema/agent-overrides.ts | 60 ++ src/config/schema/babysitting.ts | 7 + src/config/schema/background-task.ts | 11 + src/config/schema/browser-automation.ts | 22 + src/config/schema/categories.ts | 40 + src/config/schema/claude-code.ts | 13 + src/config/schema/commands.ts | 13 + src/config/schema/comment-checker.ts | 8 + src/config/schema/dynamic-context-pruning.ts | 55 ++ src/config/schema/experimental.ts | 20 + src/config/schema/git-master.ts | 10 + src/config/schema/hooks.ts | 51 ++ src/config/schema/internal/permission.ts | 20 + src/config/schema/notification.ts | 8 + src/config/schema/oh-my-opencode-config.ts | 57 ++ src/config/schema/ralph-loop.ts | 12 + src/config/schema/sisyphus-agent.ts | 10 + src/config/schema/sisyphus.ts | 17 + src/config/schema/skills.ts | 45 + src/config/schema/tmux.ts | 20 + src/config/schema/websearch.ts | 15 + src/hooks/atlas/atlas-hook.ts | 25 + .../atlas/boulder-continuation-injector.ts | 68 ++ src/hooks/atlas/event-handler.ts | 187 ++++ src/hooks/atlas/git-diff-stats.ts | 108 +++ src/hooks/atlas/hook-name.ts | 1 + src/hooks/atlas/index.ts | 807 +----------------- src/hooks/atlas/is-abort-error.ts | 20 + src/hooks/atlas/recent-model-resolver.ts | 38 + src/hooks/atlas/session-last-agent.ts | 9 + src/hooks/atlas/sisyphus-path.ts | 7 + src/hooks/atlas/subagent-session-id.ts | 4 + src/hooks/atlas/system-reminder-templates.ts | 154 ++++ src/hooks/atlas/tool-execute-after.ts | 109 +++ src/hooks/atlas/tool-execute-before.ts | 52 ++ src/hooks/atlas/types.ts | 27 + src/hooks/atlas/verification-reminders.ts | 83 ++ src/hooks/atlas/write-edit-tool-policy.ts | 5 + src/hooks/auto-update-checker/checker.ts | 306 +------ .../checker/cached-version.ts | 45 + .../checker/check-for-update.ts | 69 ++ .../checker/config-paths.ts | 37 + .../checker/jsonc-strip.ts | 7 + .../checker/latest-version.ts | 23 + .../checker/local-dev-path.ts | 35 + .../checker/local-dev-version.ts | 19 + .../checker/package-json-locator.ts | 30 + .../checker/pinned-version-updater.ts | 53 ++ .../checker/plugin-entry.ts | 38 + src/hooks/auto-update-checker/hook.ts | 64 ++ .../hook/background-update-check.ts | 79 ++ .../hook/config-errors-toast.ts | 23 + .../hook/connected-providers-status.ts | 29 + .../hook/model-cache-warning.ts | 21 + .../auto-update-checker/hook/spinner-toast.ts | 25 + .../hook/startup-toasts.ts | 22 + .../auto-update-checker/hook/update-toasts.ts | 34 + src/hooks/auto-update-checker/index.ts | 308 +------ .../auto-update-checker/version-channel.ts | 33 + .../claude-code-hooks-hook.ts | 421 +++++++++ src/hooks/claude-code-hooks/index.ts | 422 +-------- src/hooks/interactive-bash-session/index.ts | 268 +----- .../interactive-bash-session-hook.ts | 267 ++++++ src/hooks/non-interactive-env/index.ts | 63 +- .../non-interactive-env-hook.ts | 66 ++ src/hooks/ralph-loop/index.ts | 426 +-------- src/hooks/ralph-loop/ralph-loop-hook.ts | 428 ++++++++++ .../session-recovery/detect-error-type.ts | 65 ++ src/hooks/session-recovery/hook.ts | 141 +++ src/hooks/session-recovery/index.ts | 439 +--------- .../recover-empty-content-message.ts | 74 ++ .../recover-thinking-block-order.ts | 36 + .../recover-thinking-disabled-violation.ts | 25 + .../recover-tool-result-missing.ts | 61 ++ src/hooks/session-recovery/resume.ts | 39 + src/hooks/session-recovery/storage.ts | 416 +-------- .../storage/empty-messages.ts | 47 + .../session-recovery/storage/empty-text.ts | 55 ++ .../session-recovery/storage/message-dir.ts | 21 + .../storage/messages-reader.ts | 27 + .../storage/orphan-thinking-search.ts | 43 + .../session-recovery/storage/part-content.ts | 28 + src/hooks/session-recovery/storage/part-id.ts | 5 + .../session-recovery/storage/parts-reader.ts | 22 + .../storage/text-part-injector.ts | 30 + .../storage/thinking-block-search.ts | 42 + .../storage/thinking-prepend.ts | 58 ++ .../storage/thinking-strip.ts | 27 + src/hooks/start-work/index.ts | 243 +----- src/hooks/start-work/start-work-hook.ts | 242 ++++++ src/hooks/todo-continuation-enforcer.ts | 517 ----------- .../abort-detection.ts | 17 + .../todo-continuation-enforcer/constants.ts | 19 + .../continuation-injection.ts | 139 +++ .../todo-continuation-enforcer/countdown.ts | 83 ++ .../todo-continuation-enforcer/handler.ts | 65 ++ .../todo-continuation-enforcer/idle-event.ts | 158 ++++ src/hooks/todo-continuation-enforcer/index.ts | 58 ++ .../message-directory.ts | 18 + .../non-idle-events.ts | 74 ++ .../session-state.ts | 62 ++ .../todo-continuation-enforcer.test.ts | 6 +- src/hooks/todo-continuation-enforcer/todo.ts | 5 + src/hooks/todo-continuation-enforcer/types.ts | 47 + src/hooks/unstable-agent-babysitter/index.ts | 251 +----- .../unstable-agent-babysitter-hook.ts | 250 ++++++ src/shared/command-executor.ts | 228 +---- .../command-executor/embedded-commands.ts | 26 + .../command-executor/execute-command.ts | 28 + .../command-executor/execute-hook-command.ts | 78 ++ src/shared/command-executor/home-directory.ts | 5 + .../resolve-commands-in-text.ts | 49 ++ src/shared/command-executor/shell-path.ts | 27 + src/shared/tmux/tmux-utils.ts | 317 +------ src/shared/tmux/tmux-utils/environment.ts | 9 + src/shared/tmux/tmux-utils/layout.ts | 49 ++ src/shared/tmux/tmux-utils/pane-close.ts | 48 ++ src/shared/tmux/tmux-utils/pane-dimensions.ts | 28 + src/shared/tmux/tmux-utils/pane-replace.ts | 69 ++ src/shared/tmux/tmux-utils/pane-spawn.ts | 91 ++ src/shared/tmux/tmux-utils/server-health.ts | 47 + src/tools/background-task/clients.ts | 32 + .../create-background-cancel.ts | 115 +++ .../create-background-output.ts | 89 ++ .../background-task/create-background-task.ts | 116 +++ src/tools/background-task/delay.ts | 3 + .../background-task/full-session-format.ts | 148 ++++ src/tools/background-task/message-dir.ts | 17 + src/tools/background-task/session-messages.ts | 22 + .../background-task/task-result-format.ts | 113 +++ .../background-task/task-status-format.ts | 68 ++ src/tools/background-task/time-format.ts | 30 + src/tools/background-task/tools.ts | 768 +---------------- src/tools/background-task/truncate-text.ts | 4 + 158 files changed, 7806 insertions(+), 7050 deletions(-) create mode 100644 src/agents/prometheus/system-prompt.ts create mode 100644 src/cli/cli-program.ts create mode 100644 src/cli/config-manager/add-plugin-to-opencode-config.ts create mode 100644 src/cli/config-manager/add-provider-config.ts create mode 100644 src/cli/config-manager/antigravity-provider-configuration.ts create mode 100644 src/cli/config-manager/auth-plugins.ts create mode 100644 src/cli/config-manager/bun-install.ts create mode 100644 src/cli/config-manager/config-context.ts create mode 100644 src/cli/config-manager/deep-merge-record.ts create mode 100644 src/cli/config-manager/detect-current-config.ts create mode 100644 src/cli/config-manager/ensure-config-directory-exists.ts create mode 100644 src/cli/config-manager/format-error-with-suggestion.ts create mode 100644 src/cli/config-manager/generate-omo-config.ts create mode 100644 src/cli/config-manager/npm-dist-tags.ts create mode 100644 src/cli/config-manager/opencode-binary.ts create mode 100644 src/cli/config-manager/opencode-config-format.ts create mode 100644 src/cli/config-manager/parse-opencode-config-file.ts create mode 100644 src/cli/config-manager/plugin-name-with-version.ts create mode 100644 src/cli/config-manager/write-omo-config.ts create mode 100644 src/config/schema/agent-names.ts create mode 100644 src/config/schema/agent-overrides.ts create mode 100644 src/config/schema/babysitting.ts create mode 100644 src/config/schema/background-task.ts create mode 100644 src/config/schema/browser-automation.ts create mode 100644 src/config/schema/categories.ts create mode 100644 src/config/schema/claude-code.ts create mode 100644 src/config/schema/commands.ts create mode 100644 src/config/schema/comment-checker.ts create mode 100644 src/config/schema/dynamic-context-pruning.ts create mode 100644 src/config/schema/experimental.ts create mode 100644 src/config/schema/git-master.ts create mode 100644 src/config/schema/hooks.ts create mode 100644 src/config/schema/internal/permission.ts create mode 100644 src/config/schema/notification.ts create mode 100644 src/config/schema/oh-my-opencode-config.ts create mode 100644 src/config/schema/ralph-loop.ts create mode 100644 src/config/schema/sisyphus-agent.ts create mode 100644 src/config/schema/sisyphus.ts create mode 100644 src/config/schema/skills.ts create mode 100644 src/config/schema/tmux.ts create mode 100644 src/config/schema/websearch.ts create mode 100644 src/hooks/atlas/atlas-hook.ts create mode 100644 src/hooks/atlas/boulder-continuation-injector.ts create mode 100644 src/hooks/atlas/event-handler.ts create mode 100644 src/hooks/atlas/git-diff-stats.ts create mode 100644 src/hooks/atlas/hook-name.ts create mode 100644 src/hooks/atlas/is-abort-error.ts create mode 100644 src/hooks/atlas/recent-model-resolver.ts create mode 100644 src/hooks/atlas/session-last-agent.ts create mode 100644 src/hooks/atlas/sisyphus-path.ts create mode 100644 src/hooks/atlas/subagent-session-id.ts create mode 100644 src/hooks/atlas/system-reminder-templates.ts create mode 100644 src/hooks/atlas/tool-execute-after.ts create mode 100644 src/hooks/atlas/tool-execute-before.ts create mode 100644 src/hooks/atlas/types.ts create mode 100644 src/hooks/atlas/verification-reminders.ts create mode 100644 src/hooks/atlas/write-edit-tool-policy.ts create mode 100644 src/hooks/auto-update-checker/checker/cached-version.ts create mode 100644 src/hooks/auto-update-checker/checker/check-for-update.ts create mode 100644 src/hooks/auto-update-checker/checker/config-paths.ts create mode 100644 src/hooks/auto-update-checker/checker/jsonc-strip.ts create mode 100644 src/hooks/auto-update-checker/checker/latest-version.ts create mode 100644 src/hooks/auto-update-checker/checker/local-dev-path.ts create mode 100644 src/hooks/auto-update-checker/checker/local-dev-version.ts create mode 100644 src/hooks/auto-update-checker/checker/package-json-locator.ts create mode 100644 src/hooks/auto-update-checker/checker/pinned-version-updater.ts create mode 100644 src/hooks/auto-update-checker/checker/plugin-entry.ts create mode 100644 src/hooks/auto-update-checker/hook.ts create mode 100644 src/hooks/auto-update-checker/hook/background-update-check.ts create mode 100644 src/hooks/auto-update-checker/hook/config-errors-toast.ts create mode 100644 src/hooks/auto-update-checker/hook/connected-providers-status.ts create mode 100644 src/hooks/auto-update-checker/hook/model-cache-warning.ts create mode 100644 src/hooks/auto-update-checker/hook/spinner-toast.ts create mode 100644 src/hooks/auto-update-checker/hook/startup-toasts.ts create mode 100644 src/hooks/auto-update-checker/hook/update-toasts.ts create mode 100644 src/hooks/auto-update-checker/version-channel.ts create mode 100644 src/hooks/claude-code-hooks/claude-code-hooks-hook.ts create mode 100644 src/hooks/interactive-bash-session/interactive-bash-session-hook.ts create mode 100644 src/hooks/non-interactive-env/non-interactive-env-hook.ts create mode 100644 src/hooks/ralph-loop/ralph-loop-hook.ts create mode 100644 src/hooks/session-recovery/detect-error-type.ts create mode 100644 src/hooks/session-recovery/hook.ts create mode 100644 src/hooks/session-recovery/recover-empty-content-message.ts create mode 100644 src/hooks/session-recovery/recover-thinking-block-order.ts create mode 100644 src/hooks/session-recovery/recover-thinking-disabled-violation.ts create mode 100644 src/hooks/session-recovery/recover-tool-result-missing.ts create mode 100644 src/hooks/session-recovery/resume.ts create mode 100644 src/hooks/session-recovery/storage/empty-messages.ts create mode 100644 src/hooks/session-recovery/storage/empty-text.ts create mode 100644 src/hooks/session-recovery/storage/message-dir.ts create mode 100644 src/hooks/session-recovery/storage/messages-reader.ts create mode 100644 src/hooks/session-recovery/storage/orphan-thinking-search.ts create mode 100644 src/hooks/session-recovery/storage/part-content.ts create mode 100644 src/hooks/session-recovery/storage/part-id.ts create mode 100644 src/hooks/session-recovery/storage/parts-reader.ts create mode 100644 src/hooks/session-recovery/storage/text-part-injector.ts create mode 100644 src/hooks/session-recovery/storage/thinking-block-search.ts create mode 100644 src/hooks/session-recovery/storage/thinking-prepend.ts create mode 100644 src/hooks/session-recovery/storage/thinking-strip.ts create mode 100644 src/hooks/start-work/start-work-hook.ts delete mode 100644 src/hooks/todo-continuation-enforcer.ts create mode 100644 src/hooks/todo-continuation-enforcer/abort-detection.ts create mode 100644 src/hooks/todo-continuation-enforcer/constants.ts create mode 100644 src/hooks/todo-continuation-enforcer/continuation-injection.ts create mode 100644 src/hooks/todo-continuation-enforcer/countdown.ts create mode 100644 src/hooks/todo-continuation-enforcer/handler.ts create mode 100644 src/hooks/todo-continuation-enforcer/idle-event.ts create mode 100644 src/hooks/todo-continuation-enforcer/index.ts create mode 100644 src/hooks/todo-continuation-enforcer/message-directory.ts create mode 100644 src/hooks/todo-continuation-enforcer/non-idle-events.ts create mode 100644 src/hooks/todo-continuation-enforcer/session-state.ts rename src/hooks/{ => todo-continuation-enforcer}/todo-continuation-enforcer.test.ts (99%) create mode 100644 src/hooks/todo-continuation-enforcer/todo.ts create mode 100644 src/hooks/todo-continuation-enforcer/types.ts create mode 100644 src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts create mode 100644 src/shared/command-executor/embedded-commands.ts create mode 100644 src/shared/command-executor/execute-command.ts create mode 100644 src/shared/command-executor/execute-hook-command.ts create mode 100644 src/shared/command-executor/home-directory.ts create mode 100644 src/shared/command-executor/resolve-commands-in-text.ts create mode 100644 src/shared/command-executor/shell-path.ts create mode 100644 src/shared/tmux/tmux-utils/environment.ts create mode 100644 src/shared/tmux/tmux-utils/layout.ts create mode 100644 src/shared/tmux/tmux-utils/pane-close.ts create mode 100644 src/shared/tmux/tmux-utils/pane-dimensions.ts create mode 100644 src/shared/tmux/tmux-utils/pane-replace.ts create mode 100644 src/shared/tmux/tmux-utils/pane-spawn.ts create mode 100644 src/shared/tmux/tmux-utils/server-health.ts create mode 100644 src/tools/background-task/clients.ts create mode 100644 src/tools/background-task/create-background-cancel.ts create mode 100644 src/tools/background-task/create-background-output.ts create mode 100644 src/tools/background-task/create-background-task.ts create mode 100644 src/tools/background-task/delay.ts create mode 100644 src/tools/background-task/full-session-format.ts create mode 100644 src/tools/background-task/message-dir.ts create mode 100644 src/tools/background-task/session-messages.ts create mode 100644 src/tools/background-task/task-result-format.ts create mode 100644 src/tools/background-task/task-status-format.ts create mode 100644 src/tools/background-task/time-format.ts create mode 100644 src/tools/background-task/truncate-text.ts diff --git a/src/agents/prometheus/index.ts b/src/agents/prometheus/index.ts index ae1afbca2..4be93697f 100644 --- a/src/agents/prometheus/index.ts +++ b/src/agents/prometheus/index.ts @@ -1,50 +1,4 @@ -/** - * Prometheus Planner System Prompt - * - * Named after the Titan who gave fire (knowledge/foresight) to humanity. - * Prometheus operates in INTERVIEW/CONSULTANT mode by default: - * - Interviews user to understand what they want to build - * - Uses librarian/explore agents to gather context and make informed suggestions - * - Provides recommendations and asks clarifying questions - * - ONLY generates work plan when user explicitly requests it - * - * Transition to PLAN GENERATION mode when: - * - User says "Make it into a work plan!" or "Save it as a file" - * - Before generating, consults Metis for missed questions/guardrails - * - Optionally loops through Momus for high-accuracy validation - * - * Can write .md files only (enforced by prometheus-md-only hook). - */ - -import { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints" -import { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode" -import { PROMETHEUS_PLAN_GENERATION } from "./plan-generation" -import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode" -import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template" -import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary" - -/** - * Combined Prometheus system prompt. - * Assembled from modular sections for maintainability. - */ -export const PROMETHEUS_SYSTEM_PROMPT = `${PROMETHEUS_IDENTITY_CONSTRAINTS} -${PROMETHEUS_INTERVIEW_MODE} -${PROMETHEUS_PLAN_GENERATION} -${PROMETHEUS_HIGH_ACCURACY_MODE} -${PROMETHEUS_PLAN_TEMPLATE} -${PROMETHEUS_BEHAVIORAL_SUMMARY}` - -/** - * Prometheus planner permission configuration. - * Allows write/edit for plan files (.md only, enforced by prometheus-md-only hook). - * Question permission allows agent to ask user questions via OpenCode's QuestionTool. - */ -export const PROMETHEUS_PERMISSION = { - edit: "allow" as const, - bash: "allow" as const, - webfetch: "allow" as const, - question: "allow" as const, -} +export { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "./system-prompt" // Re-export individual sections for granular access export { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints" diff --git a/src/agents/prometheus/system-prompt.ts b/src/agents/prometheus/system-prompt.ts new file mode 100644 index 000000000..079c68c23 --- /dev/null +++ b/src/agents/prometheus/system-prompt.ts @@ -0,0 +1,29 @@ +import { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints" +import { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode" +import { PROMETHEUS_PLAN_GENERATION } from "./plan-generation" +import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode" +import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template" +import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary" + +/** + * Combined Prometheus system prompt. + * Assembled from modular sections for maintainability. + */ +export const PROMETHEUS_SYSTEM_PROMPT = `${PROMETHEUS_IDENTITY_CONSTRAINTS} +${PROMETHEUS_INTERVIEW_MODE} +${PROMETHEUS_PLAN_GENERATION} +${PROMETHEUS_HIGH_ACCURACY_MODE} +${PROMETHEUS_PLAN_TEMPLATE} +${PROMETHEUS_BEHAVIORAL_SUMMARY}` + +/** + * Prometheus planner permission configuration. + * Allows write/edit for plan files (.md only, enforced by prometheus-md-only hook). + * Question permission allows agent to ask user questions via OpenCode's QuestionTool. + */ +export const PROMETHEUS_PERMISSION = { + edit: "allow" as const, + bash: "allow" as const, + webfetch: "allow" as const, + question: "allow" as const, +} diff --git a/src/cli/cli-program.ts b/src/cli/cli-program.ts new file mode 100644 index 000000000..8cc80be04 --- /dev/null +++ b/src/cli/cli-program.ts @@ -0,0 +1,191 @@ +import { Command } from "commander" +import { install } from "./install" +import { run } from "./run" +import { getLocalVersion } from "./get-local-version" +import { doctor } from "./doctor" +import { createMcpOAuthCommand } from "./mcp-oauth" +import type { InstallArgs } from "./types" +import type { RunOptions } from "./run" +import type { GetLocalVersionOptions } from "./get-local-version/types" +import type { DoctorOptions } from "./doctor" +import packageJson from "../../package.json" with { type: "json" } + +const VERSION = packageJson.version + +const program = new Command() + +program + .name("oh-my-opencode") + .description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more") + .version(VERSION, "-v, --version", "Show version number") + .enablePositionalOptions() + +program + .command("install") + .description("Install and configure oh-my-opencode with interactive setup") + .option("--no-tui", "Run in non-interactive mode (requires all options)") + .option("--claude ", "Claude subscription: no, yes, max20") + .option("--openai ", "OpenAI/ChatGPT subscription: no, yes (default: no)") + .option("--gemini ", "Gemini integration: no, yes") + .option("--copilot ", "GitHub Copilot subscription: no, yes") + .option("--opencode-zen ", "OpenCode Zen access: no, yes (default: no)") + .option("--zai-coding-plan ", "Z.ai Coding Plan subscription: no, yes (default: no)") + .option("--kimi-for-coding ", "Kimi For Coding subscription: no, yes (default: no)") + .option("--skip-auth", "Skip authentication setup hints") + .addHelpText("after", ` +Examples: + $ bunx oh-my-opencode install + $ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no + $ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes + +Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi): + Claude Native anthropic/ models (Opus, Sonnet, Haiku) + OpenAI Native openai/ models (GPT-5.2 for Oracle) + Gemini Native google/ models (Gemini 3 Pro, Flash) + Copilot github-copilot/ models (fallback) + OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.) + Z.ai zai-coding-plan/glm-4.7 (Librarian priority) + Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback) +`) + .action(async (options) => { + const args: InstallArgs = { + tui: options.tui !== false, + claude: options.claude, + openai: options.openai, + gemini: options.gemini, + copilot: options.copilot, + opencodeZen: options.opencodeZen, + zaiCodingPlan: options.zaiCodingPlan, + kimiForCoding: options.kimiForCoding, + skipAuth: options.skipAuth ?? false, + } + const exitCode = await install(args) + process.exit(exitCode) + }) + +program + .command("run ") + .allowUnknownOption() + .passThroughOptions() + .description("Run opencode with todo/background task completion enforcement") + .option("-a, --agent ", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)") + .option("-d, --directory ", "Working directory") + .option("-t, --timeout ", "Timeout in milliseconds (default: 30 minutes)", parseInt) + .option("-p, --port ", "Server port (attaches if port already in use)", parseInt) + .option("--attach ", "Attach to existing opencode server URL") + .option("--on-complete ", "Shell command to run after completion") + .option("--json", "Output structured JSON result to stdout") + .option("--session-id ", "Resume existing session instead of creating new one") + .addHelpText("after", ` +Examples: + $ bunx oh-my-opencode run "Fix the bug in index.ts" + $ bunx oh-my-opencode run --agent Sisyphus "Implement feature X" + $ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task" + $ bunx oh-my-opencode run --port 4321 "Fix the bug" + $ bunx oh-my-opencode run --attach http://127.0.0.1:4321 "Fix the bug" + $ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId + $ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug" + $ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work" + +Agent resolution order: + 1) --agent flag + 2) OPENCODE_DEFAULT_AGENT + 3) oh-my-opencode.json "default_run_agent" + 4) Sisyphus (fallback) + +Available core agents: + Sisyphus, Hephaestus, Prometheus, Atlas + +Unlike 'opencode run', this command waits until: + - All todos are completed or cancelled + - All child sessions (background tasks) are idle +`) + .action(async (message: string, options) => { + if (options.port && options.attach) { + console.error("Error: --port and --attach are mutually exclusive") + process.exit(1) + } + const runOptions: RunOptions = { + message, + agent: options.agent, + directory: options.directory, + timeout: options.timeout, + port: options.port, + attach: options.attach, + onComplete: options.onComplete, + json: options.json ?? false, + sessionId: options.sessionId, + } + const exitCode = await run(runOptions) + process.exit(exitCode) + }) + +program + .command("get-local-version") + .description("Show current installed version and check for updates") + .option("-d, --directory ", "Working directory to check config from") + .option("--json", "Output in JSON format for scripting") + .addHelpText("after", ` +Examples: + $ bunx oh-my-opencode get-local-version + $ bunx oh-my-opencode get-local-version --json + $ bunx oh-my-opencode get-local-version --directory /path/to/project + +This command shows: + - Current installed version + - Latest available version on npm + - Whether you're up to date + - Special modes (local dev, pinned version) +`) + .action(async (options) => { + const versionOptions: GetLocalVersionOptions = { + directory: options.directory, + json: options.json ?? false, + } + const exitCode = await getLocalVersion(versionOptions) + process.exit(exitCode) + }) + +program + .command("doctor") + .description("Check oh-my-opencode installation health and diagnose issues") + .option("--verbose", "Show detailed diagnostic information") + .option("--json", "Output results in JSON format") + .option("--category ", "Run only specific category") + .addHelpText("after", ` +Examples: + $ bunx oh-my-opencode doctor + $ bunx oh-my-opencode doctor --verbose + $ bunx oh-my-opencode doctor --json + $ bunx oh-my-opencode doctor --category authentication + +Categories: + installation Check OpenCode and plugin installation + configuration Validate configuration files + authentication Check auth provider status + dependencies Check external dependencies + tools Check LSP and MCP servers + updates Check for version updates +`) + .action(async (options) => { + const doctorOptions: DoctorOptions = { + verbose: options.verbose ?? false, + json: options.json ?? false, + category: options.category, + } + const exitCode = await doctor(doctorOptions) + process.exit(exitCode) + }) + +program + .command("version") + .description("Show version information") + .action(() => { + console.log(`oh-my-opencode v${VERSION}`) + }) + +program.addCommand(createMcpOAuthCommand()) + +export function runCli(): void { + program.parse() +} diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index eac107bf5..cfb6a9178 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -1,657 +1,23 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs" -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" +export type { ConfigContext } from "./config-manager/config-context" +export { + initConfigContext, + getConfigContext, + resetConfigContext, +} from "./config-manager/config-context" -const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const +export { fetchNpmDistTags } from "./config-manager/npm-dist-tags" +export { getPluginNameWithVersion } from "./config-manager/plugin-name-with-version" +export { addPluginToOpenCodeConfig } from "./config-manager/add-plugin-to-opencode-config" -interface ConfigContext { - binary: OpenCodeBinaryType - version: string | null - paths: OpenCodeConfigPaths -} +export { generateOmoConfig } from "./config-manager/generate-omo-config" +export { writeOmoConfig } from "./config-manager/write-omo-config" -let configContext: ConfigContext | null = null +export { isOpenCodeInstalled, getOpenCodeVersion } from "./config-manager/opencode-binary" -export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void { - const paths = getOpenCodeConfigPaths({ binary, version }) - configContext = { binary, version, paths } -} +export { fetchLatestVersion, addAuthPlugins } from "./config-manager/auth-plugins" +export { ANTIGRAVITY_PROVIDER_CONFIG } from "./config-manager/antigravity-provider-configuration" +export { addProviderConfig } from "./config-manager/add-provider-config" +export { detectCurrentConfig } from "./config-manager/detect-current-config" -export function getConfigContext(): ConfigContext { - if (!configContext) { - const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) - configContext = { binary: "opencode", version: null, paths } - } - return configContext -} - -export function resetConfigContext(): void { - configContext = null -} - -function getConfigDir(): string { - return getConfigContext().paths.configDir -} - -function getConfigJson(): string { - return getConfigContext().paths.configJson -} - -function getConfigJsonc(): string { - return getConfigContext().paths.configJsonc -} - -function getOmoConfig(): string { - return getConfigContext().paths.omoConfig -} - -const BUN_INSTALL_TIMEOUT_SECONDS = 60 -const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000 - -interface NodeError extends Error { - code?: string -} - -function isPermissionError(err: unknown): boolean { - const nodeErr = err as NodeError - return nodeErr?.code === "EACCES" || nodeErr?.code === "EPERM" -} - -function isFileNotFoundError(err: unknown): boolean { - const nodeErr = err as NodeError - return nodeErr?.code === "ENOENT" -} - -function formatErrorWithSuggestion(err: unknown, context: string): string { - if (isPermissionError(err)) { - return `Permission denied: Cannot ${context}. Try running with elevated permissions or check file ownership.` - } - - if (isFileNotFoundError(err)) { - return `File not found while trying to ${context}. The file may have been deleted or moved.` - } - - if (err instanceof SyntaxError) { - return `JSON syntax error while trying to ${context}: ${err.message}. Check for missing commas, brackets, or invalid characters.` - } - - const message = err instanceof Error ? err.message : String(err) - - if (message.includes("ENOSPC")) { - return `Disk full: Cannot ${context}. Free up disk space and try again.` - } - - if (message.includes("EROFS")) { - return `Read-only filesystem: Cannot ${context}. Check if the filesystem is mounted read-only.` - } - - return `Failed to ${context}: ${message}` -} - -export async function fetchLatestVersion(packageName: string): Promise { - try { - const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`) - if (!res.ok) return null - const data = await res.json() as { version: string } - return data.version - } catch { - return null - } -} - -interface NpmDistTags { - latest?: string - beta?: string - next?: string - [tag: string]: string | undefined -} - -const NPM_FETCH_TIMEOUT_MS = 5000 - -export async function fetchNpmDistTags(packageName: string): Promise { - try { - const res = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`, { - signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS), - }) - if (!res.ok) return null - const data = await res.json() as NpmDistTags - return data - } catch { - return null - } -} - -const PACKAGE_NAME = "oh-my-opencode" - -const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const - -export async function getPluginNameWithVersion(currentVersion: string): Promise { - const distTags = await fetchNpmDistTags(PACKAGE_NAME) - - if (distTags) { - const allTags = new Set([...PRIORITIZED_TAGS, ...Object.keys(distTags)]) - for (const tag of allTags) { - if (distTags[tag] === currentVersion) { - return `${PACKAGE_NAME}@${tag}` - } - } - } - - return `${PACKAGE_NAME}@${currentVersion}` -} - -type ConfigFormat = "json" | "jsonc" | "none" - -interface OpenCodeConfig { - plugin?: string[] - [key: string]: unknown -} - -export function detectConfigFormat(): { format: ConfigFormat; path: string } { - const configJsonc = getConfigJsonc() - const configJson = getConfigJson() - - if (existsSync(configJsonc)) { - return { format: "jsonc", path: configJsonc } - } - if (existsSync(configJson)) { - return { format: "json", path: configJson } - } - return { format: "none", path: configJson } -} - -interface ParseConfigResult { - config: OpenCodeConfig | null - error?: string -} - -function isEmptyOrWhitespace(content: string): boolean { - return content.trim().length === 0 -} - -function parseConfigWithError(path: string): ParseConfigResult { - try { - const stat = statSync(path) - if (stat.size === 0) { - return { config: null, error: `Config file is empty: ${path}. Delete it or add valid JSON content.` } - } - - const content = readFileSync(path, "utf-8") - - if (isEmptyOrWhitespace(content)) { - return { config: null, error: `Config file contains only whitespace: ${path}. Delete it or add valid JSON content.` } - } - - const config = parseJsonc(content) - - if (config === null || config === undefined) { - return { config: null, error: `Config file parsed to null/undefined: ${path}. Ensure it contains valid JSON.` } - } - - if (typeof config !== "object" || Array.isArray(config)) { - return { config: null, error: `Config file must contain a JSON object, not ${Array.isArray(config) ? "an array" : typeof config}: ${path}` } - } - - return { config } - } catch (err) { - return { config: null, error: formatErrorWithSuggestion(err, `parse config file ${path}`) } - } -} - -function ensureConfigDir(): void { - const configDir = getConfigDir() - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }) - } -} - -export async function addPluginToOpenCodeConfig(currentVersion: string): Promise { - try { - ensureConfigDir() - } catch (err) { - return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") } - } - - const { format, path } = detectConfigFormat() - const pluginEntry = await getPluginNameWithVersion(currentVersion) - - try { - if (format === "none") { - const config: OpenCodeConfig = { plugin: [pluginEntry] } - writeFileSync(path, JSON.stringify(config, null, 2) + "\n") - return { success: true, configPath: path } - } - - const parseResult = parseConfigWithError(path) - if (!parseResult.config) { - return { success: false, configPath: path, error: parseResult.error ?? "Failed to parse config file" } - } - - const config = parseResult.config - const plugins = config.plugin ?? [] - const existingIndex = plugins.findIndex((p) => p === PACKAGE_NAME || p.startsWith(`${PACKAGE_NAME}@`)) - - if (existingIndex !== -1) { - if (plugins[existingIndex] === pluginEntry) { - return { success: true, configPath: path } - } - plugins[existingIndex] = pluginEntry - } else { - plugins.push(pluginEntry) - } - - config.plugin = plugins - - if (format === "jsonc") { - const content = readFileSync(path, "utf-8") - const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/ - const match = content.match(pluginArrayRegex) - - if (match) { - const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ") - const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`) - writeFileSync(path, newContent) - } else { - const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`) - writeFileSync(path, newContent) - } - } else { - writeFileSync(path, JSON.stringify(config, null, 2) + "\n") - } - - return { success: true, configPath: path } - } catch (err) { - return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "update opencode config") } - } -} - -function deepMerge>(target: T, source: Partial): T { - const result = { ...target } - - for (const key of Object.keys(source) as Array) { - const sourceValue = source[key] - const targetValue = result[key] - - if ( - sourceValue !== null && - typeof sourceValue === "object" && - !Array.isArray(sourceValue) && - targetValue !== null && - typeof targetValue === "object" && - !Array.isArray(targetValue) - ) { - result[key] = deepMerge( - targetValue as Record, - sourceValue as Record - ) as T[keyof T] - } else if (sourceValue !== undefined) { - result[key] = sourceValue as T[keyof T] - } - } - - return result -} - -export function generateOmoConfig(installConfig: InstallConfig): Record { - return generateModelConfig(installConfig) -} - -export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult { - try { - ensureConfigDir() - } catch (err) { - return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") } - } - - const omoConfigPath = getOmoConfig() - - try { - const newConfig = generateOmoConfig(installConfig) - - if (existsSync(omoConfigPath)) { - try { - const stat = statSync(omoConfigPath) - const content = readFileSync(omoConfigPath, "utf-8") - - if (stat.size === 0 || isEmptyOrWhitespace(content)) { - writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: omoConfigPath } - } - - const existing = parseJsonc>(content) - if (!existing || typeof existing !== "object" || Array.isArray(existing)) { - writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: omoConfigPath } - } - - const merged = deepMerge(existing, newConfig) - writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n") - } catch (parseErr) { - if (parseErr instanceof SyntaxError) { - writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: omoConfigPath } - } - throw parseErr - } - } else { - writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") - } - - return { success: true, configPath: omoConfigPath } - } catch (err) { - return { success: false, configPath: omoConfigPath, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") } - } -} - -interface OpenCodeBinaryResult { - binary: OpenCodeBinaryType - version: string -} - -async function findOpenCodeBinaryWithVersion(): Promise { - for (const binary of OPENCODE_BINARIES) { - try { - const proc = Bun.spawn([binary, "--version"], { - stdout: "pipe", - stderr: "pipe", - }) - const output = await new Response(proc.stdout).text() - await proc.exited - if (proc.exitCode === 0) { - const version = output.trim() - initConfigContext(binary, version) - return { binary, version } - } - } catch { - continue - } - } - return null -} - -export async function isOpenCodeInstalled(): Promise { - const result = await findOpenCodeBinaryWithVersion() - return result !== null -} - -export async function getOpenCodeVersion(): Promise { - const result = await findOpenCodeBinaryWithVersion() - return result?.version ?? null -} - -export async function addAuthPlugins(config: InstallConfig): Promise { - try { - ensureConfigDir() - } catch (err) { - return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") } - } - - const { format, path } = detectConfigFormat() - - try { - let existingConfig: OpenCodeConfig | null = null - if (format !== "none") { - const parseResult = parseConfigWithError(path) - if (parseResult.error && !parseResult.config) { - existingConfig = {} - } else { - existingConfig = parseResult.config - } - } - - const plugins: string[] = existingConfig?.plugin ?? [] - - if (config.hasGemini) { - const version = await fetchLatestVersion("opencode-antigravity-auth") - const pluginEntry = version ? `opencode-antigravity-auth@${version}` : "opencode-antigravity-auth" - if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) { - plugins.push(pluginEntry) - } - } - - - - const newConfig = { ...(existingConfig ?? {}), plugin: plugins } - writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: path } - } catch (err) { - return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "add auth plugins to config") } - } -} - -export interface BunInstallResult { - success: boolean - timedOut?: boolean - error?: string -} - -export async function runBunInstall(): Promise { - const result = await runBunInstallWithDetails() - return result.success -} - -export async function runBunInstallWithDetails(): Promise { - try { - const proc = Bun.spawn(["bun", "install"], { - cwd: getConfigDir(), - stdout: "pipe", - stderr: "pipe", - }) - - const timeoutPromise = new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS) - ) - - const exitPromise = proc.exited.then(() => "completed" as const) - - const result = await Promise.race([exitPromise, timeoutPromise]) - - if (result === "timeout") { - try { - proc.kill() - } catch { - /* intentionally empty - process may have already exited */ - } - return { - success: false, - timedOut: true, - error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ~/.config/opencode && bun i`, - } - } - - if (proc.exitCode !== 0) { - const stderr = await new Response(proc.stderr).text() - return { - success: false, - error: stderr.trim() || `bun install failed with exit code ${proc.exitCode}`, - } - } - - return { success: true } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { - success: false, - error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`, - } - } -} - -/** - * Antigravity Provider Configuration - * - * IMPORTANT: Model names MUST use `antigravity-` prefix for stability. - * - * Since opencode-antigravity-auth v1.3.0, models use a variant system: - * - `antigravity-gemini-3-pro` with variants: low, high - * - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high - * - * Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work - * but variants are the recommended approach. - * - * @see https://github.com/NoeFabris/opencode-antigravity-auth#models - */ -export const ANTIGRAVITY_PROVIDER_CONFIG = { - google: { - name: "Google", - models: { - "antigravity-gemini-3-pro": { - name: "Gemini 3 Pro (Antigravity)", - limit: { context: 1048576, output: 65535 }, - modalities: { input: ["text", "image", "pdf"], output: ["text"] }, - variants: { - low: { thinkingLevel: "low" }, - high: { thinkingLevel: "high" }, - }, - }, - "antigravity-gemini-3-flash": { - name: "Gemini 3 Flash (Antigravity)", - limit: { context: 1048576, output: 65536 }, - modalities: { input: ["text", "image", "pdf"], output: ["text"] }, - variants: { - minimal: { thinkingLevel: "minimal" }, - low: { thinkingLevel: "low" }, - medium: { thinkingLevel: "medium" }, - high: { thinkingLevel: "high" }, - }, - }, - "antigravity-claude-sonnet-4-5": { - name: "Claude Sonnet 4.5 (Antigravity)", - limit: { context: 200000, output: 64000 }, - modalities: { input: ["text", "image", "pdf"], output: ["text"] }, - }, - "antigravity-claude-sonnet-4-5-thinking": { - name: "Claude Sonnet 4.5 Thinking (Antigravity)", - limit: { context: 200000, output: 64000 }, - modalities: { input: ["text", "image", "pdf"], output: ["text"] }, - variants: { - low: { thinkingConfig: { thinkingBudget: 8192 } }, - max: { thinkingConfig: { thinkingBudget: 32768 } }, - }, - }, - "antigravity-claude-opus-4-5-thinking": { - name: "Claude Opus 4.5 Thinking (Antigravity)", - limit: { context: 200000, output: 64000 }, - modalities: { input: ["text", "image", "pdf"], output: ["text"] }, - variants: { - low: { thinkingConfig: { thinkingBudget: 8192 } }, - max: { thinkingConfig: { thinkingBudget: 32768 } }, - }, - }, - }, - }, -} - - - -export function addProviderConfig(config: InstallConfig): ConfigMergeResult { - try { - ensureConfigDir() - } catch (err) { - return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") } - } - - const { format, path } = detectConfigFormat() - - try { - let existingConfig: OpenCodeConfig | null = null - if (format !== "none") { - const parseResult = parseConfigWithError(path) - if (parseResult.error && !parseResult.config) { - existingConfig = {} - } else { - existingConfig = parseResult.config - } - } - - const newConfig = { ...(existingConfig ?? {}) } - - const providers = (newConfig.provider ?? {}) as Record - - if (config.hasGemini) { - providers.google = ANTIGRAVITY_PROVIDER_CONFIG.google - } - - if (Object.keys(providers).length > 0) { - newConfig.provider = providers - } - - writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: path } - } catch (err) { - return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "add provider config") } - } -} - -function detectProvidersFromOmoConfig(): { hasOpenAI: boolean; hasOpencodeZen: boolean; hasZaiCodingPlan: boolean; hasKimiForCoding: boolean } { - const omoConfigPath = getOmoConfig() - if (!existsSync(omoConfigPath)) { - return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false } - } - - try { - const content = readFileSync(omoConfigPath, "utf-8") - const omoConfig = parseJsonc>(content) - if (!omoConfig || typeof omoConfig !== "object") { - return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false } - } - - const configStr = JSON.stringify(omoConfig) - const hasOpenAI = configStr.includes('"openai/') - const hasOpencodeZen = configStr.includes('"opencode/') - const hasZaiCodingPlan = configStr.includes('"zai-coding-plan/') - const hasKimiForCoding = configStr.includes('"kimi-for-coding/') - - return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } - } catch { - return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false } - } -} - -export function detectCurrentConfig(): DetectedConfig { - const result: DetectedConfig = { - isInstalled: false, - hasClaude: true, - isMax20: true, - hasOpenAI: true, - hasGemini: false, - hasCopilot: false, - hasOpencodeZen: true, - hasZaiCodingPlan: false, - hasKimiForCoding: false, - } - - const { format, path } = detectConfigFormat() - if (format === "none") { - return result - } - - const parseResult = parseConfigWithError(path) - if (!parseResult.config) { - return result - } - - const openCodeConfig = parseResult.config - const plugins = openCodeConfig.plugin ?? [] - result.isInstalled = plugins.some((p) => p.startsWith("oh-my-opencode")) - - if (!result.isInstalled) { - return result - } - - // Gemini auth plugin detection still works via plugin presence - result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth")) - - const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } = detectProvidersFromOmoConfig() - result.hasOpenAI = hasOpenAI - result.hasOpencodeZen = hasOpencodeZen - result.hasZaiCodingPlan = hasZaiCodingPlan - result.hasKimiForCoding = hasKimiForCoding - - return result -} +export type { BunInstallResult } from "./config-manager/bun-install" +export { runBunInstall, runBunInstallWithDetails } from "./config-manager/bun-install" diff --git a/src/cli/config-manager/add-plugin-to-opencode-config.ts b/src/cli/config-manager/add-plugin-to-opencode-config.ts new file mode 100644 index 000000000..0262cc53f --- /dev/null +++ b/src/cli/config-manager/add-plugin-to-opencode-config.ts @@ -0,0 +1,82 @@ +import { readFileSync, writeFileSync } from "node:fs" +import type { ConfigMergeResult } from "../types" +import { getConfigDir } from "./config-context" +import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" +import { formatErrorWithSuggestion } from "./format-error-with-suggestion" +import { detectConfigFormat } from "./opencode-config-format" +import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file" +import { getPluginNameWithVersion } from "./plugin-name-with-version" + +const PACKAGE_NAME = "oh-my-opencode" + +export async function addPluginToOpenCodeConfig(currentVersion: string): Promise { + try { + ensureConfigDirectoryExists() + } catch (err) { + return { + success: false, + configPath: getConfigDir(), + error: formatErrorWithSuggestion(err, "create config directory"), + } + } + + const { format, path } = detectConfigFormat() + const pluginEntry = await getPluginNameWithVersion(currentVersion) + + try { + if (format === "none") { + const config: OpenCodeConfig = { plugin: [pluginEntry] } + writeFileSync(path, JSON.stringify(config, null, 2) + "\n") + return { success: true, configPath: path } + } + + const parseResult = parseOpenCodeConfigFileWithError(path) + if (!parseResult.config) { + return { + success: false, + configPath: path, + error: parseResult.error ?? "Failed to parse config file", + } + } + + const config = parseResult.config + const plugins = config.plugin ?? [] + const existingIndex = plugins.findIndex((p) => p === PACKAGE_NAME || p.startsWith(`${PACKAGE_NAME}@`)) + + if (existingIndex !== -1) { + if (plugins[existingIndex] === pluginEntry) { + return { success: true, configPath: path } + } + plugins[existingIndex] = pluginEntry + } else { + plugins.push(pluginEntry) + } + + config.plugin = plugins + + if (format === "jsonc") { + const content = readFileSync(path, "utf-8") + const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/ + const match = content.match(pluginArrayRegex) + + if (match) { + const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ") + const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`) + writeFileSync(path, newContent) + } else { + const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`) + writeFileSync(path, newContent) + } + } else { + writeFileSync(path, JSON.stringify(config, null, 2) + "\n") + } + + return { success: true, configPath: path } + } catch (err) { + return { + success: false, + configPath: path, + error: formatErrorWithSuggestion(err, "update opencode config"), + } + } +} diff --git a/src/cli/config-manager/add-provider-config.ts b/src/cli/config-manager/add-provider-config.ts new file mode 100644 index 000000000..bc25c7a7a --- /dev/null +++ b/src/cli/config-manager/add-provider-config.ts @@ -0,0 +1,54 @@ +import { writeFileSync } from "node:fs" +import type { ConfigMergeResult, InstallConfig } from "../types" +import { getConfigDir } from "./config-context" +import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" +import { formatErrorWithSuggestion } from "./format-error-with-suggestion" +import { detectConfigFormat } from "./opencode-config-format" +import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file" +import { ANTIGRAVITY_PROVIDER_CONFIG } from "./antigravity-provider-configuration" + +export function addProviderConfig(config: InstallConfig): ConfigMergeResult { + try { + ensureConfigDirectoryExists() + } catch (err) { + return { + success: false, + configPath: getConfigDir(), + error: formatErrorWithSuggestion(err, "create config directory"), + } + } + + const { format, path } = detectConfigFormat() + + try { + let existingConfig: OpenCodeConfig | null = null + if (format !== "none") { + const parseResult = parseOpenCodeConfigFileWithError(path) + if (parseResult.error && !parseResult.config) { + existingConfig = {} + } else { + existingConfig = parseResult.config + } + } + + const newConfig = { ...(existingConfig ?? {}) } + const providers = (newConfig.provider ?? {}) as Record + + if (config.hasGemini) { + providers.google = ANTIGRAVITY_PROVIDER_CONFIG.google + } + + if (Object.keys(providers).length > 0) { + newConfig.provider = providers + } + + writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: path } + } catch (err) { + return { + success: false, + configPath: path, + error: formatErrorWithSuggestion(err, "add provider config"), + } + } +} diff --git a/src/cli/config-manager/antigravity-provider-configuration.ts b/src/cli/config-manager/antigravity-provider-configuration.ts new file mode 100644 index 000000000..192113917 --- /dev/null +++ b/src/cli/config-manager/antigravity-provider-configuration.ts @@ -0,0 +1,64 @@ +/** + * Antigravity Provider Configuration + * + * IMPORTANT: Model names MUST use `antigravity-` prefix for stability. + * + * Since opencode-antigravity-auth v1.3.0, models use a variant system: + * - `antigravity-gemini-3-pro` with variants: low, high + * - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high + * + * Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work + * but variants are the recommended approach. + * + * @see https://github.com/NoeFabris/opencode-antigravity-auth#models + */ +export const ANTIGRAVITY_PROVIDER_CONFIG = { + google: { + name: "Google", + models: { + "antigravity-gemini-3-pro": { + name: "Gemini 3 Pro (Antigravity)", + limit: { context: 1048576, output: 65535 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { thinkingLevel: "low" }, + high: { thinkingLevel: "high" }, + }, + }, + "antigravity-gemini-3-flash": { + name: "Gemini 3 Flash (Antigravity)", + limit: { context: 1048576, output: 65536 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + minimal: { thinkingLevel: "minimal" }, + low: { thinkingLevel: "low" }, + medium: { thinkingLevel: "medium" }, + high: { thinkingLevel: "high" }, + }, + }, + "antigravity-claude-sonnet-4-5": { + name: "Claude Sonnet 4.5 (Antigravity)", + limit: { context: 200000, output: 64000 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + }, + "antigravity-claude-sonnet-4-5-thinking": { + name: "Claude Sonnet 4.5 Thinking (Antigravity)", + limit: { context: 200000, output: 64000 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { thinkingConfig: { thinkingBudget: 8192 } }, + max: { thinkingConfig: { thinkingBudget: 32768 } }, + }, + }, + "antigravity-claude-opus-4-5-thinking": { + name: "Claude Opus 4.5 Thinking (Antigravity)", + limit: { context: 200000, output: 64000 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { thinkingConfig: { thinkingBudget: 8192 } }, + max: { thinkingConfig: { thinkingBudget: 32768 } }, + }, + }, + }, + }, +} diff --git a/src/cli/config-manager/auth-plugins.ts b/src/cli/config-manager/auth-plugins.ts new file mode 100644 index 000000000..77a38369d --- /dev/null +++ b/src/cli/config-manager/auth-plugins.ts @@ -0,0 +1,64 @@ +import { writeFileSync } from "node:fs" +import type { ConfigMergeResult, InstallConfig } from "../types" +import { getConfigDir } from "./config-context" +import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" +import { formatErrorWithSuggestion } from "./format-error-with-suggestion" +import { detectConfigFormat } from "./opencode-config-format" +import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file" + +export async function fetchLatestVersion(packageName: string): Promise { + try { + const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`) + if (!res.ok) return null + const data = (await res.json()) as { version: string } + return data.version + } catch { + return null + } +} + +export async function addAuthPlugins(config: InstallConfig): Promise { + try { + ensureConfigDirectoryExists() + } catch (err) { + return { + success: false, + configPath: getConfigDir(), + error: formatErrorWithSuggestion(err, "create config directory"), + } + } + + const { format, path } = detectConfigFormat() + + try { + let existingConfig: OpenCodeConfig | null = null + if (format !== "none") { + const parseResult = parseOpenCodeConfigFileWithError(path) + if (parseResult.error && !parseResult.config) { + existingConfig = {} + } else { + existingConfig = parseResult.config + } + } + + const plugins: string[] = existingConfig?.plugin ?? [] + + if (config.hasGemini) { + const version = await fetchLatestVersion("opencode-antigravity-auth") + const pluginEntry = version ? `opencode-antigravity-auth@${version}` : "opencode-antigravity-auth" + if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) { + plugins.push(pluginEntry) + } + } + + const newConfig = { ...(existingConfig ?? {}), plugin: plugins } + writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: path } + } catch (err) { + return { + success: false, + configPath: path, + error: formatErrorWithSuggestion(err, "add auth plugins to config"), + } + } +} diff --git a/src/cli/config-manager/bun-install.ts b/src/cli/config-manager/bun-install.ts new file mode 100644 index 000000000..a9401f100 --- /dev/null +++ b/src/cli/config-manager/bun-install.ts @@ -0,0 +1,60 @@ +import { getConfigDir } from "./config-context" + +const BUN_INSTALL_TIMEOUT_SECONDS = 60 +const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000 + +export interface BunInstallResult { + success: boolean + timedOut?: boolean + error?: string +} + +export async function runBunInstall(): Promise { + const result = await runBunInstallWithDetails() + return result.success +} + +export async function runBunInstallWithDetails(): Promise { + try { + const proc = Bun.spawn(["bun", "install"], { + cwd: getConfigDir(), + stdout: "pipe", + stderr: "pipe", + }) + + const timeoutPromise = new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS) + ) + const exitPromise = proc.exited.then(() => "completed" as const) + const result = await Promise.race([exitPromise, timeoutPromise]) + + if (result === "timeout") { + try { + proc.kill() + } catch { + /* intentionally empty - process may have already exited */ + } + return { + success: false, + timedOut: true, + error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ~/.config/opencode && bun i`, + } + } + + if (proc.exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + return { + success: false, + error: stderr.trim() || `bun install failed with exit code ${proc.exitCode}`, + } + } + + return { success: true } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { + success: false, + error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`, + } + } +} diff --git a/src/cli/config-manager/config-context.ts b/src/cli/config-manager/config-context.ts new file mode 100644 index 000000000..78eb88d77 --- /dev/null +++ b/src/cli/config-manager/config-context.ts @@ -0,0 +1,46 @@ +import { getOpenCodeConfigPaths } from "../../shared" +import type { + OpenCodeBinaryType, + OpenCodeConfigPaths, +} from "../../shared/opencode-config-dir-types" + +export interface ConfigContext { + binary: OpenCodeBinaryType + version: string | null + paths: OpenCodeConfigPaths +} + +let configContext: ConfigContext | null = null + +export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void { + const paths = getOpenCodeConfigPaths({ binary, version }) + configContext = { binary, version, paths } +} + +export function getConfigContext(): ConfigContext { + if (!configContext) { + const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) + configContext = { binary: "opencode", version: null, paths } + } + return configContext +} + +export function resetConfigContext(): void { + configContext = null +} + +export function getConfigDir(): string { + return getConfigContext().paths.configDir +} + +export function getConfigJson(): string { + return getConfigContext().paths.configJson +} + +export function getConfigJsonc(): string { + return getConfigContext().paths.configJsonc +} + +export function getOmoConfigPath(): string { + return getConfigContext().paths.omoConfig +} diff --git a/src/cli/config-manager/deep-merge-record.ts b/src/cli/config-manager/deep-merge-record.ts new file mode 100644 index 000000000..54c0daa57 --- /dev/null +++ b/src/cli/config-manager/deep-merge-record.ts @@ -0,0 +1,29 @@ +export function deepMergeRecord>( + target: TTarget, + source: Partial +): TTarget { + const result: TTarget = { ...target } + + for (const key of Object.keys(source) as Array) { + const sourceValue = source[key] + const targetValue = result[key] + + if ( + sourceValue !== null && + typeof sourceValue === "object" && + !Array.isArray(sourceValue) && + targetValue !== null && + typeof targetValue === "object" && + !Array.isArray(targetValue) + ) { + result[key] = deepMergeRecord( + targetValue as Record, + sourceValue as Record + ) as TTarget[keyof TTarget] + } else if (sourceValue !== undefined) { + result[key] = sourceValue as TTarget[keyof TTarget] + } + } + + return result +} diff --git a/src/cli/config-manager/detect-current-config.ts b/src/cli/config-manager/detect-current-config.ts new file mode 100644 index 000000000..fd7855336 --- /dev/null +++ b/src/cli/config-manager/detect-current-config.ts @@ -0,0 +1,78 @@ +import { existsSync, readFileSync } from "node:fs" +import { parseJsonc } from "../../shared" +import type { DetectedConfig } from "../types" +import { getOmoConfigPath } from "./config-context" +import { detectConfigFormat } from "./opencode-config-format" +import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file" + +function detectProvidersFromOmoConfig(): { + hasOpenAI: boolean + hasOpencodeZen: boolean + hasZaiCodingPlan: boolean + hasKimiForCoding: boolean +} { + const omoConfigPath = getOmoConfigPath() + if (!existsSync(omoConfigPath)) { + return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false } + } + + try { + const content = readFileSync(omoConfigPath, "utf-8") + const omoConfig = parseJsonc>(content) + if (!omoConfig || typeof omoConfig !== "object") { + return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false } + } + + const configStr = JSON.stringify(omoConfig) + const hasOpenAI = configStr.includes('"openai/') + const hasOpencodeZen = configStr.includes('"opencode/') + const hasZaiCodingPlan = configStr.includes('"zai-coding-plan/') + const hasKimiForCoding = configStr.includes('"kimi-for-coding/') + + return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } + } catch { + return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false } + } +} + +export function detectCurrentConfig(): DetectedConfig { + const result: DetectedConfig = { + isInstalled: false, + hasClaude: true, + isMax20: true, + hasOpenAI: true, + hasGemini: false, + hasCopilot: false, + hasOpencodeZen: true, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + } + + const { format, path } = detectConfigFormat() + if (format === "none") { + return result + } + + const parseResult = parseOpenCodeConfigFileWithError(path) + if (!parseResult.config) { + return result + } + + const openCodeConfig = parseResult.config + const plugins = openCodeConfig.plugin ?? [] + result.isInstalled = plugins.some((p) => p.startsWith("oh-my-opencode")) + + if (!result.isInstalled) { + return result + } + + result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth")) + + const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } = detectProvidersFromOmoConfig() + result.hasOpenAI = hasOpenAI + result.hasOpencodeZen = hasOpencodeZen + result.hasZaiCodingPlan = hasZaiCodingPlan + result.hasKimiForCoding = hasKimiForCoding + + return result +} diff --git a/src/cli/config-manager/ensure-config-directory-exists.ts b/src/cli/config-manager/ensure-config-directory-exists.ts new file mode 100644 index 000000000..bd2c8b1ab --- /dev/null +++ b/src/cli/config-manager/ensure-config-directory-exists.ts @@ -0,0 +1,9 @@ +import { existsSync, mkdirSync } from "node:fs" +import { getConfigDir } from "./config-context" + +export function ensureConfigDirectoryExists(): void { + const configDir = getConfigDir() + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }) + } +} diff --git a/src/cli/config-manager/format-error-with-suggestion.ts b/src/cli/config-manager/format-error-with-suggestion.ts new file mode 100644 index 000000000..ca5533e5a --- /dev/null +++ b/src/cli/config-manager/format-error-with-suggestion.ts @@ -0,0 +1,39 @@ +interface NodeError extends Error { + code?: string +} + +function isPermissionError(err: unknown): boolean { + const nodeErr = err as NodeError + return nodeErr?.code === "EACCES" || nodeErr?.code === "EPERM" +} + +function isFileNotFoundError(err: unknown): boolean { + const nodeErr = err as NodeError + return nodeErr?.code === "ENOENT" +} + +export function formatErrorWithSuggestion(err: unknown, context: string): string { + if (isPermissionError(err)) { + return `Permission denied: Cannot ${context}. Try running with elevated permissions or check file ownership.` + } + + if (isFileNotFoundError(err)) { + return `File not found while trying to ${context}. The file may have been deleted or moved.` + } + + if (err instanceof SyntaxError) { + return `JSON syntax error while trying to ${context}: ${err.message}. Check for missing commas, brackets, or invalid characters.` + } + + const message = err instanceof Error ? err.message : String(err) + + if (message.includes("ENOSPC")) { + return `Disk full: Cannot ${context}. Free up disk space and try again.` + } + + if (message.includes("EROFS")) { + return `Read-only filesystem: Cannot ${context}. Check if the filesystem is mounted read-only.` + } + + return `Failed to ${context}: ${message}` +} diff --git a/src/cli/config-manager/generate-omo-config.ts b/src/cli/config-manager/generate-omo-config.ts new file mode 100644 index 000000000..c7060dad2 --- /dev/null +++ b/src/cli/config-manager/generate-omo-config.ts @@ -0,0 +1,6 @@ +import type { InstallConfig } from "../types" +import { generateModelConfig } from "../model-fallback" + +export function generateOmoConfig(installConfig: InstallConfig): Record { + return generateModelConfig(installConfig) +} diff --git a/src/cli/config-manager/npm-dist-tags.ts b/src/cli/config-manager/npm-dist-tags.ts new file mode 100644 index 000000000..f653fc2fc --- /dev/null +++ b/src/cli/config-manager/npm-dist-tags.ts @@ -0,0 +1,21 @@ +export interface NpmDistTags { + latest?: string + beta?: string + next?: string + [tag: string]: string | undefined +} + +const NPM_FETCH_TIMEOUT_MS = 5000 + +export async function fetchNpmDistTags(packageName: string): Promise { + try { + const res = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`, { + signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS), + }) + if (!res.ok) return null + const data = (await res.json()) as NpmDistTags + return data + } catch { + return null + } +} diff --git a/src/cli/config-manager/opencode-binary.ts b/src/cli/config-manager/opencode-binary.ts new file mode 100644 index 000000000..6d889faee --- /dev/null +++ b/src/cli/config-manager/opencode-binary.ts @@ -0,0 +1,40 @@ +import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types" +import { initConfigContext } from "./config-context" + +const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const + +interface OpenCodeBinaryResult { + binary: OpenCodeBinaryType + version: string +} + +async function findOpenCodeBinaryWithVersion(): Promise { + for (const binary of OPENCODE_BINARIES) { + try { + const proc = Bun.spawn([binary, "--version"], { + stdout: "pipe", + stderr: "pipe", + }) + const output = await new Response(proc.stdout).text() + await proc.exited + if (proc.exitCode === 0) { + const version = output.trim() + initConfigContext(binary, version) + return { binary, version } + } + } catch { + continue + } + } + return null +} + +export async function isOpenCodeInstalled(): Promise { + const result = await findOpenCodeBinaryWithVersion() + return result !== null +} + +export async function getOpenCodeVersion(): Promise { + const result = await findOpenCodeBinaryWithVersion() + return result?.version ?? null +} diff --git a/src/cli/config-manager/opencode-config-format.ts b/src/cli/config-manager/opencode-config-format.ts new file mode 100644 index 000000000..135cb511c --- /dev/null +++ b/src/cli/config-manager/opencode-config-format.ts @@ -0,0 +1,17 @@ +import { existsSync } from "node:fs" +import { getConfigJson, getConfigJsonc } from "./config-context" + +export type ConfigFormat = "json" | "jsonc" | "none" + +export function detectConfigFormat(): { format: ConfigFormat; path: string } { + const configJsonc = getConfigJsonc() + const configJson = getConfigJson() + + if (existsSync(configJsonc)) { + return { format: "jsonc", path: configJsonc } + } + if (existsSync(configJson)) { + return { format: "json", path: configJson } + } + return { format: "none", path: configJson } +} diff --git a/src/cli/config-manager/parse-opencode-config-file.ts b/src/cli/config-manager/parse-opencode-config-file.ts new file mode 100644 index 000000000..3e399d847 --- /dev/null +++ b/src/cli/config-manager/parse-opencode-config-file.ts @@ -0,0 +1,48 @@ +import { readFileSync, statSync } from "node:fs" +import { parseJsonc } from "../../shared" +import { formatErrorWithSuggestion } from "./format-error-with-suggestion" + +interface ParseConfigResult { + config: OpenCodeConfig | null + error?: string +} + +export interface OpenCodeConfig { + plugin?: string[] + [key: string]: unknown +} + +function isEmptyOrWhitespace(content: string): boolean { + return content.trim().length === 0 +} + +export function parseOpenCodeConfigFileWithError(path: string): ParseConfigResult { + try { + const stat = statSync(path) + if (stat.size === 0) { + return { config: null, error: `Config file is empty: ${path}. Delete it or add valid JSON content.` } + } + + const content = readFileSync(path, "utf-8") + if (isEmptyOrWhitespace(content)) { + return { config: null, error: `Config file contains only whitespace: ${path}. Delete it or add valid JSON content.` } + } + + const config = parseJsonc(content) + + if (config === null || config === undefined) { + return { config: null, error: `Config file parsed to null/undefined: ${path}. Ensure it contains valid JSON.` } + } + + if (typeof config !== "object" || Array.isArray(config)) { + return { + config: null, + error: `Config file must contain a JSON object, not ${Array.isArray(config) ? "an array" : typeof config}: ${path}`, + } + } + + return { config } + } catch (err) { + return { config: null, error: formatErrorWithSuggestion(err, `parse config file ${path}`) } + } +} diff --git a/src/cli/config-manager/plugin-name-with-version.ts b/src/cli/config-manager/plugin-name-with-version.ts new file mode 100644 index 000000000..a80ada643 --- /dev/null +++ b/src/cli/config-manager/plugin-name-with-version.ts @@ -0,0 +1,19 @@ +import { fetchNpmDistTags } from "./npm-dist-tags" + +const PACKAGE_NAME = "oh-my-opencode" +const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const + +export async function getPluginNameWithVersion(currentVersion: string): Promise { + const distTags = await fetchNpmDistTags(PACKAGE_NAME) + + if (distTags) { + const allTags = new Set([...PRIORITIZED_TAGS, ...Object.keys(distTags)]) + for (const tag of allTags) { + if (distTags[tag] === currentVersion) { + return `${PACKAGE_NAME}@${tag}` + } + } + } + + return `${PACKAGE_NAME}@${currentVersion}` +} diff --git a/src/cli/config-manager/write-omo-config.ts b/src/cli/config-manager/write-omo-config.ts new file mode 100644 index 000000000..09fcce15b --- /dev/null +++ b/src/cli/config-manager/write-omo-config.ts @@ -0,0 +1,67 @@ +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs" +import { parseJsonc } from "../../shared" +import type { ConfigMergeResult, InstallConfig } from "../types" +import { getConfigDir, getOmoConfigPath } from "./config-context" +import { deepMergeRecord } from "./deep-merge-record" +import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" +import { formatErrorWithSuggestion } from "./format-error-with-suggestion" +import { generateOmoConfig } from "./generate-omo-config" + +function isEmptyOrWhitespace(content: string): boolean { + return content.trim().length === 0 +} + +export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult { + try { + ensureConfigDirectoryExists() + } catch (err) { + return { + success: false, + configPath: getConfigDir(), + error: formatErrorWithSuggestion(err, "create config directory"), + } + } + + const omoConfigPath = getOmoConfigPath() + + try { + const newConfig = generateOmoConfig(installConfig) + + if (existsSync(omoConfigPath)) { + try { + const stat = statSync(omoConfigPath) + const content = readFileSync(omoConfigPath, "utf-8") + + if (stat.size === 0 || isEmptyOrWhitespace(content)) { + writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: omoConfigPath } + } + + const existing = parseJsonc>(content) + if (!existing || typeof existing !== "object" || Array.isArray(existing)) { + writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: omoConfigPath } + } + + const merged = deepMergeRecord(existing, newConfig) + writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n") + } catch (parseErr) { + if (parseErr instanceof SyntaxError) { + writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: omoConfigPath } + } + throw parseErr + } + } else { + writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") + } + + return { success: true, configPath: omoConfigPath } + } catch (err) { + return { + success: false, + configPath: omoConfigPath, + error: formatErrorWithSuggestion(err, "write oh-my-opencode config"), + } + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 90b413be6..46d39a309 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,190 +1,4 @@ #!/usr/bin/env bun -import { Command } from "commander" -import { install } from "./install" -import { run } from "./run" -import { getLocalVersion } from "./get-local-version" -import { doctor } from "./doctor" -import { createMcpOAuthCommand } from "./mcp-oauth" -import type { InstallArgs } from "./types" -import type { RunOptions } from "./run" -import type { GetLocalVersionOptions } from "./get-local-version/types" -import type { DoctorOptions } from "./doctor" -import packageJson from "../../package.json" with { type: "json" } +import { runCli } from "./cli-program" -const VERSION = packageJson.version - -const program = new Command() - -program - .name("oh-my-opencode") - .description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more") - .version(VERSION, "-v, --version", "Show version number") - .enablePositionalOptions() - -program - .command("install") - .description("Install and configure oh-my-opencode with interactive setup") - .option("--no-tui", "Run in non-interactive mode (requires all options)") - .option("--claude ", "Claude subscription: no, yes, max20") - .option("--openai ", "OpenAI/ChatGPT subscription: no, yes (default: no)") - .option("--gemini ", "Gemini integration: no, yes") - .option("--copilot ", "GitHub Copilot subscription: no, yes") - .option("--opencode-zen ", "OpenCode Zen access: no, yes (default: no)") - .option("--zai-coding-plan ", "Z.ai Coding Plan subscription: no, yes (default: no)") - .option("--kimi-for-coding ", "Kimi For Coding subscription: no, yes (default: no)") - .option("--skip-auth", "Skip authentication setup hints") - .addHelpText("after", ` -Examples: - $ bunx oh-my-opencode install - $ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no - $ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes - -Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi): - Claude Native anthropic/ models (Opus, Sonnet, Haiku) - OpenAI Native openai/ models (GPT-5.2 for Oracle) - Gemini Native google/ models (Gemini 3 Pro, Flash) - Copilot github-copilot/ models (fallback) - OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.) - Z.ai zai-coding-plan/glm-4.7 (Librarian priority) - Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback) -`) - .action(async (options) => { - const args: InstallArgs = { - tui: options.tui !== false, - claude: options.claude, - openai: options.openai, - gemini: options.gemini, - copilot: options.copilot, - opencodeZen: options.opencodeZen, - zaiCodingPlan: options.zaiCodingPlan, - kimiForCoding: options.kimiForCoding, - skipAuth: options.skipAuth ?? false, - } - const exitCode = await install(args) - process.exit(exitCode) - }) - -program - .command("run ") - .allowUnknownOption() - .passThroughOptions() - .description("Run opencode with todo/background task completion enforcement") - .option("-a, --agent ", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)") - .option("-d, --directory ", "Working directory") - .option("-t, --timeout ", "Timeout in milliseconds (default: 30 minutes)", parseInt) - .option("-p, --port ", "Server port (attaches if port already in use)", parseInt) - .option("--attach ", "Attach to existing opencode server URL") - .option("--on-complete ", "Shell command to run after completion") - .option("--json", "Output structured JSON result to stdout") - .option("--session-id ", "Resume existing session instead of creating new one") - .addHelpText("after", ` -Examples: - $ bunx oh-my-opencode run "Fix the bug in index.ts" - $ bunx oh-my-opencode run --agent Sisyphus "Implement feature X" - $ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task" - $ bunx oh-my-opencode run --port 4321 "Fix the bug" - $ bunx oh-my-opencode run --attach http://127.0.0.1:4321 "Fix the bug" - $ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId - $ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug" - $ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work" - -Agent resolution order: - 1) --agent flag - 2) OPENCODE_DEFAULT_AGENT - 3) oh-my-opencode.json "default_run_agent" - 4) Sisyphus (fallback) - -Available core agents: - Sisyphus, Hephaestus, Prometheus, Atlas - -Unlike 'opencode run', this command waits until: - - All todos are completed or cancelled - - All child sessions (background tasks) are idle -`) - .action(async (message: string, options) => { - if (options.port && options.attach) { - console.error("Error: --port and --attach are mutually exclusive") - process.exit(1) - } - const runOptions: RunOptions = { - message, - agent: options.agent, - directory: options.directory, - timeout: options.timeout, - port: options.port, - attach: options.attach, - onComplete: options.onComplete, - json: options.json ?? false, - sessionId: options.sessionId, - } - const exitCode = await run(runOptions) - process.exit(exitCode) - }) - -program - .command("get-local-version") - .description("Show current installed version and check for updates") - .option("-d, --directory ", "Working directory to check config from") - .option("--json", "Output in JSON format for scripting") - .addHelpText("after", ` -Examples: - $ bunx oh-my-opencode get-local-version - $ bunx oh-my-opencode get-local-version --json - $ bunx oh-my-opencode get-local-version --directory /path/to/project - -This command shows: - - Current installed version - - Latest available version on npm - - Whether you're up to date - - Special modes (local dev, pinned version) -`) - .action(async (options) => { - const versionOptions: GetLocalVersionOptions = { - directory: options.directory, - json: options.json ?? false, - } - const exitCode = await getLocalVersion(versionOptions) - process.exit(exitCode) - }) - -program - .command("doctor") - .description("Check oh-my-opencode installation health and diagnose issues") - .option("--verbose", "Show detailed diagnostic information") - .option("--json", "Output results in JSON format") - .option("--category ", "Run only specific category") - .addHelpText("after", ` -Examples: - $ bunx oh-my-opencode doctor - $ bunx oh-my-opencode doctor --verbose - $ bunx oh-my-opencode doctor --json - $ bunx oh-my-opencode doctor --category authentication - -Categories: - installation Check OpenCode and plugin installation - configuration Validate configuration files - authentication Check auth provider status - dependencies Check external dependencies - tools Check LSP and MCP servers - updates Check for version updates -`) - .action(async (options) => { - const doctorOptions: DoctorOptions = { - verbose: options.verbose ?? false, - json: options.json ?? false, - category: options.category, - } - const exitCode = await doctor(doctorOptions) - process.exit(exitCode) - }) - -program - .command("version") - .description("Show version information") - .action(() => { - console.log(`oh-my-opencode v${VERSION}`) - }) - -program.addCommand(createMcpOAuthCommand()) - -program.parse() +runCli() diff --git a/src/config/schema.ts b/src/config/schema.ts index 34ec376be..e4c55c6ff 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,464 +1,23 @@ -import { z } from "zod" -import { AnyMcpNameSchema, McpNameSchema } from "../mcp/types" - -const PermissionValue = z.enum(["ask", "allow", "deny"]) - -const BashPermission = z.union([ - PermissionValue, - z.record(z.string(), PermissionValue), -]) - -const AgentPermissionSchema = z.object({ - edit: PermissionValue.optional(), - bash: BashPermission.optional(), - webfetch: PermissionValue.optional(), - task: PermissionValue.optional(), - doom_loop: PermissionValue.optional(), - external_directory: PermissionValue.optional(), -}) - -export const BuiltinAgentNameSchema = z.enum([ - "sisyphus", - "hephaestus", - "prometheus", - "oracle", - "librarian", - "explore", - "multimodal-looker", - "metis", - "momus", - "atlas", -]) - -export const BuiltinSkillNameSchema = z.enum([ - "playwright", - "agent-browser", - "dev-browser", - "frontend-ui-ux", - "git-master", -]) - -export const OverridableAgentNameSchema = z.enum([ - "build", - "plan", - "sisyphus", - "hephaestus", - "sisyphus-junior", - "OpenCode-Builder", - "prometheus", - "metis", - "momus", - "oracle", - "librarian", - "explore", - "multimodal-looker", - "atlas", -]) - -export const AgentNameSchema = BuiltinAgentNameSchema - -export const HookNameSchema = z.enum([ - "todo-continuation-enforcer", - "context-window-monitor", - "session-recovery", - "session-notification", - "comment-checker", - "grep-output-truncator", - "tool-output-truncator", - "question-label-truncator", - "directory-agents-injector", - "directory-readme-injector", - "empty-task-response-detector", - "think-mode", - "subagent-question-blocker", - "anthropic-context-window-limit-recovery", - "preemptive-compaction", - "rules-injector", - "background-notification", - "auto-update-checker", - "startup-toast", - "keyword-detector", - "agent-usage-reminder", - "non-interactive-env", - "interactive-bash-session", - - "thinking-block-validator", - "ralph-loop", - "category-skill-reminder", - - "compaction-context-injector", - "compaction-todo-preserver", - "claude-code-hooks", - "auto-slash-command", - "edit-error-recovery", - "delegate-task-retry", - "prometheus-md-only", - "sisyphus-junior-notepad", - "start-work", - "atlas", - "unstable-agent-babysitter", - "task-reminder", - "task-resume-info", - "stop-continuation-guard", - "tasks-todowrite-disabler", - "write-existing-file-guard", - "anthropic-effort", -]) - -export const BuiltinCommandNameSchema = z.enum([ - "init-deep", - "ralph-loop", - "ulw-loop", - "cancel-ralph", - "refactor", - "start-work", - "stop-continuation", -]) - -export const AgentOverrideConfigSchema = z.object({ - /** @deprecated Use `category` instead. Model is inherited from category defaults. */ - model: z.string().optional(), - variant: z.string().optional(), - /** Category name to inherit model and other settings from CategoryConfig */ - category: z.string().optional(), - /** Skill names to inject into agent prompt */ - skills: z.array(z.string()).optional(), - temperature: z.number().min(0).max(2).optional(), - top_p: z.number().min(0).max(1).optional(), - prompt: z.string().optional(), - prompt_append: z.string().optional(), - tools: z.record(z.string(), z.boolean()).optional(), - disable: z.boolean().optional(), - description: z.string().optional(), - mode: z.enum(["subagent", "primary", "all"]).optional(), - color: z - .string() - .regex(/^#[0-9A-Fa-f]{6}$/) - .optional(), - permission: AgentPermissionSchema.optional(), - /** Maximum tokens for response. Passed directly to OpenCode SDK. */ - maxTokens: z.number().optional(), - /** Extended thinking configuration (Anthropic). Overrides category and default settings. */ - thinking: z.object({ - type: z.enum(["enabled", "disabled"]), - budgetTokens: z.number().optional(), - }).optional(), - /** Reasoning effort level (OpenAI). Overrides category and default settings. */ - reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(), - /** Text verbosity level. */ - textVerbosity: z.enum(["low", "medium", "high"]).optional(), - /** Provider-specific options. Passed directly to OpenCode SDK. */ - providerOptions: z.record(z.string(), z.unknown()).optional(), -}) - -export const AgentOverridesSchema = z.object({ - build: AgentOverrideConfigSchema.optional(), - plan: AgentOverrideConfigSchema.optional(), - sisyphus: AgentOverrideConfigSchema.optional(), - hephaestus: AgentOverrideConfigSchema.optional(), - "sisyphus-junior": AgentOverrideConfigSchema.optional(), - "OpenCode-Builder": AgentOverrideConfigSchema.optional(), - prometheus: AgentOverrideConfigSchema.optional(), - metis: AgentOverrideConfigSchema.optional(), - momus: AgentOverrideConfigSchema.optional(), - oracle: AgentOverrideConfigSchema.optional(), - librarian: AgentOverrideConfigSchema.optional(), - explore: AgentOverrideConfigSchema.optional(), - "multimodal-looker": AgentOverrideConfigSchema.optional(), - atlas: AgentOverrideConfigSchema.optional(), -}) - -export const ClaudeCodeConfigSchema = z.object({ - mcp: z.boolean().optional(), - commands: z.boolean().optional(), - skills: z.boolean().optional(), - agents: z.boolean().optional(), - hooks: z.boolean().optional(), - plugins: z.boolean().optional(), - plugins_override: z.record(z.string(), z.boolean()).optional(), -}) - -export const SisyphusAgentConfigSchema = z.object({ - disabled: z.boolean().optional(), - default_builder_enabled: z.boolean().optional(), - planner_enabled: z.boolean().optional(), - replace_plan: z.boolean().optional(), -}) - -export const CategoryConfigSchema = z.object({ - /** Human-readable description of the category's purpose. Shown in task prompt. */ - description: z.string().optional(), - model: z.string().optional(), - variant: z.string().optional(), - temperature: z.number().min(0).max(2).optional(), - top_p: z.number().min(0).max(1).optional(), - maxTokens: z.number().optional(), - thinking: z.object({ - type: z.enum(["enabled", "disabled"]), - budgetTokens: z.number().optional(), - }).optional(), - reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(), - textVerbosity: z.enum(["low", "medium", "high"]).optional(), - tools: z.record(z.string(), z.boolean()).optional(), - prompt_append: z.string().optional(), - /** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */ - is_unstable_agent: z.boolean().optional(), -}) - -export const BuiltinCategoryNameSchema = z.enum([ - "visual-engineering", - "ultrabrain", - "deep", - "artistry", - "quick", - "unspecified-low", - "unspecified-high", - "writing", -]) - -export const CategoriesConfigSchema = z.record(z.string(), CategoryConfigSchema) - -export const CommentCheckerConfigSchema = z.object({ - /** Custom prompt to replace the default warning message. Use {{comments}} placeholder for detected comments XML. */ - custom_prompt: z.string().optional(), -}) - -export const DynamicContextPruningConfigSchema = z.object({ - /** Enable dynamic context pruning (default: false) */ - enabled: z.boolean().default(false), - /** Notification level: off, minimal, or detailed (default: detailed) */ - notification: z.enum(["off", "minimal", "detailed"]).default("detailed"), - /** Turn protection - prevent pruning recent tool outputs */ - turn_protection: z.object({ - enabled: z.boolean().default(true), - turns: z.number().min(1).max(10).default(3), - }).optional(), - /** Tools that should never be pruned */ - protected_tools: z.array(z.string()).default([ - "task", "todowrite", "todoread", - "lsp_rename", - "session_read", "session_write", "session_search", - ]), - /** Pruning strategies configuration */ - strategies: z.object({ - /** Remove duplicate tool calls (same tool + same args) */ - deduplication: z.object({ - enabled: z.boolean().default(true), - }).optional(), - /** Prune write inputs when file subsequently read */ - supersede_writes: z.object({ - enabled: z.boolean().default(true), - /** Aggressive mode: prune any write if ANY subsequent read */ - aggressive: z.boolean().default(false), - }).optional(), - /** Prune errored tool inputs after N turns */ - purge_errors: z.object({ - enabled: z.boolean().default(true), - turns: z.number().min(1).max(20).default(5), - }).optional(), - }).optional(), -}) - -export const ExperimentalConfigSchema = z.object({ - aggressive_truncation: z.boolean().optional(), - auto_resume: z.boolean().optional(), - preemptive_compaction: z.boolean().optional(), - /** Truncate all tool outputs, not just whitelisted tools (default: false). Tool output truncator is enabled by default - disable via disabled_hooks. */ - truncate_all_tool_outputs: z.boolean().optional(), - /** Dynamic context pruning configuration */ - dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(), - /** Enable experimental task system for Todowrite disabler hook */ - task_system: z.boolean().optional(), - /** Timeout in ms for loadAllPluginComponents during config handler init (default: 10000, min: 1000) */ - plugin_load_timeout_ms: z.number().min(1000).optional(), - /** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */ - safe_hook_creation: z.boolean().optional(), -}) - -export const SkillSourceSchema = z.union([ - z.string(), - z.object({ - path: z.string(), - recursive: z.boolean().optional(), - glob: z.string().optional(), - }), -]) - -export const SkillDefinitionSchema = z.object({ - description: z.string().optional(), - template: z.string().optional(), - from: z.string().optional(), - model: z.string().optional(), - agent: z.string().optional(), - subtask: z.boolean().optional(), - "argument-hint": z.string().optional(), - license: z.string().optional(), - compatibility: z.string().optional(), - metadata: z.record(z.string(), z.unknown()).optional(), - "allowed-tools": z.array(z.string()).optional(), - disable: z.boolean().optional(), -}) - -export const SkillEntrySchema = z.union([ - z.boolean(), - SkillDefinitionSchema, -]) - -export const SkillsConfigSchema = z.union([ - z.array(z.string()), - z.record(z.string(), SkillEntrySchema).and(z.object({ - sources: z.array(SkillSourceSchema).optional(), - enable: z.array(z.string()).optional(), - disable: z.array(z.string()).optional(), - }).partial()), -]) - -export const RalphLoopConfigSchema = z.object({ - /** Enable ralph loop functionality (default: false - opt-in feature) */ - enabled: z.boolean().default(false), - /** Default max iterations if not specified in command (default: 100) */ - default_max_iterations: z.number().min(1).max(1000).default(100), - /** Custom state file directory relative to project root (default: .opencode/) */ - state_dir: z.string().optional(), -}) - -export const BackgroundTaskConfigSchema = z.object({ - defaultConcurrency: z.number().min(1).optional(), - providerConcurrency: z.record(z.string(), z.number().min(0)).optional(), - modelConcurrency: z.record(z.string(), z.number().min(0)).optional(), - /** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */ - staleTimeoutMs: z.number().min(60000).optional(), -}) - -export const NotificationConfigSchema = z.object({ - /** Force enable session-notification even if external notification plugins are detected (default: false) */ - force_enable: z.boolean().optional(), -}) - -export const BabysittingConfigSchema = z.object({ - timeout_ms: z.number().default(120000), -}) - -export const GitMasterConfigSchema = z.object({ - /** Add "Ultraworked with Sisyphus" footer to commit messages (default: true). Can be boolean or custom string. */ - commit_footer: z.union([z.boolean(), z.string()]).default(true), - /** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */ - include_co_authored_by: z.boolean().default(true), -}) - -export const BrowserAutomationProviderSchema = z.enum(["playwright", "agent-browser", "dev-browser"]) - -export const BrowserAutomationConfigSchema = z.object({ - /** - * Browser automation provider to use for the "playwright" skill. - * - "playwright": Uses Playwright MCP server (@playwright/mcp) - default - * - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser) - * - "dev-browser": Uses dev-browser skill with persistent browser state - */ - provider: BrowserAutomationProviderSchema.default("playwright"), -}) - -export const WebsearchProviderSchema = z.enum(["exa", "tavily"]) - -export const WebsearchConfigSchema = z.object({ - /** - * Websearch provider to use. - * - "exa": Uses Exa websearch (default, works without API key) - * - "tavily": Uses Tavily websearch (requires TAVILY_API_KEY) - */ - provider: WebsearchProviderSchema.optional(), -}) - -export const TmuxLayoutSchema = z.enum([ - 'main-horizontal', // main pane top, agent panes bottom stack - 'main-vertical', // main pane left, agent panes right stack (default) - 'tiled', // all panes same size grid - 'even-horizontal', // all panes horizontal row - 'even-vertical', // all panes vertical stack -]) - -export const TmuxConfigSchema = z.object({ - enabled: z.boolean().default(false), - layout: TmuxLayoutSchema.default('main-vertical'), - main_pane_size: z.number().min(20).max(80).default(60), - main_pane_min_width: z.number().min(40).default(120), - agent_pane_min_width: z.number().min(20).default(40), -}) - -export const SisyphusTasksConfigSchema = z.object({ - /** Absolute or relative storage path override. When set, bypasses global config dir. */ - storage_path: z.string().optional(), - /** Force task list ID (alternative to env ULTRAWORK_TASK_LIST_ID) */ - task_list_id: z.string().optional(), - /** Enable Claude Code path compatibility mode */ - claude_code_compat: z.boolean().default(false), -}) - -export const SisyphusConfigSchema = z.object({ - tasks: SisyphusTasksConfigSchema.optional(), -}) -export const OhMyOpenCodeConfigSchema = z.object({ - $schema: z.string().optional(), - /** Enable new task system (default: false) */ - new_task_system_enabled: z.boolean().optional(), - /** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */ - default_run_agent: z.string().optional(), - disabled_mcps: z.array(AnyMcpNameSchema).optional(), - disabled_agents: z.array(BuiltinAgentNameSchema).optional(), - disabled_skills: z.array(BuiltinSkillNameSchema).optional(), - disabled_hooks: z.array(HookNameSchema).optional(), - disabled_commands: z.array(BuiltinCommandNameSchema).optional(), - /** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */ - disabled_tools: z.array(z.string()).optional(), - agents: AgentOverridesSchema.optional(), - categories: CategoriesConfigSchema.optional(), - claude_code: ClaudeCodeConfigSchema.optional(), - sisyphus_agent: SisyphusAgentConfigSchema.optional(), - comment_checker: CommentCheckerConfigSchema.optional(), - experimental: ExperimentalConfigSchema.optional(), - auto_update: z.boolean().optional(), - skills: SkillsConfigSchema.optional(), - ralph_loop: RalphLoopConfigSchema.optional(), - background_task: BackgroundTaskConfigSchema.optional(), - notification: NotificationConfigSchema.optional(), - babysitting: BabysittingConfigSchema.optional(), - git_master: GitMasterConfigSchema.optional(), - browser_automation_engine: BrowserAutomationConfigSchema.optional(), - websearch: WebsearchConfigSchema.optional(), - tmux: TmuxConfigSchema.optional(), - sisyphus: SisyphusConfigSchema.optional(), - /** Migration history to prevent re-applying migrations (e.g., model version upgrades) */ - _migrations: z.array(z.string()).optional(), -}) - -export type OhMyOpenCodeConfig = z.infer -export type AgentOverrideConfig = z.infer -export type AgentOverrides = z.infer -export type BackgroundTaskConfig = z.infer -export type AgentName = z.infer -export type HookName = z.infer -export type BuiltinCommandName = z.infer -export type BuiltinSkillName = z.infer -export type SisyphusAgentConfig = z.infer -export type CommentCheckerConfig = z.infer -export type ExperimentalConfig = z.infer -export type DynamicContextPruningConfig = z.infer -export type SkillsConfig = z.infer -export type SkillDefinition = z.infer -export type RalphLoopConfig = z.infer -export type NotificationConfig = z.infer -export type BabysittingConfig = z.infer -export type CategoryConfig = z.infer -export type CategoriesConfig = z.infer -export type BuiltinCategoryName = z.infer -export type GitMasterConfig = z.infer -export type BrowserAutomationProvider = z.infer -export type BrowserAutomationConfig = z.infer -export type WebsearchProvider = z.infer -export type WebsearchConfig = z.infer -export type TmuxConfig = z.infer -export type TmuxLayout = z.infer -export type SisyphusTasksConfig = z.infer -export type SisyphusConfig = z.infer +export * from "./schema/agent-names" +export * from "./schema/agent-overrides" +export * from "./schema/babysitting" +export * from "./schema/background-task" +export * from "./schema/browser-automation" +export * from "./schema/categories" +export * from "./schema/claude-code" +export * from "./schema/comment-checker" +export * from "./schema/commands" +export * from "./schema/dynamic-context-pruning" +export * from "./schema/experimental" +export * from "./schema/git-master" +export * from "./schema/hooks" +export * from "./schema/notification" +export * from "./schema/oh-my-opencode-config" +export * from "./schema/ralph-loop" +export * from "./schema/skills" +export * from "./schema/sisyphus" +export * from "./schema/sisyphus-agent" +export * from "./schema/tmux" +export * from "./schema/websearch" export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types" diff --git a/src/config/schema/agent-names.ts b/src/config/schema/agent-names.ts new file mode 100644 index 000000000..814077d88 --- /dev/null +++ b/src/config/schema/agent-names.ts @@ -0,0 +1,44 @@ +import { z } from "zod" + +export const BuiltinAgentNameSchema = z.enum([ + "sisyphus", + "hephaestus", + "prometheus", + "oracle", + "librarian", + "explore", + "multimodal-looker", + "metis", + "momus", + "atlas", +]) + +export const BuiltinSkillNameSchema = z.enum([ + "playwright", + "agent-browser", + "dev-browser", + "frontend-ui-ux", + "git-master", +]) + +export const OverridableAgentNameSchema = z.enum([ + "build", + "plan", + "sisyphus", + "hephaestus", + "sisyphus-junior", + "OpenCode-Builder", + "prometheus", + "metis", + "momus", + "oracle", + "librarian", + "explore", + "multimodal-looker", + "atlas", +]) + +export const AgentNameSchema = BuiltinAgentNameSchema +export type AgentName = z.infer + +export type BuiltinSkillName = z.infer diff --git a/src/config/schema/agent-overrides.ts b/src/config/schema/agent-overrides.ts new file mode 100644 index 000000000..8fd48e330 --- /dev/null +++ b/src/config/schema/agent-overrides.ts @@ -0,0 +1,60 @@ +import { z } from "zod" +import { AgentPermissionSchema } from "./internal/permission" + +export const AgentOverrideConfigSchema = z.object({ + /** @deprecated Use `category` instead. Model is inherited from category defaults. */ + model: z.string().optional(), + variant: z.string().optional(), + /** Category name to inherit model and other settings from CategoryConfig */ + category: z.string().optional(), + /** Skill names to inject into agent prompt */ + skills: z.array(z.string()).optional(), + temperature: z.number().min(0).max(2).optional(), + top_p: z.number().min(0).max(1).optional(), + prompt: z.string().optional(), + prompt_append: z.string().optional(), + tools: z.record(z.string(), z.boolean()).optional(), + disable: z.boolean().optional(), + description: z.string().optional(), + mode: z.enum(["subagent", "primary", "all"]).optional(), + color: z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/) + .optional(), + permission: AgentPermissionSchema.optional(), + /** Maximum tokens for response. Passed directly to OpenCode SDK. */ + maxTokens: z.number().optional(), + /** Extended thinking configuration (Anthropic). Overrides category and default settings. */ + thinking: z + .object({ + type: z.enum(["enabled", "disabled"]), + budgetTokens: z.number().optional(), + }) + .optional(), + /** Reasoning effort level (OpenAI). Overrides category and default settings. */ + reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(), + /** Text verbosity level. */ + textVerbosity: z.enum(["low", "medium", "high"]).optional(), + /** Provider-specific options. Passed directly to OpenCode SDK. */ + providerOptions: z.record(z.string(), z.unknown()).optional(), +}) + +export const AgentOverridesSchema = z.object({ + build: AgentOverrideConfigSchema.optional(), + plan: AgentOverrideConfigSchema.optional(), + sisyphus: AgentOverrideConfigSchema.optional(), + hephaestus: AgentOverrideConfigSchema.optional(), + "sisyphus-junior": AgentOverrideConfigSchema.optional(), + "OpenCode-Builder": AgentOverrideConfigSchema.optional(), + prometheus: AgentOverrideConfigSchema.optional(), + metis: AgentOverrideConfigSchema.optional(), + momus: AgentOverrideConfigSchema.optional(), + oracle: AgentOverrideConfigSchema.optional(), + librarian: AgentOverrideConfigSchema.optional(), + explore: AgentOverrideConfigSchema.optional(), + "multimodal-looker": AgentOverrideConfigSchema.optional(), + atlas: AgentOverrideConfigSchema.optional(), +}) + +export type AgentOverrideConfig = z.infer +export type AgentOverrides = z.infer diff --git a/src/config/schema/babysitting.ts b/src/config/schema/babysitting.ts new file mode 100644 index 000000000..76b5d0ac8 --- /dev/null +++ b/src/config/schema/babysitting.ts @@ -0,0 +1,7 @@ +import { z } from "zod" + +export const BabysittingConfigSchema = z.object({ + timeout_ms: z.number().default(120000), +}) + +export type BabysittingConfig = z.infer diff --git a/src/config/schema/background-task.ts b/src/config/schema/background-task.ts new file mode 100644 index 000000000..6e6ad331d --- /dev/null +++ b/src/config/schema/background-task.ts @@ -0,0 +1,11 @@ +import { z } from "zod" + +export const BackgroundTaskConfigSchema = z.object({ + defaultConcurrency: z.number().min(1).optional(), + providerConcurrency: z.record(z.string(), z.number().min(0)).optional(), + modelConcurrency: z.record(z.string(), z.number().min(0)).optional(), + /** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */ + staleTimeoutMs: z.number().min(60000).optional(), +}) + +export type BackgroundTaskConfig = z.infer diff --git a/src/config/schema/browser-automation.ts b/src/config/schema/browser-automation.ts new file mode 100644 index 000000000..294dcb965 --- /dev/null +++ b/src/config/schema/browser-automation.ts @@ -0,0 +1,22 @@ +import { z } from "zod" + +export const BrowserAutomationProviderSchema = z.enum([ + "playwright", + "agent-browser", + "dev-browser", +]) + +export const BrowserAutomationConfigSchema = z.object({ + /** + * Browser automation provider to use for the "playwright" skill. + * - "playwright": Uses Playwright MCP server (@playwright/mcp) - default + * - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser) + * - "dev-browser": Uses dev-browser skill with persistent browser state + */ + provider: BrowserAutomationProviderSchema.default("playwright"), +}) + +export type BrowserAutomationProvider = z.infer< + typeof BrowserAutomationProviderSchema +> +export type BrowserAutomationConfig = z.infer diff --git a/src/config/schema/categories.ts b/src/config/schema/categories.ts new file mode 100644 index 000000000..b8028c572 --- /dev/null +++ b/src/config/schema/categories.ts @@ -0,0 +1,40 @@ +import { z } from "zod" + +export const CategoryConfigSchema = z.object({ + /** Human-readable description of the category's purpose. Shown in task prompt. */ + description: z.string().optional(), + model: z.string().optional(), + variant: z.string().optional(), + temperature: z.number().min(0).max(2).optional(), + top_p: z.number().min(0).max(1).optional(), + maxTokens: z.number().optional(), + thinking: z + .object({ + type: z.enum(["enabled", "disabled"]), + budgetTokens: z.number().optional(), + }) + .optional(), + reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(), + textVerbosity: z.enum(["low", "medium", "high"]).optional(), + tools: z.record(z.string(), z.boolean()).optional(), + prompt_append: z.string().optional(), + /** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */ + is_unstable_agent: z.boolean().optional(), +}) + +export const BuiltinCategoryNameSchema = z.enum([ + "visual-engineering", + "ultrabrain", + "deep", + "artistry", + "quick", + "unspecified-low", + "unspecified-high", + "writing", +]) + +export const CategoriesConfigSchema = z.record(z.string(), CategoryConfigSchema) + +export type CategoryConfig = z.infer +export type CategoriesConfig = z.infer +export type BuiltinCategoryName = z.infer diff --git a/src/config/schema/claude-code.ts b/src/config/schema/claude-code.ts new file mode 100644 index 000000000..90cab9a95 --- /dev/null +++ b/src/config/schema/claude-code.ts @@ -0,0 +1,13 @@ +import { z } from "zod" + +export const ClaudeCodeConfigSchema = z.object({ + mcp: z.boolean().optional(), + commands: z.boolean().optional(), + skills: z.boolean().optional(), + agents: z.boolean().optional(), + hooks: z.boolean().optional(), + plugins: z.boolean().optional(), + plugins_override: z.record(z.string(), z.boolean()).optional(), +}) + +export type ClaudeCodeConfig = z.infer diff --git a/src/config/schema/commands.ts b/src/config/schema/commands.ts new file mode 100644 index 000000000..967254538 --- /dev/null +++ b/src/config/schema/commands.ts @@ -0,0 +1,13 @@ +import { z } from "zod" + +export const BuiltinCommandNameSchema = z.enum([ + "init-deep", + "ralph-loop", + "ulw-loop", + "cancel-ralph", + "refactor", + "start-work", + "stop-continuation", +]) + +export type BuiltinCommandName = z.infer diff --git a/src/config/schema/comment-checker.ts b/src/config/schema/comment-checker.ts new file mode 100644 index 000000000..ca8b81cd5 --- /dev/null +++ b/src/config/schema/comment-checker.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +export const CommentCheckerConfigSchema = z.object({ + /** Custom prompt to replace the default warning message. Use {{comments}} placeholder for detected comments XML. */ + custom_prompt: z.string().optional(), +}) + +export type CommentCheckerConfig = z.infer diff --git a/src/config/schema/dynamic-context-pruning.ts b/src/config/schema/dynamic-context-pruning.ts new file mode 100644 index 000000000..1d99c95c2 --- /dev/null +++ b/src/config/schema/dynamic-context-pruning.ts @@ -0,0 +1,55 @@ +import { z } from "zod" + +export const DynamicContextPruningConfigSchema = z.object({ + /** Enable dynamic context pruning (default: false) */ + enabled: z.boolean().default(false), + /** Notification level: off, minimal, or detailed (default: detailed) */ + notification: z.enum(["off", "minimal", "detailed"]).default("detailed"), + /** Turn protection - prevent pruning recent tool outputs */ + turn_protection: z + .object({ + enabled: z.boolean().default(true), + turns: z.number().min(1).max(10).default(3), + }) + .optional(), + /** Tools that should never be pruned */ + protected_tools: z.array(z.string()).default([ + "task", + "todowrite", + "todoread", + "lsp_rename", + "session_read", + "session_write", + "session_search", + ]), + /** Pruning strategies configuration */ + strategies: z + .object({ + /** Remove duplicate tool calls (same tool + same args) */ + deduplication: z + .object({ + enabled: z.boolean().default(true), + }) + .optional(), + /** Prune write inputs when file subsequently read */ + supersede_writes: z + .object({ + enabled: z.boolean().default(true), + /** Aggressive mode: prune any write if ANY subsequent read */ + aggressive: z.boolean().default(false), + }) + .optional(), + /** Prune errored tool inputs after N turns */ + purge_errors: z + .object({ + enabled: z.boolean().default(true), + turns: z.number().min(1).max(20).default(5), + }) + .optional(), + }) + .optional(), +}) + +export type DynamicContextPruningConfig = z.infer< + typeof DynamicContextPruningConfigSchema +> diff --git a/src/config/schema/experimental.ts b/src/config/schema/experimental.ts new file mode 100644 index 000000000..52747aae9 --- /dev/null +++ b/src/config/schema/experimental.ts @@ -0,0 +1,20 @@ +import { z } from "zod" +import { DynamicContextPruningConfigSchema } from "./dynamic-context-pruning" + +export const ExperimentalConfigSchema = z.object({ + aggressive_truncation: z.boolean().optional(), + auto_resume: z.boolean().optional(), + preemptive_compaction: z.boolean().optional(), + /** Truncate all tool outputs, not just whitelisted tools (default: false). Tool output truncator is enabled by default - disable via disabled_hooks. */ + truncate_all_tool_outputs: z.boolean().optional(), + /** Dynamic context pruning configuration */ + dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(), + /** Enable experimental task system for Todowrite disabler hook */ + task_system: z.boolean().optional(), + /** Timeout in ms for loadAllPluginComponents during config handler init (default: 10000, min: 1000) */ + plugin_load_timeout_ms: z.number().min(1000).optional(), + /** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */ + safe_hook_creation: z.boolean().optional(), +}) + +export type ExperimentalConfig = z.infer diff --git a/src/config/schema/git-master.ts b/src/config/schema/git-master.ts new file mode 100644 index 000000000..0574de860 --- /dev/null +++ b/src/config/schema/git-master.ts @@ -0,0 +1,10 @@ +import { z } from "zod" + +export const GitMasterConfigSchema = z.object({ + /** Add "Ultraworked with Sisyphus" footer to commit messages (default: true). Can be boolean or custom string. */ + commit_footer: z.union([z.boolean(), z.string()]).default(true), + /** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */ + include_co_authored_by: z.boolean().default(true), +}) + +export type GitMasterConfig = z.infer diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts new file mode 100644 index 000000000..bb5f6bdb0 --- /dev/null +++ b/src/config/schema/hooks.ts @@ -0,0 +1,51 @@ +import { z } from "zod" + +export const HookNameSchema = z.enum([ + "todo-continuation-enforcer", + "context-window-monitor", + "session-recovery", + "session-notification", + "comment-checker", + "grep-output-truncator", + "tool-output-truncator", + "question-label-truncator", + "directory-agents-injector", + "directory-readme-injector", + "empty-task-response-detector", + "think-mode", + "subagent-question-blocker", + "anthropic-context-window-limit-recovery", + "preemptive-compaction", + "rules-injector", + "background-notification", + "auto-update-checker", + "startup-toast", + "keyword-detector", + "agent-usage-reminder", + "non-interactive-env", + "interactive-bash-session", + + "thinking-block-validator", + "ralph-loop", + "category-skill-reminder", + + "compaction-context-injector", + "compaction-todo-preserver", + "claude-code-hooks", + "auto-slash-command", + "edit-error-recovery", + "delegate-task-retry", + "prometheus-md-only", + "sisyphus-junior-notepad", + "start-work", + "atlas", + "unstable-agent-babysitter", + "task-reminder", + "task-resume-info", + "stop-continuation-guard", + "tasks-todowrite-disabler", + "write-existing-file-guard", + "anthropic-effort", +]) + +export type HookName = z.infer diff --git a/src/config/schema/internal/permission.ts b/src/config/schema/internal/permission.ts new file mode 100644 index 000000000..66e76cc5d --- /dev/null +++ b/src/config/schema/internal/permission.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +export const PermissionValueSchema = z.enum(["ask", "allow", "deny"]) +export type PermissionValue = z.infer + +const BashPermissionSchema = z.union([ + PermissionValueSchema, + z.record(z.string(), PermissionValueSchema), +]) + +export const AgentPermissionSchema = z.object({ + edit: PermissionValueSchema.optional(), + bash: BashPermissionSchema.optional(), + webfetch: PermissionValueSchema.optional(), + task: PermissionValueSchema.optional(), + doom_loop: PermissionValueSchema.optional(), + external_directory: PermissionValueSchema.optional(), +}) + +export type AgentPermission = z.infer diff --git a/src/config/schema/notification.ts b/src/config/schema/notification.ts new file mode 100644 index 000000000..48b73da35 --- /dev/null +++ b/src/config/schema/notification.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +export const NotificationConfigSchema = z.object({ + /** Force enable session-notification even if external notification plugins are detected (default: false) */ + force_enable: z.boolean().optional(), +}) + +export type NotificationConfig = z.infer diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts new file mode 100644 index 000000000..be0ebd914 --- /dev/null +++ b/src/config/schema/oh-my-opencode-config.ts @@ -0,0 +1,57 @@ +import { z } from "zod" +import { AnyMcpNameSchema } from "../../mcp/types" +import { BuiltinAgentNameSchema, BuiltinSkillNameSchema } from "./agent-names" +import { AgentOverridesSchema } from "./agent-overrides" +import { BabysittingConfigSchema } from "./babysitting" +import { BackgroundTaskConfigSchema } from "./background-task" +import { BrowserAutomationConfigSchema } from "./browser-automation" +import { CategoriesConfigSchema } from "./categories" +import { ClaudeCodeConfigSchema } from "./claude-code" +import { CommentCheckerConfigSchema } from "./comment-checker" +import { BuiltinCommandNameSchema } from "./commands" +import { ExperimentalConfigSchema } from "./experimental" +import { GitMasterConfigSchema } from "./git-master" +import { HookNameSchema } from "./hooks" +import { NotificationConfigSchema } from "./notification" +import { RalphLoopConfigSchema } from "./ralph-loop" +import { SkillsConfigSchema } from "./skills" +import { SisyphusConfigSchema } from "./sisyphus" +import { SisyphusAgentConfigSchema } from "./sisyphus-agent" +import { TmuxConfigSchema } from "./tmux" +import { WebsearchConfigSchema } from "./websearch" + +export const OhMyOpenCodeConfigSchema = z.object({ + $schema: z.string().optional(), + /** Enable new task system (default: false) */ + new_task_system_enabled: z.boolean().optional(), + /** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */ + default_run_agent: z.string().optional(), + disabled_mcps: z.array(AnyMcpNameSchema).optional(), + disabled_agents: z.array(BuiltinAgentNameSchema).optional(), + disabled_skills: z.array(BuiltinSkillNameSchema).optional(), + disabled_hooks: z.array(HookNameSchema).optional(), + disabled_commands: z.array(BuiltinCommandNameSchema).optional(), + /** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */ + disabled_tools: z.array(z.string()).optional(), + agents: AgentOverridesSchema.optional(), + categories: CategoriesConfigSchema.optional(), + claude_code: ClaudeCodeConfigSchema.optional(), + sisyphus_agent: SisyphusAgentConfigSchema.optional(), + comment_checker: CommentCheckerConfigSchema.optional(), + experimental: ExperimentalConfigSchema.optional(), + auto_update: z.boolean().optional(), + skills: SkillsConfigSchema.optional(), + ralph_loop: RalphLoopConfigSchema.optional(), + background_task: BackgroundTaskConfigSchema.optional(), + notification: NotificationConfigSchema.optional(), + babysitting: BabysittingConfigSchema.optional(), + git_master: GitMasterConfigSchema.optional(), + browser_automation_engine: BrowserAutomationConfigSchema.optional(), + websearch: WebsearchConfigSchema.optional(), + tmux: TmuxConfigSchema.optional(), + sisyphus: SisyphusConfigSchema.optional(), + /** Migration history to prevent re-applying migrations (e.g., model version upgrades) */ + _migrations: z.array(z.string()).optional(), +}) + +export type OhMyOpenCodeConfig = z.infer diff --git a/src/config/schema/ralph-loop.ts b/src/config/schema/ralph-loop.ts new file mode 100644 index 000000000..1dbcde4fc --- /dev/null +++ b/src/config/schema/ralph-loop.ts @@ -0,0 +1,12 @@ +import { z } from "zod" + +export const RalphLoopConfigSchema = z.object({ + /** Enable ralph loop functionality (default: false - opt-in feature) */ + enabled: z.boolean().default(false), + /** Default max iterations if not specified in command (default: 100) */ + default_max_iterations: z.number().min(1).max(1000).default(100), + /** Custom state file directory relative to project root (default: .opencode/) */ + state_dir: z.string().optional(), +}) + +export type RalphLoopConfig = z.infer diff --git a/src/config/schema/sisyphus-agent.ts b/src/config/schema/sisyphus-agent.ts new file mode 100644 index 000000000..76ee2c373 --- /dev/null +++ b/src/config/schema/sisyphus-agent.ts @@ -0,0 +1,10 @@ +import { z } from "zod" + +export const SisyphusAgentConfigSchema = z.object({ + disabled: z.boolean().optional(), + default_builder_enabled: z.boolean().optional(), + planner_enabled: z.boolean().optional(), + replace_plan: z.boolean().optional(), +}) + +export type SisyphusAgentConfig = z.infer diff --git a/src/config/schema/sisyphus.ts b/src/config/schema/sisyphus.ts new file mode 100644 index 000000000..9ac3d2d14 --- /dev/null +++ b/src/config/schema/sisyphus.ts @@ -0,0 +1,17 @@ +import { z } from "zod" + +export const SisyphusTasksConfigSchema = z.object({ + /** Absolute or relative storage path override. When set, bypasses global config dir. */ + storage_path: z.string().optional(), + /** Force task list ID (alternative to env ULTRAWORK_TASK_LIST_ID) */ + task_list_id: z.string().optional(), + /** Enable Claude Code path compatibility mode */ + claude_code_compat: z.boolean().default(false), +}) + +export const SisyphusConfigSchema = z.object({ + tasks: SisyphusTasksConfigSchema.optional(), +}) + +export type SisyphusTasksConfig = z.infer +export type SisyphusConfig = z.infer diff --git a/src/config/schema/skills.ts b/src/config/schema/skills.ts new file mode 100644 index 000000000..0e7fbaa8a --- /dev/null +++ b/src/config/schema/skills.ts @@ -0,0 +1,45 @@ +import { z } from "zod" + +export const SkillSourceSchema = z.union([ + z.string(), + z.object({ + path: z.string(), + recursive: z.boolean().optional(), + glob: z.string().optional(), + }), +]) + +export const SkillDefinitionSchema = z.object({ + description: z.string().optional(), + template: z.string().optional(), + from: z.string().optional(), + model: z.string().optional(), + agent: z.string().optional(), + subtask: z.boolean().optional(), + "argument-hint": z.string().optional(), + license: z.string().optional(), + compatibility: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + "allowed-tools": z.array(z.string()).optional(), + disable: z.boolean().optional(), +}) + +export const SkillEntrySchema = z.union([z.boolean(), SkillDefinitionSchema]) + +export const SkillsConfigSchema = z.union([ + z.array(z.string()), + z + .record(z.string(), SkillEntrySchema) + .and( + z + .object({ + sources: z.array(SkillSourceSchema).optional(), + enable: z.array(z.string()).optional(), + disable: z.array(z.string()).optional(), + }) + .partial() + ), +]) + +export type SkillsConfig = z.infer +export type SkillDefinition = z.infer diff --git a/src/config/schema/tmux.ts b/src/config/schema/tmux.ts new file mode 100644 index 000000000..17685f025 --- /dev/null +++ b/src/config/schema/tmux.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +export const TmuxLayoutSchema = z.enum([ + "main-horizontal", // main pane top, agent panes bottom stack + "main-vertical", // main pane left, agent panes right stack (default) + "tiled", // all panes same size grid + "even-horizontal", // all panes horizontal row + "even-vertical", // all panes vertical stack +]) + +export const TmuxConfigSchema = z.object({ + enabled: z.boolean().default(false), + layout: TmuxLayoutSchema.default("main-vertical"), + main_pane_size: z.number().min(20).max(80).default(60), + main_pane_min_width: z.number().min(40).default(120), + agent_pane_min_width: z.number().min(20).default(40), +}) + +export type TmuxConfig = z.infer +export type TmuxLayout = z.infer diff --git a/src/config/schema/websearch.ts b/src/config/schema/websearch.ts new file mode 100644 index 000000000..1f9efee6f --- /dev/null +++ b/src/config/schema/websearch.ts @@ -0,0 +1,15 @@ +import { z } from "zod" + +export const WebsearchProviderSchema = z.enum(["exa", "tavily"]) + +export const WebsearchConfigSchema = z.object({ + /** + * Websearch provider to use. + * - "exa": Uses Exa websearch (default, works without API key) + * - "tavily": Uses Tavily websearch (requires TAVILY_API_KEY) + */ + provider: WebsearchProviderSchema.optional(), +}) + +export type WebsearchProvider = z.infer +export type WebsearchConfig = z.infer diff --git a/src/hooks/atlas/atlas-hook.ts b/src/hooks/atlas/atlas-hook.ts new file mode 100644 index 000000000..5d8c47f49 --- /dev/null +++ b/src/hooks/atlas/atlas-hook.ts @@ -0,0 +1,25 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { createAtlasEventHandler } from "./event-handler" +import { createToolExecuteAfterHandler } from "./tool-execute-after" +import { createToolExecuteBeforeHandler } from "./tool-execute-before" +import type { AtlasHookOptions, SessionState } from "./types" + +export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { + const sessions = new Map() + const pendingFilePaths = new Map() + + function getState(sessionID: string): SessionState { + let state = sessions.get(sessionID) + if (!state) { + state = { promptFailureCount: 0 } + sessions.set(sessionID, state) + } + return state + } + + return { + handler: createAtlasEventHandler({ ctx, options, sessions, getState }), + "tool.execute.before": createToolExecuteBeforeHandler({ pendingFilePaths }), + "tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths }), + } +} diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts new file mode 100644 index 000000000..93ccaefac --- /dev/null +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -0,0 +1,68 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { BackgroundManager } from "../../features/background-agent" +import { log } from "../../shared/logger" +import { HOOK_NAME } from "./hook-name" +import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates" +import { resolveRecentModelForSession } from "./recent-model-resolver" +import type { SessionState } from "./types" + +export async function injectBoulderContinuation(input: { + ctx: PluginInput + sessionID: string + planName: string + remaining: number + total: number + agent?: string + backgroundManager?: BackgroundManager + sessionState: SessionState +}): Promise { + const { + ctx, + sessionID, + planName, + remaining, + total, + agent, + backgroundManager, + sessionState, + } = input + + const hasRunningBgTasks = backgroundManager + ? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running") + : false + + if (hasRunningBgTasks) { + log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID }) + return + } + + const prompt = + BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) + + `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` + + try { + log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining }) + + const model = await resolveRecentModelForSession(ctx, sessionID) + + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { + agent: agent ?? "atlas", + ...(model !== undefined ? { model } : {}), + parts: [{ type: "text", text: prompt }], + }, + query: { directory: ctx.directory }, + }) + + sessionState.promptFailureCount = 0 + log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID }) + } catch (err) { + sessionState.promptFailureCount += 1 + log(`[${HOOK_NAME}] Boulder continuation failed`, { + sessionID, + error: String(err), + promptFailureCount: sessionState.promptFailureCount, + }) + } +} diff --git a/src/hooks/atlas/event-handler.ts b/src/hooks/atlas/event-handler.ts new file mode 100644 index 000000000..a9d26a329 --- /dev/null +++ b/src/hooks/atlas/event-handler.ts @@ -0,0 +1,187 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { getPlanProgress, readBoulderState } from "../../features/boulder-state" +import { getMainSessionID, subagentSessions } from "../../features/claude-code-session-state" +import { log } from "../../shared/logger" +import { HOOK_NAME } from "./hook-name" +import { isAbortError } from "./is-abort-error" +import { injectBoulderContinuation } from "./boulder-continuation-injector" +import { getLastAgentFromSession } from "./session-last-agent" +import type { AtlasHookOptions, SessionState } from "./types" + +const CONTINUATION_COOLDOWN_MS = 5000 + +export function createAtlasEventHandler(input: { + ctx: PluginInput + options?: AtlasHookOptions + sessions: Map + getState: (sessionID: string) => SessionState +}): (arg: { event: { type: string; properties?: unknown } }) => Promise { + const { ctx, options, sessions, getState } = input + + return async ({ event }): Promise => { + const props = event.properties as Record | undefined + + if (event.type === "session.error") { + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + const state = getState(sessionID) + const isAbort = isAbortError(props?.error) + state.lastEventWasAbortError = isAbort + + log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort }) + return + } + + if (event.type === "session.idle") { + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + log(`[${HOOK_NAME}] session.idle`, { sessionID }) + + // Read boulder state FIRST to check if this session is part of an active boulder + const boulderState = readBoulderState(ctx.directory) + const isBoulderSession = boulderState?.session_ids.includes(sessionID) ?? false + + const mainSessionID = getMainSessionID() + const isMainSession = sessionID === mainSessionID + const isBackgroundTaskSession = subagentSessions.has(sessionID) + + // Allow continuation if: main session OR background task OR boulder session + if (mainSessionID && !isMainSession && !isBackgroundTaskSession && !isBoulderSession) { + log(`[${HOOK_NAME}] Skipped: not main, background task, or boulder session`, { sessionID }) + return + } + + const state = getState(sessionID) + + if (state.lastEventWasAbortError) { + state.lastEventWasAbortError = false + log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID }) + return + } + + if (state.promptFailureCount >= 2) { + log(`[${HOOK_NAME}] Skipped: continuation disabled after repeated prompt failures`, { + sessionID, + promptFailureCount: state.promptFailureCount, + }) + return + } + + const backgroundManager = options?.backgroundManager + const hasRunningBgTasks = backgroundManager + ? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running") + : false + + if (hasRunningBgTasks) { + log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) + return + } + + if (!boulderState) { + log(`[${HOOK_NAME}] No active boulder`, { sessionID }) + return + } + + if (options?.isContinuationStopped?.(sessionID)) { + log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) + return + } + + const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase() + const lastAgent = getLastAgentFromSession(sessionID) + if (!lastAgent || lastAgent !== requiredAgent) { + log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, { + sessionID, + lastAgent: lastAgent ?? "unknown", + requiredAgent, + }) + return + } + + const progress = getPlanProgress(boulderState.active_plan) + if (progress.isComplete) { + log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) + return + } + + const now = Date.now() + if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) { + log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { + sessionID, + cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt), + }) + return + } + + state.lastContinuationInjectedAt = now + const remaining = progress.total - progress.completed + injectBoulderContinuation({ + ctx, + sessionID, + planName: boulderState.plan_name, + remaining, + total: progress.total, + agent: boulderState.agent, + backgroundManager, + sessionState: state, + }) + return + } + + if (event.type === "message.updated") { + const info = props?.info as Record | undefined + const sessionID = info?.sessionID as string | undefined + if (!sessionID) return + + const state = sessions.get(sessionID) + if (state) { + state.lastEventWasAbortError = false + } + return + } + + if (event.type === "message.part.updated") { + const info = props?.info as Record | undefined + const sessionID = info?.sessionID as string | undefined + const role = info?.role as string | undefined + + if (sessionID && role === "assistant") { + const state = sessions.get(sessionID) + if (state) { + state.lastEventWasAbortError = false + } + } + return + } + + if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { + const sessionID = props?.sessionID as string | undefined + if (sessionID) { + const state = sessions.get(sessionID) + if (state) { + state.lastEventWasAbortError = false + } + } + return + } + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + sessions.delete(sessionInfo.id) + log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) + } + return + } + + if (event.type === "session.compacted") { + const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined + if (sessionID) { + sessions.delete(sessionID) + log(`[${HOOK_NAME}] Session compacted: cleaned up`, { sessionID }) + } + } + } +} diff --git a/src/hooks/atlas/git-diff-stats.ts b/src/hooks/atlas/git-diff-stats.ts new file mode 100644 index 000000000..404942938 --- /dev/null +++ b/src/hooks/atlas/git-diff-stats.ts @@ -0,0 +1,108 @@ +import { execSync } from "node:child_process" + +interface GitFileStat { + path: string + added: number + removed: number + status: "modified" | "added" | "deleted" +} + +export function getGitDiffStats(directory: string): GitFileStat[] { + try { + const output = execSync("git diff --numstat HEAD", { + cwd: directory, + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim() + + if (!output) return [] + + const statusOutput = execSync("git status --porcelain", { + cwd: directory, + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim() + + const statusMap = new Map() + for (const line of statusOutput.split("\n")) { + if (!line) continue + const status = line.substring(0, 2).trim() + const filePath = line.substring(3) + if (status === "A" || status === "??") { + statusMap.set(filePath, "added") + } else if (status === "D") { + statusMap.set(filePath, "deleted") + } else { + statusMap.set(filePath, "modified") + } + } + + const stats: GitFileStat[] = [] + for (const line of output.split("\n")) { + const parts = line.split("\t") + if (parts.length < 3) continue + + const [addedStr, removedStr, path] = parts + const added = addedStr === "-" ? 0 : parseInt(addedStr, 10) + const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10) + + stats.push({ + path, + added, + removed, + status: statusMap.get(path) ?? "modified", + }) + } + + return stats + } catch { + return [] + } +} + +export function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string { + if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n" + + const modified = stats.filter((s) => s.status === "modified") + const added = stats.filter((s) => s.status === "added") + const deleted = stats.filter((s) => s.status === "deleted") + + const lines: string[] = ["[FILE CHANGES SUMMARY]"] + + if (modified.length > 0) { + lines.push("Modified files:") + for (const f of modified) { + lines.push(` ${f.path} (+${f.added}, -${f.removed})`) + } + lines.push("") + } + + if (added.length > 0) { + lines.push("Created files:") + for (const f of added) { + lines.push(` ${f.path} (+${f.added})`) + } + lines.push("") + } + + if (deleted.length > 0) { + lines.push("Deleted files:") + for (const f of deleted) { + lines.push(` ${f.path} (-${f.removed})`) + } + lines.push("") + } + + if (notepadPath) { + const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus")) + if (notepadStat) { + lines.push("[NOTEPAD UPDATED]") + lines.push(` ${notepadStat.path} (+${notepadStat.added})`) + lines.push("") + } + } + + return lines.join("\n") +} diff --git a/src/hooks/atlas/hook-name.ts b/src/hooks/atlas/hook-name.ts new file mode 100644 index 000000000..569fcd46b --- /dev/null +++ b/src/hooks/atlas/hook-name.ts @@ -0,0 +1 @@ +export const HOOK_NAME = "atlas" diff --git a/src/hooks/atlas/index.ts b/src/hooks/atlas/index.ts index ffad04598..ab8074167 100644 --- a/src/hooks/atlas/index.ts +++ b/src/hooks/atlas/index.ts @@ -1,804 +1,3 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { execSync } from "node:child_process" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { - readBoulderState, - appendSessionId, - getPlanProgress, -} from "../../features/boulder-state" -import { getMainSessionID, subagentSessions } from "../../features/claude-code-session-state" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" -import { log } from "../../shared/logger" -import { createSystemDirective, SYSTEM_DIRECTIVE_PREFIX, SystemDirectiveTypes } from "../../shared/system-directive" -import { isCallerOrchestrator, getMessageDir } from "../../shared/session-utils" -import type { BackgroundManager } from "../../features/background-agent" - -export const HOOK_NAME = "atlas" - -/** - * Cross-platform check if a path is inside .sisyphus/ directory. - * Handles both forward slashes (Unix) and backslashes (Windows). - */ -function isSisyphusPath(filePath: string): boolean { - return /\.sisyphus[/\\]/.test(filePath) -} - -const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"] - -function getLastAgentFromSession(sessionID: string): string | null { - const messageDir = getMessageDir(sessionID) - if (!messageDir) return null - const nearest = findNearestMessageWithFields(messageDir) - return nearest?.agent?.toLowerCase() ?? null -} - -const DIRECT_WORK_REMINDER = ` - ---- - -${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)} - -You just performed direct file modifications outside \`.sisyphus/\`. - -**You are an ORCHESTRATOR, not an IMPLEMENTER.** - -As an orchestrator, you should: -- **DELEGATE** implementation work to subagents via \`task\` -- **VERIFY** the work done by subagents -- **COORDINATE** multiple tasks and ensure completion - -You should NOT: -- Write code directly (except for \`.sisyphus/\` files like plans and notepads) -- Make direct file edits outside \`.sisyphus/\` -- Implement features yourself - -**If you need to make changes:** -1. Use \`task\` to delegate to an appropriate subagent -2. Provide clear instructions in the prompt -3. Verify the subagent's work after completion - ---- -` - -const BOULDER_CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.BOULDER_CONTINUATION)} - -You have an active work plan with incomplete tasks. Continue working. - -RULES: -- Proceed without asking for permission -- Change \`- [ ]\` to \`- [x]\` in the plan file when done -- Use the notepad at .sisyphus/notepads/{PLAN_NAME}/ to record learnings -- Do not stop until all tasks are complete -- If blocked, document the blocker and move to the next task` - -const VERIFICATION_REMINDER = `**MANDATORY: WHAT YOU MUST DO RIGHT NOW** - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -CRITICAL: Subagents FREQUENTLY LIE about completion. -Tests FAILING, code has ERRORS, implementation INCOMPLETE - but they say "done". - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**STEP 1: VERIFY WITH YOUR OWN TOOL CALLS (DO THIS NOW)** - -Run these commands YOURSELF - do NOT trust agent's claims: -1. \`lsp_diagnostics\` on changed files → Must be CLEAN -2. \`bash\` to run tests → Must PASS -3. \`bash\` to run build/typecheck → Must succeed -4. \`Read\` the actual code → Must match requirements - -**STEP 2: DETERMINE IF HANDS-ON QA IS NEEDED** - -| Deliverable Type | QA Method | Tool | -|------------------|-----------|------| -| **Frontend/UI** | Browser interaction | \`/playwright\` skill | -| **TUI/CLI** | Run interactively | \`interactive_bash\` (tmux) | -| **API/Backend** | Send real requests | \`bash\` with curl | - -Static analysis CANNOT catch: visual bugs, animation issues, user flow breakages. - -**STEP 3: IF QA IS NEEDED - ADD TO TODO IMMEDIATELY** - -\`\`\` -todowrite([ - { id: "qa-X", content: "HANDS-ON QA: [specific verification action]", status: "pending", priority: "high" } -]) -\`\`\` - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**BLOCKING: DO NOT proceed to Step 4 until Steps 1-3 are VERIFIED.**` - -const ORCHESTRATOR_DELEGATION_REQUIRED = ` - ---- - -${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)} - -**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.** - -You (Atlas) are attempting to directly modify a file outside \`.sisyphus/\`. - -**Path attempted:** $FILE_PATH - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**THIS IS FORBIDDEN** (except for VERIFICATION purposes) - -As an ORCHESTRATOR, you MUST: -1. **DELEGATE** all implementation work via \`task\` -2. **VERIFY** the work done by subagents (reading files is OK) -3. **COORDINATE** - you orchestrate, you don't implement - -**ALLOWED direct file operations:** -- Files inside \`.sisyphus/\` (plans, notepads, drafts) -- Reading files for verification -- Running diagnostics/tests - -**FORBIDDEN direct file operations:** -- Writing/editing source code -- Creating new files outside \`.sisyphus/\` -- Any implementation work - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**IF THIS IS FOR VERIFICATION:** -Proceed if you are verifying subagent work by making a small fix. -But for any substantial changes, USE \`task\`. - -**CORRECT APPROACH:** -\`\`\` -task( - category="...", - prompt="[specific single task with clear acceptance criteria]" -) -\`\`\` - -DELEGATE. DON'T IMPLEMENT. - ---- -` - -const SINGLE_TASK_DIRECTIVE = ` - -${createSystemDirective(SystemDirectiveTypes.SINGLE_TASK_ONLY)} - -**STOP. READ THIS BEFORE PROCEEDING.** - -If you were NOT given **exactly ONE atomic task**, you MUST: -1. **IMMEDIATELY REFUSE** this request -2. **DEMAND** the orchestrator provide a single, specific task - -**Your response if multiple tasks detected:** -> "I refuse to proceed. You provided multiple tasks. An orchestrator's impatience destroys work quality. -> -> PROVIDE EXACTLY ONE TASK. One file. One change. One verification. -> -> Your rushing will cause: incomplete work, missed edge cases, broken tests, wasted context." - -**WARNING TO ORCHESTRATOR:** -- Your hasty batching RUINS deliverables -- Each task needs FULL attention and PROPER verification -- Batch delegation = sloppy work = rework = wasted tokens - -**REFUSE multi-task requests. DEMAND single-task clarity.** -` - -function buildVerificationReminder(sessionId: string): string { - return `${VERIFICATION_REMINDER} - ---- - -**If ANY verification fails, use this immediately:** -\`\`\` -task(session_id="${sessionId}", prompt="fix: [describe the specific failure]") -\`\`\`` -} - -function buildOrchestratorReminder(planName: string, progress: { total: number; completed: number }, sessionId: string): string { - const remaining = progress.total - progress.completed - return ` ---- - -**BOULDER STATE:** Plan: \`${planName}\` | ${progress.completed}/${progress.total} done | ${remaining} remaining - ---- - -${buildVerificationReminder(sessionId)} - -**STEP 4: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)** - -RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY. - -Update the plan file \`.sisyphus/tasks/${planName}.yaml\`: -- Change \`- [ ]\` to \`- [x]\` for the completed task -- Use \`Edit\` tool to modify the checkbox - -**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.** - -**STEP 5: COMMIT ATOMIC UNIT** - -- Stage ONLY the verified changes -- Commit with clear message describing what was done - -**STEP 6: PROCEED TO NEXT TASK** - -- Read the plan file to identify the next \`- [ ]\` task -- Start immediately - DO NOT STOP - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**${remaining} tasks remain. Keep bouldering.**` -} - -function buildStandaloneVerificationReminder(sessionId: string): string { - return ` ---- - -${buildVerificationReminder(sessionId)} - -**STEP 4: UPDATE TODO STATUS (IMMEDIATELY)** - -RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY. - -1. Run \`todoread\` to see your todo list -2. Mark the completed task as \`completed\` using \`todowrite\` - -**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.** - -**STEP 5: EXECUTE QA TASKS (IF ANY)** - -If QA tasks exist in your todo list: -- Execute them BEFORE proceeding -- Mark each QA task complete after successful verification - -**STEP 6: PROCEED TO NEXT PENDING TASK** - -- Identify the next \`pending\` task from your todo list -- Start immediately - DO NOT STOP - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**NO TODO = NO TRACKING = INCOMPLETE WORK. Use todowrite aggressively.**` -} - -function extractSessionIdFromOutput(output: string): string { - const match = output.match(/Session ID:\s*(ses_[a-zA-Z0-9]+)/) - return match?.[1] ?? "" -} - -interface GitFileStat { - path: string - added: number - removed: number - status: "modified" | "added" | "deleted" -} - -function getGitDiffStats(directory: string): GitFileStat[] { - try { - const output = execSync("git diff --numstat HEAD", { - cwd: directory, - encoding: "utf-8", - timeout: 5000, - stdio: ["pipe", "pipe", "pipe"], - }).trim() - - if (!output) return [] - - const statusOutput = execSync("git status --porcelain", { - cwd: directory, - encoding: "utf-8", - timeout: 5000, - stdio: ["pipe", "pipe", "pipe"], - }).trim() - - const statusMap = new Map() - for (const line of statusOutput.split("\n")) { - if (!line) continue - const status = line.substring(0, 2).trim() - const filePath = line.substring(3) - if (status === "A" || status === "??") { - statusMap.set(filePath, "added") - } else if (status === "D") { - statusMap.set(filePath, "deleted") - } else { - statusMap.set(filePath, "modified") - } - } - - const stats: GitFileStat[] = [] - for (const line of output.split("\n")) { - const parts = line.split("\t") - if (parts.length < 3) continue - - const [addedStr, removedStr, path] = parts - const added = addedStr === "-" ? 0 : parseInt(addedStr, 10) - const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10) - - stats.push({ - path, - added, - removed, - status: statusMap.get(path) ?? "modified", - }) - } - - return stats - } catch { - return [] - } -} - -function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string { - if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n" - - const modified = stats.filter((s) => s.status === "modified") - const added = stats.filter((s) => s.status === "added") - const deleted = stats.filter((s) => s.status === "deleted") - - const lines: string[] = ["[FILE CHANGES SUMMARY]"] - - if (modified.length > 0) { - lines.push("Modified files:") - for (const f of modified) { - lines.push(` ${f.path} (+${f.added}, -${f.removed})`) - } - lines.push("") - } - - if (added.length > 0) { - lines.push("Created files:") - for (const f of added) { - lines.push(` ${f.path} (+${f.added})`) - } - lines.push("") - } - - if (deleted.length > 0) { - lines.push("Deleted files:") - for (const f of deleted) { - lines.push(` ${f.path} (-${f.removed})`) - } - lines.push("") - } - - if (notepadPath) { - const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus")) - if (notepadStat) { - lines.push("[NOTEPAD UPDATED]") - lines.push(` ${notepadStat.path} (+${notepadStat.added})`) - lines.push("") - } - } - - return lines.join("\n") -} - -interface ToolExecuteAfterInput { - tool: string - sessionID?: string - callID?: string -} - -interface ToolExecuteAfterOutput { - title: string - output: string - metadata: Record -} - -interface SessionState { - lastEventWasAbortError?: boolean - lastContinuationInjectedAt?: number - promptFailureCount: number -} - -const CONTINUATION_COOLDOWN_MS = 5000 - -export interface AtlasHookOptions { - directory: string - backgroundManager?: BackgroundManager - isContinuationStopped?: (sessionID: string) => boolean -} - -function isAbortError(error: unknown): boolean { - if (!error) return false - - if (typeof error === "object") { - const errObj = error as Record - const name = errObj.name as string | undefined - const message = (errObj.message as string | undefined)?.toLowerCase() ?? "" - - if (name === "MessageAbortedError" || name === "AbortError") return true - if (name === "DOMException" && message.includes("abort")) return true - if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true - } - - if (typeof error === "string") { - const lower = error.toLowerCase() - return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt") - } - - return false -} - -export function createAtlasHook( - ctx: PluginInput, - options?: AtlasHookOptions -) { - const backgroundManager = options?.backgroundManager - const sessions = new Map() - const pendingFilePaths = new Map() - - function getState(sessionID: string): SessionState { - let state = sessions.get(sessionID) - if (!state) { - state = { promptFailureCount: 0 } - sessions.set(sessionID, state) - } - return state - } - - async function injectContinuation(sessionID: string, planName: string, remaining: number, total: number, agent?: string): Promise { - const state = getState(sessionID) - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID }) - return - } - - const prompt = BOULDER_CONTINUATION_PROMPT - .replace(/{PLAN_NAME}/g, planName) + - `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` - - try { - log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining }) - - let model: { providerID: string; modelID: string } | undefined - try { - const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - const msgModel = info?.model - if (msgModel?.providerID && msgModel?.modelID) { - model = { providerID: msgModel.providerID, modelID: msgModel.modelID } - break - } - if (info?.providerID && info?.modelID) { - model = { providerID: info.providerID, modelID: info.modelID } - break - } - } - } catch { - const messageDir = getMessageDir(sessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - } - - await ctx.client.session.promptAsync({ - path: { id: sessionID }, - body: { - agent: agent ?? "atlas", - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: prompt }], - }, - query: { directory: ctx.directory }, - }) - - state.promptFailureCount = 0 - - log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID }) - } catch (err) { - state.promptFailureCount += 1 - log(`[${HOOK_NAME}] Boulder continuation failed`, { - sessionID, - error: String(err), - promptFailureCount: state.promptFailureCount, - }) - } - } - - return { - handler: async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { - const props = event.properties as Record | undefined - - if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const state = getState(sessionID) - const isAbort = isAbortError(props?.error) - state.lastEventWasAbortError = isAbort - - log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort }) - return - } - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - log(`[${HOOK_NAME}] session.idle`, { sessionID }) - - // Read boulder state FIRST to check if this session is part of an active boulder - const boulderState = readBoulderState(ctx.directory) - const isBoulderSession = boulderState?.session_ids.includes(sessionID) ?? false - - const mainSessionID = getMainSessionID() - const isMainSession = sessionID === mainSessionID - const isBackgroundTaskSession = subagentSessions.has(sessionID) - - // Allow continuation if: main session OR background task OR boulder session - if (mainSessionID && !isMainSession && !isBackgroundTaskSession && !isBoulderSession) { - log(`[${HOOK_NAME}] Skipped: not main, background task, or boulder session`, { sessionID }) - return - } - - const state = getState(sessionID) - - if (state.lastEventWasAbortError) { - state.lastEventWasAbortError = false - log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID }) - return - } - - if (state.promptFailureCount >= 2) { - log(`[${HOOK_NAME}] Skipped: continuation disabled after repeated prompt failures`, { - sessionID, - promptFailureCount: state.promptFailureCount, - }) - return - } - - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) - return - } - - - if (!boulderState) { - log(`[${HOOK_NAME}] No active boulder`, { sessionID }) - return - } - - if (options?.isContinuationStopped?.(sessionID)) { - log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) - return - } - - const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase() - const lastAgent = getLastAgentFromSession(sessionID) - if (!lastAgent || lastAgent !== requiredAgent) { - log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, { - sessionID, - lastAgent: lastAgent ?? "unknown", - requiredAgent, - }) - return - } - - const progress = getPlanProgress(boulderState.active_plan) - if (progress.isComplete) { - log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) - return - } - - const now = Date.now() - if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) { - log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { sessionID, cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt) }) - return - } - - state.lastContinuationInjectedAt = now - const remaining = progress.total - progress.completed - injectContinuation(sessionID, boulderState.plan_name, remaining, progress.total, boulderState.agent) - return - } - - if (event.type === "message.updated") { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - - if (!sessionID) return - - const state = sessions.get(sessionID) - if (state) { - state.lastEventWasAbortError = false - } - return - } - - if (event.type === "message.part.updated") { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - const role = info?.role as string | undefined - - if (sessionID && role === "assistant") { - const state = sessions.get(sessionID) - if (state) { - state.lastEventWasAbortError = false - } - } - return - } - - if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { - const sessionID = props?.sessionID as string | undefined - if (sessionID) { - const state = sessions.get(sessionID) - if (state) { - state.lastEventWasAbortError = false - } - } - return - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - sessions.delete(sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) - } - return - } - - if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as - | string - | undefined - if (sessionID) { - sessions.delete(sessionID) - log(`[${HOOK_NAME}] Session compacted: cleaned up`, { sessionID }) - } - return - } - }, - - "tool.execute.before": async ( - input: { tool: string; sessionID?: string; callID?: string }, - output: { args: Record; message?: string } - ): Promise => { - if (!isCallerOrchestrator(input.sessionID)) { - return - } - - // Check Write/Edit tools for orchestrator - inject strong warning - if (WRITE_EDIT_TOOLS.includes(input.tool)) { - const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined - if (filePath && !isSisyphusPath(filePath)) { - // Store filePath for use in tool.execute.after - if (input.callID) { - pendingFilePaths.set(input.callID, filePath) - } - const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath) - output.message = (output.message || "") + warning - log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, { - sessionID: input.sessionID, - tool: input.tool, - filePath, - }) - } - return - } - - // Check task - inject single-task directive - if (input.tool === "task") { - const prompt = output.args.prompt as string | undefined - if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) { - output.args.prompt = `${SINGLE_TASK_DIRECTIVE}\n` + prompt - log(`[${HOOK_NAME}] Injected single-task directive to task`, { - sessionID: input.sessionID, - }) - } - } - }, - - "tool.execute.after": async ( - input: ToolExecuteAfterInput, - output: ToolExecuteAfterOutput - ): Promise => { - // Guard against undefined output (e.g., from /review command - see issue #1035) - if (!output) { - return - } - - if (!isCallerOrchestrator(input.sessionID)) { - return - } - - if (WRITE_EDIT_TOOLS.includes(input.tool)) { - let filePath = input.callID ? pendingFilePaths.get(input.callID) : undefined - if (input.callID) { - pendingFilePaths.delete(input.callID) - } - if (!filePath) { - filePath = output.metadata?.filePath as string | undefined - } - if (filePath && !isSisyphusPath(filePath)) { - output.output = (output.output || "") + DIRECT_WORK_REMINDER - log(`[${HOOK_NAME}] Direct work reminder appended`, { - sessionID: input.sessionID, - tool: input.tool, - filePath, - }) - } - return - } - - if (input.tool !== "task") { - return - } - - const outputStr = output.output && typeof output.output === "string" ? output.output : "" - const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task continued") - - if (isBackgroundLaunch) { - return - } - - if (output.output && typeof output.output === "string") { - const gitStats = getGitDiffStats(ctx.directory) - const fileChanges = formatFileChanges(gitStats) - const subagentSessionId = extractSessionIdFromOutput(output.output) - - const boulderState = readBoulderState(ctx.directory) - - if (boulderState) { - const progress = getPlanProgress(boulderState.active_plan) - - if (input.sessionID && !boulderState.session_ids.includes(input.sessionID)) { - appendSessionId(ctx.directory, input.sessionID) - log(`[${HOOK_NAME}] Appended session to boulder`, { - sessionID: input.sessionID, - plan: boulderState.plan_name, - }) - } - - // Preserve original subagent response - critical for debugging failed tasks - const originalResponse = output.output - - output.output = ` -## SUBAGENT WORK COMPLETED - -${fileChanges} - ---- - -**Subagent Response:** - -${originalResponse} - - -${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId)} -` - - log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, { - plan: boulderState.plan_name, - progress: `${progress.completed}/${progress.total}`, - fileCount: gitStats.length, - }) - } else { - output.output += `\n\n${buildStandaloneVerificationReminder(subagentSessionId)}\n` - - log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, { - sessionID: input.sessionID, - fileCount: gitStats.length, - }) - } - } - }, - } -} +export { HOOK_NAME } from "./hook-name" +export { createAtlasHook } from "./atlas-hook" +export type { AtlasHookOptions } from "./types" diff --git a/src/hooks/atlas/is-abort-error.ts b/src/hooks/atlas/is-abort-error.ts new file mode 100644 index 000000000..3a8c92c1a --- /dev/null +++ b/src/hooks/atlas/is-abort-error.ts @@ -0,0 +1,20 @@ +export function isAbortError(error: unknown): boolean { + if (!error) return false + + if (typeof error === "object") { + const errObj = error as Record + const name = errObj.name as string | undefined + const message = (errObj.message as string | undefined)?.toLowerCase() ?? "" + + if (name === "MessageAbortedError" || name === "AbortError") return true + if (name === "DOMException" && message.includes("abort")) return true + if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true + } + + if (typeof error === "string") { + const lower = error.toLowerCase() + return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt") + } + + return false +} diff --git a/src/hooks/atlas/recent-model-resolver.ts b/src/hooks/atlas/recent-model-resolver.ts new file mode 100644 index 000000000..814e6af85 --- /dev/null +++ b/src/hooks/atlas/recent-model-resolver.ts @@ -0,0 +1,38 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { findNearestMessageWithFields } from "../../features/hook-message-injector" +import { getMessageDir } from "../../shared/session-utils" +import type { ModelInfo } from "./types" + +export async function resolveRecentModelForSession( + ctx: PluginInput, + sessionID: string +): Promise { + try { + const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) + const messages = (messagesResp.data ?? []) as Array<{ + info?: { model?: ModelInfo; modelID?: string; providerID?: string } + }> + + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i].info + const model = info?.model + if (model?.providerID && model?.modelID) { + return { providerID: model.providerID, modelID: model.modelID } + } + + if (info?.providerID && info?.modelID) { + return { providerID: info.providerID, modelID: info.modelID } + } + } + } catch { + // ignore - fallback to message storage + } + + const messageDir = getMessageDir(sessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + const model = currentMessage?.model + if (!model?.providerID || !model?.modelID) { + return undefined + } + return { providerID: model.providerID, modelID: model.modelID } +} diff --git a/src/hooks/atlas/session-last-agent.ts b/src/hooks/atlas/session-last-agent.ts new file mode 100644 index 000000000..341eda6f2 --- /dev/null +++ b/src/hooks/atlas/session-last-agent.ts @@ -0,0 +1,9 @@ +import { findNearestMessageWithFields } from "../../features/hook-message-injector" +import { getMessageDir } from "../../shared/session-utils" + +export function getLastAgentFromSession(sessionID: string): string | null { + const messageDir = getMessageDir(sessionID) + if (!messageDir) return null + const nearest = findNearestMessageWithFields(messageDir) + return nearest?.agent?.toLowerCase() ?? null +} diff --git a/src/hooks/atlas/sisyphus-path.ts b/src/hooks/atlas/sisyphus-path.ts new file mode 100644 index 000000000..c60722e0d --- /dev/null +++ b/src/hooks/atlas/sisyphus-path.ts @@ -0,0 +1,7 @@ +/** + * Cross-platform check if a path is inside .sisyphus/ directory. + * Handles both forward slashes (Unix) and backslashes (Windows). + */ +export function isSisyphusPath(filePath: string): boolean { + return /\.sisyphus[/\\]/.test(filePath) +} diff --git a/src/hooks/atlas/subagent-session-id.ts b/src/hooks/atlas/subagent-session-id.ts new file mode 100644 index 000000000..12cf619b1 --- /dev/null +++ b/src/hooks/atlas/subagent-session-id.ts @@ -0,0 +1,4 @@ +export function extractSessionIdFromOutput(output: string): string { + const match = output.match(/Session ID:\s*(ses_[a-zA-Z0-9]+)/) + return match?.[1] ?? "" +} diff --git a/src/hooks/atlas/system-reminder-templates.ts b/src/hooks/atlas/system-reminder-templates.ts new file mode 100644 index 000000000..c9d4cd749 --- /dev/null +++ b/src/hooks/atlas/system-reminder-templates.ts @@ -0,0 +1,154 @@ +import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive" + +export const DIRECT_WORK_REMINDER = ` + +--- + +${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)} + +You just performed direct file modifications outside \`.sisyphus/\`. + +**You are an ORCHESTRATOR, not an IMPLEMENTER.** + +As an orchestrator, you should: +- **DELEGATE** implementation work to subagents via \`task\` +- **VERIFY** the work done by subagents +- **COORDINATE** multiple tasks and ensure completion + +You should NOT: +- Write code directly (except for \`.sisyphus/\` files like plans and notepads) +- Make direct file edits outside \`.sisyphus/\` +- Implement features yourself + +**If you need to make changes:** +1. Use \`task\` to delegate to an appropriate subagent +2. Provide clear instructions in the prompt +3. Verify the subagent's work after completion + +--- +` + +export const BOULDER_CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.BOULDER_CONTINUATION)} + +You have an active work plan with incomplete tasks. Continue working. + +RULES: +- Proceed without asking for permission +- Change \`- [ ]\` to \`- [x]\` in the plan file when done +- Use the notepad at .sisyphus/notepads/{PLAN_NAME}/ to record learnings +- Do not stop until all tasks are complete +- If blocked, document the blocker and move to the next task` + +export const VERIFICATION_REMINDER = `**MANDATORY: WHAT YOU MUST DO RIGHT NOW** + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +CRITICAL: Subagents FREQUENTLY LIE about completion. +Tests FAILING, code has ERRORS, implementation INCOMPLETE - but they say "done". + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**STEP 1: VERIFY WITH YOUR OWN TOOL CALLS (DO THIS NOW)** + +Run these commands YOURSELF - do NOT trust agent's claims: +1. \`lsp_diagnostics\` on changed files → Must be CLEAN +2. \`bash\` to run tests → Must PASS +3. \`bash\` to run build/typecheck → Must succeed +4. \`Read\` the actual code → Must match requirements + +**STEP 2: DETERMINE IF HANDS-ON QA IS NEEDED** + +| Deliverable Type | QA Method | Tool | +|------------------|-----------|------| +| **Frontend/UI** | Browser interaction | \`/playwright\` skill | +| **TUI/CLI** | Run interactively | \`interactive_bash\` (tmux) | +| **API/Backend** | Send real requests | \`bash\` with curl | + +Static analysis CANNOT catch: visual bugs, animation issues, user flow breakages. + +**STEP 3: IF QA IS NEEDED - ADD TO TODO IMMEDIATELY** + +\`\`\` +todowrite([ + { id: "qa-X", content: "HANDS-ON QA: [specific verification action]", status: "pending", priority: "high" } +]) +\`\`\` + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**BLOCKING: DO NOT proceed to Step 4 until Steps 1-3 are VERIFIED.**` + +export const ORCHESTRATOR_DELEGATION_REQUIRED = ` + +--- + +${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)} + +**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.** + +You (Atlas) are attempting to directly modify a file outside \`.sisyphus/\`. + +**Path attempted:** $FILE_PATH + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**THIS IS FORBIDDEN** (except for VERIFICATION purposes) + +As an ORCHESTRATOR, you MUST: +1. **DELEGATE** all implementation work via \`task\` +2. **VERIFY** the work done by subagents (reading files is OK) +3. **COORDINATE** - you orchestrate, you don't implement + +**ALLOWED direct file operations:** +- Files inside \`.sisyphus/\` (plans, notepads, drafts) +- Reading files for verification +- Running diagnostics/tests + +**FORBIDDEN direct file operations:** +- Writing/editing source code +- Creating new files outside \`.sisyphus/\` +- Any implementation work + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**IF THIS IS FOR VERIFICATION:** +Proceed if you are verifying subagent work by making a small fix. +But for any substantial changes, USE \`task\`. + +**CORRECT APPROACH:** +\`\`\` +task( + category="...", + prompt="[specific single task with clear acceptance criteria]" +) +\`\`\` + +DELEGATE. DON'T IMPLEMENT. + +--- +` + +export const SINGLE_TASK_DIRECTIVE = ` + +${createSystemDirective(SystemDirectiveTypes.SINGLE_TASK_ONLY)} + +**STOP. READ THIS BEFORE PROCEEDING.** + +If you were NOT given **exactly ONE atomic task**, you MUST: +1. **IMMEDIATELY REFUSE** this request +2. **DEMAND** the orchestrator provide a single, specific task + +**Your response if multiple tasks detected:** +> "I refuse to proceed. You provided multiple tasks. An orchestrator's impatience destroys work quality. +> +> PROVIDE EXACTLY ONE TASK. One file. One change. One verification. +> +> Your rushing will cause: incomplete work, missed edge cases, broken tests, wasted context." + +**WARNING TO ORCHESTRATOR:** +- Your hasty batching RUINS deliverables +- Each task needs FULL attention and PROPER verification +- Batch delegation = sloppy work = rework = wasted tokens + +**REFUSE multi-task requests. DEMAND single-task clarity.** +` diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts new file mode 100644 index 000000000..ef21ac334 --- /dev/null +++ b/src/hooks/atlas/tool-execute-after.ts @@ -0,0 +1,109 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { appendSessionId, getPlanProgress, readBoulderState } from "../../features/boulder-state" +import { log } from "../../shared/logger" +import { isCallerOrchestrator } from "../../shared/session-utils" +import { HOOK_NAME } from "./hook-name" +import { DIRECT_WORK_REMINDER } from "./system-reminder-templates" +import { formatFileChanges, getGitDiffStats } from "./git-diff-stats" +import { isSisyphusPath } from "./sisyphus-path" +import { extractSessionIdFromOutput } from "./subagent-session-id" +import { buildOrchestratorReminder, buildStandaloneVerificationReminder } from "./verification-reminders" +import { isWriteOrEditToolName } from "./write-edit-tool-policy" +import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types" + +export function createToolExecuteAfterHandler(input: { + ctx: PluginInput + pendingFilePaths: Map +}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise { + const { ctx, pendingFilePaths } = input + + return async (toolInput, toolOutput): Promise => { + // Guard against undefined output (e.g., from /review command - see issue #1035) + if (!toolOutput) { + return + } + + if (!isCallerOrchestrator(toolInput.sessionID)) { + return + } + + if (isWriteOrEditToolName(toolInput.tool)) { + let filePath = toolInput.callID ? pendingFilePaths.get(toolInput.callID) : undefined + if (toolInput.callID) { + pendingFilePaths.delete(toolInput.callID) + } + if (!filePath) { + filePath = toolOutput.metadata?.filePath as string | undefined + } + if (filePath && !isSisyphusPath(filePath)) { + toolOutput.output = (toolOutput.output || "") + DIRECT_WORK_REMINDER + log(`[${HOOK_NAME}] Direct work reminder appended`, { + sessionID: toolInput.sessionID, + tool: toolInput.tool, + filePath, + }) + } + return + } + + if (toolInput.tool !== "task") { + return + } + + const outputStr = toolOutput.output && typeof toolOutput.output === "string" ? toolOutput.output : "" + const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task continued") + if (isBackgroundLaunch) { + return + } + + if (toolOutput.output && typeof toolOutput.output === "string") { + const gitStats = getGitDiffStats(ctx.directory) + const fileChanges = formatFileChanges(gitStats) + const subagentSessionId = extractSessionIdFromOutput(toolOutput.output) + + const boulderState = readBoulderState(ctx.directory) + if (boulderState) { + const progress = getPlanProgress(boulderState.active_plan) + + if (toolInput.sessionID && !boulderState.session_ids.includes(toolInput.sessionID)) { + appendSessionId(ctx.directory, toolInput.sessionID) + log(`[${HOOK_NAME}] Appended session to boulder`, { + sessionID: toolInput.sessionID, + plan: boulderState.plan_name, + }) + } + + // Preserve original subagent response - critical for debugging failed tasks + const originalResponse = toolOutput.output + + toolOutput.output = ` +## SUBAGENT WORK COMPLETED + +${fileChanges} + +--- + +**Subagent Response:** + +${originalResponse} + + +${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId)} +` + + log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, { + plan: boulderState.plan_name, + progress: `${progress.completed}/${progress.total}`, + fileCount: gitStats.length, + }) + } else { + toolOutput.output += `\n\n${buildStandaloneVerificationReminder(subagentSessionId)}\n` + + log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, { + sessionID: toolInput.sessionID, + fileCount: gitStats.length, + }) + } + } + } +} diff --git a/src/hooks/atlas/tool-execute-before.ts b/src/hooks/atlas/tool-execute-before.ts new file mode 100644 index 000000000..018390759 --- /dev/null +++ b/src/hooks/atlas/tool-execute-before.ts @@ -0,0 +1,52 @@ +import { log } from "../../shared/logger" +import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" +import { isCallerOrchestrator } from "../../shared/session-utils" +import { HOOK_NAME } from "./hook-name" +import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates" +import { isSisyphusPath } from "./sisyphus-path" +import { isWriteOrEditToolName } from "./write-edit-tool-policy" + +export function createToolExecuteBeforeHandler(input: { + pendingFilePaths: Map +}): ( + toolInput: { tool: string; sessionID?: string; callID?: string }, + toolOutput: { args: Record; message?: string } +) => Promise { + const { pendingFilePaths } = input + + return async (toolInput, toolOutput): Promise => { + if (!isCallerOrchestrator(toolInput.sessionID)) { + return + } + + // Check Write/Edit tools for orchestrator - inject strong warning + if (isWriteOrEditToolName(toolInput.tool)) { + const filePath = (toolOutput.args.filePath ?? toolOutput.args.path ?? toolOutput.args.file) as string | undefined + if (filePath && !isSisyphusPath(filePath)) { + // Store filePath for use in tool.execute.after + if (toolInput.callID) { + pendingFilePaths.set(toolInput.callID, filePath) + } + const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath) + toolOutput.message = (toolOutput.message || "") + warning + log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, { + sessionID: toolInput.sessionID, + tool: toolInput.tool, + filePath, + }) + } + return + } + + // Check task - inject single-task directive + if (toolInput.tool === "task") { + const prompt = toolOutput.args.prompt as string | undefined + if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) { + toolOutput.args.prompt = `${SINGLE_TASK_DIRECTIVE}\n` + prompt + log(`[${HOOK_NAME}] Injected single-task directive to task`, { + sessionID: toolInput.sessionID, + }) + } + } + } +} diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts new file mode 100644 index 000000000..e08c1f47c --- /dev/null +++ b/src/hooks/atlas/types.ts @@ -0,0 +1,27 @@ +import type { BackgroundManager } from "../../features/background-agent" + +export type ModelInfo = { providerID: string; modelID: string } + +export interface AtlasHookOptions { + directory: string + backgroundManager?: BackgroundManager + isContinuationStopped?: (sessionID: string) => boolean +} + +export interface ToolExecuteAfterInput { + tool: string + sessionID?: string + callID?: string +} + +export interface ToolExecuteAfterOutput { + title: string + output: string + metadata: Record +} + +export interface SessionState { + lastEventWasAbortError?: boolean + lastContinuationInjectedAt?: number + promptFailureCount: number +} diff --git a/src/hooks/atlas/verification-reminders.ts b/src/hooks/atlas/verification-reminders.ts new file mode 100644 index 000000000..e792bf3e5 --- /dev/null +++ b/src/hooks/atlas/verification-reminders.ts @@ -0,0 +1,83 @@ +import { VERIFICATION_REMINDER } from "./system-reminder-templates" + +function buildVerificationReminder(sessionId: string): string { + return `${VERIFICATION_REMINDER} + +--- + +**If ANY verification fails, use this immediately:** +\`\`\` +task(session_id="${sessionId}", prompt="fix: [describe the specific failure]") +\`\`\`` +} + +export function buildOrchestratorReminder( + planName: string, + progress: { total: number; completed: number }, + sessionId: string +): string { + const remaining = progress.total - progress.completed + return ` +--- + +**BOULDER STATE:** Plan: \`${planName}\` | ${progress.completed}/${progress.total} done | ${remaining} remaining + +--- + +${buildVerificationReminder(sessionId)} + +**STEP 4: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)** + +RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY. + +Update the plan file \`.sisyphus/tasks/${planName}.yaml\`: +- Change \`- [ ]\` to \`- [x]\` for the completed task +- Use \`Edit\` tool to modify the checkbox + +**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.** + +**STEP 5: COMMIT ATOMIC UNIT** + +- Stage ONLY the verified changes +- Commit with clear message describing what was done + +**STEP 6: PROCEED TO NEXT TASK** + +- Read the plan file to identify the next \`- [ ]\` task +- Start immediately - DO NOT STOP + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**${remaining} tasks remain. Keep bouldering.**` +} + +export function buildStandaloneVerificationReminder(sessionId: string): string { + return ` +--- + +${buildVerificationReminder(sessionId)} + +**STEP 4: UPDATE TODO STATUS (IMMEDIATELY)** + +RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY. + +1. Run \`todoread\` to see your todo list +2. Mark the completed task as \`completed\` using \`todowrite\` + +**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.** + +**STEP 5: EXECUTE QA TASKS (IF ANY)** + +If QA tasks exist in your todo list: +- Execute them BEFORE proceeding +- Mark each QA task complete after successful verification + +**STEP 6: PROCEED TO NEXT PENDING TASK** + +- Identify the next \`pending\` task from your todo list +- Start immediately - DO NOT STOP + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**NO TODO = NO TRACKING = INCOMPLETE WORK. Use todowrite aggressively.**` +} diff --git a/src/hooks/atlas/write-edit-tool-policy.ts b/src/hooks/atlas/write-edit-tool-policy.ts new file mode 100644 index 000000000..af75d2727 --- /dev/null +++ b/src/hooks/atlas/write-edit-tool-policy.ts @@ -0,0 +1,5 @@ +const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"] + +export function isWriteOrEditToolName(toolName: string): boolean { + return WRITE_EDIT_TOOLS.includes(toolName) +} diff --git a/src/hooks/auto-update-checker/checker.ts b/src/hooks/auto-update-checker/checker.ts index 975d73831..7f06e3626 100644 --- a/src/hooks/auto-update-checker/checker.ts +++ b/src/hooks/auto-update-checker/checker.ts @@ -1,298 +1,8 @@ -import * as fs from "node:fs" -import * as path from "node:path" -import { fileURLToPath } from "node:url" -import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types" -import { - PACKAGE_NAME, - NPM_REGISTRY_URL, - NPM_FETCH_TIMEOUT, - INSTALLED_PACKAGE_JSON, - USER_OPENCODE_CONFIG, - USER_OPENCODE_CONFIG_JSONC, - USER_CONFIG_DIR, - getWindowsAppdataDir, -} from "./constants" -import * as os from "node:os" -import { log } from "../../shared/logger" - -export function isLocalDevMode(directory: string): boolean { - return getLocalDevPath(directory) !== null -} - -function stripJsonComments(json: string): string { - return json - .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => (g ? "" : m)) - .replace(/,(\s*[}\]])/g, "$1") -} - -function getConfigPaths(directory: string): string[] { - const paths = [ - path.join(directory, ".opencode", "opencode.json"), - path.join(directory, ".opencode", "opencode.jsonc"), - USER_OPENCODE_CONFIG, - USER_OPENCODE_CONFIG_JSONC, - ] - - if (process.platform === "win32") { - const crossPlatformDir = path.join(os.homedir(), ".config") - const appdataDir = getWindowsAppdataDir() - - if (appdataDir) { - const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir - const alternateConfig = path.join(alternateDir, "opencode", "opencode.json") - const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc") - - if (!paths.includes(alternateConfig)) { - paths.push(alternateConfig) - } - if (!paths.includes(alternateConfigJsonc)) { - paths.push(alternateConfigJsonc) - } - } - } - - return paths -} - -export function getLocalDevPath(directory: string): string | null { - for (const configPath of getConfigPaths(directory)) { - try { - if (!fs.existsSync(configPath)) continue - const content = fs.readFileSync(configPath, "utf-8") - const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig - const plugins = config.plugin ?? [] - - for (const entry of plugins) { - if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) { - try { - return fileURLToPath(entry) - } catch { - return entry.replace("file://", "") - } - } - } - } catch { - continue - } - } - - return null -} - -function findPackageJsonUp(startPath: string): string | null { - try { - const stat = fs.statSync(startPath) - let dir = stat.isDirectory() ? startPath : path.dirname(startPath) - - for (let i = 0; i < 10; i++) { - const pkgPath = path.join(dir, "package.json") - if (fs.existsSync(pkgPath)) { - try { - const content = fs.readFileSync(pkgPath, "utf-8") - const pkg = JSON.parse(content) as PackageJson - if (pkg.name === PACKAGE_NAME) return pkgPath - } catch {} - } - const parent = path.dirname(dir) - if (parent === dir) break - dir = parent - } - } catch {} - return null -} - -export function getLocalDevVersion(directory: string): string | null { - const localPath = getLocalDevPath(directory) - if (!localPath) return null - - try { - const pkgPath = findPackageJsonUp(localPath) - if (!pkgPath) return null - const content = fs.readFileSync(pkgPath, "utf-8") - const pkg = JSON.parse(content) as PackageJson - return pkg.version ?? null - } catch { - return null - } -} - -export interface PluginEntryInfo { - entry: string - isPinned: boolean - pinnedVersion: string | null - configPath: string -} - -export function findPluginEntry(directory: string): PluginEntryInfo | null { - for (const configPath of getConfigPaths(directory)) { - try { - if (!fs.existsSync(configPath)) continue - const content = fs.readFileSync(configPath, "utf-8") - const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig - const plugins = config.plugin ?? [] - - for (const entry of plugins) { - if (entry === PACKAGE_NAME) { - return { entry, isPinned: false, pinnedVersion: null, configPath } - } - if (entry.startsWith(`${PACKAGE_NAME}@`)) { - const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1) - const isPinned = pinnedVersion !== "latest" - return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath } - } - } - } catch { - continue - } - } - - return null -} - -export function getCachedVersion(): string | null { - try { - if (fs.existsSync(INSTALLED_PACKAGE_JSON)) { - const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8") - const pkg = JSON.parse(content) as PackageJson - if (pkg.version) return pkg.version - } - } catch {} - - try { - const currentDir = path.dirname(fileURLToPath(import.meta.url)) - const pkgPath = findPackageJsonUp(currentDir) - if (pkgPath) { - const content = fs.readFileSync(pkgPath, "utf-8") - const pkg = JSON.parse(content) as PackageJson - if (pkg.version) return pkg.version - } - } catch (err) { - log("[auto-update-checker] Failed to resolve version from current directory:", err) - } - - // Fallback for compiled binaries (npm global install) - // process.execPath points to the actual binary location - try { - const execDir = path.dirname(fs.realpathSync(process.execPath)) - const pkgPath = findPackageJsonUp(execDir) - if (pkgPath) { - const content = fs.readFileSync(pkgPath, "utf-8") - const pkg = JSON.parse(content) as PackageJson - if (pkg.version) return pkg.version - } - } catch (err) { - log("[auto-update-checker] Failed to resolve version from execPath:", err) - } - - return null -} - -/** - * Updates a pinned version entry in the config file. - * Only replaces within the "plugin" array to avoid unintended edits. - * Preserves JSONC comments and formatting via string replacement. - */ -export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean { - try { - const content = fs.readFileSync(configPath, "utf-8") - const newEntry = `${PACKAGE_NAME}@${newVersion}` - - // Find the "plugin" array region to scope replacement - const pluginMatch = content.match(/"plugin"\s*:\s*\[/) - if (!pluginMatch || pluginMatch.index === undefined) { - log(`[auto-update-checker] No "plugin" array found in ${configPath}`) - return false - } - - // Find the closing bracket of the plugin array - const startIdx = pluginMatch.index + pluginMatch[0].length - let bracketCount = 1 - let endIdx = startIdx - - for (let i = startIdx; i < content.length && bracketCount > 0; i++) { - if (content[i] === "[") bracketCount++ - else if (content[i] === "]") bracketCount-- - endIdx = i - } - - const before = content.slice(0, startIdx) - const pluginArrayContent = content.slice(startIdx, endIdx) - const after = content.slice(endIdx) - - // Only replace first occurrence within plugin array - const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - const regex = new RegExp(`["']${escapedOldEntry}["']`) - - if (!regex.test(pluginArrayContent)) { - log(`[auto-update-checker] Entry "${oldEntry}" not found in plugin array of ${configPath}`) - return false - } - - const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`) - const updatedContent = before + updatedPluginArray + after - - if (updatedContent === content) { - log(`[auto-update-checker] No changes made to ${configPath}`) - return false - } - - fs.writeFileSync(configPath, updatedContent, "utf-8") - log(`[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`) - return true - } catch (err) { - log(`[auto-update-checker] Failed to update config file ${configPath}:`, err) - return false - } -} - -export async function getLatestVersion(channel: string = "latest"): Promise { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT) - - try { - const response = await fetch(NPM_REGISTRY_URL, { - signal: controller.signal, - headers: { Accept: "application/json" }, - }) - - if (!response.ok) return null - - const data = (await response.json()) as NpmDistTags - return data[channel] ?? data.latest ?? null - } catch { - return null - } finally { - clearTimeout(timeoutId) - } -} - -export async function checkForUpdate(directory: string): Promise { - if (isLocalDevMode(directory)) { - log("[auto-update-checker] Local dev mode detected, skipping update check") - return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true, isPinned: false } - } - - const pluginInfo = findPluginEntry(directory) - if (!pluginInfo) { - log("[auto-update-checker] Plugin not found in config") - return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false } - } - - const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion - if (!currentVersion) { - log("[auto-update-checker] No cached version found") - return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false } - } - - const { extractChannel } = await import("./index") - const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion) - const latestVersion = await getLatestVersion(channel) - if (!latestVersion) { - log("[auto-update-checker] Failed to fetch latest version for channel:", channel) - return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned } - } - - const needsUpdate = currentVersion !== latestVersion - log(`[auto-update-checker] Current: ${currentVersion}, Latest (${channel}): ${latestVersion}, NeedsUpdate: ${needsUpdate}`) - return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: pluginInfo.isPinned } -} +export { isLocalDevMode, getLocalDevPath } from "./checker/local-dev-path" +export { getLocalDevVersion } from "./checker/local-dev-version" +export { findPluginEntry } from "./checker/plugin-entry" +export type { PluginEntryInfo } from "./checker/plugin-entry" +export { getCachedVersion } from "./checker/cached-version" +export { updatePinnedVersion } from "./checker/pinned-version-updater" +export { getLatestVersion } from "./checker/latest-version" +export { checkForUpdate } from "./checker/check-for-update" diff --git a/src/hooks/auto-update-checker/checker/cached-version.ts b/src/hooks/auto-update-checker/checker/cached-version.ts new file mode 100644 index 000000000..15aef4eff --- /dev/null +++ b/src/hooks/auto-update-checker/checker/cached-version.ts @@ -0,0 +1,45 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { fileURLToPath } from "node:url" +import { log } from "../../../shared/logger" +import type { PackageJson } from "../types" +import { INSTALLED_PACKAGE_JSON } from "../constants" +import { findPackageJsonUp } from "./package-json-locator" + +export function getCachedVersion(): string | null { + try { + if (fs.existsSync(INSTALLED_PACKAGE_JSON)) { + const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8") + const pkg = JSON.parse(content) as PackageJson + if (pkg.version) return pkg.version + } + } catch { + // ignore + } + + try { + const currentDir = path.dirname(fileURLToPath(import.meta.url)) + const pkgPath = findPackageJsonUp(currentDir) + if (pkgPath) { + const content = fs.readFileSync(pkgPath, "utf-8") + const pkg = JSON.parse(content) as PackageJson + if (pkg.version) return pkg.version + } + } catch (err) { + log("[auto-update-checker] Failed to resolve version from current directory:", err) + } + + try { + const execDir = path.dirname(fs.realpathSync(process.execPath)) + const pkgPath = findPackageJsonUp(execDir) + if (pkgPath) { + const content = fs.readFileSync(pkgPath, "utf-8") + const pkg = JSON.parse(content) as PackageJson + if (pkg.version) return pkg.version + } + } catch (err) { + log("[auto-update-checker] Failed to resolve version from execPath:", err) + } + + return null +} diff --git a/src/hooks/auto-update-checker/checker/check-for-update.ts b/src/hooks/auto-update-checker/checker/check-for-update.ts new file mode 100644 index 000000000..e315eeed3 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/check-for-update.ts @@ -0,0 +1,69 @@ +import { log } from "../../../shared/logger" +import type { UpdateCheckResult } from "../types" +import { extractChannel } from "../version-channel" +import { isLocalDevMode } from "./local-dev-path" +import { findPluginEntry } from "./plugin-entry" +import { getCachedVersion } from "./cached-version" +import { getLatestVersion } from "./latest-version" + +export async function checkForUpdate(directory: string): Promise { + if (isLocalDevMode(directory)) { + log("[auto-update-checker] Local dev mode detected, skipping update check") + return { + needsUpdate: false, + currentVersion: null, + latestVersion: null, + isLocalDev: true, + isPinned: false, + } + } + + const pluginInfo = findPluginEntry(directory) + if (!pluginInfo) { + log("[auto-update-checker] Plugin not found in config") + return { + needsUpdate: false, + currentVersion: null, + latestVersion: null, + isLocalDev: false, + isPinned: false, + } + } + + const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion + if (!currentVersion) { + log("[auto-update-checker] No cached version found") + return { + needsUpdate: false, + currentVersion: null, + latestVersion: null, + isLocalDev: false, + isPinned: false, + } + } + + const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion) + const latestVersion = await getLatestVersion(channel) + if (!latestVersion) { + log("[auto-update-checker] Failed to fetch latest version for channel:", channel) + return { + needsUpdate: false, + currentVersion, + latestVersion: null, + isLocalDev: false, + isPinned: pluginInfo.isPinned, + } + } + + const needsUpdate = currentVersion !== latestVersion + log( + `[auto-update-checker] Current: ${currentVersion}, Latest (${channel}): ${latestVersion}, NeedsUpdate: ${needsUpdate}` + ) + return { + needsUpdate, + currentVersion, + latestVersion, + isLocalDev: false, + isPinned: pluginInfo.isPinned, + } +} diff --git a/src/hooks/auto-update-checker/checker/config-paths.ts b/src/hooks/auto-update-checker/checker/config-paths.ts new file mode 100644 index 000000000..998696b6d --- /dev/null +++ b/src/hooks/auto-update-checker/checker/config-paths.ts @@ -0,0 +1,37 @@ +import * as os from "node:os" +import * as path from "node:path" +import { + USER_CONFIG_DIR, + USER_OPENCODE_CONFIG, + USER_OPENCODE_CONFIG_JSONC, + getWindowsAppdataDir, +} from "../constants" + +export function getConfigPaths(directory: string): string[] { + const paths = [ + path.join(directory, ".opencode", "opencode.json"), + path.join(directory, ".opencode", "opencode.jsonc"), + USER_OPENCODE_CONFIG, + USER_OPENCODE_CONFIG_JSONC, + ] + + if (process.platform === "win32") { + const crossPlatformDir = path.join(os.homedir(), ".config") + const appdataDir = getWindowsAppdataDir() + + if (appdataDir) { + const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir + const alternateConfig = path.join(alternateDir, "opencode", "opencode.json") + const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc") + + if (!paths.includes(alternateConfig)) { + paths.push(alternateConfig) + } + if (!paths.includes(alternateConfigJsonc)) { + paths.push(alternateConfigJsonc) + } + } + } + + return paths +} diff --git a/src/hooks/auto-update-checker/checker/jsonc-strip.ts b/src/hooks/auto-update-checker/checker/jsonc-strip.ts new file mode 100644 index 000000000..02a340bb8 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/jsonc-strip.ts @@ -0,0 +1,7 @@ +export function stripJsonComments(json: string): string { + return json + .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (match, group) => + group ? "" : match + ) + .replace(/,(\s*[}\]])/g, "$1") +} diff --git a/src/hooks/auto-update-checker/checker/latest-version.ts b/src/hooks/auto-update-checker/checker/latest-version.ts new file mode 100644 index 000000000..aba7273b0 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/latest-version.ts @@ -0,0 +1,23 @@ +import { NPM_FETCH_TIMEOUT, NPM_REGISTRY_URL } from "../constants" +import type { NpmDistTags } from "../types" + +export async function getLatestVersion(channel: string = "latest"): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT) + + try { + const response = await fetch(NPM_REGISTRY_URL, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }) + + if (!response.ok) return null + + const data = (await response.json()) as NpmDistTags + return data[channel] ?? data.latest ?? null + } catch { + return null + } finally { + clearTimeout(timeoutId) + } +} diff --git a/src/hooks/auto-update-checker/checker/local-dev-path.ts b/src/hooks/auto-update-checker/checker/local-dev-path.ts new file mode 100644 index 000000000..5bf1e5ced --- /dev/null +++ b/src/hooks/auto-update-checker/checker/local-dev-path.ts @@ -0,0 +1,35 @@ +import * as fs from "node:fs" +import { fileURLToPath } from "node:url" +import type { OpencodeConfig } from "../types" +import { PACKAGE_NAME } from "../constants" +import { getConfigPaths } from "./config-paths" +import { stripJsonComments } from "./jsonc-strip" + +export function isLocalDevMode(directory: string): boolean { + return getLocalDevPath(directory) !== null +} + +export function getLocalDevPath(directory: string): string | null { + for (const configPath of getConfigPaths(directory)) { + try { + if (!fs.existsSync(configPath)) continue + const content = fs.readFileSync(configPath, "utf-8") + const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig + const plugins = config.plugin ?? [] + + for (const entry of plugins) { + if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) { + try { + return fileURLToPath(entry) + } catch { + return entry.replace("file://", "") + } + } + } + } catch { + continue + } + } + + return null +} diff --git a/src/hooks/auto-update-checker/checker/local-dev-version.ts b/src/hooks/auto-update-checker/checker/local-dev-version.ts new file mode 100644 index 000000000..b84c056b7 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/local-dev-version.ts @@ -0,0 +1,19 @@ +import * as fs from "node:fs" +import type { PackageJson } from "../types" +import { getLocalDevPath } from "./local-dev-path" +import { findPackageJsonUp } from "./package-json-locator" + +export function getLocalDevVersion(directory: string): string | null { + const localPath = getLocalDevPath(directory) + if (!localPath) return null + + try { + const pkgPath = findPackageJsonUp(localPath) + if (!pkgPath) return null + const content = fs.readFileSync(pkgPath, "utf-8") + const pkg = JSON.parse(content) as PackageJson + return pkg.version ?? null + } catch { + return null + } +} diff --git a/src/hooks/auto-update-checker/checker/package-json-locator.ts b/src/hooks/auto-update-checker/checker/package-json-locator.ts new file mode 100644 index 000000000..308cad163 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/package-json-locator.ts @@ -0,0 +1,30 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import type { PackageJson } from "../types" +import { PACKAGE_NAME } from "../constants" + +export function findPackageJsonUp(startPath: string): string | null { + try { + const stat = fs.statSync(startPath) + let dir = stat.isDirectory() ? startPath : path.dirname(startPath) + + for (let i = 0; i < 10; i++) { + const pkgPath = path.join(dir, "package.json") + if (fs.existsSync(pkgPath)) { + try { + const content = fs.readFileSync(pkgPath, "utf-8") + const pkg = JSON.parse(content) as PackageJson + if (pkg.name === PACKAGE_NAME) return pkgPath + } catch { + // ignore + } + } + const parent = path.dirname(dir) + if (parent === dir) break + dir = parent + } + } catch { + // ignore + } + return null +} diff --git a/src/hooks/auto-update-checker/checker/pinned-version-updater.ts b/src/hooks/auto-update-checker/checker/pinned-version-updater.ts new file mode 100644 index 000000000..688767ede --- /dev/null +++ b/src/hooks/auto-update-checker/checker/pinned-version-updater.ts @@ -0,0 +1,53 @@ +import * as fs from "node:fs" +import { log } from "../../../shared/logger" +import { PACKAGE_NAME } from "../constants" + +export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean { + try { + const content = fs.readFileSync(configPath, "utf-8") + const newEntry = `${PACKAGE_NAME}@${newVersion}` + + const pluginMatch = content.match(/"plugin"\s*:\s*\[/) + if (!pluginMatch || pluginMatch.index === undefined) { + log(`[auto-update-checker] No "plugin" array found in ${configPath}`) + return false + } + + const startIndex = pluginMatch.index + pluginMatch[0].length + let bracketCount = 1 + let endIndex = startIndex + + for (let i = startIndex; i < content.length && bracketCount > 0; i++) { + if (content[i] === "[") bracketCount++ + else if (content[i] === "]") bracketCount-- + endIndex = i + } + + const before = content.slice(0, startIndex) + const pluginArrayContent = content.slice(startIndex, endIndex) + const after = content.slice(endIndex) + + const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const regex = new RegExp(`["']${escapedOldEntry}["']`) + + if (!regex.test(pluginArrayContent)) { + log(`[auto-update-checker] Entry "${oldEntry}" not found in plugin array of ${configPath}`) + return false + } + + const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`) + const updatedContent = before + updatedPluginArray + after + + if (updatedContent === content) { + log(`[auto-update-checker] No changes made to ${configPath}`) + return false + } + + fs.writeFileSync(configPath, updatedContent, "utf-8") + log(`[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`) + return true + } catch (err) { + log(`[auto-update-checker] Failed to update config file ${configPath}:`, err) + return false + } +} diff --git a/src/hooks/auto-update-checker/checker/plugin-entry.ts b/src/hooks/auto-update-checker/checker/plugin-entry.ts new file mode 100644 index 000000000..eb9f198d7 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/plugin-entry.ts @@ -0,0 +1,38 @@ +import * as fs from "node:fs" +import type { OpencodeConfig } from "../types" +import { PACKAGE_NAME } from "../constants" +import { getConfigPaths } from "./config-paths" +import { stripJsonComments } from "./jsonc-strip" + +export interface PluginEntryInfo { + entry: string + isPinned: boolean + pinnedVersion: string | null + configPath: string +} + +export function findPluginEntry(directory: string): PluginEntryInfo | null { + for (const configPath of getConfigPaths(directory)) { + try { + if (!fs.existsSync(configPath)) continue + const content = fs.readFileSync(configPath, "utf-8") + const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig + const plugins = config.plugin ?? [] + + for (const entry of plugins) { + if (entry === PACKAGE_NAME) { + return { entry, isPinned: false, pinnedVersion: null, configPath } + } + if (entry.startsWith(`${PACKAGE_NAME}@`)) { + const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1) + const isPinned = pinnedVersion !== "latest" + return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath } + } + } + } catch { + continue + } + } + + return null +} diff --git a/src/hooks/auto-update-checker/hook.ts b/src/hooks/auto-update-checker/hook.ts new file mode 100644 index 000000000..64513b6fa --- /dev/null +++ b/src/hooks/auto-update-checker/hook.ts @@ -0,0 +1,64 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared/logger" +import { getCachedVersion, getLocalDevVersion } from "./checker" +import type { AutoUpdateCheckerOptions } from "./types" +import { runBackgroundUpdateCheck } from "./hook/background-update-check" +import { showConfigErrorsIfAny } from "./hook/config-errors-toast" +import { updateAndShowConnectedProvidersCacheStatus } from "./hook/connected-providers-status" +import { showModelCacheWarningIfNeeded } from "./hook/model-cache-warning" +import { showLocalDevToast, showVersionToast } from "./hook/startup-toasts" + +export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) { + const { showStartupToast = true, isSisyphusEnabled = false, autoUpdate = true } = options + + const getToastMessage = (isUpdate: boolean, latestVersion?: string): string => { + if (isSisyphusEnabled) { + return isUpdate + ? `Sisyphus on steroids is steering OpenCode.\nv${latestVersion} available. Restart to apply.` + : "Sisyphus on steroids is steering OpenCode." + } + return isUpdate + ? `OpenCode is now on Steroids. oMoMoMoMo...\nv${latestVersion} available. Restart OpenCode to apply.` + : "OpenCode is now on Steroids. oMoMoMoMo..." + } + + let hasChecked = false + + return { + event: ({ event }: { event: { type: string; properties?: unknown } }) => { + if (event.type !== "session.created") return + if (hasChecked) return + + const props = event.properties as { info?: { parentID?: string } } | undefined + if (props?.info?.parentID) return + + hasChecked = true + + setTimeout(async () => { + const cachedVersion = getCachedVersion() + const localDevVersion = getLocalDevVersion(ctx.directory) + const displayVersion = localDevVersion ?? cachedVersion + + await showConfigErrorsIfAny(ctx) + await showModelCacheWarningIfNeeded(ctx) + await updateAndShowConnectedProvidersCacheStatus(ctx) + + if (localDevVersion) { + if (showStartupToast) { + showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {}) + } + log("[auto-update-checker] Local development mode") + return + } + + if (showStartupToast) { + showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {}) + } + + runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch((err) => { + log("[auto-update-checker] Background update check failed:", err) + }) + }, 0) + }, + } +} diff --git a/src/hooks/auto-update-checker/hook/background-update-check.ts b/src/hooks/auto-update-checker/hook/background-update-check.ts new file mode 100644 index 000000000..908b56349 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/background-update-check.ts @@ -0,0 +1,79 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { runBunInstall } from "../../../cli/config-manager" +import { log } from "../../../shared/logger" +import { invalidatePackage } from "../cache" +import { PACKAGE_NAME } from "../constants" +import { extractChannel } from "../version-channel" +import { findPluginEntry, getCachedVersion, getLatestVersion, updatePinnedVersion } from "../checker" +import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts" + +async function runBunInstallSafe(): Promise { + try { + return await runBunInstall() + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + log("[auto-update-checker] bun install error:", errorMessage) + return false + } +} + +export async function runBackgroundUpdateCheck( + ctx: PluginInput, + autoUpdate: boolean, + getToastMessage: (isUpdate: boolean, latestVersion?: string) => string +): Promise { + const pluginInfo = findPluginEntry(ctx.directory) + if (!pluginInfo) { + log("[auto-update-checker] Plugin not found in config") + return + } + + const cachedVersion = getCachedVersion() + const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion + if (!currentVersion) { + log("[auto-update-checker] No version found (cached or pinned)") + return + } + + const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion) + const latestVersion = await getLatestVersion(channel) + if (!latestVersion) { + log("[auto-update-checker] Failed to fetch latest version for channel:", channel) + return + } + + if (currentVersion === latestVersion) { + log("[auto-update-checker] Already on latest version for channel:", channel) + return + } + + log(`[auto-update-checker] Update available (${channel}): ${currentVersion} → ${latestVersion}`) + + if (!autoUpdate) { + await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + log("[auto-update-checker] Auto-update disabled, notification only") + return + } + + if (pluginInfo.isPinned) { + const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion) + if (!updated) { + await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + log("[auto-update-checker] Failed to update pinned version in config") + return + } + log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`) + } + + invalidatePackage(PACKAGE_NAME) + + const installSuccess = await runBunInstallSafe() + + if (installSuccess) { + await showAutoUpdatedToast(ctx, currentVersion, latestVersion) + log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`) + } else { + await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)") + } +} diff --git a/src/hooks/auto-update-checker/hook/config-errors-toast.ts b/src/hooks/auto-update-checker/hook/config-errors-toast.ts new file mode 100644 index 000000000..b05605e9b --- /dev/null +++ b/src/hooks/auto-update-checker/hook/config-errors-toast.ts @@ -0,0 +1,23 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { getConfigLoadErrors, clearConfigLoadErrors } from "../../../shared/config-errors" +import { log } from "../../../shared/logger" + +export async function showConfigErrorsIfAny(ctx: PluginInput): Promise { + const errors = getConfigLoadErrors() + if (errors.length === 0) return + + const errorMessages = errors.map((error: { path: string; error: string }) => `${error.path}: ${error.error}`).join("\n") + await ctx.client.tui + .showToast({ + body: { + title: "Config Load Error", + message: `Failed to load config:\n${errorMessages}`, + variant: "error" as const, + duration: 10000, + }, + }) + .catch(() => {}) + + log(`[auto-update-checker] Config load errors shown: ${errors.length} error(s)`) + clearConfigLoadErrors() +} diff --git a/src/hooks/auto-update-checker/hook/connected-providers-status.ts b/src/hooks/auto-update-checker/hook/connected-providers-status.ts new file mode 100644 index 000000000..4eaf42250 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/connected-providers-status.ts @@ -0,0 +1,29 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { + hasConnectedProvidersCache, + updateConnectedProvidersCache, +} from "../../../shared/connected-providers-cache" +import { log } from "../../../shared/logger" + +export async function updateAndShowConnectedProvidersCacheStatus(ctx: PluginInput): Promise { + const hadCache = hasConnectedProvidersCache() + + updateConnectedProvidersCache(ctx.client).catch(() => {}) + + if (!hadCache) { + await ctx.client.tui + .showToast({ + body: { + title: "Connected Providers Cache", + message: "Building provider cache for first time. Restart OpenCode for full model filtering.", + variant: "info" as const, + duration: 8000, + }, + }) + .catch(() => {}) + + log("[auto-update-checker] Connected providers cache toast shown (first run)") + } else { + log("[auto-update-checker] Connected providers cache exists, updating in background") + } +} diff --git a/src/hooks/auto-update-checker/hook/model-cache-warning.ts b/src/hooks/auto-update-checker/hook/model-cache-warning.ts new file mode 100644 index 000000000..2c4a799d1 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/model-cache-warning.ts @@ -0,0 +1,21 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { isModelCacheAvailable } from "../../../shared/model-availability" +import { log } from "../../../shared/logger" + +export async function showModelCacheWarningIfNeeded(ctx: PluginInput): Promise { + if (isModelCacheAvailable()) return + + await ctx.client.tui + .showToast({ + body: { + title: "Model Cache Not Found", + message: + "Run 'opencode models --refresh' or restart OpenCode to populate the models cache for optimal agent model selection.", + variant: "warning" as const, + duration: 10000, + }, + }) + .catch(() => {}) + + log("[auto-update-checker] Model cache warning shown") +} diff --git a/src/hooks/auto-update-checker/hook/spinner-toast.ts b/src/hooks/auto-update-checker/hook/spinner-toast.ts new file mode 100644 index 000000000..21506aab4 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/spinner-toast.ts @@ -0,0 +1,25 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +const SISYPHUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "] + +export async function showSpinnerToast(ctx: PluginInput, version: string, message: string): Promise { + const totalDuration = 5000 + const frameInterval = 100 + const totalFrames = Math.floor(totalDuration / frameInterval) + + for (let i = 0; i < totalFrames; i++) { + const spinner = SISYPHUS_SPINNER[i % SISYPHUS_SPINNER.length] + await ctx.client.tui + .showToast({ + body: { + title: `${spinner} OhMyOpenCode ${version}`, + message, + variant: "info" as const, + duration: frameInterval + 50, + }, + }) + .catch(() => {}) + + await new Promise((resolve) => setTimeout(resolve, frameInterval)) + } +} diff --git a/src/hooks/auto-update-checker/hook/startup-toasts.ts b/src/hooks/auto-update-checker/hook/startup-toasts.ts new file mode 100644 index 000000000..5d3c77e56 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/startup-toasts.ts @@ -0,0 +1,22 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../../shared/logger" +import { showSpinnerToast } from "./spinner-toast" + +export async function showVersionToast(ctx: PluginInput, version: string | null, message: string): Promise { + const displayVersion = version ?? "unknown" + await showSpinnerToast(ctx, displayVersion, message) + log(`[auto-update-checker] Startup toast shown: v${displayVersion}`) +} + +export async function showLocalDevToast( + ctx: PluginInput, + version: string | null, + isSisyphusEnabled: boolean +): Promise { + const displayVersion = version ?? "dev" + const message = isSisyphusEnabled + ? "Sisyphus running in local development mode." + : "Running in local development mode. oMoMoMo..." + await showSpinnerToast(ctx, `${displayVersion} (dev)`, message) + log(`[auto-update-checker] Local dev toast shown: v${displayVersion}`) +} diff --git a/src/hooks/auto-update-checker/hook/update-toasts.ts b/src/hooks/auto-update-checker/hook/update-toasts.ts new file mode 100644 index 000000000..462bd0af8 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/update-toasts.ts @@ -0,0 +1,34 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../../shared/logger" + +export async function showUpdateAvailableToast( + ctx: PluginInput, + latestVersion: string, + getToastMessage: (isUpdate: boolean, latestVersion?: string) => string +): Promise { + await ctx.client.tui + .showToast({ + body: { + title: `OhMyOpenCode ${latestVersion}`, + message: getToastMessage(true, latestVersion), + variant: "info" as const, + duration: 8000, + }, + }) + .catch(() => {}) + log(`[auto-update-checker] Update available toast shown: v${latestVersion}`) +} + +export async function showAutoUpdatedToast(ctx: PluginInput, oldVersion: string, newVersion: string): Promise { + await ctx.client.tui + .showToast({ + body: { + title: "OhMyOpenCode Updated!", + message: `v${oldVersion} → v${newVersion}\nRestart OpenCode to apply.`, + variant: "success" as const, + duration: 8000, + }, + }) + .catch(() => {}) + log(`[auto-update-checker] Auto-updated toast shown: v${oldVersion} → v${newVersion}`) +} diff --git a/src/hooks/auto-update-checker/index.ts b/src/hooks/auto-update-checker/index.ts index 5222ca2f9..4032ab185 100644 --- a/src/hooks/auto-update-checker/index.ts +++ b/src/hooks/auto-update-checker/index.ts @@ -1,304 +1,12 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { getCachedVersion, getLocalDevVersion, findPluginEntry, getLatestVersion, updatePinnedVersion } from "./checker" -import { invalidatePackage } from "./cache" -import { PACKAGE_NAME } from "./constants" -import { log } from "../../shared/logger" -import { getConfigLoadErrors, clearConfigLoadErrors } from "../../shared/config-errors" -import { runBunInstall } from "../../cli/config-manager" -import { isModelCacheAvailable } from "../../shared/model-availability" -import { hasConnectedProvidersCache, updateConnectedProvidersCache } from "../../shared/connected-providers-cache" -import type { AutoUpdateCheckerOptions } from "./types" +export { createAutoUpdateCheckerHook } from "./hook" -const SISYPHUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "] +export { + isPrereleaseVersion, + isDistTag, + isPrereleaseOrDistTag, + extractChannel, +} from "./version-channel" -export function isPrereleaseVersion(version: string): boolean { - return version.includes("-") -} - -export function isDistTag(version: string): boolean { - const startsWithDigit = /^\d/.test(version) - return !startsWithDigit -} - -export function isPrereleaseOrDistTag(pinnedVersion: string | null): boolean { - if (!pinnedVersion) return false - return isPrereleaseVersion(pinnedVersion) || isDistTag(pinnedVersion) -} - -export function extractChannel(version: string | null): string { - if (!version) return "latest" - - if (isDistTag(version)) { - return version - } - - if (isPrereleaseVersion(version)) { - const prereleasePart = version.split("-")[1] - if (prereleasePart) { - const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/) - if (channelMatch) { - return channelMatch[1] - } - } - } - - return "latest" -} - -export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) { - const { showStartupToast = true, isSisyphusEnabled = false, autoUpdate = true } = options - - const getToastMessage = (isUpdate: boolean, latestVersion?: string): string => { - if (isSisyphusEnabled) { - return isUpdate - ? `Sisyphus on steroids is steering OpenCode.\nv${latestVersion} available. Restart to apply.` - : `Sisyphus on steroids is steering OpenCode.` - } - return isUpdate - ? `OpenCode is now on Steroids. oMoMoMoMo...\nv${latestVersion} available. Restart OpenCode to apply.` - : `OpenCode is now on Steroids. oMoMoMoMo...` - } - - let hasChecked = false - - return { - event: ({ event }: { event: { type: string; properties?: unknown } }) => { - if (event.type !== "session.created") return - if (hasChecked) return - - const props = event.properties as { info?: { parentID?: string } } | undefined - if (props?.info?.parentID) return - - hasChecked = true - - setTimeout(async () => { - const cachedVersion = getCachedVersion() - const localDevVersion = getLocalDevVersion(ctx.directory) - const displayVersion = localDevVersion ?? cachedVersion - - await showConfigErrorsIfAny(ctx) - await showModelCacheWarningIfNeeded(ctx) - await updateAndShowConnectedProvidersCacheStatus(ctx) - - if (localDevVersion) { - if (showStartupToast) { - showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {}) - } - log("[auto-update-checker] Local development mode") - return - } - - if (showStartupToast) { - showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {}) - } - - runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch(err => { - log("[auto-update-checker] Background update check failed:", err) - }) - }, 0) - }, - } -} - -async function runBackgroundUpdateCheck( - ctx: PluginInput, - autoUpdate: boolean, - getToastMessage: (isUpdate: boolean, latestVersion?: string) => string -): Promise { - const pluginInfo = findPluginEntry(ctx.directory) - if (!pluginInfo) { - log("[auto-update-checker] Plugin not found in config") - return - } - - const cachedVersion = getCachedVersion() - const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion - if (!currentVersion) { - log("[auto-update-checker] No version found (cached or pinned)") - return - } - - const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion) - const latestVersion = await getLatestVersion(channel) - if (!latestVersion) { - log("[auto-update-checker] Failed to fetch latest version for channel:", channel) - return - } - - if (currentVersion === latestVersion) { - log("[auto-update-checker] Already on latest version for channel:", channel) - return - } - - log(`[auto-update-checker] Update available (${channel}): ${currentVersion} → ${latestVersion}`) - - if (!autoUpdate) { - await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) - log("[auto-update-checker] Auto-update disabled, notification only") - return - } - - if (pluginInfo.isPinned) { - const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion) - if (!updated) { - await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) - log("[auto-update-checker] Failed to update pinned version in config") - return - } - log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`) - } - - invalidatePackage(PACKAGE_NAME) - - const installSuccess = await runBunInstallSafe() - - if (installSuccess) { - await showAutoUpdatedToast(ctx, currentVersion, latestVersion) - log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`) - } else { - await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) - log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)") - } -} - -async function runBunInstallSafe(): Promise { - try { - return await runBunInstall() - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - log("[auto-update-checker] bun install error:", errorMessage) - return false - } -} - -async function showModelCacheWarningIfNeeded(ctx: PluginInput): Promise { - if (isModelCacheAvailable()) return - - await ctx.client.tui - .showToast({ - body: { - title: "Model Cache Not Found", - message: "Run 'opencode models --refresh' or restart OpenCode to populate the models cache for optimal agent model selection.", - variant: "warning" as const, - duration: 10000, - }, - }) - .catch(() => {}) - - log("[auto-update-checker] Model cache warning shown") -} - -async function updateAndShowConnectedProvidersCacheStatus(ctx: PluginInput): Promise { - const hadCache = hasConnectedProvidersCache() - - updateConnectedProvidersCache(ctx.client).catch(() => {}) - - if (!hadCache) { - await ctx.client.tui - .showToast({ - body: { - title: "Connected Providers Cache", - message: "Building provider cache for first time. Restart OpenCode for full model filtering.", - variant: "info" as const, - duration: 8000, - }, - }) - .catch(() => {}) - - log("[auto-update-checker] Connected providers cache toast shown (first run)") - } else { - log("[auto-update-checker] Connected providers cache exists, updating in background") - } -} - -async function showConfigErrorsIfAny(ctx: PluginInput): Promise { - const errors = getConfigLoadErrors() - if (errors.length === 0) return - - const errorMessages = errors.map(e => `${e.path}: ${e.error}`).join("\n") - await ctx.client.tui - .showToast({ - body: { - title: "Config Load Error", - message: `Failed to load config:\n${errorMessages}`, - variant: "error" as const, - duration: 10000, - }, - }) - .catch(() => {}) - - log(`[auto-update-checker] Config load errors shown: ${errors.length} error(s)`) - clearConfigLoadErrors() -} - -async function showVersionToast(ctx: PluginInput, version: string | null, message: string): Promise { - const displayVersion = version ?? "unknown" - await showSpinnerToast(ctx, displayVersion, message) - log(`[auto-update-checker] Startup toast shown: v${displayVersion}`) -} - -async function showSpinnerToast(ctx: PluginInput, version: string, message: string): Promise { - const totalDuration = 5000 - const frameInterval = 100 - const totalFrames = Math.floor(totalDuration / frameInterval) - - for (let i = 0; i < totalFrames; i++) { - const spinner = SISYPHUS_SPINNER[i % SISYPHUS_SPINNER.length] - await ctx.client.tui - .showToast({ - body: { - title: `${spinner} OhMyOpenCode ${version}`, - message, - variant: "info" as const, - duration: frameInterval + 50, - }, - }) - .catch(() => { }) - await new Promise(resolve => setTimeout(resolve, frameInterval)) - } -} - -async function showUpdateAvailableToast( - ctx: PluginInput, - latestVersion: string, - getToastMessage: (isUpdate: boolean, latestVersion?: string) => string -): Promise { - await ctx.client.tui - .showToast({ - body: { - title: `OhMyOpenCode ${latestVersion}`, - message: getToastMessage(true, latestVersion), - variant: "info" as const, - duration: 8000, - }, - }) - .catch(() => {}) - log(`[auto-update-checker] Update available toast shown: v${latestVersion}`) -} - -async function showAutoUpdatedToast(ctx: PluginInput, oldVersion: string, newVersion: string): Promise { - await ctx.client.tui - .showToast({ - body: { - title: `OhMyOpenCode Updated!`, - message: `v${oldVersion} → v${newVersion}\nRestart OpenCode to apply.`, - variant: "success" as const, - duration: 8000, - }, - }) - .catch(() => {}) - log(`[auto-update-checker] Auto-updated toast shown: v${oldVersion} → v${newVersion}`) -} - -async function showLocalDevToast(ctx: PluginInput, version: string | null, isSisyphusEnabled: boolean): Promise { - const displayVersion = version ?? "dev" - const message = isSisyphusEnabled - ? "Sisyphus running in local development mode." - : "Running in local development mode. oMoMoMo..." - await showSpinnerToast(ctx, `${displayVersion} (dev)`, message) - log(`[auto-update-checker] Local dev toast shown: v${displayVersion}`) -} - -export type { UpdateCheckResult, AutoUpdateCheckerOptions } from "./types" export { checkForUpdate } from "./checker" export { invalidatePackage, invalidateCache } from "./cache" +export type { UpdateCheckResult, AutoUpdateCheckerOptions } from "./types" diff --git a/src/hooks/auto-update-checker/version-channel.ts b/src/hooks/auto-update-checker/version-channel.ts new file mode 100644 index 000000000..80b3c8eb9 --- /dev/null +++ b/src/hooks/auto-update-checker/version-channel.ts @@ -0,0 +1,33 @@ +export function isPrereleaseVersion(version: string): boolean { + return version.includes("-") +} + +export function isDistTag(version: string): boolean { + const startsWithDigit = /^\d/.test(version) + return !startsWithDigit +} + +export function isPrereleaseOrDistTag(pinnedVersion: string | null): boolean { + if (!pinnedVersion) return false + return isPrereleaseVersion(pinnedVersion) || isDistTag(pinnedVersion) +} + +export function extractChannel(version: string | null): string { + if (!version) return "latest" + + if (isDistTag(version)) { + return version + } + + if (isPrereleaseVersion(version)) { + const prereleasePart = version.split("-")[1] + if (prereleasePart) { + const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/) + if (channelMatch) { + return channelMatch[1] + } + } + } + + return "latest" +} diff --git a/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts b/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts new file mode 100644 index 000000000..de8bc9c47 --- /dev/null +++ b/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts @@ -0,0 +1,421 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { loadClaudeHooksConfig } from "./config" +import { loadPluginExtendedConfig } from "./config-loader" +import { + executePreToolUseHooks, + type PreToolUseContext, +} from "./pre-tool-use" +import { + executePostToolUseHooks, + type PostToolUseContext, + type PostToolUseClient, +} from "./post-tool-use" +import { + executeUserPromptSubmitHooks, + type UserPromptSubmitContext, + type MessagePart, +} from "./user-prompt-submit" +import { + executeStopHooks, + type StopContext, +} from "./stop" +import { + executePreCompactHooks, + type PreCompactContext, +} from "./pre-compact" +import { cacheToolInput, getToolInput } from "./tool-input-cache" +import { appendTranscriptEntry, getTranscriptPath } from "./transcript" +import type { PluginConfig } from "./types" +import { log, isHookDisabled } from "../../shared" +import type { ContextCollector } from "../../features/context-injector" + +const sessionFirstMessageProcessed = new Set() +const sessionErrorState = new Map() +const sessionInterruptState = new Map() + +export function createClaudeCodeHooksHook( + ctx: PluginInput, + config: PluginConfig = {}, + contextCollector?: ContextCollector +) { + return { + "experimental.session.compacting": async ( + input: { sessionID: string }, + output: { context: string[] } + ): Promise => { + if (isHookDisabled(config, "PreCompact")) { + return + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const preCompactCtx: PreCompactContext = { + sessionId: input.sessionID, + cwd: ctx.directory, + } + + const result = await executePreCompactHooks(preCompactCtx, claudeConfig, extendedConfig) + + if (result.context.length > 0) { + log("PreCompact hooks injecting context", { + sessionID: input.sessionID, + contextCount: result.context.length, + hookName: result.hookName, + elapsedMs: result.elapsedMs, + }) + output.context.push(...result.context) + } + }, + + "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 interruptState = sessionInterruptState.get(input.sessionID) + if (interruptState?.interrupted) { + log("chat.message hook skipped - session interrupted", { sessionID: input.sessionID }) + return + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const textParts = output.parts.filter((p) => p.type === "text" && p.text) + const prompt = textParts.map((p) => p.text ?? "").join("\n") + + appendTranscriptEntry(input.sessionID, { + type: "user", + timestamp: new Date().toISOString(), + content: prompt, + }) + + const messageParts: MessagePart[] = textParts.map((p) => ({ + type: p.type as "text", + text: p.text, + })) + + const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID) + if (interruptStateBeforeHooks?.interrupted) { + log("chat.message hooks skipped - interrupted during preparation", { sessionID: input.sessionID }) + return + } + + let parentSessionId: string | undefined + try { + const sessionInfo = await ctx.client.session.get({ + path: { id: input.sessionID }, + }) + parentSessionId = sessionInfo.data?.parentID + } catch {} + + const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID) + sessionFirstMessageProcessed.add(input.sessionID) + + if (!isHookDisabled(config, "UserPromptSubmit")) { + const userPromptCtx: UserPromptSubmitContext = { + sessionId: input.sessionID, + parentSessionId, + prompt, + parts: messageParts, + cwd: ctx.directory, + } + + const result = await executeUserPromptSubmitHooks( + userPromptCtx, + claudeConfig, + extendedConfig + ) + + if (result.block) { + throw new Error(result.reason ?? "Hook blocked the prompt") + } + + const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID) + if (interruptStateAfterHooks?.interrupted) { + log("chat.message injection skipped - interrupted during hooks", { sessionID: input.sessionID }) + return + } + + if (result.messages.length > 0) { + const hookContent = result.messages.join("\n\n") + log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage }) + + if (contextCollector) { + log("[DEBUG] Registering hook content to contextCollector", { + sessionID: input.sessionID, + contentLength: hookContent.length, + contentPreview: hookContent.slice(0, 100), + }) + contextCollector.register(input.sessionID, { + id: "hook-context", + source: "custom", + content: hookContent, + priority: "high", + }) + + log("Hook content registered for synthetic message injection", { + sessionID: input.sessionID, + contentLength: hookContent.length, + }) + } + } + } + }, + + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record } + ): Promise => { + if (input.tool.trim() === "todowrite" && typeof output.args.todos === "string") { + let parsed: unknown + try { + parsed = JSON.parse(output.args.todos) + } catch (e) { + throw new Error( + `[todowrite ERROR] Failed to parse todos string as JSON. ` + + `Received: ${output.args.todos.length > 100 ? output.args.todos.slice(0, 100) + '...' : output.args.todos} ` + + `Expected: Valid JSON array. Pass todos as an array, not a string.` + ) + } + + if (!Array.isArray(parsed)) { + throw new Error( + `[todowrite ERROR] Parsed JSON is not an array. ` + + `Received type: ${typeof parsed}. ` + + `Expected: Array of todo objects. Pass todos as [{id, content, status, priority}, ...].` + ) + } + + output.args.todos = parsed + log("todowrite: parsed todos string to array", { sessionID: input.sessionID }) + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + appendTranscriptEntry(input.sessionID, { + type: "tool_use", + timestamp: new Date().toISOString(), + tool_name: input.tool, + tool_input: output.args as Record, + }) + + cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record) + + if (!isHookDisabled(config, "PreToolUse")) { + const preCtx: PreToolUseContext = { + sessionId: input.sessionID, + toolName: input.tool, + toolInput: output.args as Record, + cwd: ctx.directory, + toolUseId: input.callID, + } + + const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig) + + if (result.decision === "deny") { + ctx.client.tui + .showToast({ + body: { + title: "PreToolUse Hook Executed", + message: `[BLOCKED] ${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`, + variant: "error" as const, + duration: 4000, + }, + }) + .catch(() => {}) + throw new Error(result.reason ?? "Hook blocked the operation") + } + + if (result.modifiedInput) { + Object.assign(output.args as Record, result.modifiedInput) + } + } + }, + + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown } + ): Promise => { + // Guard against undefined output (e.g., from /review command - see issue #1035) + if (!output) { + return + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {} + + // Use metadata if available and non-empty, otherwise wrap output.output in a structured object + // This ensures plugin tools (call_omo_agent, task) that return strings + // get their results properly recorded in transcripts instead of empty {} + const metadata = output.metadata as Record | undefined + const hasMetadata = metadata && typeof metadata === "object" && Object.keys(metadata).length > 0 + const toolOutput = hasMetadata ? metadata : { output: output.output } + appendTranscriptEntry(input.sessionID, { + type: "tool_result", + timestamp: new Date().toISOString(), + tool_name: input.tool, + tool_input: cachedInput, + tool_output: toolOutput, + }) + + if (!isHookDisabled(config, "PostToolUse")) { + const postClient: PostToolUseClient = { + session: { + messages: (opts) => ctx.client.session.messages(opts), + }, + } + + const postCtx: PostToolUseContext = { + sessionId: input.sessionID, + toolName: input.tool, + toolInput: cachedInput, + toolOutput: { + title: input.tool, + output: output.output, + metadata: output.metadata as Record, + }, + cwd: ctx.directory, + transcriptPath: getTranscriptPath(input.sessionID), + toolUseId: input.callID, + client: postClient, + permissionMode: "bypassPermissions", + } + + const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig) + + if (result.block) { + ctx.client.tui + .showToast({ + body: { + title: "PostToolUse Hook Warning", + message: result.reason ?? "Hook returned warning", + variant: "warning", + duration: 4000, + }, + }) + .catch(() => {}) + } + + if (result.warnings && result.warnings.length > 0) { + output.output = `${output.output}\n\n${result.warnings.join("\n")}` + } + + if (result.message) { + output.output = `${output.output}\n\n${result.message}` + } + + if (result.hookName) { + ctx.client.tui + .showToast({ + body: { + title: "PostToolUse Hook Executed", + message: `▶ ${result.toolName ?? input.tool} ${result.hookName}: ${result.elapsedMs ?? 0}ms`, + variant: "success", + duration: 2000, + }, + }) + .catch(() => {}) + } + } + }, + + event: async (input: { event: { type: string; properties?: unknown } }) => { + const { event } = input + + if (event.type === "session.error") { + const props = event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + if (sessionID) { + sessionErrorState.set(sessionID, { + hasError: true, + errorMessage: String(props?.error ?? "Unknown error"), + }) + } + return + } + + if (event.type === "session.deleted") { + const props = event.properties as Record | undefined + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + sessionErrorState.delete(sessionInfo.id) + sessionInterruptState.delete(sessionInfo.id) + sessionFirstMessageProcessed.delete(sessionInfo.id) + } + return + } + + if (event.type === "session.idle") { + const props = event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + + if (!sessionID) return + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const errorStateBefore = sessionErrorState.get(sessionID) + const endedWithErrorBefore = errorStateBefore?.hasError === true + const interruptStateBefore = sessionInterruptState.get(sessionID) + const interruptedBefore = interruptStateBefore?.interrupted === true + + let parentSessionId: string | undefined + try { + const sessionInfo = await ctx.client.session.get({ + path: { id: sessionID }, + }) + parentSessionId = sessionInfo.data?.parentID + } catch {} + + if (!isHookDisabled(config, "Stop")) { + const stopCtx: StopContext = { + sessionId: sessionID, + parentSessionId, + cwd: ctx.directory, + } + + const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig) + + const errorStateAfter = sessionErrorState.get(sessionID) + const endedWithErrorAfter = errorStateAfter?.hasError === true + const interruptStateAfter = sessionInterruptState.get(sessionID) + const interruptedAfter = interruptStateAfter?.interrupted === true + + const shouldBypass = endedWithErrorBefore || endedWithErrorAfter || interruptedBefore || interruptedAfter + + if (shouldBypass && stopResult.block) { + const interrupted = interruptedBefore || interruptedAfter + const endedWithError = endedWithErrorBefore || endedWithErrorAfter + log("Stop hook block ignored", { sessionID, block: stopResult.block, interrupted, endedWithError }) + } else if (stopResult.block && stopResult.injectPrompt) { + log("Stop hook returned block with inject_prompt", { sessionID }) + ctx.client.session + .prompt({ + path: { id: sessionID }, + body: { parts: [{ type: "text", text: stopResult.injectPrompt }] }, + query: { directory: ctx.directory }, + }) + .catch((err: unknown) => log("Failed to inject prompt from Stop hook", err)) + } else if (stopResult.block) { + log("Stop hook returned block", { sessionID, reason: stopResult.reason }) + } + } + + sessionErrorState.delete(sessionID) + sessionInterruptState.delete(sessionID) + } + }, + } +} diff --git a/src/hooks/claude-code-hooks/index.ts b/src/hooks/claude-code-hooks/index.ts index 9555ea797..a532c8ee1 100644 --- a/src/hooks/claude-code-hooks/index.ts +++ b/src/hooks/claude-code-hooks/index.ts @@ -1,421 +1 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { loadClaudeHooksConfig } from "./config" -import { loadPluginExtendedConfig } from "./config-loader" -import { - executePreToolUseHooks, - type PreToolUseContext, -} from "./pre-tool-use" -import { - executePostToolUseHooks, - type PostToolUseContext, - type PostToolUseClient, -} from "./post-tool-use" -import { - executeUserPromptSubmitHooks, - type UserPromptSubmitContext, - type MessagePart, -} from "./user-prompt-submit" -import { - executeStopHooks, - type StopContext, -} from "./stop" -import { - executePreCompactHooks, - type PreCompactContext, -} from "./pre-compact" -import { cacheToolInput, getToolInput } from "./tool-input-cache" -import { appendTranscriptEntry, getTranscriptPath } from "./transcript" -import type { PluginConfig } from "./types" -import { log, isHookDisabled } from "../../shared" -import type { ContextCollector } from "../../features/context-injector" - -const sessionFirstMessageProcessed = new Set() -const sessionErrorState = new Map() -const sessionInterruptState = new Map() - -export function createClaudeCodeHooksHook( - ctx: PluginInput, - config: PluginConfig = {}, - contextCollector?: ContextCollector -) { - return { - "experimental.session.compacting": async ( - input: { sessionID: string }, - output: { context: string[] } - ): Promise => { - if (isHookDisabled(config, "PreCompact")) { - return - } - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const preCompactCtx: PreCompactContext = { - sessionId: input.sessionID, - cwd: ctx.directory, - } - - const result = await executePreCompactHooks(preCompactCtx, claudeConfig, extendedConfig) - - if (result.context.length > 0) { - log("PreCompact hooks injecting context", { - sessionID: input.sessionID, - contextCount: result.context.length, - hookName: result.hookName, - elapsedMs: result.elapsedMs, - }) - output.context.push(...result.context) - } - }, - - "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 interruptState = sessionInterruptState.get(input.sessionID) - if (interruptState?.interrupted) { - log("chat.message hook skipped - session interrupted", { sessionID: input.sessionID }) - return - } - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const textParts = output.parts.filter((p) => p.type === "text" && p.text) - const prompt = textParts.map((p) => p.text ?? "").join("\n") - - appendTranscriptEntry(input.sessionID, { - type: "user", - timestamp: new Date().toISOString(), - content: prompt, - }) - - const messageParts: MessagePart[] = textParts.map((p) => ({ - type: p.type as "text", - text: p.text, - })) - - const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID) - if (interruptStateBeforeHooks?.interrupted) { - log("chat.message hooks skipped - interrupted during preparation", { sessionID: input.sessionID }) - return - } - - let parentSessionId: string | undefined - try { - const sessionInfo = await ctx.client.session.get({ - path: { id: input.sessionID }, - }) - parentSessionId = sessionInfo.data?.parentID - } catch {} - - const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID) - sessionFirstMessageProcessed.add(input.sessionID) - - if (!isHookDisabled(config, "UserPromptSubmit")) { - const userPromptCtx: UserPromptSubmitContext = { - sessionId: input.sessionID, - parentSessionId, - prompt, - parts: messageParts, - cwd: ctx.directory, - } - - const result = await executeUserPromptSubmitHooks( - userPromptCtx, - claudeConfig, - extendedConfig - ) - - if (result.block) { - throw new Error(result.reason ?? "Hook blocked the prompt") - } - - const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID) - if (interruptStateAfterHooks?.interrupted) { - log("chat.message injection skipped - interrupted during hooks", { sessionID: input.sessionID }) - return - } - - if (result.messages.length > 0) { - const hookContent = result.messages.join("\n\n") - log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage }) - - if (contextCollector) { - log("[DEBUG] Registering hook content to contextCollector", { - sessionID: input.sessionID, - contentLength: hookContent.length, - contentPreview: hookContent.slice(0, 100), - }) - contextCollector.register(input.sessionID, { - id: "hook-context", - source: "custom", - content: hookContent, - priority: "high", - }) - - log("Hook content registered for synthetic message injection", { - sessionID: input.sessionID, - contentLength: hookContent.length, - }) - } - } - } - }, - - "tool.execute.before": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: Record } - ): Promise => { - if (input.tool.trim() === "todowrite" && typeof output.args.todos === "string") { - let parsed: unknown - try { - parsed = JSON.parse(output.args.todos) - } catch (e) { - throw new Error( - `[todowrite ERROR] Failed to parse todos string as JSON. ` + - `Received: ${output.args.todos.length > 100 ? output.args.todos.slice(0, 100) + '...' : output.args.todos} ` + - `Expected: Valid JSON array. Pass todos as an array, not a string.` - ) - } - - if (!Array.isArray(parsed)) { - throw new Error( - `[todowrite ERROR] Parsed JSON is not an array. ` + - `Received type: ${typeof parsed}. ` + - `Expected: Array of todo objects. Pass todos as [{id, content, status, priority}, ...].` - ) - } - - output.args.todos = parsed - log("todowrite: parsed todos string to array", { sessionID: input.sessionID }) - } - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - appendTranscriptEntry(input.sessionID, { - type: "tool_use", - timestamp: new Date().toISOString(), - tool_name: input.tool, - tool_input: output.args as Record, - }) - - cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record) - - if (!isHookDisabled(config, "PreToolUse")) { - const preCtx: PreToolUseContext = { - sessionId: input.sessionID, - toolName: input.tool, - toolInput: output.args as Record, - cwd: ctx.directory, - toolUseId: input.callID, - } - - const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig) - - if (result.decision === "deny") { - ctx.client.tui - .showToast({ - body: { - title: "PreToolUse Hook Executed", - message: `[BLOCKED] ${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`, - variant: "error", - duration: 4000, - }, - }) - .catch(() => {}) - throw new Error(result.reason ?? "Hook blocked the operation") - } - - if (result.modifiedInput) { - Object.assign(output.args as Record, result.modifiedInput) - } - } - }, - - "tool.execute.after": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { title: string; output: string; metadata: unknown } - ): Promise => { - // Guard against undefined output (e.g., from /review command - see issue #1035) - if (!output) { - return - } - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {} - - // Use metadata if available and non-empty, otherwise wrap output.output in a structured object - // This ensures plugin tools (call_omo_agent, task) that return strings - // get their results properly recorded in transcripts instead of empty {} - const metadata = output.metadata as Record | undefined - const hasMetadata = metadata && typeof metadata === "object" && Object.keys(metadata).length > 0 - const toolOutput = hasMetadata ? metadata : { output: output.output } - appendTranscriptEntry(input.sessionID, { - type: "tool_result", - timestamp: new Date().toISOString(), - tool_name: input.tool, - tool_input: cachedInput, - tool_output: toolOutput, - }) - - if (!isHookDisabled(config, "PostToolUse")) { - const postClient: PostToolUseClient = { - session: { - messages: (opts) => ctx.client.session.messages(opts), - }, - } - - const postCtx: PostToolUseContext = { - sessionId: input.sessionID, - toolName: input.tool, - toolInput: cachedInput, - toolOutput: { - title: input.tool, - output: output.output, - metadata: output.metadata as Record, - }, - cwd: ctx.directory, - transcriptPath: getTranscriptPath(input.sessionID), - toolUseId: input.callID, - client: postClient, - permissionMode: "bypassPermissions", - } - - const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig) - - if (result.block) { - ctx.client.tui - .showToast({ - body: { - title: "PostToolUse Hook Warning", - message: result.reason ?? "Hook returned warning", - variant: "warning", - duration: 4000, - }, - }) - .catch(() => {}) - } - - if (result.warnings && result.warnings.length > 0) { - output.output = `${output.output}\n\n${result.warnings.join("\n")}` - } - - if (result.message) { - output.output = `${output.output}\n\n${result.message}` - } - - if (result.hookName) { - ctx.client.tui - .showToast({ - body: { - title: "PostToolUse Hook Executed", - message: `▶ ${result.toolName ?? input.tool} ${result.hookName}: ${result.elapsedMs ?? 0}ms`, - variant: "success", - duration: 2000, - }, - }) - .catch(() => {}) - } - } - }, - - event: async (input: { event: { type: string; properties?: unknown } }) => { - const { event } = input - - if (event.type === "session.error") { - const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined - if (sessionID) { - sessionErrorState.set(sessionID, { - hasError: true, - errorMessage: String(props?.error ?? "Unknown error"), - }) - } - return - } - - if (event.type === "session.deleted") { - const props = event.properties as Record | undefined - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - sessionErrorState.delete(sessionInfo.id) - sessionInterruptState.delete(sessionInfo.id) - sessionFirstMessageProcessed.delete(sessionInfo.id) - } - return - } - - if (event.type === "session.idle") { - const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined - - if (!sessionID) return - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const errorStateBefore = sessionErrorState.get(sessionID) - const endedWithErrorBefore = errorStateBefore?.hasError === true - const interruptStateBefore = sessionInterruptState.get(sessionID) - const interruptedBefore = interruptStateBefore?.interrupted === true - - let parentSessionId: string | undefined - try { - const sessionInfo = await ctx.client.session.get({ - path: { id: sessionID }, - }) - parentSessionId = sessionInfo.data?.parentID - } catch {} - - if (!isHookDisabled(config, "Stop")) { - const stopCtx: StopContext = { - sessionId: sessionID, - parentSessionId, - cwd: ctx.directory, - } - - const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig) - - const errorStateAfter = sessionErrorState.get(sessionID) - const endedWithErrorAfter = errorStateAfter?.hasError === true - const interruptStateAfter = sessionInterruptState.get(sessionID) - const interruptedAfter = interruptStateAfter?.interrupted === true - - const shouldBypass = endedWithErrorBefore || endedWithErrorAfter || interruptedBefore || interruptedAfter - - if (shouldBypass && stopResult.block) { - const interrupted = interruptedBefore || interruptedAfter - const endedWithError = endedWithErrorBefore || endedWithErrorAfter - log("Stop hook block ignored", { sessionID, block: stopResult.block, interrupted, endedWithError }) - } else if (stopResult.block && stopResult.injectPrompt) { - log("Stop hook returned block with inject_prompt", { sessionID }) - ctx.client.session - .prompt({ - path: { id: sessionID }, - body: { parts: [{ type: "text", text: stopResult.injectPrompt }] }, - query: { directory: ctx.directory }, - }) - .catch((err: unknown) => log("Failed to inject prompt from Stop hook", err)) - } else if (stopResult.block) { - log("Stop hook returned block", { sessionID, reason: stopResult.reason }) - } - } - - sessionErrorState.delete(sessionID) - sessionInterruptState.delete(sessionID) - } - }, - } -} +export { createClaudeCodeHooksHook } from "./claude-code-hooks-hook" diff --git a/src/hooks/interactive-bash-session/index.ts b/src/hooks/interactive-bash-session/index.ts index 307441629..b9be8e12b 100644 --- a/src/hooks/interactive-bash-session/index.ts +++ b/src/hooks/interactive-bash-session/index.ts @@ -1,267 +1 @@ -import type { PluginInput } from "@opencode-ai/plugin"; -import { - loadInteractiveBashSessionState, - saveInteractiveBashSessionState, - clearInteractiveBashSessionState, -} from "./storage"; -import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants"; -import type { InteractiveBashSessionState } from "./types"; -import { subagentSessions } from "../../features/claude-code-session-state"; - -interface ToolExecuteInput { - tool: string; - sessionID: string; - callID: string; - args?: Record; -} - -interface ToolExecuteOutput { - title: string; - output: string; - metadata: unknown; -} - -interface EventInput { - event: { - type: string; - properties?: unknown; - }; -} - -/** - * Quote-aware command tokenizer with escape handling - * Handles single/double quotes and backslash escapes - */ -function tokenizeCommand(cmd: string): string[] { - const tokens: string[] = [] - let current = "" - let inQuote = false - let quoteChar = "" - let escaped = false - - for (let i = 0; i < cmd.length; i++) { - const char = cmd[i] - - if (escaped) { - current += char - escaped = false - continue - } - - if (char === "\\") { - escaped = true - continue - } - - if ((char === "'" || char === '"') && !inQuote) { - inQuote = true - quoteChar = char - } else if (char === quoteChar && inQuote) { - inQuote = false - quoteChar = "" - } else if (char === " " && !inQuote) { - if (current) { - tokens.push(current) - current = "" - } - } else { - current += char - } - } - - if (current) tokens.push(current) - return tokens -} - -/** - * Normalize session name by stripping :window and .pane suffixes - * e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x" - */ -function normalizeSessionName(name: string): string { - return name.split(":")[0].split(".")[0] -} - -function findFlagValue(tokens: string[], flag: string): string | null { - for (let i = 0; i < tokens.length - 1; i++) { - if (tokens[i] === flag) return tokens[i + 1] - } - return null -} - -/** - * Extract session name from tokens, considering the subCommand - * For new-session: prioritize -s over -t - * For other commands: use -t - */ -function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null { - if (subCommand === "new-session") { - const sFlag = findFlagValue(tokens, "-s") - if (sFlag) return normalizeSessionName(sFlag) - const tFlag = findFlagValue(tokens, "-t") - if (tFlag) return normalizeSessionName(tFlag) - } else { - const tFlag = findFlagValue(tokens, "-t") - if (tFlag) return normalizeSessionName(tFlag) - } - return null -} - -/** - * Find the tmux subcommand from tokens, skipping global options. - * tmux allows global options before the subcommand: - * e.g., `tmux -L socket-name new-session -s omo-x` - * Global options with args: -L, -S, -f, -c, -T - * Standalone flags: -C, -v, -V, etc. - * Special: -- (end of options marker) - */ -function findSubcommand(tokens: string[]): string { - // Options that require an argument: -L, -S, -f, -c, -T - const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"]) - - let i = 0 - while (i < tokens.length) { - const token = tokens[i] - - // Handle end of options marker - if (token === "--") { - // Next token is the subcommand - return tokens[i + 1] ?? "" - } - - if (globalOptionsWithArgs.has(token)) { - // Skip the option and its argument - i += 2 - continue - } - - if (token.startsWith("-")) { - // Skip standalone flags like -C, -v, -V - i++ - continue - } - - // Found the subcommand - return token - } - - return "" -} - -export function createInteractiveBashSessionHook(ctx: PluginInput) { - const sessionStates = new Map(); - - function getOrCreateState(sessionID: string): InteractiveBashSessionState { - if (!sessionStates.has(sessionID)) { - const persisted = loadInteractiveBashSessionState(sessionID); - const state: InteractiveBashSessionState = persisted ?? { - sessionID, - tmuxSessions: new Set(), - updatedAt: Date.now(), - }; - sessionStates.set(sessionID, state); - } - return sessionStates.get(sessionID)!; - } - - function isOmoSession(sessionName: string | null): boolean { - return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX); - } - - async function killAllTrackedSessions( - state: InteractiveBashSessionState, - ): Promise { - for (const sessionName of state.tmuxSessions) { - try { - const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { - stdout: "ignore", - stderr: "ignore", - }); - await proc.exited; - } catch {} - } - - for (const sessionId of subagentSessions) { - ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {}) - } - } - - const toolExecuteAfter = async ( - input: ToolExecuteInput, - output: ToolExecuteOutput, - ) => { - const { tool, sessionID, args } = input; - const toolLower = tool.toLowerCase(); - - if (toolLower !== "interactive_bash") { - return; - } - - if (typeof args?.tmux_command !== "string") { - return; - } - - const tmuxCommand = args.tmux_command; - const tokens = tokenizeCommand(tmuxCommand); - const subCommand = findSubcommand(tokens); - const state = getOrCreateState(sessionID); - let stateChanged = false; - - const toolOutput = output?.output ?? "" - if (toolOutput.startsWith("Error:")) { - return - } - - const isNewSession = subCommand === "new-session"; - const isKillSession = subCommand === "kill-session"; - const isKillServer = subCommand === "kill-server"; - - const sessionName = extractSessionNameFromTokens(tokens, subCommand); - - if (isNewSession && isOmoSession(sessionName)) { - state.tmuxSessions.add(sessionName!); - stateChanged = true; - } else if (isKillSession && isOmoSession(sessionName)) { - state.tmuxSessions.delete(sessionName!); - stateChanged = true; - } else if (isKillServer) { - state.tmuxSessions.clear(); - stateChanged = true; - } - - if (stateChanged) { - state.updatedAt = Date.now(); - saveInteractiveBashSessionState(state); - } - - const isSessionOperation = isNewSession || isKillSession || isKillServer; - if (isSessionOperation) { - const reminder = buildSessionReminderMessage( - Array.from(state.tmuxSessions), - ); - if (reminder) { - output.output += reminder; - } - } - }; - - 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; - const sessionID = sessionInfo?.id; - - if (sessionID) { - const state = getOrCreateState(sessionID); - await killAllTrackedSessions(state); - sessionStates.delete(sessionID); - clearInteractiveBashSessionState(sessionID); - } - } - }; - - return { - "tool.execute.after": toolExecuteAfter, - event: eventHandler, - }; -} +export { createInteractiveBashSessionHook } from "./interactive-bash-session-hook" diff --git a/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts b/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts new file mode 100644 index 000000000..dd0a87002 --- /dev/null +++ b/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts @@ -0,0 +1,267 @@ +import type { PluginInput } from "@opencode-ai/plugin"; +import { + loadInteractiveBashSessionState, + saveInteractiveBashSessionState, + clearInteractiveBashSessionState, +} from "./storage"; +import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants"; +import type { InteractiveBashSessionState } from "./types"; +import { subagentSessions } from "../../features/claude-code-session-state"; + +interface ToolExecuteInput { + tool: string; + sessionID: string; + callID: string; + args?: Record; +} + +interface ToolExecuteOutput { + title: string; + output: string; + metadata: unknown; +} + +interface EventInput { + event: { + type: string; + properties?: unknown; + }; +} + +/** + * Quote-aware command tokenizer with escape handling + * Handles single/double quotes and backslash escapes + */ +function tokenizeCommand(cmd: string): string[] { + const tokens: string[] = [] + let current = "" + let inQuote = false + let quoteChar = "" + let escaped = false + + for (let i = 0; i < cmd.length; i++) { + const char = cmd[i] + + if (escaped) { + current += char + escaped = false + continue + } + + if (char === "\\") { + escaped = true + continue + } + + if ((char === "'" || char === '"') && !inQuote) { + inQuote = true + quoteChar = char + } else if (char === quoteChar && inQuote) { + inQuote = false + quoteChar = "" + } else if (char === " " && !inQuote) { + if (current) { + tokens.push(current) + current = "" + } + } else { + current += char + } + } + + if (current) tokens.push(current) + return tokens +} + +/** + * Normalize session name by stripping :window and .pane suffixes + * e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x" + */ +function normalizeSessionName(name: string): string { + return name.split(":")[0].split(".")[0] +} + +function findFlagValue(tokens: string[], flag: string): string | null { + for (let i = 0; i < tokens.length - 1; i++) { + if (tokens[i] === flag) return tokens[i + 1] + } + return null +} + +/** + * Extract session name from tokens, considering the subCommand + * For new-session: prioritize -s over -t + * For other commands: use -t + */ +function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null { + if (subCommand === "new-session") { + const sFlag = findFlagValue(tokens, "-s") + if (sFlag) return normalizeSessionName(sFlag) + const tFlag = findFlagValue(tokens, "-t") + if (tFlag) return normalizeSessionName(tFlag) + } else { + const tFlag = findFlagValue(tokens, "-t") + if (tFlag) return normalizeSessionName(tFlag) + } + return null +} + +/** + * Find the tmux subcommand from tokens, skipping global options. + * tmux allows global options before the subcommand: + * e.g., `tmux -L socket-name new-session -s omo-x` + * Global options with args: -L, -S, -f, -c, -T + * Standalone flags: -C, -v, -V, etc. + * Special: -- (end of options marker) + */ +function findSubcommand(tokens: string[]): string { + // Options that require an argument: -L, -S, -f, -c, -T + const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"]) + + let i = 0 + while (i < tokens.length) { + const token = tokens[i] + + // Handle end of options marker + if (token === "--") { + // Next token is the subcommand + return tokens[i + 1] ?? "" + } + + if (globalOptionsWithArgs.has(token)) { + // Skip the option and its argument + i += 2 + continue + } + + if (token.startsWith("-")) { + // Skip standalone flags like -C, -v, -V + i++ + continue + } + + // Found the subcommand + return token + } + + return "" +} + +export function createInteractiveBashSessionHook(ctx: PluginInput) { + const sessionStates = new Map(); + + function getOrCreateState(sessionID: string): InteractiveBashSessionState { + if (!sessionStates.has(sessionID)) { + const persisted = loadInteractiveBashSessionState(sessionID); + const state: InteractiveBashSessionState = persisted ?? { + sessionID, + tmuxSessions: new Set(), + updatedAt: Date.now(), + }; + sessionStates.set(sessionID, state); + } + return sessionStates.get(sessionID)!; + } + + function isOmoSession(sessionName: string | null): boolean { + return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX); + } + + async function killAllTrackedSessions( + state: InteractiveBashSessionState, + ): Promise { + for (const sessionName of state.tmuxSessions) { + try { + const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { + stdout: "ignore", + stderr: "ignore", + }); + await proc.exited; + } catch {} + } + + for (const sessionId of subagentSessions) { + ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {}) + } + } + + const toolExecuteAfter = async ( + input: ToolExecuteInput, + output: ToolExecuteOutput, + ) => { + const { tool, sessionID, args } = input; + const toolLower = tool.toLowerCase(); + + if (toolLower !== "interactive_bash") { + return; + } + + if (typeof args?.tmux_command !== "string") { + return; + } + + const tmuxCommand = args.tmux_command; + const tokens = tokenizeCommand(tmuxCommand); + const subCommand = findSubcommand(tokens); + const state = getOrCreateState(sessionID); + let stateChanged = false; + + const toolOutput = output?.output ?? "" + if (toolOutput.startsWith("Error:")) { + return + } + + const isNewSession = subCommand === "new-session"; + const isKillSession = subCommand === "kill-session"; + const isKillServer = subCommand === "kill-server"; + + const sessionName = extractSessionNameFromTokens(tokens, subCommand); + + if (isNewSession && isOmoSession(sessionName)) { + state.tmuxSessions.add(sessionName!); + stateChanged = true; + } else if (isKillSession && isOmoSession(sessionName)) { + state.tmuxSessions.delete(sessionName!); + stateChanged = true; + } else if (isKillServer) { + state.tmuxSessions.clear(); + stateChanged = true; + } + + if (stateChanged) { + state.updatedAt = Date.now(); + saveInteractiveBashSessionState(state); + } + + const isSessionOperation = isNewSession || isKillSession || isKillServer; + if (isSessionOperation) { + const reminder = buildSessionReminderMessage( + Array.from(state.tmuxSessions), + ); + if (reminder) { + output.output += reminder; + } + } + }; + + 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; + const sessionID = sessionInfo?.id; + + if (sessionID) { + const state = getOrCreateState(sessionID); + await killAllTrackedSessions(state); + sessionStates.delete(sessionID); + clearInteractiveBashSessionState(sessionID); + } + } + }; + + return { + "tool.execute.after": toolExecuteAfter, + event: eventHandler, + }; +} diff --git a/src/hooks/non-interactive-env/index.ts b/src/hooks/non-interactive-env/index.ts index 0acfaadcc..a5411ad55 100644 --- a/src/hooks/non-interactive-env/index.ts +++ b/src/hooks/non-interactive-env/index.ts @@ -1,66 +1,5 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants" -import { log, buildEnvPrefix } from "../../shared" - export * from "./constants" export * from "./detector" export * from "./types" -const BANNED_COMMAND_PATTERNS = SHELL_COMMAND_PATTERNS.banned - .filter((cmd) => !cmd.includes("(")) - .map((cmd) => new RegExp(`\\b${cmd}\\b`)) - -function detectBannedCommand(command: string): string | undefined { - for (let i = 0; i < BANNED_COMMAND_PATTERNS.length; i++) { - if (BANNED_COMMAND_PATTERNS[i].test(command)) { - return SHELL_COMMAND_PATTERNS.banned[i] - } - } - return undefined -} - -export function createNonInteractiveEnvHook(_ctx: PluginInput) { - return { - "tool.execute.before": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: Record; message?: string } - ): Promise => { - if (input.tool.toLowerCase() !== "bash") { - return - } - - const command = output.args.command as string | undefined - if (!command) { - return - } - - const bannedCmd = detectBannedCommand(command) - if (bannedCmd) { - output.message = `Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.` - } - - // Only prepend env vars for git commands (editor blocking, pager, etc.) - const isGitCommand = /\bgit\b/.test(command) - if (!isGitCommand) { - return - } - - // NOTE: We intentionally removed the isNonInteractive() check here. - // Even when OpenCode runs in a TTY, the agent cannot interact with - // spawned bash processes. Git commands like `git rebase --continue` - // would open editors (vim/nvim) that hang forever. - // The env vars (GIT_EDITOR=:, EDITOR=:, etc.) must ALWAYS be injected - // for git commands to prevent interactive prompts. - - // The bash tool always runs in a Unix-like shell (bash/sh), even on Windows - // (via Git Bash, WSL, etc.), so always use unix export syntax. - const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, "unix") - output.args.command = `${envPrefix} ${command}` - - log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, { - sessionID: input.sessionID, - envPrefix, - }) - }, - } -} +export { createNonInteractiveEnvHook } from "./non-interactive-env-hook" diff --git a/src/hooks/non-interactive-env/non-interactive-env-hook.ts b/src/hooks/non-interactive-env/non-interactive-env-hook.ts new file mode 100644 index 000000000..90686f64d --- /dev/null +++ b/src/hooks/non-interactive-env/non-interactive-env-hook.ts @@ -0,0 +1,66 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants" +import { log, buildEnvPrefix } from "../../shared" + +export * from "./constants" +export * from "./detector" +export * from "./types" + +const BANNED_COMMAND_PATTERNS = SHELL_COMMAND_PATTERNS.banned + .filter((command) => !command.includes("(")) + .map((cmd) => new RegExp(`\\b${cmd}\\b`)) + +function detectBannedCommand(command: string): string | undefined { + for (let i = 0; i < BANNED_COMMAND_PATTERNS.length; i++) { + if (BANNED_COMMAND_PATTERNS[i].test(command)) { + return SHELL_COMMAND_PATTERNS.banned[i] + } + } + return undefined +} + +export function createNonInteractiveEnvHook(_ctx: PluginInput) { + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record; message?: string } + ): Promise => { + if (input.tool.toLowerCase() !== "bash") { + return + } + + const command = output.args.command as string | undefined + if (!command) { + return + } + + const bannedCmd = detectBannedCommand(command) + if (bannedCmd) { + output.message = `Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.` + } + + // Only prepend env vars for git commands (editor blocking, pager, etc.) + const isGitCommand = /\bgit\b/.test(command) + if (!isGitCommand) { + return + } + + // NOTE: We intentionally removed the isNonInteractive() check here. + // Even when OpenCode runs in a TTY, the agent cannot interact with + // spawned bash processes. Git commands like `git rebase --continue` + // would open editors (vim/nvim) that hang forever. + // The env vars (GIT_EDITOR=:, EDITOR=:, etc.) must ALWAYS be injected + // for git commands to prevent interactive prompts. + + // The bash tool always runs in a Unix-like shell (bash/sh), even on Windows + // (via Git Bash, WSL, etc.), so always use unix export syntax. + const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, "unix") + output.args.command = `${envPrefix} ${command}` + + log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, { + sessionID: input.sessionID, + envPrefix, + }) + }, + } +} diff --git a/src/hooks/ralph-loop/index.ts b/src/hooks/ralph-loop/index.ts index 3cc77edd2..f85290677 100644 --- a/src/hooks/ralph-loop/index.ts +++ b/src/hooks/ralph-loop/index.ts @@ -1,428 +1,6 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { existsSync, readFileSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { log } from "../../shared/logger" -import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" -import { readState, writeState, clearState, incrementIteration } from "./storage" -import { - HOOK_NAME, - DEFAULT_MAX_ITERATIONS, - DEFAULT_COMPLETION_PROMISE, -} from "./constants" -import type { RalphLoopState, RalphLoopOptions } from "./types" -import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" - -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 -} - export * from "./types" export * from "./constants" export { readState, writeState, clearState, incrementIteration } from "./storage" -interface SessionState { - isRecovering?: boolean -} - -interface OpenCodeSessionMessage { - info?: { - role?: string - } - parts?: Array<{ - type: string - text?: string - [key: string]: unknown - }> -} - -const CONTINUATION_PROMPT = `${SYSTEM_DIRECTIVE_PREFIX} - RALPH LOOP {{ITERATION}}/{{MAX}}] - -Your previous attempt did not output the completion promise. Continue working on the task. - -IMPORTANT: -- Review your progress so far -- Continue from where you left off -- When FULLY complete, output: {{PROMISE}} -- Do not stop until the task is truly done - -Original task: -{{PROMPT}}` - -export interface RalphLoopHook { - event: (input: { event: { type: string; properties?: unknown } }) => Promise - startLoop: ( - sessionID: string, - prompt: string, - options?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean } - ) => boolean - cancelLoop: (sessionID: string) => boolean - getState: () => RalphLoopState | null -} - -const DEFAULT_API_TIMEOUT = 3000 - -export function createRalphLoopHook( - ctx: PluginInput, - options?: RalphLoopOptions -): RalphLoopHook { - const sessions = new Map() - const config = options?.config - const stateDir = config?.state_dir - const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath - const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT - const checkSessionExists = options?.checkSessionExists - - function getSessionState(sessionID: string): SessionState { - let state = sessions.get(sessionID) - if (!state) { - state = {} - sessions.set(sessionID, state) - } - return state - } - - function detectCompletionPromise( - transcriptPath: string | undefined, - promise: string - ): boolean { - if (!transcriptPath) return false - - try { - if (!existsSync(transcriptPath)) return false - - const content = readFileSync(transcriptPath, "utf-8") - const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") - const lines = content.split("\n").filter(l => l.trim()) - - for (const line of lines) { - try { - const entry = JSON.parse(line) - if (entry.type === "user") continue - if (pattern.test(line)) return true - } catch { - continue - } - } - return false - } catch { - return false - } - } - - function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - } - - async function detectCompletionInSessionMessages( - sessionID: string, - promise: string - ): Promise { - try { - const response = await Promise.race([ - ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("API timeout")), apiTimeout) - ), - ]) - - const messages = (response as { data?: unknown[] }).data ?? [] - if (!Array.isArray(messages)) return false - - const assistantMessages = (messages as OpenCodeSessionMessage[]).filter( - (msg) => msg.info?.role === "assistant" - ) - const lastAssistant = assistantMessages[assistantMessages.length - 1] - if (!lastAssistant?.parts) return false - - const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") - const responseText = lastAssistant.parts - .filter((p) => p.type === "text") - .map((p) => p.text ?? "") - .join("\n") - - return pattern.test(responseText) - } catch (err) { - log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) }) - return false - } - } - - const startLoop = ( - sessionID: string, - prompt: string, - loopOptions?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean } - ): boolean => { - const state: RalphLoopState = { - active: true, - iteration: 1, - max_iterations: - loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS, - completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE, - ultrawork: loopOptions?.ultrawork, - started_at: new Date().toISOString(), - prompt, - session_id: sessionID, - } - - const success = writeState(ctx.directory, state, stateDir) - if (success) { - log(`[${HOOK_NAME}] Loop started`, { - sessionID, - maxIterations: state.max_iterations, - completionPromise: state.completion_promise, - }) - } - return success - } - - const cancelLoop = (sessionID: string): boolean => { - const state = readState(ctx.directory, stateDir) - if (!state || state.session_id !== sessionID) { - return false - } - - const success = clearState(ctx.directory, stateDir) - if (success) { - log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration }) - } - return success - } - - const getState = (): RalphLoopState | null => { - return readState(ctx.directory, stateDir) - } - - const event = async ({ - event, - }: { - event: { type: string; properties?: unknown } - }): Promise => { - const props = event.properties as Record | undefined - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const sessionState = getSessionState(sessionID) - if (sessionState.isRecovering) { - log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) - return - } - - const state = readState(ctx.directory, stateDir) - if (!state || !state.active) { - return - } - - if (state.session_id && state.session_id !== sessionID) { - if (checkSessionExists) { - try { - const originalSessionExists = await checkSessionExists(state.session_id) - if (!originalSessionExists) { - clearState(ctx.directory, stateDir) - log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, { - orphanedSessionId: state.session_id, - currentSessionId: sessionID, - }) - return - } - } catch (err) { - log(`[${HOOK_NAME}] Failed to check session existence`, { - sessionId: state.session_id, - error: String(err), - }) - } - } - return - } - - const transcriptPath = getTranscriptPath(sessionID) - const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise) - - const completionDetectedViaApi = completionDetectedViaTranscript - ? false - : await detectCompletionInSessionMessages(sessionID, state.completion_promise) - - if (completionDetectedViaTranscript || completionDetectedViaApi) { - log(`[${HOOK_NAME}] Completion detected!`, { - sessionID, - iteration: state.iteration, - promise: state.completion_promise, - detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api", - }) - clearState(ctx.directory, stateDir) - - const title = state.ultrawork - ? "ULTRAWORK LOOP COMPLETE!" - : "Ralph Loop Complete!" - const message = state.ultrawork - ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` - : `Task completed after ${state.iteration} iteration(s)` - - await ctx.client.tui - .showToast({ - body: { - title, - message, - variant: "success", - duration: 5000, - }, - }) - .catch(() => {}) - - return - } - - if (state.iteration >= state.max_iterations) { - log(`[${HOOK_NAME}] Max iterations reached`, { - sessionID, - iteration: state.iteration, - max: state.max_iterations, - }) - clearState(ctx.directory, stateDir) - - await ctx.client.tui - .showToast({ - body: { - title: "Ralph Loop Stopped", - message: `Max iterations (${state.max_iterations}) reached without completion`, - variant: "warning", - duration: 5000, - }, - }) - .catch(() => {}) - - return - } - - const newState = incrementIteration(ctx.directory, stateDir) - if (!newState) { - log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID }) - return - } - - log(`[${HOOK_NAME}] Continuing loop`, { - sessionID, - iteration: newState.iteration, - max: newState.max_iterations, - }) - - const continuationPrompt = CONTINUATION_PROMPT.replace("{{ITERATION}}", String(newState.iteration)) - .replace("{{MAX}}", String(newState.max_iterations)) - .replace("{{PROMISE}}", newState.completion_promise) - .replace("{{PROMPT}}", newState.prompt) - - const finalPrompt = newState.ultrawork - ? `ultrawork ${continuationPrompt}` - : continuationPrompt - - await ctx.client.tui - .showToast({ - body: { - title: "Ralph Loop", - message: `Iteration ${newState.iteration}/${newState.max_iterations}`, - variant: "info", - duration: 2000, - }, - }) - .catch(() => {}) - - try { - let agent: string | undefined - let model: { providerID: string; modelID: string } | undefined - - try { - const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - agent = info.agent - model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) - break - } - } - } catch { - const messageDir = getMessageDir(sessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agent = currentMessage?.agent - model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - } - - await ctx.client.session.promptAsync({ - path: { id: sessionID }, - body: { - ...(agent !== undefined ? { agent } : {}), - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: finalPrompt }], - }, - query: { directory: ctx.directory }, - }) - } catch (err) { - log(`[${HOOK_NAME}] Failed to inject continuation`, { - sessionID, - error: String(err), - }) - } - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - const state = readState(ctx.directory, stateDir) - if (state?.session_id === sessionInfo.id) { - clearState(ctx.directory, stateDir) - log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id }) - } - sessions.delete(sessionInfo.id) - } - } - - if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined - const error = props?.error as { name?: string } | undefined - - if (error?.name === "MessageAbortedError") { - if (sessionID) { - const state = readState(ctx.directory, stateDir) - if (state?.session_id === sessionID) { - clearState(ctx.directory, stateDir) - log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID }) - } - sessions.delete(sessionID) - } - return - } - - if (sessionID) { - const sessionState = getSessionState(sessionID) - sessionState.isRecovering = true - setTimeout(() => { - sessionState.isRecovering = false - }, 5000) - } - } - } - - return { - event, - startLoop, - cancelLoop, - getState, - } -} +export { createRalphLoopHook } from "./ralph-loop-hook" +export type { RalphLoopHook } from "./ralph-loop-hook" diff --git a/src/hooks/ralph-loop/ralph-loop-hook.ts b/src/hooks/ralph-loop/ralph-loop-hook.ts new file mode 100644 index 000000000..6be3a5e8e --- /dev/null +++ b/src/hooks/ralph-loop/ralph-loop-hook.ts @@ -0,0 +1,428 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { existsSync, readFileSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { log } from "../../shared/logger" +import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" +import { readState, writeState, clearState, incrementIteration } from "./storage" +import { + HOOK_NAME, + DEFAULT_MAX_ITERATIONS, + DEFAULT_COMPLETION_PROMISE, +} from "./constants" +import type { RalphLoopState, RalphLoopOptions } from "./types" +import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript" +import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" + +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 +} + +export * from "./types" +export * from "./constants" +export { readState, writeState, clearState, incrementIteration } from "./storage" + +interface SessionState { + isRecovering?: boolean +} + +interface OpenCodeSessionMessage { + info?: { + role?: string + } + parts?: Array<{ + type: string + text?: string + [key: string]: unknown + }> +} + +const CONTINUATION_PROMPT = `${SYSTEM_DIRECTIVE_PREFIX} - RALPH LOOP {{ITERATION}}/{{MAX}}] + +Your previous attempt did not output the completion promise. Continue working on the task. + +IMPORTANT: +- Review your progress so far +- Continue from where you left off +- When FULLY complete, output: {{PROMISE}} +- Do not stop until the task is truly done + +Original task: +{{PROMPT}}` + +export interface RalphLoopHook { + event: (input: { event: { type: string; properties?: unknown } }) => Promise + startLoop: ( + sessionID: string, + prompt: string, + options?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean } + ) => boolean + cancelLoop: (sessionID: string) => boolean + getState: () => RalphLoopState | null +} + +const DEFAULT_API_TIMEOUT = 3000 as const + +export function createRalphLoopHook( + ctx: PluginInput, + options?: RalphLoopOptions +): RalphLoopHook { + const sessions = new Map() + const config = options?.config + const stateDir = config?.state_dir + const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath + const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT + const checkSessionExists = options?.checkSessionExists + + function getSessionState(sessionID: string): SessionState { + let state = sessions.get(sessionID) + if (!state) { + state = {} + sessions.set(sessionID, state) + } + return state + } + + function detectCompletionPromise( + transcriptPath: string | undefined, + promise: string + ): boolean { + if (!transcriptPath) return false + + try { + if (!existsSync(transcriptPath)) return false + + const content = readFileSync(transcriptPath, "utf-8") + const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") + const lines = content.split("\n").filter(l => l.trim()) + + for (const line of lines) { + try { + const entry = JSON.parse(line) + if (entry.type === "user") continue + if (pattern.test(line)) return true + } catch { + continue + } + } + return false + } catch { + return false + } + } + + function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + } + + async function detectCompletionInSessionMessages( + sessionID: string, + promise: string + ): Promise { + try { + const response = await Promise.race([ + ctx.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("API timeout")), apiTimeout) + ), + ]) + + const messages = (response as { data?: unknown[] }).data ?? [] + if (!Array.isArray(messages)) return false + + const assistantMessages = (messages as OpenCodeSessionMessage[]).filter( + (msg) => msg.info?.role === "assistant" + ) + const lastAssistant = assistantMessages[assistantMessages.length - 1] + if (!lastAssistant?.parts) return false + + const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") + const responseText = lastAssistant.parts + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join("\n") + + return pattern.test(responseText) + } catch (err) { + log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) }) + return false + } + } + + const startLoop = ( + sessionID: string, + prompt: string, + loopOptions?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean } + ): boolean => { + const state: RalphLoopState = { + active: true, + iteration: 1, + max_iterations: + loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS, + completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE, + ultrawork: loopOptions?.ultrawork, + started_at: new Date().toISOString(), + prompt, + session_id: sessionID, + } + + const success = writeState(ctx.directory, state, stateDir) + if (success) { + log(`[${HOOK_NAME}] Loop started`, { + sessionID, + maxIterations: state.max_iterations, + completionPromise: state.completion_promise, + }) + } + return success + } + + const cancelLoop = (sessionID: string): boolean => { + const state = readState(ctx.directory, stateDir) + if (!state || state.session_id !== sessionID) { + return false + } + + const success = clearState(ctx.directory, stateDir) + if (success) { + log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration }) + } + return success + } + + const getState = (): RalphLoopState | null => { + return readState(ctx.directory, stateDir) + } + + const event = async ({ + event, + }: { + event: { type: string; properties?: unknown } + }): Promise => { + const props = event.properties as Record | undefined + + if (event.type === "session.idle") { + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + const sessionState = getSessionState(sessionID) + if (sessionState.isRecovering) { + log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) + return + } + + const state = readState(ctx.directory, stateDir) + if (!state || !state.active) { + return + } + + if (state.session_id && state.session_id !== sessionID) { + if (checkSessionExists) { + try { + const originalSessionExists = await checkSessionExists(state.session_id) + if (!originalSessionExists) { + clearState(ctx.directory, stateDir) + log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, { + orphanedSessionId: state.session_id, + currentSessionId: sessionID, + }) + return + } + } catch (err) { + log(`[${HOOK_NAME}] Failed to check session existence`, { + sessionId: state.session_id, + error: String(err), + }) + } + } + return + } + + const transcriptPath = getTranscriptPath(sessionID) + const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise) + + const completionDetectedViaApi = completionDetectedViaTranscript + ? false + : await detectCompletionInSessionMessages(sessionID, state.completion_promise) + + if (completionDetectedViaTranscript || completionDetectedViaApi) { + log(`[${HOOK_NAME}] Completion detected!`, { + sessionID, + iteration: state.iteration, + promise: state.completion_promise, + detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api", + }) + clearState(ctx.directory, stateDir) + + const title = state.ultrawork + ? "ULTRAWORK LOOP COMPLETE!" + : "Ralph Loop Complete!" + const message = state.ultrawork + ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` + : `Task completed after ${state.iteration} iteration(s)` + + await ctx.client.tui + .showToast({ + body: { + title, + message, + variant: "success", + duration: 5000, + }, + }) + .catch(() => {}) + + return + } + + if (state.iteration >= state.max_iterations) { + log(`[${HOOK_NAME}] Max iterations reached`, { + sessionID, + iteration: state.iteration, + max: state.max_iterations, + }) + clearState(ctx.directory, stateDir) + + await ctx.client.tui + .showToast({ + body: { + title: "Ralph Loop Stopped", + message: `Max iterations (${state.max_iterations}) reached without completion`, + variant: "warning", + duration: 5000, + }, + }) + .catch(() => {}) + + return + } + + const newState = incrementIteration(ctx.directory, stateDir) + if (!newState) { + log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID }) + return + } + + log(`[${HOOK_NAME}] Continuing loop`, { + sessionID, + iteration: newState.iteration, + max: newState.max_iterations, + }) + + const continuationPrompt = CONTINUATION_PROMPT.replace("{{ITERATION}}", String(newState.iteration)) + .replace("{{MAX}}", String(newState.max_iterations)) + .replace("{{PROMISE}}", newState.completion_promise) + .replace("{{PROMPT}}", newState.prompt) + + const finalPrompt = newState.ultrawork + ? `ultrawork ${continuationPrompt}` + : continuationPrompt + + await ctx.client.tui + .showToast({ + body: { + title: "Ralph Loop", + message: `Iteration ${newState.iteration}/${newState.max_iterations}`, + variant: "info", + duration: 2000, + }, + }) + .catch(() => {}) + + try { + let agent: string | undefined + let model: { providerID: string; modelID: string } | undefined + + try { + const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) + const messages = (messagesResp.data ?? []) as Array<{ + info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } + }> + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i].info + if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { + agent = info.agent + model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) + break + } + } + } catch { + const messageDir = getMessageDir(sessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agent = currentMessage?.agent + model = currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined + } + + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), + parts: [{ type: "text", text: finalPrompt }], + }, + query: { directory: ctx.directory }, + }) + } catch (err) { + log(`[${HOOK_NAME}] Failed to inject continuation`, { + sessionID, + error: String(err), + }) + } + } + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + const state = readState(ctx.directory, stateDir) + if (state?.session_id === sessionInfo.id) { + clearState(ctx.directory, stateDir) + log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id }) + } + sessions.delete(sessionInfo.id) + } + } + + if (event.type === "session.error") { + const sessionID = props?.sessionID as string | undefined + const error = props?.error as { name?: string } | undefined + + if (error?.name === "MessageAbortedError") { + if (sessionID) { + const state = readState(ctx.directory, stateDir) + if (state?.session_id === sessionID) { + clearState(ctx.directory, stateDir) + log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID }) + } + sessions.delete(sessionID) + } + return + } + + if (sessionID) { + const sessionState = getSessionState(sessionID) + sessionState.isRecovering = true + setTimeout(() => { + sessionState.isRecovering = false + }, 5000) + } + } + } + + return { + event, + startLoop, + cancelLoop, + getState, + } +} diff --git a/src/hooks/session-recovery/detect-error-type.ts b/src/hooks/session-recovery/detect-error-type.ts new file mode 100644 index 000000000..763370d16 --- /dev/null +++ b/src/hooks/session-recovery/detect-error-type.ts @@ -0,0 +1,65 @@ +export type RecoveryErrorType = + | "tool_result_missing" + | "thinking_block_order" + | "thinking_disabled_violation" + | null + +function getErrorMessage(error: unknown): string { + if (!error) return "" + if (typeof error === "string") return error.toLowerCase() + + const errorObj = error as Record + const paths = [ + errorObj.data, + errorObj.error, + errorObj, + (errorObj.data as Record)?.error, + ] + + for (const obj of paths) { + if (obj && typeof obj === "object") { + const msg = (obj as Record).message + if (typeof msg === "string" && msg.length > 0) { + return msg.toLowerCase() + } + } + } + + try { + return JSON.stringify(error).toLowerCase() + } catch { + return "" + } +} + +export function extractMessageIndex(error: unknown): number | null { + const message = getErrorMessage(error) + const match = message.match(/messages\.(\d+)/) + return match ? parseInt(match[1], 10) : null +} + +export function detectErrorType(error: unknown): RecoveryErrorType { + const message = getErrorMessage(error) + + if ( + message.includes("thinking") && + (message.includes("first block") || + message.includes("must start with") || + message.includes("preceeding") || + message.includes("final block") || + message.includes("cannot be thinking") || + (message.includes("expected") && message.includes("found"))) + ) { + return "thinking_block_order" + } + + if (message.includes("thinking is disabled") && message.includes("cannot contain")) { + return "thinking_disabled_violation" + } + + if (message.includes("tool_use") && message.includes("tool_result")) { + return "tool_result_missing" + } + + return null +} diff --git a/src/hooks/session-recovery/hook.ts b/src/hooks/session-recovery/hook.ts new file mode 100644 index 000000000..55a16662c --- /dev/null +++ b/src/hooks/session-recovery/hook.ts @@ -0,0 +1,141 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { ExperimentalConfig } from "../../config" +import { log } from "../../shared/logger" +import { detectErrorType } from "./detect-error-type" +import type { RecoveryErrorType } from "./detect-error-type" +import type { MessageData } from "./types" +import { recoverToolResultMissing } from "./recover-tool-result-missing" +import { recoverThinkingBlockOrder } from "./recover-thinking-block-order" +import { recoverThinkingDisabledViolation } from "./recover-thinking-disabled-violation" +import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume" + +interface MessageInfo { + id?: string + role?: string + sessionID?: string + parentID?: string + error?: unknown +} + +export interface SessionRecoveryOptions { + experimental?: ExperimentalConfig +} + +export interface SessionRecoveryHook { + handleSessionRecovery: (info: MessageInfo) => Promise + isRecoverableError: (error: unknown) => boolean + setOnAbortCallback: (callback: (sessionID: string) => void) => void + setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void +} + +export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRecoveryOptions): SessionRecoveryHook { + const processingErrors = new Set() + const experimental = options?.experimental + let onAbortCallback: ((sessionID: string) => void) | null = null + let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null + + const setOnAbortCallback = (callback: (sessionID: string) => void): void => { + onAbortCallback = callback + } + + const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => { + onRecoveryCompleteCallback = callback + } + + const isRecoverableError = (error: unknown): boolean => { + return detectErrorType(error) !== null + } + + const handleSessionRecovery = async (info: MessageInfo): Promise => { + if (!info || info.role !== "assistant" || !info.error) return false + + const errorType = detectErrorType(info.error) + if (!errorType) return false + + const sessionID = info.sessionID + const assistantMsgID = info.id + + if (!sessionID || !assistantMsgID) return false + if (processingErrors.has(assistantMsgID)) return false + processingErrors.add(assistantMsgID) + + try { + if (onAbortCallback) { + onAbortCallback(sessionID) + } + + await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {}) + + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }) + const msgs = (messagesResp as { data?: MessageData[] }).data + + const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID) + if (!failedMsg) { + return false + } + + const toastTitles: Record = { + tool_result_missing: "Tool Crash Recovery", + thinking_block_order: "Thinking Block Recovery", + thinking_disabled_violation: "Thinking Strip Recovery", + } + const toastMessages: Record = { + tool_result_missing: "Injecting cancelled tool results...", + thinking_block_order: "Fixing message structure...", + thinking_disabled_violation: "Stripping thinking blocks...", + } + + await ctx.client.tui + .showToast({ + body: { + title: toastTitles[errorType], + message: toastMessages[errorType], + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}) + + let success = false + + if (errorType === "tool_result_missing") { + success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg) + } else if (errorType === "thinking_block_order") { + success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error) + if (success && experimental?.auto_resume) { + const lastUser = findLastUserMessage(msgs ?? []) + const resumeConfig = extractResumeConfig(lastUser, sessionID) + await resumeSession(ctx.client, resumeConfig) + } + } else if (errorType === "thinking_disabled_violation") { + success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg) + if (success && experimental?.auto_resume) { + const lastUser = findLastUserMessage(msgs ?? []) + const resumeConfig = extractResumeConfig(lastUser, sessionID) + await resumeSession(ctx.client, resumeConfig) + } + } + + return success + } catch (err) { + log("[session-recovery] Recovery failed:", err) + return false + } finally { + processingErrors.delete(assistantMsgID) + + if (sessionID && onRecoveryCompleteCallback) { + onRecoveryCompleteCallback(sessionID) + } + } + } + + return { + handleSessionRecovery, + isRecoverableError, + setOnAbortCallback, + setOnRecoveryCompleteCallback, + } +} diff --git a/src/hooks/session-recovery/index.ts b/src/hooks/session-recovery/index.ts index 2aecee157..f1ecc4366 100644 --- a/src/hooks/session-recovery/index.ts +++ b/src/hooks/session-recovery/index.ts @@ -1,436 +1,7 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import type { createOpencodeClient } from "@opencode-ai/sdk" -import type { ExperimentalConfig } from "../../config" -import { - findEmptyMessages, - findEmptyMessageByIndex, - findMessageByIndexNeedingThinking, - findMessagesWithEmptyTextParts, - findMessagesWithOrphanThinking, - findMessagesWithThinkingBlocks, - findMessagesWithThinkingOnly, - injectTextPart, - prependThinkingPart, - readParts, - replaceEmptyTextParts, - stripThinkingParts, -} from "./storage" -import type { MessageData, ResumeConfig } from "./types" -import { log } from "../../shared/logger" +export { createSessionRecoveryHook } from "./hook" +export type { SessionRecoveryHook, SessionRecoveryOptions } from "./hook" -export interface SessionRecoveryOptions { - experimental?: ExperimentalConfig -} +export { detectErrorType } from "./detect-error-type" +export type { RecoveryErrorType } from "./detect-error-type" -type Client = ReturnType - -type RecoveryErrorType = - | "tool_result_missing" - | "thinking_block_order" - | "thinking_disabled_violation" - | null - -interface MessageInfo { - id?: string - role?: string - sessionID?: string - parentID?: string - error?: unknown -} - -interface ToolUsePart { - type: "tool_use" - id: string - name: string - input: Record -} - -interface MessagePart { - type: string - id?: string - text?: string - thinking?: string - name?: string - input?: Record -} - -const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]" - -function findLastUserMessage(messages: MessageData[]): MessageData | undefined { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].info?.role === "user") { - return messages[i] - } - } - return undefined -} - -function extractResumeConfig(userMessage: MessageData | undefined, sessionID: string): ResumeConfig { - return { - sessionID, - agent: userMessage?.info?.agent, - model: userMessage?.info?.model, - } -} - -async function resumeSession(client: Client, config: ResumeConfig): Promise { - try { - await client.session.promptAsync({ - path: { id: config.sessionID }, - body: { - parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }], - agent: config.agent, - model: config.model, - }, - }) - return true - } catch { - return false - } -} - -function getErrorMessage(error: unknown): string { - if (!error) return "" - if (typeof error === "string") return error.toLowerCase() - - const errorObj = error as Record - const paths = [ - errorObj.data, - errorObj.error, - errorObj, - (errorObj.data as Record)?.error, - ] - - for (const obj of paths) { - if (obj && typeof obj === "object") { - const msg = (obj as Record).message - if (typeof msg === "string" && msg.length > 0) { - return msg.toLowerCase() - } - } - } - - try { - return JSON.stringify(error).toLowerCase() - } catch { - return "" - } -} - -function extractMessageIndex(error: unknown): number | null { - const message = getErrorMessage(error) - const match = message.match(/messages\.(\d+)/) - return match ? parseInt(match[1], 10) : null -} - -export function detectErrorType(error: unknown): RecoveryErrorType { - const message = getErrorMessage(error) - - // IMPORTANT: Check thinking_block_order BEFORE tool_result_missing - // because Anthropic's extended thinking error messages contain "tool_use" and "tool_result" - // in the documentation URL, which would incorrectly match tool_result_missing - if ( - message.includes("thinking") && - (message.includes("first block") || - message.includes("must start with") || - message.includes("preceeding") || - message.includes("final block") || - message.includes("cannot be thinking") || - (message.includes("expected") && message.includes("found"))) - ) { - return "thinking_block_order" - } - - if (message.includes("thinking is disabled") && message.includes("cannot contain")) { - return "thinking_disabled_violation" - } - - if (message.includes("tool_use") && message.includes("tool_result")) { - return "tool_result_missing" - } - - return null -} - -function extractToolUseIds(parts: MessagePart[]): string[] { - return parts.filter((p): p is ToolUsePart => p.type === "tool_use" && !!p.id).map((p) => p.id) -} - -async function recoverToolResultMissing( - client: Client, - sessionID: string, - failedAssistantMsg: MessageData -): Promise { - // Try API parts first, fallback to filesystem if empty - let parts = failedAssistantMsg.parts || [] - if (parts.length === 0 && failedAssistantMsg.info?.id) { - const storedParts = readParts(failedAssistantMsg.info.id) - parts = storedParts.map((p) => ({ - type: p.type === "tool" ? "tool_use" : p.type, - id: "callID" in p ? (p as { callID?: string }).callID : p.id, - name: "tool" in p ? (p as { tool?: string }).tool : undefined, - input: "state" in p ? (p as { state?: { input?: Record } }).state?.input : undefined, - })) - } - const toolUseIds = extractToolUseIds(parts) - - if (toolUseIds.length === 0) { - return false - } - - const toolResultParts = toolUseIds.map((id) => ({ - type: "tool_result" as const, - tool_use_id: id, - content: "Operation cancelled by user (ESC pressed)", - })) - - try { - await client.session.promptAsync({ - path: { id: sessionID }, - // @ts-expect-error - SDK types may not include tool_result parts - body: { parts: toolResultParts }, - }) - - return true - } catch { - return false - } -} - -async function recoverThinkingBlockOrder( - _client: Client, - sessionID: string, - _failedAssistantMsg: MessageData, - _directory: string, - error: unknown -): Promise { - const targetIndex = extractMessageIndex(error) - if (targetIndex !== null) { - const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex) - if (targetMessageID) { - return prependThinkingPart(sessionID, targetMessageID) - } - } - - const orphanMessages = findMessagesWithOrphanThinking(sessionID) - - if (orphanMessages.length === 0) { - return false - } - - let anySuccess = false - for (const messageID of orphanMessages) { - if (prependThinkingPart(sessionID, messageID)) { - anySuccess = true - } - } - - return anySuccess -} - -async function recoverThinkingDisabledViolation( - _client: Client, - sessionID: string, - _failedAssistantMsg: MessageData -): Promise { - const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID) - - if (messagesWithThinking.length === 0) { - return false - } - - let anySuccess = false - for (const messageID of messagesWithThinking) { - if (stripThinkingParts(messageID)) { - anySuccess = true - } - } - - return anySuccess -} - -const PLACEHOLDER_TEXT = "[user interrupted]" - -async function recoverEmptyContentMessage( - _client: Client, - sessionID: string, - failedAssistantMsg: MessageData, - _directory: string, - error: unknown -): Promise { - const targetIndex = extractMessageIndex(error) - const failedID = failedAssistantMsg.info?.id - let anySuccess = false - - const messagesWithEmptyText = findMessagesWithEmptyTextParts(sessionID) - for (const messageID of messagesWithEmptyText) { - if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) { - anySuccess = true - } - } - - const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID) - for (const messageID of thinkingOnlyIDs) { - if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) { - anySuccess = true - } - } - - if (targetIndex !== null) { - const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex) - if (targetMessageID) { - if (replaceEmptyTextParts(targetMessageID, PLACEHOLDER_TEXT)) { - return true - } - if (injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)) { - return true - } - } - } - - if (failedID) { - if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) { - return true - } - if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) { - return true - } - } - - const emptyMessageIDs = findEmptyMessages(sessionID) - for (const messageID of emptyMessageIDs) { - if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) { - anySuccess = true - } - if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) { - anySuccess = true - } - } - - return anySuccess -} - -// NOTE: fallbackRevertStrategy was removed (2025-12-08) -// Reason: Function was defined but never called - no error recovery paths used it. -// All error types have dedicated recovery functions (recoverToolResultMissing, -// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage). - -export interface SessionRecoveryHook { - handleSessionRecovery: (info: MessageInfo) => Promise - isRecoverableError: (error: unknown) => boolean - setOnAbortCallback: (callback: (sessionID: string) => void) => void - setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void -} - -export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRecoveryOptions): SessionRecoveryHook { - const processingErrors = new Set() - const experimental = options?.experimental - let onAbortCallback: ((sessionID: string) => void) | null = null - let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null - - const setOnAbortCallback = (callback: (sessionID: string) => void): void => { - onAbortCallback = callback - } - - const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => { - onRecoveryCompleteCallback = callback - } - - const isRecoverableError = (error: unknown): boolean => { - return detectErrorType(error) !== null - } - - const handleSessionRecovery = async (info: MessageInfo): Promise => { - if (!info || info.role !== "assistant" || !info.error) return false - - const errorType = detectErrorType(info.error) - if (!errorType) return false - - const sessionID = info.sessionID - const assistantMsgID = info.id - - if (!sessionID || !assistantMsgID) return false - if (processingErrors.has(assistantMsgID)) return false - processingErrors.add(assistantMsgID) - - try { - if (onAbortCallback) { - onAbortCallback(sessionID) // Mark recovering BEFORE abort - } - - await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {}) - - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }) - const msgs = (messagesResp as { data?: MessageData[] }).data - - const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID) - if (!failedMsg) { - return false - } - - const toastTitles: Record = { - tool_result_missing: "Tool Crash Recovery", - thinking_block_order: "Thinking Block Recovery", - thinking_disabled_violation: "Thinking Strip Recovery", - } - const toastMessages: Record = { - tool_result_missing: "Injecting cancelled tool results...", - thinking_block_order: "Fixing message structure...", - thinking_disabled_violation: "Stripping thinking blocks...", - } - - await ctx.client.tui - .showToast({ - body: { - title: toastTitles[errorType], - message: toastMessages[errorType], - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}) - - let success = false - - if (errorType === "tool_result_missing") { - success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg) - } else if (errorType === "thinking_block_order") { - success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error) - if (success && experimental?.auto_resume) { - const lastUser = findLastUserMessage(msgs ?? []) - const resumeConfig = extractResumeConfig(lastUser, sessionID) - await resumeSession(ctx.client, resumeConfig) - } - } else if (errorType === "thinking_disabled_violation") { - success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg) - if (success && experimental?.auto_resume) { - const lastUser = findLastUserMessage(msgs ?? []) - const resumeConfig = extractResumeConfig(lastUser, sessionID) - await resumeSession(ctx.client, resumeConfig) - } - } - - return success - } catch (err) { - log("[session-recovery] Recovery failed:", err) - return false - } finally { - processingErrors.delete(assistantMsgID) - - // Always notify recovery complete, regardless of success or failure - if (sessionID && onRecoveryCompleteCallback) { - onRecoveryCompleteCallback(sessionID) - } - } - } - - return { - handleSessionRecovery, - isRecoverableError, - setOnAbortCallback, - setOnRecoveryCompleteCallback, - } -} +export type { MessageData, ResumeConfig } from "./types" diff --git a/src/hooks/session-recovery/recover-empty-content-message.ts b/src/hooks/session-recovery/recover-empty-content-message.ts new file mode 100644 index 000000000..f095eb2e8 --- /dev/null +++ b/src/hooks/session-recovery/recover-empty-content-message.ts @@ -0,0 +1,74 @@ +import type { createOpencodeClient } from "@opencode-ai/sdk" +import type { MessageData } from "./types" +import { extractMessageIndex } from "./detect-error-type" +import { + findEmptyMessageByIndex, + findEmptyMessages, + findMessagesWithEmptyTextParts, + findMessagesWithThinkingOnly, + injectTextPart, + replaceEmptyTextParts, +} from "./storage" + +type Client = ReturnType + +const PLACEHOLDER_TEXT = "[user interrupted]" + +export async function recoverEmptyContentMessage( + _client: Client, + sessionID: string, + failedAssistantMsg: MessageData, + _directory: string, + error: unknown +): Promise { + const targetIndex = extractMessageIndex(error) + const failedID = failedAssistantMsg.info?.id + let anySuccess = false + + const messagesWithEmptyText = findMessagesWithEmptyTextParts(sessionID) + for (const messageID of messagesWithEmptyText) { + if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) { + anySuccess = true + } + } + + const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID) + for (const messageID of thinkingOnlyIDs) { + if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) { + anySuccess = true + } + } + + if (targetIndex !== null) { + const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex) + if (targetMessageID) { + if (replaceEmptyTextParts(targetMessageID, PLACEHOLDER_TEXT)) { + return true + } + if (injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)) { + return true + } + } + } + + if (failedID) { + if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) { + return true + } + if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) { + return true + } + } + + const emptyMessageIDs = findEmptyMessages(sessionID) + for (const messageID of emptyMessageIDs) { + if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) { + anySuccess = true + } + if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) { + anySuccess = true + } + } + + return anySuccess +} diff --git a/src/hooks/session-recovery/recover-thinking-block-order.ts b/src/hooks/session-recovery/recover-thinking-block-order.ts new file mode 100644 index 000000000..f26bf4f11 --- /dev/null +++ b/src/hooks/session-recovery/recover-thinking-block-order.ts @@ -0,0 +1,36 @@ +import type { createOpencodeClient } from "@opencode-ai/sdk" +import type { MessageData } from "./types" +import { extractMessageIndex } from "./detect-error-type" +import { findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, prependThinkingPart } from "./storage" + +type Client = ReturnType + +export async function recoverThinkingBlockOrder( + _client: Client, + sessionID: string, + _failedAssistantMsg: MessageData, + _directory: string, + error: unknown +): Promise { + const targetIndex = extractMessageIndex(error) + if (targetIndex !== null) { + const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex) + if (targetMessageID) { + return prependThinkingPart(sessionID, targetMessageID) + } + } + + const orphanMessages = findMessagesWithOrphanThinking(sessionID) + if (orphanMessages.length === 0) { + return false + } + + let anySuccess = false + for (const messageID of orphanMessages) { + if (prependThinkingPart(sessionID, messageID)) { + anySuccess = true + } + } + + return anySuccess +} diff --git a/src/hooks/session-recovery/recover-thinking-disabled-violation.ts b/src/hooks/session-recovery/recover-thinking-disabled-violation.ts new file mode 100644 index 000000000..6eeded936 --- /dev/null +++ b/src/hooks/session-recovery/recover-thinking-disabled-violation.ts @@ -0,0 +1,25 @@ +import type { createOpencodeClient } from "@opencode-ai/sdk" +import type { MessageData } from "./types" +import { findMessagesWithThinkingBlocks, stripThinkingParts } from "./storage" + +type Client = ReturnType + +export async function recoverThinkingDisabledViolation( + _client: Client, + sessionID: string, + _failedAssistantMsg: MessageData +): Promise { + const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID) + if (messagesWithThinking.length === 0) { + return false + } + + let anySuccess = false + for (const messageID of messagesWithThinking) { + if (stripThinkingParts(messageID)) { + anySuccess = true + } + } + + return anySuccess +} diff --git a/src/hooks/session-recovery/recover-tool-result-missing.ts b/src/hooks/session-recovery/recover-tool-result-missing.ts new file mode 100644 index 000000000..1f114fe33 --- /dev/null +++ b/src/hooks/session-recovery/recover-tool-result-missing.ts @@ -0,0 +1,61 @@ +import type { createOpencodeClient } from "@opencode-ai/sdk" +import type { MessageData } from "./types" +import { readParts } from "./storage" + +type Client = ReturnType + +interface ToolUsePart { + type: "tool_use" + id: string + name: string + input: Record +} + +interface MessagePart { + type: string + id?: string +} + +function extractToolUseIds(parts: MessagePart[]): string[] { + return parts.filter((part): part is ToolUsePart => part.type === "tool_use" && !!part.id).map((part) => part.id) +} + +export async function recoverToolResultMissing( + client: Client, + sessionID: string, + failedAssistantMsg: MessageData +): Promise { + let parts = failedAssistantMsg.parts || [] + if (parts.length === 0 && failedAssistantMsg.info?.id) { + const storedParts = readParts(failedAssistantMsg.info.id) + parts = storedParts.map((part) => ({ + type: part.type === "tool" ? "tool_use" : part.type, + id: "callID" in part ? (part as { callID?: string }).callID : part.id, + })) + } + + const toolUseIds = extractToolUseIds(parts) + if (toolUseIds.length === 0) { + return false + } + + const toolResultParts = toolUseIds.map((id) => ({ + type: "tool_result" as const, + tool_use_id: id, + content: "Operation cancelled by user (ESC pressed)", + })) + + const promptInput = { + path: { id: sessionID }, + body: { parts: toolResultParts }, + } + + try { + // @ts-expect-error - SDK types may not include tool_result parts + await client.session.promptAsync(promptInput) + + return true + } catch { + return false + } +} diff --git a/src/hooks/session-recovery/resume.ts b/src/hooks/session-recovery/resume.ts new file mode 100644 index 000000000..48e6bfff0 --- /dev/null +++ b/src/hooks/session-recovery/resume.ts @@ -0,0 +1,39 @@ +import type { createOpencodeClient } from "@opencode-ai/sdk" +import type { MessageData, ResumeConfig } from "./types" + +const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]" + +type Client = ReturnType + +export function findLastUserMessage(messages: MessageData[]): MessageData | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].info?.role === "user") { + return messages[i] + } + } + return undefined +} + +export function extractResumeConfig(userMessage: MessageData | undefined, sessionID: string): ResumeConfig { + return { + sessionID, + agent: userMessage?.info?.agent, + model: userMessage?.info?.model, + } +} + +export async function resumeSession(client: Client, config: ResumeConfig): Promise { + try { + await client.session.promptAsync({ + path: { id: config.sessionID }, + body: { + parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }], + agent: config.agent, + model: config.model, + }, + }) + return true + } catch { + return false + } +} diff --git a/src/hooks/session-recovery/storage.ts b/src/hooks/session-recovery/storage.ts index 7b00ffcdd..b9dbccb94 100644 --- a/src/hooks/session-recovery/storage.ts +++ b/src/hooks/session-recovery/storage.ts @@ -1,390 +1,26 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs" -import { join } from "node:path" -import { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from "./constants" -import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types" - -export function generatePartId(): string { - const timestamp = Date.now().toString(16) - const random = Math.random().toString(36).substring(2, 10) - return `prt_${timestamp}${random}` -} - -export function getMessageDir(sessionID: string): string { - if (!existsSync(MESSAGE_STORAGE)) return "" - - 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 "" -} - -export function readMessages(sessionID: string): StoredMessageMeta[] { - const messageDir = getMessageDir(sessionID) - if (!messageDir || !existsSync(messageDir)) return [] - - const messages: StoredMessageMeta[] = [] - for (const file of readdirSync(messageDir)) { - if (!file.endsWith(".json")) continue - try { - const content = readFileSync(join(messageDir, file), "utf-8") - messages.push(JSON.parse(content)) - } catch { - continue - } - } - - return messages.sort((a, b) => { - const aTime = a.time?.created ?? 0 - const bTime = b.time?.created ?? 0 - if (aTime !== bTime) return aTime - bTime - return a.id.localeCompare(b.id) - }) -} - -export function readParts(messageID: string): StoredPart[] { - const partDir = join(PART_STORAGE, messageID) - if (!existsSync(partDir)) return [] - - const parts: StoredPart[] = [] - for (const file of readdirSync(partDir)) { - if (!file.endsWith(".json")) continue - try { - const content = readFileSync(join(partDir, file), "utf-8") - parts.push(JSON.parse(content)) - } catch { - continue - } - } - - return parts -} - -export function hasContent(part: StoredPart): boolean { - if (THINKING_TYPES.has(part.type)) return false - if (META_TYPES.has(part.type)) return false - - if (part.type === "text") { - const textPart = part as StoredTextPart - return !!(textPart.text?.trim()) - } - - if (part.type === "tool" || part.type === "tool_use") { - return true - } - - if (part.type === "tool_result") { - return true - } - - return false -} - -export function messageHasContent(messageID: string): boolean { - const parts = readParts(messageID) - return parts.some(hasContent) -} - -export function injectTextPart(sessionID: string, messageID: string, text: string): boolean { - const partDir = join(PART_STORAGE, messageID) - - if (!existsSync(partDir)) { - mkdirSync(partDir, { recursive: true }) - } - - const partId = generatePartId() - const part: StoredTextPart = { - id: partId, - sessionID, - messageID, - type: "text", - text, - synthetic: true, - } - - try { - writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)) - return true - } catch { - return false - } -} - -export function findEmptyMessages(sessionID: string): string[] { - const messages = readMessages(sessionID) - const emptyIds: string[] = [] - - for (const msg of messages) { - if (!messageHasContent(msg.id)) { - emptyIds.push(msg.id) - } - } - - return emptyIds -} - -export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null { - const messages = readMessages(sessionID) - - // API index may differ from storage index due to system messages - const indicesToTry = [ - targetIndex, - targetIndex - 1, - targetIndex + 1, - targetIndex - 2, - targetIndex + 2, - targetIndex - 3, - targetIndex - 4, - targetIndex - 5, - ] - - for (const idx of indicesToTry) { - if (idx < 0 || idx >= messages.length) continue - - const targetMsg = messages[idx] - - if (!messageHasContent(targetMsg.id)) { - return targetMsg.id - } - } - - return null -} - -export function findFirstEmptyMessage(sessionID: string): string | null { - const emptyIds = findEmptyMessages(sessionID) - return emptyIds.length > 0 ? emptyIds[0] : null -} - -export function findMessagesWithThinkingBlocks(sessionID: string): string[] { - const messages = readMessages(sessionID) - const result: string[] = [] - - for (const msg of messages) { - if (msg.role !== "assistant") continue - - const parts = readParts(msg.id) - const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type)) - if (hasThinking) { - result.push(msg.id) - } - } - - return result -} - -export function findMessagesWithThinkingOnly(sessionID: string): string[] { - const messages = readMessages(sessionID) - const result: string[] = [] - - for (const msg of messages) { - if (msg.role !== "assistant") continue - - const parts = readParts(msg.id) - if (parts.length === 0) continue - - const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type)) - const hasTextContent = parts.some(hasContent) - - // Has thinking but no text content = orphan thinking - if (hasThinking && !hasTextContent) { - result.push(msg.id) - } - } - - return result -} - -export function findMessagesWithOrphanThinking(sessionID: string): string[] { - const messages = readMessages(sessionID) - const result: string[] = [] - - for (let i = 0; i < messages.length; i++) { - const msg = messages[i] - if (msg.role !== "assistant") continue - - // NOTE: Removed isLastMessage skip - recovery needs to fix last message too - // when "thinking must start with" errors occur on final assistant message - - const parts = readParts(msg.id) - if (parts.length === 0) continue - - const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id)) - const firstPart = sortedParts[0] - - const firstIsThinking = THINKING_TYPES.has(firstPart.type) - - // NOTE: Changed condition - if first part is not thinking, it's orphan - // regardless of whether thinking blocks exist elsewhere in the message - if (!firstIsThinking) { - result.push(msg.id) - } - } - - return result -} - -/** - * Find the most recent thinking content from previous assistant messages - * Following Anthropic's recommendation to include thinking blocks from previous turns - */ -function findLastThinkingContent(sessionID: string, beforeMessageID: string): string { - const messages = readMessages(sessionID) - - // Find the index of the current message - const currentIndex = messages.findIndex(m => m.id === beforeMessageID) - if (currentIndex === -1) return "" - - // Search backwards through previous assistant messages - for (let i = currentIndex - 1; i >= 0; i--) { - const msg = messages[i] - if (msg.role !== "assistant") continue - - // Look for thinking parts in this message - const parts = readParts(msg.id) - for (const part of parts) { - if (THINKING_TYPES.has(part.type)) { - // Found thinking content - return it - // Note: 'thinking' type uses 'thinking' property, 'reasoning' type uses 'text' property - const thinking = (part as { thinking?: string; text?: string }).thinking - const reasoning = (part as { thinking?: string; text?: string }).text - const content = thinking || reasoning - if (content && content.trim().length > 0) { - return content - } - } - } - } - - return "" -} - -export function prependThinkingPart(sessionID: string, messageID: string): boolean { - const partDir = join(PART_STORAGE, messageID) - - if (!existsSync(partDir)) { - mkdirSync(partDir, { recursive: true }) - } - - // Try to get thinking content from previous turns (Anthropic's recommendation) - const previousThinking = findLastThinkingContent(sessionID, messageID) - - const partId = `prt_0000000000_thinking` - const part = { - id: partId, - sessionID, - messageID, - type: "thinking", - thinking: previousThinking || "[Continuing from previous reasoning]", - synthetic: true, - } - - try { - writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)) - return true - } catch { - return false - } -} - -export function stripThinkingParts(messageID: string): boolean { - const partDir = join(PART_STORAGE, messageID) - if (!existsSync(partDir)) return false - - let anyRemoved = false - for (const file of readdirSync(partDir)) { - if (!file.endsWith(".json")) continue - try { - const filePath = join(partDir, file) - const content = readFileSync(filePath, "utf-8") - const part = JSON.parse(content) as StoredPart - if (THINKING_TYPES.has(part.type)) { - unlinkSync(filePath) - anyRemoved = true - } - } catch { - continue - } - } - - return anyRemoved -} - -export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean { - const partDir = join(PART_STORAGE, messageID) - if (!existsSync(partDir)) return false - - let anyReplaced = false - for (const file of readdirSync(partDir)) { - if (!file.endsWith(".json")) continue - try { - const filePath = join(partDir, file) - const content = readFileSync(filePath, "utf-8") - const part = JSON.parse(content) as StoredPart - - if (part.type === "text") { - const textPart = part as StoredTextPart - if (!textPart.text?.trim()) { - textPart.text = replacementText - textPart.synthetic = true - writeFileSync(filePath, JSON.stringify(textPart, null, 2)) - anyReplaced = true - } - } - } catch { - continue - } - } - - return anyReplaced -} - -export function findMessagesWithEmptyTextParts(sessionID: string): string[] { - const messages = readMessages(sessionID) - const result: string[] = [] - - for (const msg of messages) { - const parts = readParts(msg.id) - const hasEmptyTextPart = parts.some((p) => { - if (p.type !== "text") return false - const textPart = p as StoredTextPart - return !textPart.text?.trim() - }) - - if (hasEmptyTextPart) { - result.push(msg.id) - } - } - - return result -} - -export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null { - const messages = readMessages(sessionID) - - if (targetIndex < 0 || targetIndex >= messages.length) return null - - const targetMsg = messages[targetIndex] - if (targetMsg.role !== "assistant") return null - - const parts = readParts(targetMsg.id) - if (parts.length === 0) return null - - const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id)) - const firstPart = sortedParts[0] - const firstIsThinking = THINKING_TYPES.has(firstPart.type) - - if (!firstIsThinking) { - return targetMsg.id - } - - return null -} +export { generatePartId } from "./storage/part-id" +export { getMessageDir } from "./storage/message-dir" +export { readMessages } from "./storage/messages-reader" +export { readParts } from "./storage/parts-reader" +export { hasContent, messageHasContent } from "./storage/part-content" +export { injectTextPart } from "./storage/text-part-injector" + +export { + findEmptyMessages, + findEmptyMessageByIndex, + findFirstEmptyMessage, +} from "./storage/empty-messages" +export { findMessagesWithEmptyTextParts } from "./storage/empty-text" + +export { + findMessagesWithThinkingBlocks, + findMessagesWithThinkingOnly, +} from "./storage/thinking-block-search" +export { + findMessagesWithOrphanThinking, + findMessageByIndexNeedingThinking, +} from "./storage/orphan-thinking-search" + +export { prependThinkingPart } from "./storage/thinking-prepend" +export { stripThinkingParts } from "./storage/thinking-strip" +export { replaceEmptyTextParts } from "./storage/empty-text" diff --git a/src/hooks/session-recovery/storage/empty-messages.ts b/src/hooks/session-recovery/storage/empty-messages.ts new file mode 100644 index 000000000..1d6211177 --- /dev/null +++ b/src/hooks/session-recovery/storage/empty-messages.ts @@ -0,0 +1,47 @@ +import { messageHasContent } from "./part-content" +import { readMessages } from "./messages-reader" + +export function findEmptyMessages(sessionID: string): string[] { + const messages = readMessages(sessionID) + const emptyIds: string[] = [] + + for (const msg of messages) { + if (!messageHasContent(msg.id)) { + emptyIds.push(msg.id) + } + } + + return emptyIds +} + +export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null { + const messages = readMessages(sessionID) + + const indicesToTry = [ + targetIndex, + targetIndex - 1, + targetIndex + 1, + targetIndex - 2, + targetIndex + 2, + targetIndex - 3, + targetIndex - 4, + targetIndex - 5, + ] + + for (const index of indicesToTry) { + if (index < 0 || index >= messages.length) continue + + const targetMessage = messages[index] + + if (!messageHasContent(targetMessage.id)) { + return targetMessage.id + } + } + + return null +} + +export function findFirstEmptyMessage(sessionID: string): string | null { + const emptyIds = findEmptyMessages(sessionID) + return emptyIds.length > 0 ? emptyIds[0] : null +} diff --git a/src/hooks/session-recovery/storage/empty-text.ts b/src/hooks/session-recovery/storage/empty-text.ts new file mode 100644 index 000000000..aa6ff2eb0 --- /dev/null +++ b/src/hooks/session-recovery/storage/empty-text.ts @@ -0,0 +1,55 @@ +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { PART_STORAGE } from "../constants" +import type { StoredPart, StoredTextPart } from "../types" +import { readMessages } from "./messages-reader" +import { readParts } from "./parts-reader" + +export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean { + const partDir = join(PART_STORAGE, messageID) + if (!existsSync(partDir)) return false + + let anyReplaced = false + for (const file of readdirSync(partDir)) { + if (!file.endsWith(".json")) continue + try { + const filePath = join(partDir, file) + const content = readFileSync(filePath, "utf-8") + const part = JSON.parse(content) as StoredPart + + if (part.type === "text") { + const textPart = part as StoredTextPart + if (!textPart.text?.trim()) { + textPart.text = replacementText + textPart.synthetic = true + writeFileSync(filePath, JSON.stringify(textPart, null, 2)) + anyReplaced = true + } + } + } catch { + continue + } + } + + return anyReplaced +} + +export function findMessagesWithEmptyTextParts(sessionID: string): string[] { + const messages = readMessages(sessionID) + const result: string[] = [] + + for (const msg of messages) { + const parts = readParts(msg.id) + const hasEmptyTextPart = parts.some((part) => { + if (part.type !== "text") return false + const textPart = part as StoredTextPart + return !textPart.text?.trim() + }) + + if (hasEmptyTextPart) { + result.push(msg.id) + } + } + + return result +} diff --git a/src/hooks/session-recovery/storage/message-dir.ts b/src/hooks/session-recovery/storage/message-dir.ts new file mode 100644 index 000000000..96f03a279 --- /dev/null +++ b/src/hooks/session-recovery/storage/message-dir.ts @@ -0,0 +1,21 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "../constants" + +export function getMessageDir(sessionID: string): string { + if (!existsSync(MESSAGE_STORAGE)) return "" + + 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 "" +} diff --git a/src/hooks/session-recovery/storage/messages-reader.ts b/src/hooks/session-recovery/storage/messages-reader.ts new file mode 100644 index 000000000..ad6c77833 --- /dev/null +++ b/src/hooks/session-recovery/storage/messages-reader.ts @@ -0,0 +1,27 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" +import type { StoredMessageMeta } from "../types" +import { getMessageDir } from "./message-dir" + +export function readMessages(sessionID: string): StoredMessageMeta[] { + const messageDir = getMessageDir(sessionID) + if (!messageDir || !existsSync(messageDir)) return [] + + const messages: StoredMessageMeta[] = [] + for (const file of readdirSync(messageDir)) { + if (!file.endsWith(".json")) continue + try { + const content = readFileSync(join(messageDir, file), "utf-8") + messages.push(JSON.parse(content)) + } catch { + continue + } + } + + return messages.sort((a, b) => { + const aTime = a.time?.created ?? 0 + const bTime = b.time?.created ?? 0 + if (aTime !== bTime) return aTime - bTime + return a.id.localeCompare(b.id) + }) +} diff --git a/src/hooks/session-recovery/storage/orphan-thinking-search.ts b/src/hooks/session-recovery/storage/orphan-thinking-search.ts new file mode 100644 index 000000000..4bb00083f --- /dev/null +++ b/src/hooks/session-recovery/storage/orphan-thinking-search.ts @@ -0,0 +1,43 @@ +import { THINKING_TYPES } from "../constants" +import { readMessages } from "./messages-reader" +import { readParts } from "./parts-reader" + +export function findMessagesWithOrphanThinking(sessionID: string): string[] { + const messages = readMessages(sessionID) + const result: string[] = [] + + for (const msg of messages) { + if (msg.role !== "assistant") continue + + const parts = readParts(msg.id) + if (parts.length === 0) continue + + const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id)) + const firstPart = sortedParts[0] + const firstIsThinking = THINKING_TYPES.has(firstPart.type) + + if (!firstIsThinking) { + result.push(msg.id) + } + } + + return result +} + +export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null { + const messages = readMessages(sessionID) + + if (targetIndex < 0 || targetIndex >= messages.length) return null + + const targetMessage = messages[targetIndex] + if (targetMessage.role !== "assistant") return null + + const parts = readParts(targetMessage.id) + if (parts.length === 0) return null + + const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id)) + const firstPart = sortedParts[0] + const firstIsThinking = THINKING_TYPES.has(firstPart.type) + + return firstIsThinking ? null : targetMessage.id +} diff --git a/src/hooks/session-recovery/storage/part-content.ts b/src/hooks/session-recovery/storage/part-content.ts new file mode 100644 index 000000000..064e2df4b --- /dev/null +++ b/src/hooks/session-recovery/storage/part-content.ts @@ -0,0 +1,28 @@ +import { THINKING_TYPES, META_TYPES } from "../constants" +import type { StoredPart, StoredTextPart } from "../types" +import { readParts } from "./parts-reader" + +export function hasContent(part: StoredPart): boolean { + if (THINKING_TYPES.has(part.type)) return false + if (META_TYPES.has(part.type)) return false + + if (part.type === "text") { + const textPart = part as StoredTextPart + return !!textPart.text?.trim() + } + + if (part.type === "tool" || part.type === "tool_use") { + return true + } + + if (part.type === "tool_result") { + return true + } + + return false +} + +export function messageHasContent(messageID: string): boolean { + const parts = readParts(messageID) + return parts.some(hasContent) +} diff --git a/src/hooks/session-recovery/storage/part-id.ts b/src/hooks/session-recovery/storage/part-id.ts new file mode 100644 index 000000000..1500b4bc1 --- /dev/null +++ b/src/hooks/session-recovery/storage/part-id.ts @@ -0,0 +1,5 @@ +export function generatePartId(): string { + const timestamp = Date.now().toString(16) + const random = Math.random().toString(36).substring(2, 10) + return `prt_${timestamp}${random}` +} diff --git a/src/hooks/session-recovery/storage/parts-reader.ts b/src/hooks/session-recovery/storage/parts-reader.ts new file mode 100644 index 000000000..c4110a59d --- /dev/null +++ b/src/hooks/session-recovery/storage/parts-reader.ts @@ -0,0 +1,22 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { PART_STORAGE } from "../constants" +import type { StoredPart } from "../types" + +export function readParts(messageID: string): StoredPart[] { + const partDir = join(PART_STORAGE, messageID) + if (!existsSync(partDir)) return [] + + const parts: StoredPart[] = [] + for (const file of readdirSync(partDir)) { + if (!file.endsWith(".json")) continue + try { + const content = readFileSync(join(partDir, file), "utf-8") + parts.push(JSON.parse(content)) + } catch { + continue + } + } + + return parts +} diff --git a/src/hooks/session-recovery/storage/text-part-injector.ts b/src/hooks/session-recovery/storage/text-part-injector.ts new file mode 100644 index 000000000..f729ca0fc --- /dev/null +++ b/src/hooks/session-recovery/storage/text-part-injector.ts @@ -0,0 +1,30 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { PART_STORAGE } from "../constants" +import type { StoredTextPart } from "../types" +import { generatePartId } from "./part-id" + +export function injectTextPart(sessionID: string, messageID: string, text: string): boolean { + const partDir = join(PART_STORAGE, messageID) + + if (!existsSync(partDir)) { + mkdirSync(partDir, { recursive: true }) + } + + const partId = generatePartId() + const part: StoredTextPart = { + id: partId, + sessionID, + messageID, + type: "text", + text, + synthetic: true, + } + + try { + writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)) + return true + } catch { + return false + } +} diff --git a/src/hooks/session-recovery/storage/thinking-block-search.ts b/src/hooks/session-recovery/storage/thinking-block-search.ts new file mode 100644 index 000000000..08b7394bb --- /dev/null +++ b/src/hooks/session-recovery/storage/thinking-block-search.ts @@ -0,0 +1,42 @@ +import { THINKING_TYPES } from "../constants" +import { hasContent } from "./part-content" +import { readMessages } from "./messages-reader" +import { readParts } from "./parts-reader" + +export function findMessagesWithThinkingBlocks(sessionID: string): string[] { + const messages = readMessages(sessionID) + const result: string[] = [] + + for (const msg of messages) { + if (msg.role !== "assistant") continue + + const parts = readParts(msg.id) + const hasThinking = parts.some((part) => THINKING_TYPES.has(part.type)) + if (hasThinking) { + result.push(msg.id) + } + } + + return result +} + +export function findMessagesWithThinkingOnly(sessionID: string): string[] { + const messages = readMessages(sessionID) + const result: string[] = [] + + for (const msg of messages) { + if (msg.role !== "assistant") continue + + const parts = readParts(msg.id) + if (parts.length === 0) continue + + const hasThinking = parts.some((part) => THINKING_TYPES.has(part.type)) + const hasTextContent = parts.some(hasContent) + + if (hasThinking && !hasTextContent) { + result.push(msg.id) + } + } + + return result +} diff --git a/src/hooks/session-recovery/storage/thinking-prepend.ts b/src/hooks/session-recovery/storage/thinking-prepend.ts new file mode 100644 index 000000000..b8c1bd861 --- /dev/null +++ b/src/hooks/session-recovery/storage/thinking-prepend.ts @@ -0,0 +1,58 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { PART_STORAGE, THINKING_TYPES } from "../constants" +import { readMessages } from "./messages-reader" +import { readParts } from "./parts-reader" + +function findLastThinkingContent(sessionID: string, beforeMessageID: string): string { + const messages = readMessages(sessionID) + + const currentIndex = messages.findIndex((message) => message.id === beforeMessageID) + if (currentIndex === -1) return "" + + for (let i = currentIndex - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== "assistant") continue + + const parts = readParts(message.id) + for (const part of parts) { + if (THINKING_TYPES.has(part.type)) { + const thinking = (part as { thinking?: string; text?: string }).thinking + const reasoning = (part as { thinking?: string; text?: string }).text + const content = thinking || reasoning + if (content && content.trim().length > 0) { + return content + } + } + } + } + + return "" +} + +export function prependThinkingPart(sessionID: string, messageID: string): boolean { + const partDir = join(PART_STORAGE, messageID) + + if (!existsSync(partDir)) { + mkdirSync(partDir, { recursive: true }) + } + + const previousThinking = findLastThinkingContent(sessionID, messageID) + + const partId = "prt_0000000000_thinking" + const part = { + id: partId, + sessionID, + messageID, + type: "thinking", + thinking: previousThinking || "[Continuing from previous reasoning]", + synthetic: true, + } + + try { + writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)) + return true + } catch { + return false + } +} diff --git a/src/hooks/session-recovery/storage/thinking-strip.ts b/src/hooks/session-recovery/storage/thinking-strip.ts new file mode 100644 index 000000000..8731508a0 --- /dev/null +++ b/src/hooks/session-recovery/storage/thinking-strip.ts @@ -0,0 +1,27 @@ +import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs" +import { join } from "node:path" +import { PART_STORAGE, THINKING_TYPES } from "../constants" +import type { StoredPart } from "../types" + +export function stripThinkingParts(messageID: string): boolean { + const partDir = join(PART_STORAGE, messageID) + if (!existsSync(partDir)) return false + + let anyRemoved = false + for (const file of readdirSync(partDir)) { + if (!file.endsWith(".json")) continue + try { + const filePath = join(partDir, file) + const content = readFileSync(filePath, "utf-8") + const part = JSON.parse(content) as StoredPart + if (THINKING_TYPES.has(part.type)) { + unlinkSync(filePath) + anyRemoved = true + } + } catch { + continue + } + } + + return anyRemoved +} diff --git a/src/hooks/start-work/index.ts b/src/hooks/start-work/index.ts index 600814bd5..41cb0b1a4 100644 --- a/src/hooks/start-work/index.ts +++ b/src/hooks/start-work/index.ts @@ -1,242 +1 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { - readBoulderState, - writeBoulderState, - appendSessionId, - findPrometheusPlans, - getPlanProgress, - createBoulderState, - getPlanName, - clearBoulderState, -} from "../../features/boulder-state" -import { log } from "../../shared/logger" -import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state" - -export const HOOK_NAME = "start-work" - -const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi - -interface StartWorkHookInput { - sessionID: string - messageID?: string -} - -interface StartWorkHookOutput { - parts: Array<{ type: string; text?: string }> -} - -function extractUserRequestPlanName(promptText: string): string | null { - const userRequestMatch = promptText.match(/\s*([\s\S]*?)\s*<\/user-request>/i) - if (!userRequestMatch) return null - - const rawArg = userRequestMatch[1].trim() - if (!rawArg) return null - - const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim() - return cleanedArg || null -} - -function findPlanByName(plans: string[], requestedName: string): string | null { - const lowerName = requestedName.toLowerCase() - - const exactMatch = plans.find(p => getPlanName(p).toLowerCase() === lowerName) - if (exactMatch) return exactMatch - - const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName)) - return partialMatch || null -} - -export function createStartWorkHook(ctx: PluginInput) { - return { - "chat.message": async ( - input: StartWorkHookInput, - output: StartWorkHookOutput - ): Promise => { - const parts = output.parts - const promptText = parts - ?.filter((p) => p.type === "text" && p.text) - .map((p) => p.text) - .join("\n") - .trim() || "" - - // Only trigger on actual command execution (contains tag) - // NOT on description text like "Start Sisyphus work session from Prometheus plan" - const isStartWorkCommand = promptText.includes("") - - if (!isStartWorkCommand) { - return - } - - log(`[${HOOK_NAME}] Processing start-work command`, { - sessionID: input.sessionID, - }) - - updateSessionAgent(input.sessionID, "atlas") // Always switch: fixes #1298 - - const existingState = readBoulderState(ctx.directory) - const sessionId = input.sessionID - const timestamp = new Date().toISOString() - - let contextInfo = "" - - const explicitPlanName = extractUserRequestPlanName(promptText) - - if (explicitPlanName) { - log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { - sessionID: input.sessionID, - }) - - const allPlans = findPrometheusPlans(ctx.directory) - const matchedPlan = findPlanByName(allPlans, explicitPlanName) - - if (matchedPlan) { - const progress = getPlanProgress(matchedPlan) - - if (progress.isComplete) { - contextInfo = ` -## Plan Already Complete - -The requested plan "${getPlanName(matchedPlan)}" has been completed. -All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` - } else { - if (existingState) { - clearBoulderState(ctx.directory) - } - const newState = createBoulderState(matchedPlan, sessionId, "atlas") - writeBoulderState(ctx.directory, newState) - - contextInfo = ` -## Auto-Selected Plan - -**Plan**: ${getPlanName(matchedPlan)} -**Path**: ${matchedPlan} -**Progress**: ${progress.completed}/${progress.total} tasks -**Session ID**: ${sessionId} -**Started**: ${timestamp} - -boulder.json has been created. Read the plan and begin execution.` - } - } else { - const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete) - if (incompletePlans.length > 0) { - const planList = incompletePlans.map((p, i) => { - const prog = getPlanProgress(p) - return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` - }).join("\n") - - contextInfo = ` -## Plan Not Found - -Could not find a plan matching "${explicitPlanName}". - -Available incomplete plans: -${planList} - -Ask the user which plan to work on.` - } else { - contextInfo = ` -## Plan Not Found - -Could not find a plan matching "${explicitPlanName}". -No incomplete plans available. Create a new plan with: /plan "your task"` - } - } - } else if (existingState) { - const progress = getPlanProgress(existingState.active_plan) - - if (!progress.isComplete) { - appendSessionId(ctx.directory, sessionId) - contextInfo = ` -## Active Work Session Found - -**Status**: RESUMING existing work -**Plan**: ${existingState.plan_name} -**Path**: ${existingState.active_plan} -**Progress**: ${progress.completed}/${progress.total} tasks completed -**Sessions**: ${existingState.session_ids.length + 1} (current session appended) -**Started**: ${existingState.started_at} - -The current session (${sessionId}) has been added to session_ids. -Read the plan file and continue from the first unchecked task.` - } else { - contextInfo = ` -## Previous Work Complete - -The previous plan (${existingState.plan_name}) has been completed. -Looking for new plans...` - } - } - - if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) { - const plans = findPrometheusPlans(ctx.directory) - const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete) - - if (plans.length === 0) { - contextInfo += ` - -## No Plans Found - -No Prometheus plan files found at .sisyphus/plans/ -Use Prometheus to create a work plan first: /plan "your task"` - } else if (incompletePlans.length === 0) { - contextInfo += ` - -## All Plans Complete - -All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"` - } else if (incompletePlans.length === 1) { - const planPath = incompletePlans[0] - const progress = getPlanProgress(planPath) - const newState = createBoulderState(planPath, sessionId, "atlas") - writeBoulderState(ctx.directory, newState) - - contextInfo += ` - -## Auto-Selected Plan - -**Plan**: ${getPlanName(planPath)} -**Path**: ${planPath} -**Progress**: ${progress.completed}/${progress.total} tasks -**Session ID**: ${sessionId} -**Started**: ${timestamp} - -boulder.json has been created. Read the plan and begin execution.` - } else { - const planList = incompletePlans.map((p, i) => { - const progress = getPlanProgress(p) - const stat = require("node:fs").statSync(p) - const modified = new Date(stat.mtimeMs).toISOString() - return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` - }).join("\n") - - contextInfo += ` - - -## Multiple Plans Found - -Current Time: ${timestamp} -Session ID: ${sessionId} - -${planList} - -Ask the user which plan to work on. Present the options above and wait for their response. -` - } - } - - const idx = output.parts.findIndex((p) => p.type === "text" && p.text) - if (idx >= 0 && output.parts[idx].text) { - output.parts[idx].text = output.parts[idx].text - .replace(/\$SESSION_ID/g, sessionId) - .replace(/\$TIMESTAMP/g, timestamp) - - output.parts[idx].text += `\n\n---\n${contextInfo}` - } - - log(`[${HOOK_NAME}] Context injected`, { - sessionID: input.sessionID, - hasExistingState: !!existingState, - }) - }, - } -} +export { HOOK_NAME, createStartWorkHook } from "./start-work-hook" diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts new file mode 100644 index 000000000..bd7e52d96 --- /dev/null +++ b/src/hooks/start-work/start-work-hook.ts @@ -0,0 +1,242 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { + readBoulderState, + writeBoulderState, + appendSessionId, + findPrometheusPlans, + getPlanProgress, + createBoulderState, + getPlanName, + clearBoulderState, +} from "../../features/boulder-state" +import { log } from "../../shared/logger" +import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state" + +export const HOOK_NAME = "start-work" as const + +const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi + +interface StartWorkHookInput { + sessionID: string + messageID?: string +} + +interface StartWorkHookOutput { + parts: Array<{ type: string; text?: string }> +} + +function extractUserRequestPlanName(promptText: string): string | null { + const userRequestMatch = promptText.match(/\s*([\s\S]*?)\s*<\/user-request>/i) + if (!userRequestMatch) return null + + const rawArg = userRequestMatch[1].trim() + if (!rawArg) return null + + const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim() + return cleanedArg || null +} + +function findPlanByName(plans: string[], requestedName: string): string | null { + const lowerName = requestedName.toLowerCase() + + const exactMatch = plans.find(p => getPlanName(p).toLowerCase() === lowerName) + if (exactMatch) return exactMatch + + const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName)) + return partialMatch || null +} + +export function createStartWorkHook(ctx: PluginInput) { + return { + "chat.message": async ( + input: StartWorkHookInput, + output: StartWorkHookOutput + ): Promise => { + const parts = output.parts + const promptText = parts + ?.filter((p) => p.type === "text" && p.text) + .map((p) => p.text) + .join("\n") + .trim() || "" + + // Only trigger on actual command execution (contains tag) + // NOT on description text like "Start Sisyphus work session from Prometheus plan" + const isStartWorkCommand = promptText.includes("") + + if (!isStartWorkCommand) { + return + } + + log(`[${HOOK_NAME}] Processing start-work command`, { + sessionID: input.sessionID, + }) + + updateSessionAgent(input.sessionID, "atlas") // Always switch: fixes #1298 + + const existingState = readBoulderState(ctx.directory) + const sessionId = input.sessionID + const timestamp = new Date().toISOString() + + let contextInfo = "" + + const explicitPlanName = extractUserRequestPlanName(promptText) + + if (explicitPlanName) { + log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { + sessionID: input.sessionID, + }) + + const allPlans = findPrometheusPlans(ctx.directory) + const matchedPlan = findPlanByName(allPlans, explicitPlanName) + + if (matchedPlan) { + const progress = getPlanProgress(matchedPlan) + + if (progress.isComplete) { + contextInfo = ` +## Plan Already Complete + +The requested plan "${getPlanName(matchedPlan)}" has been completed. +All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` + } else { + if (existingState) { + clearBoulderState(ctx.directory) + } + const newState = createBoulderState(matchedPlan, sessionId, "atlas") + writeBoulderState(ctx.directory, newState) + + contextInfo = ` +## Auto-Selected Plan + +**Plan**: ${getPlanName(matchedPlan)} +**Path**: ${matchedPlan} +**Progress**: ${progress.completed}/${progress.total} tasks +**Session ID**: ${sessionId} +**Started**: ${timestamp} + +boulder.json has been created. Read the plan and begin execution.` + } + } else { + const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete) + if (incompletePlans.length > 0) { + const planList = incompletePlans.map((p, i) => { + const prog = getPlanProgress(p) + return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` + }).join("\n") + + contextInfo = ` +## Plan Not Found + +Could not find a plan matching "${explicitPlanName}". + +Available incomplete plans: +${planList} + +Ask the user which plan to work on.` + } else { + contextInfo = ` +## Plan Not Found + +Could not find a plan matching "${explicitPlanName}". +No incomplete plans available. Create a new plan with: /plan "your task"` + } + } + } else if (existingState) { + const progress = getPlanProgress(existingState.active_plan) + + if (!progress.isComplete) { + appendSessionId(ctx.directory, sessionId) + contextInfo = ` +## Active Work Session Found + +**Status**: RESUMING existing work +**Plan**: ${existingState.plan_name} +**Path**: ${existingState.active_plan} +**Progress**: ${progress.completed}/${progress.total} tasks completed +**Sessions**: ${existingState.session_ids.length + 1} (current session appended) +**Started**: ${existingState.started_at} + +The current session (${sessionId}) has been added to session_ids. +Read the plan file and continue from the first unchecked task.` + } else { + contextInfo = ` +## Previous Work Complete + +The previous plan (${existingState.plan_name}) has been completed. +Looking for new plans...` + } + } + + if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) { + const plans = findPrometheusPlans(ctx.directory) + const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete) + + if (plans.length === 0) { + contextInfo += ` + +## No Plans Found + +No Prometheus plan files found at .sisyphus/plans/ +Use Prometheus to create a work plan first: /plan "your task"` + } else if (incompletePlans.length === 0) { + contextInfo += ` + +## All Plans Complete + +All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"` + } else if (incompletePlans.length === 1) { + const planPath = incompletePlans[0] + const progress = getPlanProgress(planPath) + const newState = createBoulderState(planPath, sessionId, "atlas") + writeBoulderState(ctx.directory, newState) + + contextInfo += ` + +## Auto-Selected Plan + +**Plan**: ${getPlanName(planPath)} +**Path**: ${planPath} +**Progress**: ${progress.completed}/${progress.total} tasks +**Session ID**: ${sessionId} +**Started**: ${timestamp} + +boulder.json has been created. Read the plan and begin execution.` + } else { + const planList = incompletePlans.map((p, i) => { + const progress = getPlanProgress(p) + const stat = require("node:fs").statSync(p) + const modified = new Date(stat.mtimeMs).toISOString() + return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` + }).join("\n") + + contextInfo += ` + + +## Multiple Plans Found + +Current Time: ${timestamp} +Session ID: ${sessionId} + +${planList} + +Ask the user which plan to work on. Present the options above and wait for their response. +` + } + } + + const idx = output.parts.findIndex((p) => p.type === "text" && p.text) + if (idx >= 0 && output.parts[idx].text) { + output.parts[idx].text = output.parts[idx].text + .replace(/\$SESSION_ID/g, sessionId) + .replace(/\$TIMESTAMP/g, timestamp) + + output.parts[idx].text += `\n\n---\n${contextInfo}` + } + + log(`[${HOOK_NAME}] Context injected`, { + sessionID: input.sessionID, + hasExistingState: !!existingState, + }) + }, + } +} diff --git a/src/hooks/todo-continuation-enforcer.ts b/src/hooks/todo-continuation-enforcer.ts deleted file mode 100644 index 3e3736beb..000000000 --- a/src/hooks/todo-continuation-enforcer.ts +++ /dev/null @@ -1,517 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import type { BackgroundManager } from "../features/background-agent" -import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state" -import { - findNearestMessageWithFields, - MESSAGE_STORAGE, - type ToolPermission, -} from "../features/hook-message-injector" -import { log } from "../shared/logger" -import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive" - -const HOOK_NAME = "todo-continuation-enforcer" - -const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"] - -export interface TodoContinuationEnforcerOptions { - backgroundManager?: BackgroundManager - skipAgents?: string[] - isContinuationStopped?: (sessionID: string) => boolean -} - -export interface TodoContinuationEnforcer { - handler: (input: { event: { type: string; properties?: unknown } }) => Promise - markRecovering: (sessionID: string) => void - markRecoveryComplete: (sessionID: string) => void - cancelAllCountdowns: () => void -} - -interface Todo { - content: string - status: string - priority: string - id: string -} - -interface SessionState { - countdownTimer?: ReturnType - countdownInterval?: ReturnType - isRecovering?: boolean - countdownStartedAt?: number - abortDetectedAt?: number -} - -const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)} - -Incomplete tasks remain in your todo list. Continue working on the next pending task. - -- Proceed without asking for permission -- Mark each task complete when finished -- Do not stop until all tasks are done` - -const COUNTDOWN_SECONDS = 2 -const TOAST_DURATION_MS = 900 -const COUNTDOWN_GRACE_PERIOD_MS = 500 - -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 getIncompleteCount(todos: Todo[]): number { - return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length -} - -interface MessageInfo { - id?: string - role?: string - error?: { name?: string; data?: unknown } -} - -function isLastAssistantMessageAborted(messages: Array<{ info?: MessageInfo }>): boolean { - if (!messages || messages.length === 0) return false - - const assistantMessages = messages.filter(m => m.info?.role === "assistant") - if (assistantMessages.length === 0) return false - - const lastAssistant = assistantMessages[assistantMessages.length - 1] - const errorName = lastAssistant.info?.error?.name - - if (!errorName) return false - - return errorName === "MessageAbortedError" || errorName === "AbortError" -} - -export function createTodoContinuationEnforcer( - ctx: PluginInput, - options: TodoContinuationEnforcerOptions = {} -): TodoContinuationEnforcer { - const { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped } = options - const sessions = new Map() - - function getState(sessionID: string): SessionState { - let state = sessions.get(sessionID) - if (!state) { - state = {} - sessions.set(sessionID, state) - } - return state - } - - function cancelCountdown(sessionID: string): void { - const state = sessions.get(sessionID) - if (!state) return - - if (state.countdownTimer) { - clearTimeout(state.countdownTimer) - state.countdownTimer = undefined - } - if (state.countdownInterval) { - clearInterval(state.countdownInterval) - state.countdownInterval = undefined - } - state.countdownStartedAt = undefined - } - - function cleanup(sessionID: string): void { - cancelCountdown(sessionID) - sessions.delete(sessionID) - } - - const markRecovering = (sessionID: string): void => { - const state = getState(sessionID) - state.isRecovering = true - cancelCountdown(sessionID) - log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID }) - } - - const markRecoveryComplete = (sessionID: string): void => { - const state = sessions.get(sessionID) - if (state) { - state.isRecovering = false - log(`[${HOOK_NAME}] Session recovery complete`, { sessionID }) - } - } - - async function showCountdownToast(seconds: number, incompleteCount: number): Promise { - await ctx.client.tui.showToast({ - body: { - title: "Todo Continuation", - message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`, - variant: "warning" as const, - duration: TOAST_DURATION_MS, - }, - }).catch(() => {}) - } - - interface ResolvedMessageInfo { - agent?: string - model?: { providerID: string; modelID: string } - tools?: Record - } - - async function injectContinuation( - sessionID: string, - incompleteCount: number, - total: number, - resolvedInfo?: ResolvedMessageInfo - ): Promise { - const state = sessions.get(sessionID) - - if (state?.isRecovering) { - log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID }) - return - } - - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID }) - return - } - - let todos: Todo[] = [] - try { - const response = await ctx.client.session.todo({ path: { id: sessionID } }) - todos = (response.data ?? response) as Todo[] - } catch (err) { - log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(err) }) - return - } - - const freshIncompleteCount = getIncompleteCount(todos) - if (freshIncompleteCount === 0) { - log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID }) - return - } - - let agentName = resolvedInfo?.agent - let model = resolvedInfo?.model - let tools = resolvedInfo?.tools - - if (!agentName || !model) { - const messageDir = getMessageDir(sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agentName = agentName ?? prevMessage?.agent - model = model ?? (prevMessage?.model?.providerID && prevMessage?.model?.modelID - ? { - providerID: prevMessage.model.providerID, - modelID: prevMessage.model.modelID, - ...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}) - } - : undefined) - tools = tools ?? prevMessage?.tools - } - - if (agentName && skipAgents.includes(agentName)) { - log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) - return - } - - const editPermission = tools?.edit - const writePermission = tools?.write - const hasWritePermission = !tools || - ((editPermission !== false && editPermission !== "deny") && - (writePermission !== false && writePermission !== "deny")) - if (!hasWritePermission) { - log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName }) - return - } - - const incompleteTodos = todos.filter(t => t.status !== "completed" && t.status !== "cancelled") - const todoList = incompleteTodos - .map(t => `- [${t.status}] ${t.content}`) - .join("\n") - const prompt = `${CONTINUATION_PROMPT} - -[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining] - -Remaining tasks: -${todoList}` - - try { - log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount }) - - await ctx.client.session.promptAsync({ - path: { id: sessionID }, - body: { - agent: agentName, - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: prompt }], - }, - query: { directory: ctx.directory }, - }) - - log(`[${HOOK_NAME}] Injection successful`, { sessionID }) - } catch (err) { - log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) }) - } - } - - function startCountdown( - sessionID: string, - incompleteCount: number, - total: number, - resolvedInfo?: ResolvedMessageInfo - ): void { - const state = getState(sessionID) - cancelCountdown(sessionID) - - let secondsRemaining = COUNTDOWN_SECONDS - showCountdownToast(secondsRemaining, incompleteCount) - state.countdownStartedAt = Date.now() - - state.countdownInterval = setInterval(() => { - secondsRemaining-- - if (secondsRemaining > 0) { - showCountdownToast(secondsRemaining, incompleteCount) - } - }, 1000) - - state.countdownTimer = setTimeout(() => { - cancelCountdown(sessionID) - injectContinuation(sessionID, incompleteCount, total, resolvedInfo) - }, COUNTDOWN_SECONDS * 1000) - - log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount }) - } - - const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { - const props = event.properties as Record | undefined - - if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const error = props?.error as { name?: string } | undefined - if (error?.name === "MessageAbortedError" || error?.name === "AbortError") { - const state = getState(sessionID) - state.abortDetectedAt = Date.now() - log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name }) - } - - cancelCountdown(sessionID) - log(`[${HOOK_NAME}] session.error`, { sessionID }) - return - } - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - log(`[${HOOK_NAME}] session.idle`, { sessionID }) - - const mainSessionID = getMainSessionID() - const isMainSession = sessionID === mainSessionID - const isBackgroundTaskSession = subagentSessions.has(sessionID) - - if (mainSessionID && !isMainSession && !isBackgroundTaskSession) { - log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID }) - return - } - - const state = getState(sessionID) - - if (state.isRecovering) { - log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) - return - } - - // Check 1: Event-based abort detection (primary, most reliable) - if (state.abortDetectedAt) { - const timeSinceAbort = Date.now() - state.abortDetectedAt - const ABORT_WINDOW_MS = 3000 - if (timeSinceAbort < ABORT_WINDOW_MS) { - log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID }) - state.abortDetectedAt = undefined - return - } - state.abortDetectedAt = undefined - } - - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) - return - } - - // Check 2: API-based abort detection (fallback, for cases where event was missed) - try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }) - const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? [] - - if (isLastAssistantMessageAborted(messages)) { - log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID }) - return - } - } catch (err) { - log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(err) }) - } - - let todos: Todo[] = [] - try { - const response = await ctx.client.session.todo({ path: { id: sessionID } }) - todos = (response.data ?? response) as Todo[] - } catch (err) { - log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(err) }) - return - } - - if (!todos || todos.length === 0) { - log(`[${HOOK_NAME}] No todos`, { sessionID }) - return - } - - const incompleteCount = getIncompleteCount(todos) - if (incompleteCount === 0) { - log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length }) - return - } - - let resolvedInfo: ResolvedMessageInfo | undefined - let hasCompactionMessage = false - try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { - agent?: string - model?: { providerID: string; modelID: string } - modelID?: string - providerID?: string - tools?: Record - } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent === "compaction") { - hasCompactionMessage = true - continue - } - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - resolvedInfo = { - agent: info.agent, - model: info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined), - tools: info.tools, - } - break - } - } - } catch (err) { - log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) }) - } - - log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage }) - if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) { - log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent }) - return - } - if (hasCompactionMessage && !resolvedInfo?.agent) { - log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID }) - return - } - - if (isContinuationStopped?.(sessionID)) { - log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) - return - } - - startCountdown(sessionID, incompleteCount, todos.length, resolvedInfo) - return - } - - if (event.type === "message.updated") { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - const role = info?.role as string | undefined - - if (!sessionID) return - - if (role === "user") { - const state = sessions.get(sessionID) - if (state?.countdownStartedAt) { - const elapsed = Date.now() - state.countdownStartedAt - if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) { - log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed }) - return - } - } - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - - if (role === "assistant") { - const state = sessions.get(sessionID) - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - return - } - - if (event.type === "message.part.updated") { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - const role = info?.role as string | undefined - - if (sessionID && role === "assistant") { - const state = sessions.get(sessionID) - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - return - } - - if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { - const sessionID = props?.sessionID as string | undefined - if (sessionID) { - const state = sessions.get(sessionID) - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - return - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - cleanup(sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) - } - return - } - } - - const cancelAllCountdowns = (): void => { - for (const sessionID of sessions.keys()) { - cancelCountdown(sessionID) - } - log(`[${HOOK_NAME}] All countdowns cancelled`) - } - - return { - handler, - markRecovering, - markRecoveryComplete, - cancelAllCountdowns, - } -} diff --git a/src/hooks/todo-continuation-enforcer/abort-detection.ts b/src/hooks/todo-continuation-enforcer/abort-detection.ts new file mode 100644 index 000000000..a9a8c2996 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/abort-detection.ts @@ -0,0 +1,17 @@ +import type { MessageInfo } from "./types" + +export function isLastAssistantMessageAborted( + messages: Array<{ info?: MessageInfo }> +): boolean { + if (!messages || messages.length === 0) return false + + const assistantMessages = messages.filter((message) => message.info?.role === "assistant") + if (assistantMessages.length === 0) return false + + const lastAssistant = assistantMessages[assistantMessages.length - 1] + const errorName = lastAssistant.info?.error?.name + + if (!errorName) return false + + return errorName === "MessageAbortedError" || errorName === "AbortError" +} diff --git a/src/hooks/todo-continuation-enforcer/constants.ts b/src/hooks/todo-continuation-enforcer/constants.ts new file mode 100644 index 000000000..03e7d01fd --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/constants.ts @@ -0,0 +1,19 @@ +import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive" + +export const HOOK_NAME = "todo-continuation-enforcer" + +export const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"] + +export const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)} + +Incomplete tasks remain in your todo list. Continue working on the next pending task. + +- Proceed without asking for permission +- Mark each task complete when finished +- Do not stop until all tasks are done` + +export const COUNTDOWN_SECONDS = 2 +export const TOAST_DURATION_MS = 900 +export const COUNTDOWN_GRACE_PERIOD_MS = 500 + +export const ABORT_WINDOW_MS = 3000 diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts new file mode 100644 index 000000000..2e8911323 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -0,0 +1,139 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import type { BackgroundManager } from "../../features/background-agent" +import { + findNearestMessageWithFields, + type ToolPermission, +} from "../../features/hook-message-injector" +import { log } from "../../shared/logger" + +import { + CONTINUATION_PROMPT, + DEFAULT_SKIP_AGENTS, + HOOK_NAME, +} from "./constants" +import { getMessageDir } from "./message-directory" +import { getIncompleteCount } from "./todo" +import type { ResolvedMessageInfo, Todo } from "./types" +import type { SessionStateStore } from "./session-state" + +function hasWritePermission(tools: Record | undefined): boolean { + const editPermission = tools?.edit + const writePermission = tools?.write + return ( + !tools || + (editPermission !== false && editPermission !== "deny" && writePermission !== false && writePermission !== "deny") + ) +} + +export async function injectContinuation(args: { + ctx: PluginInput + sessionID: string + backgroundManager?: BackgroundManager + skipAgents?: string[] + resolvedInfo?: ResolvedMessageInfo + sessionStateStore: SessionStateStore +}): Promise { + const { + ctx, + sessionID, + backgroundManager, + skipAgents = DEFAULT_SKIP_AGENTS, + resolvedInfo, + sessionStateStore, + } = args + + const state = sessionStateStore.getExistingState(sessionID) + if (state?.isRecovering) { + log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID }) + return + } + + const hasRunningBgTasks = backgroundManager + ? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running") + : false + + if (hasRunningBgTasks) { + log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID }) + return + } + + let todos: Todo[] = [] + try { + const response = await ctx.client.session.todo({ path: { id: sessionID } }) + todos = (response.data ?? response) as Todo[] + } catch (error) { + log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(error) }) + return + } + + const freshIncompleteCount = getIncompleteCount(todos) + if (freshIncompleteCount === 0) { + log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID }) + return + } + + let agentName = resolvedInfo?.agent + let model = resolvedInfo?.model + let tools = resolvedInfo?.tools + + if (!agentName || !model) { + const messageDir = getMessageDir(sessionID) + const previousMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agentName = agentName ?? previousMessage?.agent + model = + model ?? + (previousMessage?.model?.providerID && previousMessage?.model?.modelID + ? { + providerID: previousMessage.model.providerID, + modelID: previousMessage.model.modelID, + ...(previousMessage.model.variant + ? { variant: previousMessage.model.variant } + : {}), + } + : undefined) + tools = tools ?? previousMessage?.tools + } + + if (agentName && skipAgents.includes(agentName)) { + log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) + return + } + + if (!hasWritePermission(tools)) { + log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName }) + return + } + + const incompleteTodos = todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled") + const todoList = incompleteTodos.map((todo) => `- [${todo.status}] ${todo.content}`).join("\n") + const prompt = `${CONTINUATION_PROMPT} + +[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining] + +Remaining tasks: +${todoList}` + + try { + log(`[${HOOK_NAME}] Injecting continuation`, { + sessionID, + agent: agentName, + model, + incompleteCount: freshIncompleteCount, + }) + + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { + agent: agentName, + ...(model !== undefined ? { model } : {}), + parts: [{ type: "text", text: prompt }], + }, + query: { directory: ctx.directory }, + }) + + log(`[${HOOK_NAME}] Injection successful`, { sessionID }) + } catch (error) { + log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(error) }) + } +} diff --git a/src/hooks/todo-continuation-enforcer/countdown.ts b/src/hooks/todo-continuation-enforcer/countdown.ts new file mode 100644 index 000000000..7404d32d1 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/countdown.ts @@ -0,0 +1,83 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import type { BackgroundManager } from "../../features/background-agent" +import { log } from "../../shared/logger" + +import { + COUNTDOWN_SECONDS, + HOOK_NAME, + TOAST_DURATION_MS, +} from "./constants" +import type { ResolvedMessageInfo } from "./types" +import type { SessionStateStore } from "./session-state" +import { injectContinuation } from "./continuation-injection" + +async function showCountdownToast( + ctx: PluginInput, + seconds: number, + incompleteCount: number +): Promise { + await ctx.client.tui + .showToast({ + body: { + title: "Todo Continuation", + message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`, + variant: "warning" as const, + duration: TOAST_DURATION_MS, + }, + }) + .catch(() => {}) +} + +export function startCountdown(args: { + ctx: PluginInput + sessionID: string + incompleteCount: number + total: number + resolvedInfo?: ResolvedMessageInfo + backgroundManager?: BackgroundManager + skipAgents: string[] + sessionStateStore: SessionStateStore +}): void { + const { + ctx, + sessionID, + incompleteCount, + resolvedInfo, + backgroundManager, + skipAgents, + sessionStateStore, + } = args + + const state = sessionStateStore.getState(sessionID) + sessionStateStore.cancelCountdown(sessionID) + + let secondsRemaining = COUNTDOWN_SECONDS + showCountdownToast(ctx, secondsRemaining, incompleteCount) + state.countdownStartedAt = Date.now() + + state.countdownInterval = setInterval(() => { + secondsRemaining-- + if (secondsRemaining > 0) { + showCountdownToast(ctx, secondsRemaining, incompleteCount) + } + }, 1000) + + state.countdownTimer = setTimeout(() => { + sessionStateStore.cancelCountdown(sessionID) + injectContinuation({ + ctx, + sessionID, + backgroundManager, + skipAgents, + resolvedInfo, + sessionStateStore, + }) + }, COUNTDOWN_SECONDS * 1000) + + log(`[${HOOK_NAME}] Countdown started`, { + sessionID, + seconds: COUNTDOWN_SECONDS, + incompleteCount, + }) +} diff --git a/src/hooks/todo-continuation-enforcer/handler.ts b/src/hooks/todo-continuation-enforcer/handler.ts new file mode 100644 index 000000000..9e96559db --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -0,0 +1,65 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import type { BackgroundManager } from "../../features/background-agent" +import { log } from "../../shared/logger" + +import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants" +import type { SessionStateStore } from "./session-state" +import { handleSessionIdle } from "./idle-event" +import { handleNonIdleEvent } from "./non-idle-events" + +export function createTodoContinuationHandler(args: { + ctx: PluginInput + sessionStateStore: SessionStateStore + backgroundManager?: BackgroundManager + skipAgents?: string[] + isContinuationStopped?: (sessionID: string) => boolean +}): (input: { event: { type: string; properties?: unknown } }) => Promise { + const { + ctx, + sessionStateStore, + backgroundManager, + skipAgents = DEFAULT_SKIP_AGENTS, + isContinuationStopped, + } = args + + return async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { + const props = event.properties as Record | undefined + + if (event.type === "session.error") { + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + const error = props?.error as { name?: string } | undefined + if (error?.name === "MessageAbortedError" || error?.name === "AbortError") { + const state = sessionStateStore.getState(sessionID) + state.abortDetectedAt = Date.now() + log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name }) + } + + sessionStateStore.cancelCountdown(sessionID) + log(`[${HOOK_NAME}] session.error`, { sessionID }) + return + } + + if (event.type === "session.idle") { + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + await handleSessionIdle({ + ctx, + sessionID, + sessionStateStore, + backgroundManager, + skipAgents, + isContinuationStopped, + }) + return + } + + handleNonIdleEvent({ + eventType: event.type, + properties: props, + sessionStateStore, + }) + } +} diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts new file mode 100644 index 000000000..62a945872 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -0,0 +1,158 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import type { BackgroundManager } from "../../features/background-agent" +import { getMainSessionID, subagentSessions } from "../../features/claude-code-session-state" +import type { ToolPermission } from "../../features/hook-message-injector" +import { log } from "../../shared/logger" + +import { + ABORT_WINDOW_MS, + DEFAULT_SKIP_AGENTS, + HOOK_NAME, +} from "./constants" +import { isLastAssistantMessageAborted } from "./abort-detection" +import { getIncompleteCount } from "./todo" +import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types" +import type { SessionStateStore } from "./session-state" +import { startCountdown } from "./countdown" + +export async function handleSessionIdle(args: { + ctx: PluginInput + sessionID: string + sessionStateStore: SessionStateStore + backgroundManager?: BackgroundManager + skipAgents?: string[] + isContinuationStopped?: (sessionID: string) => boolean +}): Promise { + const { + ctx, + sessionID, + sessionStateStore, + backgroundManager, + skipAgents = DEFAULT_SKIP_AGENTS, + isContinuationStopped, + } = args + + log(`[${HOOK_NAME}] session.idle`, { sessionID }) + + const mainSessionID = getMainSessionID() + const isMainSession = sessionID === mainSessionID + const isBackgroundTaskSession = subagentSessions.has(sessionID) + + if (mainSessionID && !isMainSession && !isBackgroundTaskSession) { + log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID }) + return + } + + const state = sessionStateStore.getState(sessionID) + if (state.isRecovering) { + log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) + return + } + + if (state.abortDetectedAt) { + const timeSinceAbort = Date.now() - state.abortDetectedAt + if (timeSinceAbort < ABORT_WINDOW_MS) { + log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID }) + state.abortDetectedAt = undefined + return + } + state.abortDetectedAt = undefined + } + + const hasRunningBgTasks = backgroundManager + ? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running") + : false + + if (hasRunningBgTasks) { + log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) + return + } + + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }) + const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? [] + if (isLastAssistantMessageAborted(messages)) { + log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID }) + return + } + } catch (error) { + log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(error) }) + } + + let todos: Todo[] = [] + try { + const response = await ctx.client.session.todo({ path: { id: sessionID } }) + todos = (response.data ?? response) as Todo[] + } catch (error) { + log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(error) }) + return + } + + if (!todos || todos.length === 0) { + log(`[${HOOK_NAME}] No todos`, { sessionID }) + return + } + + const incompleteCount = getIncompleteCount(todos) + if (incompleteCount === 0) { + log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length }) + return + } + + let resolvedInfo: ResolvedMessageInfo | undefined + let hasCompactionMessage = false + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }> + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i].info + if (info?.agent === "compaction") { + hasCompactionMessage = true + continue + } + if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { + resolvedInfo = { + agent: info.agent, + model: info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined), + tools: info.tools as Record | undefined, + } + break + } + } + } catch (error) { + log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(error) }) + } + + log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage }) + + if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) { + log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent }) + return + } + if (hasCompactionMessage && !resolvedInfo?.agent) { + log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID }) + return + } + + if (isContinuationStopped?.(sessionID)) { + log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) + return + } + + startCountdown({ + ctx, + sessionID, + incompleteCount, + total: todos.length, + resolvedInfo, + backgroundManager, + skipAgents, + sessionStateStore, + }) +} diff --git a/src/hooks/todo-continuation-enforcer/index.ts b/src/hooks/todo-continuation-enforcer/index.ts new file mode 100644 index 000000000..85a2a6bc9 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/index.ts @@ -0,0 +1,58 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { log } from "../../shared/logger" + +import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants" +import { createTodoContinuationHandler } from "./handler" +import { createSessionStateStore } from "./session-state" +import type { TodoContinuationEnforcer, TodoContinuationEnforcerOptions } from "./types" + +export type { TodoContinuationEnforcer, TodoContinuationEnforcerOptions } from "./types" + +export function createTodoContinuationEnforcer( + ctx: PluginInput, + options: TodoContinuationEnforcerOptions = {} +): TodoContinuationEnforcer { + const { + backgroundManager, + skipAgents = DEFAULT_SKIP_AGENTS, + isContinuationStopped, + } = options + + const sessionStateStore = createSessionStateStore() + + const markRecovering = (sessionID: string): void => { + const state = sessionStateStore.getState(sessionID) + state.isRecovering = true + sessionStateStore.cancelCountdown(sessionID) + log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID }) + } + + const markRecoveryComplete = (sessionID: string): void => { + const state = sessionStateStore.getExistingState(sessionID) + if (state) { + state.isRecovering = false + log(`[${HOOK_NAME}] Session recovery complete`, { sessionID }) + } + } + + const handler = createTodoContinuationHandler({ + ctx, + sessionStateStore, + backgroundManager, + skipAgents, + isContinuationStopped, + }) + + const cancelAllCountdowns = (): void => { + sessionStateStore.cancelAllCountdowns() + log(`[${HOOK_NAME}] All countdowns cancelled`) + } + + return { + handler, + markRecovering, + markRecoveryComplete, + cancelAllCountdowns, + } +} diff --git a/src/hooks/todo-continuation-enforcer/message-directory.ts b/src/hooks/todo-continuation-enforcer/message-directory.ts new file mode 100644 index 000000000..85e682427 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/message-directory.ts @@ -0,0 +1,18 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" + +import { MESSAGE_STORAGE } from "../../features/hook-message-injector" + +export 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 +} diff --git a/src/hooks/todo-continuation-enforcer/non-idle-events.ts b/src/hooks/todo-continuation-enforcer/non-idle-events.ts new file mode 100644 index 000000000..dc4677047 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/non-idle-events.ts @@ -0,0 +1,74 @@ +import { log } from "../../shared/logger" + +import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants" +import type { SessionStateStore } from "./session-state" + +export function handleNonIdleEvent(args: { + eventType: string + properties: Record | undefined + sessionStateStore: SessionStateStore +}): void { + const { eventType, properties, sessionStateStore } = args + + if (eventType === "message.updated") { + const info = properties?.info as Record | undefined + const sessionID = info?.sessionID as string | undefined + const role = info?.role as string | undefined + if (!sessionID) return + + if (role === "user") { + const state = sessionStateStore.getExistingState(sessionID) + if (state?.countdownStartedAt) { + const elapsed = Date.now() - state.countdownStartedAt + if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) { + log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed }) + return + } + } + if (state) state.abortDetectedAt = undefined + sessionStateStore.cancelCountdown(sessionID) + return + } + + if (role === "assistant") { + const state = sessionStateStore.getExistingState(sessionID) + if (state) state.abortDetectedAt = undefined + sessionStateStore.cancelCountdown(sessionID) + return + } + + return + } + + if (eventType === "message.part.updated") { + const info = properties?.info as Record | undefined + const sessionID = info?.sessionID as string | undefined + const role = info?.role as string | undefined + + if (sessionID && role === "assistant") { + const state = sessionStateStore.getExistingState(sessionID) + if (state) state.abortDetectedAt = undefined + sessionStateStore.cancelCountdown(sessionID) + } + return + } + + if (eventType === "tool.execute.before" || eventType === "tool.execute.after") { + const sessionID = properties?.sessionID as string | undefined + if (sessionID) { + const state = sessionStateStore.getExistingState(sessionID) + if (state) state.abortDetectedAt = undefined + sessionStateStore.cancelCountdown(sessionID) + } + return + } + + if (eventType === "session.deleted") { + const sessionInfo = properties?.info as { id?: string } | undefined + if (sessionInfo?.id) { + sessionStateStore.cleanup(sessionInfo.id) + log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) + } + return + } +} diff --git a/src/hooks/todo-continuation-enforcer/session-state.ts b/src/hooks/todo-continuation-enforcer/session-state.ts new file mode 100644 index 000000000..fc96437ab --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/session-state.ts @@ -0,0 +1,62 @@ +import type { SessionState } from "./types" + +export interface SessionStateStore { + getState: (sessionID: string) => SessionState + getExistingState: (sessionID: string) => SessionState | undefined + cancelCountdown: (sessionID: string) => void + cleanup: (sessionID: string) => void + cancelAllCountdowns: () => void +} + +export function createSessionStateStore(): SessionStateStore { + const sessions = new Map() + + function getState(sessionID: string): SessionState { + const existingState = sessions.get(sessionID) + if (existingState) return existingState + + const state: SessionState = {} + sessions.set(sessionID, state) + return state + } + + function getExistingState(sessionID: string): SessionState | undefined { + return sessions.get(sessionID) + } + + function cancelCountdown(sessionID: string): void { + const state = sessions.get(sessionID) + if (!state) return + + if (state.countdownTimer) { + clearTimeout(state.countdownTimer) + state.countdownTimer = undefined + } + + if (state.countdownInterval) { + clearInterval(state.countdownInterval) + state.countdownInterval = undefined + } + + state.countdownStartedAt = undefined + } + + function cleanup(sessionID: string): void { + cancelCountdown(sessionID) + sessions.delete(sessionID) + } + + function cancelAllCountdowns(): void { + for (const sessionID of sessions.keys()) { + cancelCountdown(sessionID) + } + } + + return { + getState, + getExistingState, + cancelCountdown, + cleanup, + cancelAllCountdowns, + } +} diff --git a/src/hooks/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts similarity index 99% rename from src/hooks/todo-continuation-enforcer.test.ts rename to src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index 626d5c951..ba8ba212a 100644 --- a/src/hooks/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import type { BackgroundManager } from "../features/background-agent" -import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state" -import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer" +import type { BackgroundManager } from "../../features/background-agent" +import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state" +import { createTodoContinuationEnforcer } from "." type TimerCallback = (...args: any[]) => void diff --git a/src/hooks/todo-continuation-enforcer/todo.ts b/src/hooks/todo-continuation-enforcer/todo.ts new file mode 100644 index 000000000..dbc6f5b61 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/todo.ts @@ -0,0 +1,5 @@ +import type { Todo } from "./types" + +export function getIncompleteCount(todos: Todo[]): number { + return todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled").length +} diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts new file mode 100644 index 000000000..8ef8745fa --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -0,0 +1,47 @@ +import type { BackgroundManager } from "../../features/background-agent" +import type { ToolPermission } from "../../features/hook-message-injector" + +export interface TodoContinuationEnforcerOptions { + backgroundManager?: BackgroundManager + skipAgents?: string[] + isContinuationStopped?: (sessionID: string) => boolean +} + +export interface TodoContinuationEnforcer { + handler: (input: { event: { type: string; properties?: unknown } }) => Promise + markRecovering: (sessionID: string) => void + markRecoveryComplete: (sessionID: string) => void + cancelAllCountdowns: () => void +} + +export interface Todo { + content: string + status: string + priority: string + id: string +} + +export interface SessionState { + countdownTimer?: ReturnType + countdownInterval?: ReturnType + isRecovering?: boolean + countdownStartedAt?: number + abortDetectedAt?: number +} + +export interface MessageInfo { + id?: string + role?: string + error?: { name?: string; data?: unknown } + agent?: string + model?: { providerID: string; modelID: string } + providerID?: string + modelID?: string + tools?: Record +} + +export interface ResolvedMessageInfo { + agent?: string + model?: { providerID: string; modelID: string } + tools?: Record +} diff --git a/src/hooks/unstable-agent-babysitter/index.ts b/src/hooks/unstable-agent-babysitter/index.ts index e492281a2..1850f1869 100644 --- a/src/hooks/unstable-agent-babysitter/index.ts +++ b/src/hooks/unstable-agent-babysitter/index.ts @@ -1,250 +1 @@ -import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" -import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state" -import { log } from "../../shared/logger" - -const HOOK_NAME = "unstable-agent-babysitter" -const DEFAULT_TIMEOUT_MS = 120000 -const COOLDOWN_MS = 5 * 60 * 1000 -const THINKING_SUMMARY_MAX_CHARS = 500 - -type BabysittingConfig = { - timeout_ms?: number -} - -type BabysitterContext = { - directory: string - client: { - session: { - messages: (args: { path: { id: string } }) => Promise<{ data?: unknown } | unknown[]> - prompt: (args: { - path: { id: string } - body: { - parts: Array<{ type: "text"; text: string }> - agent?: string - model?: { providerID: string; modelID: string } - } - query?: { directory?: string } - }) => Promise - promptAsync: (args: { - path: { id: string } - body: { - parts: Array<{ type: "text"; text: string }> - agent?: string - model?: { providerID: string; modelID: string } - } - query?: { directory?: string } - }) => Promise - } - } -} - -type BabysitterOptions = { - backgroundManager: Pick - config?: BabysittingConfig -} - -type MessageInfo = { - role?: string - agent?: string - model?: { providerID: string; modelID: string } - providerID?: string - modelID?: string -} - -type MessagePart = { - type?: string - text?: string - thinking?: string -} - -function hasData(value: unknown): value is { data?: unknown } { - return typeof value === "object" && value !== null && "data" in value -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} - -function getMessageInfo(value: unknown): MessageInfo | undefined { - if (!isRecord(value)) return undefined - if (!isRecord(value.info)) return undefined - const info = value.info - const modelValue = isRecord(info.model) - ? info.model - : undefined - const model = modelValue && typeof modelValue.providerID === "string" && typeof modelValue.modelID === "string" - ? { providerID: modelValue.providerID, modelID: modelValue.modelID } - : undefined - return { - role: typeof info.role === "string" ? info.role : undefined, - agent: typeof info.agent === "string" ? info.agent : undefined, - model, - providerID: typeof info.providerID === "string" ? info.providerID : undefined, - modelID: typeof info.modelID === "string" ? info.modelID : undefined, - } -} - -function getMessageParts(value: unknown): MessagePart[] { - if (!isRecord(value)) return [] - if (!Array.isArray(value.parts)) return [] - return value.parts.filter(isRecord).map((part) => ({ - type: typeof part.type === "string" ? part.type : undefined, - text: typeof part.text === "string" ? part.text : undefined, - thinking: typeof part.thinking === "string" ? part.thinking : undefined, - })) -} - -function extractMessages(value: unknown): unknown[] { - if (Array.isArray(value)) { - return value - } - if (hasData(value) && Array.isArray(value.data)) { - return value.data - } - return [] -} - -function isUnstableTask(task: BackgroundTask): boolean { - if (task.isUnstableAgent === true) return true - const modelId = task.model?.modelID?.toLowerCase() - return modelId ? modelId.includes("gemini") || modelId.includes("minimax") : false -} - -async function resolveMainSessionTarget( - ctx: BabysitterContext, - sessionID: string -): Promise<{ agent?: string; model?: { providerID: string; modelID: string } }> { - let agent = getSessionAgent(sessionID) - let model: { providerID: string; modelID: string } | undefined - - try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - }) - const messages = extractMessages(messagesResp) - for (let i = messages.length - 1; i >= 0; i--) { - const info = getMessageInfo(messages[i]) - if (info?.agent || info?.model || (info?.providerID && info?.modelID)) { - agent = agent ?? info?.agent - model = info?.model ?? (info?.providerID && info?.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) - break - } - } - } catch (error) { - log(`[${HOOK_NAME}] Failed to resolve main session agent`, { sessionID, error: String(error) }) - } - - return { agent, model } -} - -async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Promise { - try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - }) - const messages = extractMessages(messagesResp) - const chunks: string[] = [] - - for (const message of messages) { - const info = getMessageInfo(message) - if (info?.role !== "assistant") continue - const parts = getMessageParts(message) - for (const part of parts) { - if (part.type === "thinking" && part.thinking) { - chunks.push(part.thinking) - } - if (part.type === "reasoning" && part.text) { - chunks.push(part.text) - } - } - } - - const combined = chunks.join("\n").trim() - if (!combined) return null - if (combined.length <= THINKING_SUMMARY_MAX_CHARS) return combined - return combined.slice(0, THINKING_SUMMARY_MAX_CHARS) + "..." - } catch (error) { - log(`[${HOOK_NAME}] Failed to fetch thinking summary`, { sessionID, error: String(error) }) - return null - } -} - -function buildReminder(task: BackgroundTask, summary: string | null, idleMs: number): string { - const idleSeconds = Math.round(idleMs / 1000) - const summaryText = summary ?? "(No thinking trace available)" - return `Unstable background agent appears idle for ${idleSeconds}s. - -Task ID: ${task.id} -Description: ${task.description} -Agent: ${task.agent} -Status: ${task.status} -Session ID: ${task.sessionID ?? "N/A"} - -Thinking summary (first ${THINKING_SUMMARY_MAX_CHARS} chars): -${summaryText} - -Suggested actions: -- background_output task_id="${task.id}" full_session=true include_thinking=true include_tool_results=true message_limit=50 -- background_cancel taskId="${task.id}" - -This is a reminder only. No automatic action was taken.` -} - -export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) { - const reminderCooldowns = new Map() - - const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { - if (event.type !== "session.idle") return - - const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const mainSessionID = getMainSessionID() - if (!mainSessionID || sessionID !== mainSessionID) return - - const tasks = options.backgroundManager.getTasksByParentSession(mainSessionID) - if (tasks.length === 0) return - - const timeoutMs = options.config?.timeout_ms ?? DEFAULT_TIMEOUT_MS - const now = Date.now() - - for (const task of tasks) { - if (task.status !== "running") continue - if (!isUnstableTask(task)) continue - - const lastMessageAt = task.progress?.lastMessageAt - if (!lastMessageAt) continue - - const idleMs = now - lastMessageAt.getTime() - if (idleMs < timeoutMs) continue - - const lastReminderAt = reminderCooldowns.get(task.id) - if (lastReminderAt && now - lastReminderAt < COOLDOWN_MS) continue - - const summary = task.sessionID ? await getThinkingSummary(ctx, task.sessionID) : null - const reminder = buildReminder(task, summary, idleMs) - const { agent, model } = await resolveMainSessionTarget(ctx, mainSessionID) - - try { - await ctx.client.session.promptAsync({ - path: { id: mainSessionID }, - body: { - ...(agent ? { agent } : {}), - ...(model ? { model } : {}), - parts: [{ type: "text", text: reminder }], - }, - query: { directory: ctx.directory }, - }) - reminderCooldowns.set(task.id, now) - log(`[${HOOK_NAME}] Reminder injected`, { taskId: task.id, sessionID: mainSessionID }) - } catch (error) { - log(`[${HOOK_NAME}] Reminder injection failed`, { taskId: task.id, error: String(error) }) - } - } - } - - return { - event: eventHandler, - } -} +export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter-hook" diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts new file mode 100644 index 000000000..a7b2b551f --- /dev/null +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -0,0 +1,250 @@ +import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" +import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state" +import { log } from "../../shared/logger" + +const HOOK_NAME = "unstable-agent-babysitter" +const DEFAULT_TIMEOUT_MS = 120000 +const COOLDOWN_MS = 5 * 60 * 1000 +const THINKING_SUMMARY_MAX_CHARS = 500 as const + +type BabysittingConfig = { + timeout_ms?: number +} + +type BabysitterContext = { + directory: string + client: { + session: { + messages: (args: { path: { id: string } }) => Promise<{ data?: unknown } | unknown[]> + prompt: (args: { + path: { id: string } + body: { + parts: Array<{ type: "text"; text: string }> + agent?: string + model?: { providerID: string; modelID: string } + } + query?: { directory?: string } + }) => Promise + promptAsync: (args: { + path: { id: string } + body: { + parts: Array<{ type: "text"; text: string }> + agent?: string + model?: { providerID: string; modelID: string } + } + query?: { directory?: string } + }) => Promise + } + } +} + +type BabysitterOptions = { + backgroundManager: Pick + config?: BabysittingConfig +} + +type MessageInfo = { + role?: string + agent?: string + model?: { providerID: string; modelID: string } + providerID?: string + modelID?: string +} + +type MessagePart = { + type?: string + text?: string + thinking?: string +} + +function hasData(value: unknown): value is { data?: unknown } { + return typeof value === "object" && value !== null && "data" in value +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function getMessageInfo(value: unknown): MessageInfo | undefined { + if (!isRecord(value)) return undefined + if (!isRecord(value.info)) return undefined + const info = value.info + const modelValue = isRecord(info.model) + ? info.model + : undefined + const model = modelValue && typeof modelValue.providerID === "string" && typeof modelValue.modelID === "string" + ? { providerID: modelValue.providerID, modelID: modelValue.modelID } + : undefined + return { + role: typeof info.role === "string" ? info.role : undefined, + agent: typeof info.agent === "string" ? info.agent : undefined, + model, + providerID: typeof info.providerID === "string" ? info.providerID : undefined, + modelID: typeof info.modelID === "string" ? info.modelID : undefined, + } +} + +function getMessageParts(value: unknown): MessagePart[] { + if (!isRecord(value)) return [] + if (!Array.isArray(value.parts)) return [] + return value.parts.filter(isRecord).map((part) => ({ + type: typeof part.type === "string" ? part.type : undefined, + text: typeof part.text === "string" ? part.text : undefined, + thinking: typeof part.thinking === "string" ? part.thinking : undefined, + })) +} + +function extractMessages(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value + } + if (hasData(value) && Array.isArray(value.data)) { + return value.data + } + return [] +} + +function isUnstableTask(task: BackgroundTask): boolean { + if (task.isUnstableAgent === true) return true + const modelId = task.model?.modelID?.toLowerCase() + return modelId ? modelId.includes("gemini") || modelId.includes("minimax") : false +} + +async function resolveMainSessionTarget( + ctx: BabysitterContext, + sessionID: string +): Promise<{ agent?: string; model?: { providerID: string; modelID: string } }> { + let agent = getSessionAgent(sessionID) + let model: { providerID: string; modelID: string } | undefined + + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + const messages = extractMessages(messagesResp) + for (let i = messages.length - 1; i >= 0; i--) { + const info = getMessageInfo(messages[i]) + if (info?.agent || info?.model || (info?.providerID && info?.modelID)) { + agent = agent ?? info?.agent + model = info?.model ?? (info?.providerID && info?.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) + break + } + } + } catch (error) { + log(`[${HOOK_NAME}] Failed to resolve main session agent`, { sessionID, error: String(error) }) + } + + return { agent, model } +} + +async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Promise { + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + const messages = extractMessages(messagesResp) + const chunks: string[] = [] + + for (const message of messages) { + const info = getMessageInfo(message) + if (info?.role !== "assistant") continue + const parts = getMessageParts(message) + for (const part of parts) { + if (part.type === "thinking" && part.thinking) { + chunks.push(part.thinking) + } + if (part.type === "reasoning" && part.text) { + chunks.push(part.text) + } + } + } + + const combined = chunks.join("\n").trim() + if (!combined) return null + if (combined.length <= THINKING_SUMMARY_MAX_CHARS) return combined + return combined.slice(0, THINKING_SUMMARY_MAX_CHARS) + "..." + } catch (error) { + log(`[${HOOK_NAME}] Failed to fetch thinking summary`, { sessionID, error: String(error) }) + return null + } +} + +function buildReminder(task: BackgroundTask, summary: string | null, idleMs: number): string { + const idleSeconds = Math.round(idleMs / 1000) + const summaryText = summary ?? "(No thinking trace available)" + return `Unstable background agent appears idle for ${idleSeconds}s. + +Task ID: ${task.id} +Description: ${task.description} +Agent: ${task.agent} +Status: ${task.status} +Session ID: ${task.sessionID ?? "N/A"} + +Thinking summary (first ${THINKING_SUMMARY_MAX_CHARS} chars): +${summaryText} + +Suggested actions: +- background_output task_id="${task.id}" full_session=true include_thinking=true include_tool_results=true message_limit=50 +- background_cancel taskId="${task.id}" + +This is a reminder only. No automatic action was taken.` +} + +export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) { + const reminderCooldowns = new Map() + + const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { + if (event.type !== "session.idle") return + + const props = event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + const mainSessionID = getMainSessionID() + if (!mainSessionID || sessionID !== mainSessionID) return + + const tasks = options.backgroundManager.getTasksByParentSession(mainSessionID) + if (tasks.length === 0) return + + const timeoutMs = options.config?.timeout_ms ?? DEFAULT_TIMEOUT_MS + const now = Date.now() + + for (const task of tasks) { + if (task.status !== "running") continue + if (!isUnstableTask(task)) continue + + const lastMessageAt = task.progress?.lastMessageAt + if (!lastMessageAt) continue + + const idleMs = now - lastMessageAt.getTime() + if (idleMs < timeoutMs) continue + + const lastReminderAt = reminderCooldowns.get(task.id) + if (lastReminderAt && now - lastReminderAt < COOLDOWN_MS) continue + + const summary = task.sessionID ? await getThinkingSummary(ctx, task.sessionID) : null + const reminder = buildReminder(task, summary, idleMs) + const { agent, model } = await resolveMainSessionTarget(ctx, mainSessionID) + + try { + await ctx.client.session.promptAsync({ + path: { id: mainSessionID }, + body: { + ...(agent ? { agent } : {}), + ...(model ? { model } : {}), + parts: [{ type: "text", text: reminder }], + }, + query: { directory: ctx.directory }, + }) + reminderCooldowns.set(task.id, now) + log(`[${HOOK_NAME}] Reminder injected`, { taskId: task.id, sessionID: mainSessionID }) + } catch (error) { + log(`[${HOOK_NAME}] Reminder injection failed`, { taskId: task.id, error: String(error) }) + } + } + } + + return { + event: eventHandler, + } +} diff --git a/src/shared/command-executor.ts b/src/shared/command-executor.ts index 9baa85aa1..5efc0491c 100644 --- a/src/shared/command-executor.ts +++ b/src/shared/command-executor.ts @@ -1,225 +1,5 @@ -import { spawn } from "child_process" -import { exec } from "child_process" -import { promisify } from "util" -import { existsSync } from "fs" -import { homedir } from "os" +export { executeHookCommand } from "./command-executor/execute-hook-command" +export type { CommandResult, ExecuteHookOptions } from "./command-executor/execute-hook-command" -const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"] -const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] - -function getHomeDir(): string { - return process.env.HOME || process.env.USERPROFILE || homedir() -} - -function findShellPath(defaultPaths: string[], customPath?: string): string | null { - if (customPath && existsSync(customPath)) { - return customPath - } - for (const path of defaultPaths) { - if (existsSync(path)) { - return path - } - } - return null -} - -function findZshPath(customZshPath?: string): string | null { - return findShellPath(DEFAULT_ZSH_PATHS, customZshPath) -} - -function findBashPath(): string | null { - return findShellPath(DEFAULT_BASH_PATHS) -} - -const execAsync = promisify(exec) - -export interface CommandResult { - exitCode: number - stdout?: string - stderr?: string -} - -export interface ExecuteHookOptions { - forceZsh?: boolean - zshPath?: string -} - -/** - * Execute a hook command with stdin input - */ -export async function executeHookCommand( - command: string, - stdin: string, - cwd: string, - options?: ExecuteHookOptions -): Promise { - const home = getHomeDir() - - let expandedCommand = command - .replace(/^~(?=\/|$)/g, home) - .replace(/\s~(?=\/)/g, ` ${home}`) - .replace(/\$CLAUDE_PROJECT_DIR/g, cwd) - .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd) - - let finalCommand = expandedCommand - - if (options?.forceZsh) { - // Always verify shell exists before using it - const zshPath = findZshPath(options.zshPath) - const escapedCommand = expandedCommand.replace(/'/g, "'\\''") - if (zshPath) { - finalCommand = `${zshPath} -lc '${escapedCommand}'` - } else { - // Fall back to bash login shell to preserve PATH from user profile - const bashPath = findBashPath() - if (bashPath) { - finalCommand = `${bashPath} -lc '${escapedCommand}'` - } - // If neither zsh nor bash found, fall through to spawn with shell: true - } - } - - return new Promise((resolve) => { - const proc = spawn(finalCommand, { - cwd, - shell: true, - env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd }, - }) - - let stdout = "" - let stderr = "" - - proc.stdout?.on("data", (data) => { - stdout += data.toString() - }) - - proc.stderr?.on("data", (data) => { - stderr += data.toString() - }) - - proc.stdin?.write(stdin) - proc.stdin?.end() - - proc.on("close", (code) => { - resolve({ - exitCode: code ?? 0, - stdout: stdout.trim(), - stderr: stderr.trim(), - }) - }) - - proc.on("error", (err) => { - resolve({ - exitCode: 1, - stderr: err.message, - }) - }) - }) -} - -/** - * Execute a simple command and return output - */ -export async function executeCommand(command: string): Promise { - try { - const { stdout, stderr } = await execAsync(command) - - const out = stdout?.toString().trim() ?? "" - const err = stderr?.toString().trim() ?? "" - - if (err) { - if (out) { - return `${out}\n[stderr: ${err}]` - } - return `[stderr: ${err}]` - } - - return out - } catch (error: unknown) { - const e = error as { stdout?: Buffer; stderr?: Buffer; message?: string } - const stdout = e?.stdout?.toString().trim() ?? "" - const stderr = e?.stderr?.toString().trim() ?? "" - const errMsg = stderr || e?.message || String(error) - - if (stdout) { - return `${stdout}\n[stderr: ${errMsg}]` - } - return `[stderr: ${errMsg}]` - } -} - -/** - * Find and execute embedded commands in text (!`command`) - */ -interface CommandMatch { - fullMatch: string - command: string - start: number - end: number -} - -const COMMAND_PATTERN = /!`([^`]+)`/g - -function findCommands(text: string): CommandMatch[] { - const matches: CommandMatch[] = [] - let match: RegExpExecArray | null - - COMMAND_PATTERN.lastIndex = 0 - - while ((match = COMMAND_PATTERN.exec(text)) !== null) { - matches.push({ - fullMatch: match[0], - command: match[1], - start: match.index, - end: match.index + match[0].length, - }) - } - - return matches -} - -/** - * Resolve embedded commands in text recursively - */ -export async function resolveCommandsInText( - text: string, - depth: number = 0, - maxDepth: number = 3 -): Promise { - if (depth >= maxDepth) { - return text - } - - const matches = findCommands(text) - if (matches.length === 0) { - return text - } - - const tasks = matches.map((m) => executeCommand(m.command)) - const results = await Promise.allSettled(tasks) - - const replacements = new Map() - - matches.forEach((match, idx) => { - const result = results[idx] - if (result.status === "rejected") { - replacements.set( - match.fullMatch, - `[error: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}]` - ) - } else { - replacements.set(match.fullMatch, result.value) - } - }) - - let resolved = text - for (const [pattern, replacement] of replacements.entries()) { - resolved = resolved.split(pattern).join(replacement) - } - - if (findCommands(resolved).length > 0) { - return resolveCommandsInText(resolved, depth + 1, maxDepth) - } - - return resolved -} +export { executeCommand } from "./command-executor/execute-command" +export { resolveCommandsInText } from "./command-executor/resolve-commands-in-text" diff --git a/src/shared/command-executor/embedded-commands.ts b/src/shared/command-executor/embedded-commands.ts new file mode 100644 index 000000000..2a856d9ec --- /dev/null +++ b/src/shared/command-executor/embedded-commands.ts @@ -0,0 +1,26 @@ +export interface CommandMatch { + fullMatch: string + command: string + start: number + end: number +} + +const COMMAND_PATTERN = /!`([^`]+)`/g + +export function findEmbeddedCommands(text: string): CommandMatch[] { + const matches: CommandMatch[] = [] + let match: RegExpExecArray | null + + COMMAND_PATTERN.lastIndex = 0 + + while ((match = COMMAND_PATTERN.exec(text)) !== null) { + matches.push({ + fullMatch: match[0], + command: match[1], + start: match.index, + end: match.index + match[0].length, + }) + } + + return matches +} diff --git a/src/shared/command-executor/execute-command.ts b/src/shared/command-executor/execute-command.ts new file mode 100644 index 000000000..9140b5eb1 --- /dev/null +++ b/src/shared/command-executor/execute-command.ts @@ -0,0 +1,28 @@ +import { exec } from "node:child_process" +import { promisify } from "node:util" + +const execAsync = promisify(exec) + +type ExecError = { stdout?: Buffer; stderr?: Buffer; message?: string } + +export async function executeCommand(command: string): Promise { + try { + const { stdout, stderr } = await execAsync(command) + + const out = stdout?.toString().trim() ?? "" + const err = stderr?.toString().trim() ?? "" + + if (err) { + return out ? `${out}\n[stderr: ${err}]` : `[stderr: ${err}]` + } + + return out + } catch (error: unknown) { + const e = error as ExecError + const stdout = e?.stdout?.toString().trim() ?? "" + const stderr = e?.stderr?.toString().trim() ?? "" + const errorMessage = stderr || e?.message || String(error) + + return stdout ? `${stdout}\n[stderr: ${errorMessage}]` : `[stderr: ${errorMessage}]` + } +} diff --git a/src/shared/command-executor/execute-hook-command.ts b/src/shared/command-executor/execute-hook-command.ts new file mode 100644 index 000000000..f0c60c994 --- /dev/null +++ b/src/shared/command-executor/execute-hook-command.ts @@ -0,0 +1,78 @@ +import { spawn } from "node:child_process" +import { getHomeDirectory } from "./home-directory" +import { findBashPath, findZshPath } from "./shell-path" + +export interface CommandResult { + exitCode: number + stdout?: string + stderr?: string +} + +export interface ExecuteHookOptions { + forceZsh?: boolean + zshPath?: string +} + +export async function executeHookCommand( + command: string, + stdin: string, + cwd: string, + options?: ExecuteHookOptions, +): Promise { + const home = getHomeDirectory() + + const expandedCommand = command + .replace(/^~(?=\/|$)/g, home) + .replace(/\s~(?=\/)/g, ` ${home}`) + .replace(/\$CLAUDE_PROJECT_DIR/g, cwd) + .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd) + + let finalCommand = expandedCommand + + if (options?.forceZsh) { + const zshPath = findZshPath(options.zshPath) + const escapedCommand = expandedCommand.replace(/'/g, "'\\''") + if (zshPath) { + finalCommand = `${zshPath} -lc '${escapedCommand}'` + } else { + const bashPath = findBashPath() + if (bashPath) { + finalCommand = `${bashPath} -lc '${escapedCommand}'` + } + } + } + + return new Promise((resolve) => { + const proc = spawn(finalCommand, { + cwd, + shell: true, + env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd }, + }) + + let stdout = "" + let stderr = "" + + proc.stdout?.on("data", (data) => { + stdout += data.toString() + }) + + proc.stderr?.on("data", (data) => { + stderr += data.toString() + }) + + proc.stdin?.write(stdin) + proc.stdin?.end() + + proc.on("close", (code) => { + resolve({ + exitCode: code ?? 0, + stdout: stdout.trim(), + stderr: stderr.trim(), + }) + }) + + proc.on("error", (err) => { + resolve({ exitCode: 1, stderr: err.message }) + }) + }) +} diff --git a/src/shared/command-executor/home-directory.ts b/src/shared/command-executor/home-directory.ts new file mode 100644 index 000000000..8ea21991b --- /dev/null +++ b/src/shared/command-executor/home-directory.ts @@ -0,0 +1,5 @@ +import { homedir } from "node:os" + +export function getHomeDirectory(): string { + return process.env.HOME || process.env.USERPROFILE || homedir() +} diff --git a/src/shared/command-executor/resolve-commands-in-text.ts b/src/shared/command-executor/resolve-commands-in-text.ts new file mode 100644 index 000000000..eb692e921 --- /dev/null +++ b/src/shared/command-executor/resolve-commands-in-text.ts @@ -0,0 +1,49 @@ +import { executeCommand } from "./execute-command" +import { findEmbeddedCommands } from "./embedded-commands" + +export async function resolveCommandsInText( + text: string, + depth: number = 0, + maxDepth: number = 3, +): Promise { + if (depth >= maxDepth) { + return text + } + + const matches = findEmbeddedCommands(text) + if (matches.length === 0) { + return text + } + + const tasks = matches.map((m) => executeCommand(m.command)) + const results = await Promise.allSettled(tasks) + + const replacements = new Map() + + matches.forEach((match, idx) => { + const result = results[idx] + if (result.status === "rejected") { + replacements.set( + match.fullMatch, + `[error: ${ + result.reason instanceof Error + ? result.reason.message + : String(result.reason) + }]`, + ) + } else { + replacements.set(match.fullMatch, result.value) + } + }) + + let resolved = text + for (const [pattern, replacement] of replacements.entries()) { + resolved = resolved.split(pattern).join(replacement) + } + + if (findEmbeddedCommands(resolved).length > 0) { + return resolveCommandsInText(resolved, depth + 1, maxDepth) + } + + return resolved +} diff --git a/src/shared/command-executor/shell-path.ts b/src/shared/command-executor/shell-path.ts new file mode 100644 index 000000000..e3131d445 --- /dev/null +++ b/src/shared/command-executor/shell-path.ts @@ -0,0 +1,27 @@ +import { existsSync } from "node:fs" + +const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"] +const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] + +function findShellPath( + defaultPaths: string[], + customPath?: string, +): string | null { + if (customPath && existsSync(customPath)) { + return customPath + } + for (const path of defaultPaths) { + if (existsSync(path)) { + return path + } + } + return null +} + +export function findZshPath(customZshPath?: string): string | null { + return findShellPath(DEFAULT_ZSH_PATHS, customZshPath) +} + +export function findBashPath(): string | null { + return findShellPath(DEFAULT_BASH_PATHS) +} diff --git a/src/shared/tmux/tmux-utils.ts b/src/shared/tmux/tmux-utils.ts index 9dd2d71ba..df4d417d6 100644 --- a/src/shared/tmux/tmux-utils.ts +++ b/src/shared/tmux/tmux-utils.ts @@ -1,312 +1,13 @@ -import { spawn } from "bun" -import type { TmuxConfig, TmuxLayout } from "../../config/schema" -import type { SpawnPaneResult } from "./types" -import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver" +export { isInsideTmux, getCurrentPaneId } from "./tmux-utils/environment" +export type { SplitDirection } from "./tmux-utils/environment" -let serverAvailable: boolean | null = null -let serverCheckUrl: string | null = null +export { isServerRunning, resetServerCheck } from "./tmux-utils/server-health" -export function isInsideTmux(): boolean { - return !!process.env.TMUX -} +export { getPaneDimensions } from "./tmux-utils/pane-dimensions" +export type { PaneDimensions } from "./tmux-utils/pane-dimensions" -export async function isServerRunning(serverUrl: string): Promise { - if (serverCheckUrl === serverUrl && serverAvailable === true) { - return true - } +export { spawnTmuxPane } from "./tmux-utils/pane-spawn" +export { closeTmuxPane } from "./tmux-utils/pane-close" +export { replaceTmuxPane } from "./tmux-utils/pane-replace" - const healthUrl = new URL("/health", serverUrl).toString() - const timeoutMs = 3000 - const maxAttempts = 2 - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), timeoutMs) - - try { - const response = await fetch(healthUrl, { signal: controller.signal }).catch( - () => null - ) - clearTimeout(timeout) - - if (response?.ok) { - serverCheckUrl = serverUrl - serverAvailable = true - return true - } - } finally { - clearTimeout(timeout) - } - - if (attempt < maxAttempts) { - await new Promise((r) => setTimeout(r, 250)) - } - } - - return false -} - -export function resetServerCheck(): void { - serverAvailable = null - serverCheckUrl = null -} - -export type SplitDirection = "-h" | "-v" - -export function getCurrentPaneId(): string | undefined { - return process.env.TMUX_PANE -} - -export interface PaneDimensions { - paneWidth: number - windowWidth: number -} - -export async function getPaneDimensions(paneId: string): Promise { - const tmux = await getTmuxPath() - if (!tmux) return null - - const proc = spawn([tmux, "display", "-p", "-t", paneId, "#{pane_width},#{window_width}"], { - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - - if (exitCode !== 0) return null - - const [paneWidth, windowWidth] = stdout.trim().split(",").map(Number) - if (isNaN(paneWidth) || isNaN(windowWidth)) return null - - return { paneWidth, windowWidth } -} - -export async function spawnTmuxPane( - sessionId: string, - description: string, - config: TmuxConfig, - serverUrl: string, - targetPaneId?: string, - splitDirection: SplitDirection = "-h" -): Promise { - const { log } = await import("../logger") - - log("[spawnTmuxPane] called", { sessionId, description, serverUrl, configEnabled: config.enabled, targetPaneId, splitDirection }) - - if (!config.enabled) { - log("[spawnTmuxPane] SKIP: config.enabled is false") - return { success: false } - } - if (!isInsideTmux()) { - log("[spawnTmuxPane] SKIP: not inside tmux", { TMUX: process.env.TMUX }) - return { success: false } - } - - const serverRunning = await isServerRunning(serverUrl) - if (!serverRunning) { - log("[spawnTmuxPane] SKIP: server not running", { serverUrl }) - return { success: false } - } - - const tmux = await getTmuxPath() - if (!tmux) { - log("[spawnTmuxPane] SKIP: tmux not found") - return { success: false } - } - - log("[spawnTmuxPane] all checks passed, spawning...") - - const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}` - - const args = [ - "split-window", - splitDirection, - "-d", - "-P", - "-F", - "#{pane_id}", - ...(targetPaneId ? ["-t", targetPaneId] : []), - opencodeCmd, - ] - - const proc = spawn([tmux, ...args], { stdout: "pipe", stderr: "pipe" }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - const paneId = stdout.trim() - - if (exitCode !== 0 || !paneId) { - return { success: false } - } - - const title = `omo-subagent-${description.slice(0, 20)}` - const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], { - stdout: "ignore", - stderr: "pipe", - }) - // Drain stderr immediately to avoid backpressure - const stderrPromise = new Response(titleProc.stderr).text().catch(() => "") - const titleExitCode = await titleProc.exited - if (titleExitCode !== 0) { - const titleStderr = await stderrPromise - log("[spawnTmuxPane] WARNING: failed to set pane title", { - paneId, - title, - exitCode: titleExitCode, - stderr: titleStderr.trim(), - }) - } - - return { success: true, paneId } -} - -export async function closeTmuxPane(paneId: string): Promise { - const { log } = await import("../logger") - - if (!isInsideTmux()) { - log("[closeTmuxPane] SKIP: not inside tmux") - return false - } - - const tmux = await getTmuxPath() - if (!tmux) { - log("[closeTmuxPane] SKIP: tmux not found") - return false - } - - // Send Ctrl+C to trigger graceful exit of opencode attach process - log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) - const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], { - stdout: "pipe", - stderr: "pipe", - }) - await ctrlCProc.exited - - // Brief delay for graceful shutdown - await new Promise((r) => setTimeout(r, 250)) - - log("[closeTmuxPane] killing pane", { paneId }) - - const proc = spawn([tmux, "kill-pane", "-t", paneId], { - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - const stderr = await new Response(proc.stderr).text() - - if (exitCode !== 0) { - log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() }) - } else { - log("[closeTmuxPane] SUCCESS", { paneId }) - } - - return exitCode === 0 -} - -export async function replaceTmuxPane( - paneId: string, - sessionId: string, - description: string, - config: TmuxConfig, - serverUrl: string -): Promise { - const { log } = await import("../logger") - - log("[replaceTmuxPane] called", { paneId, sessionId, description }) - - if (!config.enabled) { - return { success: false } - } - if (!isInsideTmux()) { - return { success: false } - } - - const tmux = await getTmuxPath() - if (!tmux) { - return { success: false } - } - - // Send Ctrl+C to trigger graceful exit of existing opencode attach process - // Note: No delay here - respawn-pane -k will handle any remaining process. - // We send Ctrl+C first to give the process a chance to exit gracefully, - // then immediately respawn. This prevents orphaned processes while avoiding - // the race condition where the pane closes before respawn-pane runs. - log("[replaceTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) - const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], { - stdout: "pipe", - stderr: "pipe", - }) - await ctrlCProc.exited - - const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}` - - const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], { - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - - if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text() - log("[replaceTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() }) - return { success: false } - } - - const title = `omo-subagent-${description.slice(0, 20)}` - const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], { - stdout: "ignore", - stderr: "pipe", - }) - // Drain stderr immediately to avoid backpressure - const stderrPromise = new Response(titleProc.stderr).text().catch(() => "") - const titleExitCode = await titleProc.exited - if (titleExitCode !== 0) { - const titleStderr = await stderrPromise - log("[replaceTmuxPane] WARNING: failed to set pane title", { - paneId, - exitCode: titleExitCode, - stderr: titleStderr.trim(), - }) - } - - log("[replaceTmuxPane] SUCCESS", { paneId, sessionId }) - return { success: true, paneId } -} - -export async function applyLayout( - tmux: string, - layout: TmuxLayout, - mainPaneSize: number -): Promise { - const layoutProc = spawn([tmux, "select-layout", layout], { stdout: "ignore", stderr: "ignore" }) - await layoutProc.exited - - if (layout.startsWith("main-")) { - const dimension = - layout === "main-horizontal" ? "main-pane-height" : "main-pane-width" - const sizeProc = spawn([tmux, "set-window-option", dimension, `${mainPaneSize}%`], { - stdout: "ignore", - stderr: "ignore", - }) - await sizeProc.exited - } -} - -export async function enforceMainPaneWidth( - mainPaneId: string, - windowWidth: number -): Promise { - const { log } = await import("../logger") - const tmux = await getTmuxPath() - if (!tmux) return - - const DIVIDER_WIDTH = 1 - const mainWidth = Math.floor((windowWidth - DIVIDER_WIDTH) / 2) - - const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], { - stdout: "ignore", - stderr: "ignore", - }) - await proc.exited - - log("[enforceMainPaneWidth] main pane resized", { mainPaneId, mainWidth, windowWidth }) -} +export { applyLayout, enforceMainPaneWidth } from "./tmux-utils/layout" diff --git a/src/shared/tmux/tmux-utils/environment.ts b/src/shared/tmux/tmux-utils/environment.ts new file mode 100644 index 000000000..5c8166b3f --- /dev/null +++ b/src/shared/tmux/tmux-utils/environment.ts @@ -0,0 +1,9 @@ +export type SplitDirection = "-h" | "-v" + +export function isInsideTmux(): boolean { + return Boolean(process.env.TMUX) +} + +export function getCurrentPaneId(): string | undefined { + return process.env.TMUX_PANE +} diff --git a/src/shared/tmux/tmux-utils/layout.ts b/src/shared/tmux/tmux-utils/layout.ts new file mode 100644 index 000000000..d7900ff73 --- /dev/null +++ b/src/shared/tmux/tmux-utils/layout.ts @@ -0,0 +1,49 @@ +import { spawn } from "bun" +import type { TmuxLayout } from "../../../config/schema" +import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" + +export async function applyLayout( + tmux: string, + layout: TmuxLayout, + mainPaneSize: number, +): Promise { + const layoutProc = spawn([tmux, "select-layout", layout], { + stdout: "ignore", + stderr: "ignore", + }) + await layoutProc.exited + + if (layout.startsWith("main-")) { + const dimension = + layout === "main-horizontal" ? "main-pane-height" : "main-pane-width" + const sizeProc = spawn( + [tmux, "set-window-option", dimension, `${mainPaneSize}%`], + { stdout: "ignore", stderr: "ignore" }, + ) + await sizeProc.exited + } +} + +export async function enforceMainPaneWidth( + mainPaneId: string, + windowWidth: number, +): Promise { + const { log } = await import("../../logger") + const tmux = await getTmuxPath() + if (!tmux) return + + const dividerWidth = 1 + const mainWidth = Math.floor((windowWidth - dividerWidth) / 2) + + const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], { + stdout: "ignore", + stderr: "ignore", + }) + await proc.exited + + log("[enforceMainPaneWidth] main pane resized", { + mainPaneId, + mainWidth, + windowWidth, + }) +} diff --git a/src/shared/tmux/tmux-utils/pane-close.ts b/src/shared/tmux/tmux-utils/pane-close.ts new file mode 100644 index 000000000..cc6f4b6c4 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-close.ts @@ -0,0 +1,48 @@ +import { spawn } from "bun" +import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" +import { isInsideTmux } from "./environment" + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +export async function closeTmuxPane(paneId: string): Promise { + const { log } = await import("../../logger") + + if (!isInsideTmux()) { + log("[closeTmuxPane] SKIP: not inside tmux") + return false + } + + const tmux = await getTmuxPath() + if (!tmux) { + log("[closeTmuxPane] SKIP: tmux not found") + return false + } + + log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) + const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], { + stdout: "pipe", + stderr: "pipe", + }) + await ctrlCProc.exited + + await delay(250) + + log("[closeTmuxPane] killing pane", { paneId }) + + const proc = spawn([tmux, "kill-pane", "-t", paneId], { + stdout: "pipe", + stderr: "pipe", + }) + const exitCode = await proc.exited + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() }) + } else { + log("[closeTmuxPane] SUCCESS", { paneId }) + } + + return exitCode === 0 +} diff --git a/src/shared/tmux/tmux-utils/pane-dimensions.ts b/src/shared/tmux/tmux-utils/pane-dimensions.ts new file mode 100644 index 000000000..a11ad2602 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-dimensions.ts @@ -0,0 +1,28 @@ +import { spawn } from "bun" +import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" + +export interface PaneDimensions { + paneWidth: number + windowWidth: number +} + +export async function getPaneDimensions( + paneId: string, +): Promise { + const tmux = await getTmuxPath() + if (!tmux) return null + + const proc = spawn( + [tmux, "display", "-p", "-t", paneId, "#{pane_width},#{window_width}"], + { stdout: "pipe", stderr: "pipe" }, + ) + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + + if (exitCode !== 0) return null + + const [paneWidth, windowWidth] = stdout.trim().split(",").map(Number) + if (Number.isNaN(paneWidth) || Number.isNaN(windowWidth)) return null + + return { paneWidth, windowWidth } +} diff --git a/src/shared/tmux/tmux-utils/pane-replace.ts b/src/shared/tmux/tmux-utils/pane-replace.ts new file mode 100644 index 000000000..728041a64 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-replace.ts @@ -0,0 +1,69 @@ +import { spawn } from "bun" +import type { TmuxConfig } from "../../../config/schema" +import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" +import type { SpawnPaneResult } from "../types" +import { isInsideTmux } from "./environment" + +export async function replaceTmuxPane( + paneId: string, + sessionId: string, + description: string, + config: TmuxConfig, + serverUrl: string, +): Promise { + const { log } = await import("../../logger") + + log("[replaceTmuxPane] called", { paneId, sessionId, description }) + + if (!config.enabled) { + return { success: false } + } + if (!isInsideTmux()) { + return { success: false } + } + + const tmux = await getTmuxPath() + if (!tmux) { + return { success: false } + } + + log("[replaceTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) + const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], { + stdout: "pipe", + stderr: "pipe", + }) + await ctrlCProc.exited + + const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}` + + const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], { + stdout: "pipe", + stderr: "pipe", + }) + const exitCode = await proc.exited + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + log("[replaceTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() }) + return { success: false } + } + + const title = `omo-subagent-${description.slice(0, 20)}` + const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], { + stdout: "ignore", + stderr: "pipe", + }) + const stderrPromise = new Response(titleProc.stderr).text().catch(() => "") + const titleExitCode = await titleProc.exited + if (titleExitCode !== 0) { + const titleStderr = await stderrPromise + log("[replaceTmuxPane] WARNING: failed to set pane title", { + paneId, + exitCode: titleExitCode, + stderr: titleStderr.trim(), + }) + } + + log("[replaceTmuxPane] SUCCESS", { paneId, sessionId }) + return { success: true, paneId } +} diff --git a/src/shared/tmux/tmux-utils/pane-spawn.ts b/src/shared/tmux/tmux-utils/pane-spawn.ts new file mode 100644 index 000000000..959a4e68c --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-spawn.ts @@ -0,0 +1,91 @@ +import { spawn } from "bun" +import type { TmuxConfig } from "../../../config/schema" +import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" +import type { SpawnPaneResult } from "../types" +import type { SplitDirection } from "./environment" +import { isInsideTmux } from "./environment" +import { isServerRunning } from "./server-health" + +export async function spawnTmuxPane( + sessionId: string, + description: string, + config: TmuxConfig, + serverUrl: string, + targetPaneId?: string, + splitDirection: SplitDirection = "-h", +): Promise { + const { log } = await import("../../logger") + + log("[spawnTmuxPane] called", { + sessionId, + description, + serverUrl, + configEnabled: config.enabled, + targetPaneId, + splitDirection, + }) + + if (!config.enabled) { + log("[spawnTmuxPane] SKIP: config.enabled is false") + return { success: false } + } + if (!isInsideTmux()) { + log("[spawnTmuxPane] SKIP: not inside tmux", { TMUX: process.env.TMUX }) + return { success: false } + } + + const serverRunning = await isServerRunning(serverUrl) + if (!serverRunning) { + log("[spawnTmuxPane] SKIP: server not running", { serverUrl }) + return { success: false } + } + + const tmux = await getTmuxPath() + if (!tmux) { + log("[spawnTmuxPane] SKIP: tmux not found") + return { success: false } + } + + log("[spawnTmuxPane] all checks passed, spawning...") + + const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}` + + const args = [ + "split-window", + splitDirection, + "-d", + "-P", + "-F", + "#{pane_id}", + ...(targetPaneId ? ["-t", targetPaneId] : []), + opencodeCmd, + ] + + const proc = spawn([tmux, ...args], { stdout: "pipe", stderr: "pipe" }) + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const paneId = stdout.trim() + + if (exitCode !== 0 || !paneId) { + return { success: false } + } + + const title = `omo-subagent-${description.slice(0, 20)}` + const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], { + stdout: "ignore", + stderr: "pipe", + }) + const stderrPromise = new Response(titleProc.stderr).text().catch(() => "") + const titleExitCode = await titleProc.exited + if (titleExitCode !== 0) { + const titleStderr = await stderrPromise + log("[spawnTmuxPane] WARNING: failed to set pane title", { + paneId, + title, + exitCode: titleExitCode, + stderr: titleStderr.trim(), + }) + } + + return { success: true, paneId } +} diff --git a/src/shared/tmux/tmux-utils/server-health.ts b/src/shared/tmux/tmux-utils/server-health.ts new file mode 100644 index 000000000..f45d8d01b --- /dev/null +++ b/src/shared/tmux/tmux-utils/server-health.ts @@ -0,0 +1,47 @@ +let serverAvailable: boolean | null = null +let serverCheckUrl: string | null = null + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +export async function isServerRunning(serverUrl: string): Promise { + if (serverCheckUrl === serverUrl && serverAvailable === true) { + return true + } + + const healthUrl = new URL("/health", serverUrl).toString() + const timeoutMs = 3000 + const maxAttempts = 2 + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(healthUrl, { + signal: controller.signal, + }).catch(() => null) + clearTimeout(timeout) + + if (response?.ok) { + serverCheckUrl = serverUrl + serverAvailable = true + return true + } + } finally { + clearTimeout(timeout) + } + + if (attempt < maxAttempts) { + await delay(250) + } + } + + return false +} + +export function resetServerCheck(): void { + serverAvailable = null + serverCheckUrl = null +} diff --git a/src/tools/background-task/clients.ts b/src/tools/background-task/clients.ts new file mode 100644 index 000000000..b94977c37 --- /dev/null +++ b/src/tools/background-task/clients.ts @@ -0,0 +1,32 @@ +import type { BackgroundManager } from "../../features/background-agent" + +export type BackgroundOutputMessage = { + id?: string + info?: { role?: string; time?: string | { created?: number }; agent?: string } + parts?: Array<{ + type?: string + text?: string + thinking?: string + content?: string | Array<{ type: string; text?: string }> + output?: string + name?: string + }> +} + +export type BackgroundOutputMessagesResult = + | { data?: BackgroundOutputMessage[]; error?: unknown } + | BackgroundOutputMessage[] + +export type BackgroundOutputClient = { + session: { + messages: (args: { path: { id: string } }) => Promise + } +} + +export type BackgroundCancelClient = { + session: { + abort: (args: { path: { id: string } }) => Promise + } +} + +export type BackgroundOutputManager = Pick diff --git a/src/tools/background-task/create-background-cancel.ts b/src/tools/background-task/create-background-cancel.ts new file mode 100644 index 000000000..fb584e9b3 --- /dev/null +++ b/src/tools/background-task/create-background-cancel.ts @@ -0,0 +1,115 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundManager } from "../../features/background-agent" +import type { BackgroundCancelArgs } from "./types" +import type { BackgroundCancelClient } from "./clients" +import { BACKGROUND_CANCEL_DESCRIPTION } from "./constants" + +export function createBackgroundCancel(manager: BackgroundManager, client: BackgroundCancelClient): ToolDefinition { + return tool({ + description: BACKGROUND_CANCEL_DESCRIPTION, + args: { + taskId: tool.schema.string().optional().describe("Task ID to cancel (required if all=false)"), + all: tool.schema.boolean().optional().describe("Cancel all running background tasks (default: false)"), + }, + async execute(args: BackgroundCancelArgs, toolContext) { + try { + const cancelAll = args.all === true + + if (!cancelAll && !args.taskId) { + return `[ERROR] Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.` + } + + if (cancelAll) { + const tasks = manager.getAllDescendantTasks(toolContext.sessionID) + const cancellableTasks = tasks.filter((t: { status: string }) => t.status === "running" || t.status === "pending") + + if (cancellableTasks.length === 0) { + return `No running or pending background tasks to cancel.` + } + + const cancelledInfo: Array<{ id: string; description: string; status: string; sessionID?: string }> = [] + + for (const task of cancellableTasks) { + const originalStatus = task.status + const cancelled = await manager.cancelTask(task.id, { + source: "background_cancel", + abortSession: originalStatus === "running", + skipNotification: true, + }) + if (!cancelled) continue + cancelledInfo.push({ + id: task.id, + description: task.description, + status: originalStatus === "pending" ? "pending" : "running", + sessionID: task.sessionID, + }) + } + + const tableRows = cancelledInfo + .map( + (t) => + `| \`${t.id}\` | ${t.description} | ${t.status} | ${t.sessionID ? `\`${t.sessionID}\`` : "(not started)"} |` + ) + .join("\n") + + const resumableTasks = cancelledInfo.filter((t) => t.sessionID) + const resumeSection = + resumableTasks.length > 0 + ? `\n## Continue Instructions + +To continue a cancelled task, use: +\`\`\` +task(session_id="", prompt="Continue: ") +\`\`\` + +Continuable sessions: +${resumableTasks.map((t) => `- \`${t.sessionID}\` (${t.description})`).join("\n")}` + : "" + + return `Cancelled ${cancelledInfo.length} background task(s): + +| Task ID | Description | Status | Session ID | +|---------|-------------|--------|------------| +${tableRows} +${resumeSection}` + } + + const task = manager.getTask(args.taskId!) + if (!task) { + return `[ERROR] Task not found: ${args.taskId}` + } + + if (task.status !== "running" && task.status !== "pending") { + return `[ERROR] Cannot cancel task: current status is "${task.status}". +Only running or pending tasks can be cancelled.` + } + + const cancelled = await manager.cancelTask(task.id, { + source: "background_cancel", + abortSession: task.status === "running", + skipNotification: true, + }) + if (!cancelled) { + return `[ERROR] Failed to cancel task: ${task.id}` + } + + if (task.status === "pending") { + return `Pending task cancelled successfully + +Task ID: ${task.id} +Description: ${task.description} +Status: ${task.status}` + } + + return `Task cancelled successfully + +Task ID: ${task.id} +Description: ${task.description} +Session ID: ${task.sessionID} +Status: ${task.status}` + } catch (error) { + return `[ERROR] Error cancelling task: ${error instanceof Error ? error.message : String(error)}` + } + }, + }) +} diff --git a/src/tools/background-task/create-background-output.ts b/src/tools/background-task/create-background-output.ts new file mode 100644 index 000000000..c498ddbf1 --- /dev/null +++ b/src/tools/background-task/create-background-output.ts @@ -0,0 +1,89 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundOutputArgs } from "./types" +import type { BackgroundOutputClient, BackgroundOutputManager } from "./clients" +import { BACKGROUND_OUTPUT_DESCRIPTION } from "./constants" +import { delay } from "./delay" +import { formatFullSession } from "./full-session-format" +import { formatTaskResult } from "./task-result-format" +import { formatTaskStatus } from "./task-status-format" + +export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition { + return tool({ + description: BACKGROUND_OUTPUT_DESCRIPTION, + args: { + task_id: tool.schema.string().describe("Task ID to get output from"), + block: tool.schema + .boolean() + .optional() + .describe( + "Wait for completion (default: false). System notifies when done, so blocking is rarely needed." + ), + timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"), + full_session: tool.schema.boolean().optional().describe("Return full session messages with filters (default: false)"), + include_thinking: tool.schema.boolean().optional().describe("Include thinking/reasoning parts in full_session output (default: false)"), + message_limit: tool.schema.number().optional().describe("Max messages to return (capped at 100)"), + since_message_id: tool.schema.string().optional().describe("Return messages after this message ID (exclusive)"), + include_tool_results: tool.schema.boolean().optional().describe("Include tool results in full_session output (default: false)"), + thinking_max_chars: tool.schema.number().optional().describe("Max characters for thinking content (default: 2000)"), + }, + async execute(args: BackgroundOutputArgs) { + try { + const task = manager.getTask(args.task_id) + if (!task) { + return `Task not found: ${args.task_id}` + } + + if (args.full_session === true) { + return await formatFullSession(task, client, { + includeThinking: args.include_thinking === true, + messageLimit: args.message_limit, + sinceMessageId: args.since_message_id, + includeToolResults: args.include_tool_results === true, + thinkingMaxChars: args.thinking_max_chars, + }) + } + + const shouldBlock = args.block === true + const timeoutMs = Math.min(args.timeout ?? 60000, 600000) + + if (task.status === "completed") { + return await formatTaskResult(task, client) + } + + if (task.status === "error" || task.status === "cancelled") { + return formatTaskStatus(task) + } + + if (!shouldBlock) { + return formatTaskStatus(task) + } + + const startTime = Date.now() + while (Date.now() - startTime < timeoutMs) { + await delay(1000) + + const currentTask = manager.getTask(args.task_id) + if (!currentTask) { + return `Task was deleted: ${args.task_id}` + } + + if (currentTask.status === "completed") { + return await formatTaskResult(currentTask, client) + } + + if (currentTask.status === "error" || currentTask.status === "cancelled") { + return formatTaskStatus(currentTask) + } + } + + const finalTask = manager.getTask(args.task_id) + if (!finalTask) { + return `Task was deleted: ${args.task_id}` + } + return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}` + } catch (error) { + return `Error getting output: ${error instanceof Error ? error.message : String(error)}` + } + }, + }) +} diff --git a/src/tools/background-task/create-background-task.ts b/src/tools/background-task/create-background-task.ts new file mode 100644 index 000000000..a3411dc41 --- /dev/null +++ b/src/tools/background-task/create-background-task.ts @@ -0,0 +1,116 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundManager } from "../../features/background-agent" +import type { BackgroundTaskArgs } from "./types" +import { BACKGROUND_TASK_DESCRIPTION } from "./constants" +import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector" +import { getSessionAgent } from "../../features/claude-code-session-state" +import { storeToolMetadata } from "../../features/tool-metadata-store" +import { log } from "../../shared/logger" +import { delay } from "./delay" +import { getMessageDir } from "./message-dir" + +type ToolContextWithMetadata = { + sessionID: string + messageID: string + agent: string + abort: AbortSignal + metadata?: (input: { title?: string; metadata?: Record }) => void + callID?: string +} + +export function createBackgroundTask(manager: BackgroundManager): ToolDefinition { + return tool({ + description: BACKGROUND_TASK_DESCRIPTION, + args: { + description: tool.schema.string().describe("Short task description (shown in status)"), + prompt: tool.schema.string().describe("Full detailed prompt for the agent"), + agent: tool.schema.string().describe("Agent type to use (any registered agent)"), + }, + async execute(args: BackgroundTaskArgs, toolContext) { + const ctx = toolContext as ToolContextWithMetadata + + if (!args.agent || args.agent.trim() === "") { + return `[ERROR] Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)` + } + + try { + 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("[background_task] parentAgent resolution", { + sessionID: ctx.sessionID, + 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 + + const task = await manager.launch({ + description: args.description, + prompt: args.prompt, + agent: args.agent.trim(), + parentSessionID: ctx.sessionID, + parentMessageID: ctx.messageID, + parentModel, + parentAgent, + }) + + const WAIT_FOR_SESSION_INTERVAL_MS = 50 + const WAIT_FOR_SESSION_TIMEOUT_MS = 30000 + const waitStart = Date.now() + let sessionId = task.sessionID + while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) { + if (ctx.abort?.aborted) { + await manager.cancelTask(task.id) + return `Task aborted and cancelled while waiting for session to start.\n\nTask ID: ${task.id}` + } + await delay(WAIT_FOR_SESSION_INTERVAL_MS) + const updated = manager.getTask(task.id) + if (!updated || updated.status === "error") { + return `Task ${!updated ? "was deleted" : `entered error state`}\.\n\nTask ID: ${task.id}` + } + sessionId = updated?.sessionID + } + + const bgMeta = { + title: args.description, + metadata: { sessionId: sessionId ?? "pending" }, + } + await ctx.metadata?.(bgMeta) + + if (ctx.callID) { + storeToolMetadata(ctx.sessionID, ctx.callID, bgMeta) + } + + return `Background task launched successfully. + +Task ID: ${task.id} +Session ID: ${sessionId ?? "pending"} +Description: ${task.description} +Agent: ${task.agent} +Status: ${task.status} + +The system will notify you when the task completes. +Use \`background_output\` tool with task_id="${task.id}" to check progress: +- block=false (default): Check status immediately - returns full status info +- block=true: Wait for completion (rarely needed since system notifies)` + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return `[ERROR] Failed to launch background task: ${message}` + } + }, + }) +} diff --git a/src/tools/background-task/delay.ts b/src/tools/background-task/delay.ts new file mode 100644 index 000000000..e0b2c7ebf --- /dev/null +++ b/src/tools/background-task/delay.ts @@ -0,0 +1,3 @@ +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/tools/background-task/full-session-format.ts b/src/tools/background-task/full-session-format.ts new file mode 100644 index 000000000..9b50a09fb --- /dev/null +++ b/src/tools/background-task/full-session-format.ts @@ -0,0 +1,148 @@ +import type { BackgroundTask } from "../../features/background-agent" +import type { BackgroundOutputClient, BackgroundOutputMessagesResult, BackgroundOutputMessage } from "./clients" +import { extractMessages, getErrorMessage } from "./session-messages" +import { formatMessageTime } from "./time-format" +import { truncateText } from "./truncate-text" +import { formatTaskStatus } from "./task-status-format" + +const MAX_MESSAGE_LIMIT = 100 +const THINKING_MAX_CHARS = 2000 + +function extractToolResultText(part: NonNullable[number]): string[] { + if (typeof part.content === "string" && part.content.length > 0) { + return [part.content] + } + + if (Array.isArray(part.content)) { + const blocks: string[] = [] + for (const block of part.content) { + if ((block.type === "text" || block.type === "reasoning") && block.text) { + blocks.push(block.text) + } + } + if (blocks.length > 0) return blocks + } + + if (part.output && part.output.length > 0) { + return [part.output] + } + + return [] +} + +export async function formatFullSession( + task: BackgroundTask, + client: BackgroundOutputClient, + options: { + includeThinking: boolean + messageLimit?: number + sinceMessageId?: string + includeToolResults: boolean + thinkingMaxChars?: number + } +): Promise { + if (!task.sessionID) { + return formatTaskStatus(task) + } + + const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ + path: { id: task.sessionID }, + }) + + const errorMessage = getErrorMessage(messagesResult) + if (errorMessage) { + return `Error fetching messages: ${errorMessage}` + } + + const rawMessages = extractMessages(messagesResult) + if (!Array.isArray(rawMessages)) { + return "Error fetching messages: invalid response" + } + + const sortedMessages = [...rawMessages].sort((a, b) => { + const timeA = String(a.info?.time ?? "") + const timeB = String(b.info?.time ?? "") + return timeA.localeCompare(timeB) + }) + + let filteredMessages = sortedMessages + if (options.sinceMessageId) { + const index = filteredMessages.findIndex((message) => message.id === options.sinceMessageId) + if (index === -1) { + return `Error: since_message_id not found: ${options.sinceMessageId}` + } + filteredMessages = filteredMessages.slice(index + 1) + } + + const includeThinking = options.includeThinking + const includeToolResults = options.includeToolResults + const thinkingMaxChars = options.thinkingMaxChars ?? THINKING_MAX_CHARS + + const normalizedMessages: BackgroundOutputMessage[] = [] + for (const message of filteredMessages) { + const parts = (message.parts ?? []).filter((part) => { + if (part.type === "thinking" || part.type === "reasoning") { + return includeThinking + } + if (part.type === "tool_result") { + return includeToolResults + } + return part.type === "text" + }) + + if (parts.length === 0) { + continue + } + + normalizedMessages.push({ ...message, parts }) + } + + const limit = typeof options.messageLimit === "number" ? Math.min(options.messageLimit, MAX_MESSAGE_LIMIT) : undefined + const hasMore = limit !== undefined && normalizedMessages.length > limit + const visibleMessages = limit !== undefined ? normalizedMessages.slice(0, limit) : normalizedMessages + + const lines: string[] = [] + lines.push("# Full Session Output") + lines.push("") + lines.push(`Task ID: ${task.id}`) + lines.push(`Description: ${task.description}`) + lines.push(`Status: ${task.status}`) + lines.push(`Session ID: ${task.sessionID}`) + lines.push(`Total messages: ${normalizedMessages.length}`) + lines.push(`Returned: ${visibleMessages.length}`) + lines.push(`Has more: ${hasMore ? "true" : "false"}`) + lines.push("") + lines.push("## Messages") + + if (visibleMessages.length === 0) { + lines.push("") + lines.push("(No messages found)") + return lines.join("\n") + } + + for (const message of visibleMessages) { + const role = message.info?.role ?? "unknown" + const agent = message.info?.agent ? ` (${message.info.agent})` : "" + const time = formatMessageTime(message.info?.time) + const idLabel = message.id ? ` id=${message.id}` : "" + lines.push("") + lines.push(`[${role}${agent}] ${time}${idLabel}`) + + for (const part of message.parts ?? []) { + if (part.type === "text" && part.text) { + lines.push(part.text.trim()) + } else if (part.type === "thinking" && part.thinking) { + lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`) + } else if (part.type === "reasoning" && part.text) { + lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`) + } else if (part.type === "tool_result") { + const toolTexts = extractToolResultText(part) + for (const toolText of toolTexts) { + lines.push(`[tool result] ${toolText}`) + } + } + } + } + + return lines.join("\n") +} diff --git a/src/tools/background-task/message-dir.ts b/src/tools/background-task/message-dir.ts new file mode 100644 index 000000000..74c496073 --- /dev/null +++ b/src/tools/background-task/message-dir.ts @@ -0,0 +1,17 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "../../features/hook-message-injector" + +export 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 +} diff --git a/src/tools/background-task/session-messages.ts b/src/tools/background-task/session-messages.ts new file mode 100644 index 000000000..783b504a7 --- /dev/null +++ b/src/tools/background-task/session-messages.ts @@ -0,0 +1,22 @@ +import type { BackgroundOutputMessage, BackgroundOutputMessagesResult } from "./clients" + +export function getErrorMessage(value: BackgroundOutputMessagesResult): string | null { + if (Array.isArray(value)) return null + if (value.error === undefined || value.error === null) return null + if (typeof value.error === "string" && value.error.length > 0) return value.error + return String(value.error) +} + +function isSessionMessage(value: unknown): value is BackgroundOutputMessage { + return typeof value === "object" && value !== null +} + +export function extractMessages(value: BackgroundOutputMessagesResult): BackgroundOutputMessage[] { + if (Array.isArray(value)) { + return value.filter(isSessionMessage) + } + if (Array.isArray(value.data)) { + return value.data.filter(isSessionMessage) + } + return [] +} diff --git a/src/tools/background-task/task-result-format.ts b/src/tools/background-task/task-result-format.ts new file mode 100644 index 000000000..564eb31fe --- /dev/null +++ b/src/tools/background-task/task-result-format.ts @@ -0,0 +1,113 @@ +import type { BackgroundTask } from "../../features/background-agent" +import { consumeNewMessages } from "../../shared/session-cursor" +import type { BackgroundOutputClient, BackgroundOutputMessagesResult } from "./clients" +import { extractMessages, getErrorMessage } from "./session-messages" +import { formatDuration } from "./time-format" + +function getTimeString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +export async function formatTaskResult(task: BackgroundTask, client: BackgroundOutputClient): Promise { + if (!task.sessionID) { + return `Error: Task has no sessionID` + } + + const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ + path: { id: task.sessionID }, + }) + + const errorMessage = getErrorMessage(messagesResult) + if (errorMessage) { + return `Error fetching messages: ${errorMessage}` + } + + const messages = extractMessages(messagesResult) + if (!Array.isArray(messages) || messages.length === 0) { + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} +Session ID: ${task.sessionID} + +--- + +(No messages found)` + } + + const relevantMessages = messages.filter((m) => m.info?.role === "assistant" || m.info?.role === "tool") + if (relevantMessages.length === 0) { + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} +Session ID: ${task.sessionID} + +--- + +(No assistant or tool response found)` + } + + const sortedMessages = [...relevantMessages].sort((a, b) => { + const timeA = getTimeString(a.info?.time) + const timeB = getTimeString(b.info?.time) + return timeA.localeCompare(timeB) + }) + + const newMessages = consumeNewMessages(task.sessionID, sortedMessages) + if (newMessages.length === 0) { + const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${duration} +Session ID: ${task.sessionID} + +--- + +(No new output since last check)` + } + + const extractedContent: string[] = [] + for (const message of newMessages) { + for (const part of message.parts ?? []) { + if ((part.type === "text" || part.type === "reasoning") && part.text) { + extractedContent.push(part.text) + continue + } + + if (part.type === "tool_result") { + const toolResult = part as { content?: string | Array<{ type: string; text?: string }> } + if (typeof toolResult.content === "string" && toolResult.content) { + extractedContent.push(toolResult.content) + continue + } + + if (Array.isArray(toolResult.content)) { + for (const block of toolResult.content) { + if ((block.type === "text" || block.type === "reasoning") && block.text) { + extractedContent.push(block.text) + } + } + } + } + } + } + + const textContent = extractedContent.filter((text) => text.length > 0).join("\n\n") + const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) + + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${duration} +Session ID: ${task.sessionID} + +--- + +${textContent || "(No text output)"}` +} diff --git a/src/tools/background-task/task-status-format.ts b/src/tools/background-task/task-status-format.ts new file mode 100644 index 000000000..8dd89132a --- /dev/null +++ b/src/tools/background-task/task-status-format.ts @@ -0,0 +1,68 @@ +import type { BackgroundTask } from "../../features/background-agent" +import { formatDuration } from "./time-format" +import { truncateText } from "./truncate-text" + +export function formatTaskStatus(task: BackgroundTask): string { + let duration: string + if (task.status === "pending" && task.queuedAt) { + duration = formatDuration(task.queuedAt, undefined) + } else if (task.startedAt) { + duration = formatDuration(task.startedAt, task.completedAt) + } else { + duration = "N/A" + } + + const promptPreview = truncateText(task.prompt, 500) + + let progressSection = "" + if (task.progress?.lastTool) { + progressSection = `\n| Last tool | ${task.progress.lastTool} |` + } + + let lastMessageSection = "" + if (task.progress?.lastMessage) { + const truncated = truncateText(task.progress.lastMessage, 500) + const messageTime = task.progress.lastMessageAt ? task.progress.lastMessageAt.toISOString() : "N/A" + lastMessageSection = ` + +## Last Message (${messageTime}) + +\`\`\` +${truncated} +\`\`\`` + } + + let statusNote = "" + if (task.status === "pending") { + statusNote = ` + +> **Queued**: Task is waiting for a concurrency slot to become available.` + } else if (task.status === "running") { + statusNote = ` + +> **Note**: No need to wait explicitly - the system will notify you when this task completes.` + } else if (task.status === "error") { + statusNote = ` + +> **Failed**: The task encountered an error. Check the last message for details.` + } + + const durationLabel = task.status === "pending" ? "Queued for" : "Duration" + + return `# Task Status + +| Field | Value | +|-------|-------| +| Task ID | \`${task.id}\` | +| Description | ${task.description} | +| Agent | ${task.agent} | +| Status | **${task.status}** | +| ${durationLabel} | ${duration} | +| Session ID | \`${task.sessionID}\` |${progressSection} +${statusNote} +## Original Prompt + +\`\`\` +${promptPreview} +\`\`\`${lastMessageSection}` +} diff --git a/src/tools/background-task/time-format.ts b/src/tools/background-task/time-format.ts new file mode 100644 index 000000000..a68bc9b0a --- /dev/null +++ b/src/tools/background-task/time-format.ts @@ -0,0 +1,30 @@ +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` +} + +export function formatMessageTime(value: unknown): string { + if (typeof value === "string") { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toISOString() + } + if (typeof value === "object" && value !== null) { + if ("created" in value) { + const created = (value as { created?: number }).created + if (typeof created === "number") { + return new Date(created).toISOString() + } + } + } + return "Unknown time" +} diff --git a/src/tools/background-task/tools.ts b/src/tools/background-task/tools.ts index ec12128cb..ce30adb91 100644 --- a/src/tools/background-task/tools.ts +++ b/src/tools/background-task/tools.ts @@ -1,757 +1,11 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" -import type { BackgroundTaskArgs, BackgroundOutputArgs, BackgroundCancelArgs } from "./types" -import { BACKGROUND_TASK_DESCRIPTION, BACKGROUND_OUTPUT_DESCRIPTION, BACKGROUND_CANCEL_DESCRIPTION } from "./constants" -import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" -import { getSessionAgent } from "../../features/claude-code-session-state" -import { log } from "../../shared/logger" -import { consumeNewMessages } from "../../shared/session-cursor" -import { storeToolMetadata } from "../../features/tool-metadata-store" - -type BackgroundOutputMessage = { - info?: { role?: string; time?: string | { created?: number }; agent?: string } - parts?: Array<{ - type?: string - text?: string - content?: string | Array<{ type: string; text?: string }> - name?: string - }> -} - -type BackgroundOutputMessagesResult = - | { data?: BackgroundOutputMessage[]; error?: unknown } - | BackgroundOutputMessage[] - -export type BackgroundOutputClient = { - session: { - messages: (args: { path: { id: string } }) => Promise - } -} - -export type BackgroundCancelClient = { - session: { - abort: (args: { path: { id: string } }) => Promise - } -} - -export type BackgroundOutputManager = Pick - -const MAX_MESSAGE_LIMIT = 100 -const THINKING_MAX_CHARS = 2000 - -type FullSessionMessagePart = { - type?: string - text?: string - thinking?: string - content?: string | Array<{ type?: string; text?: string }> - output?: string -} - -type FullSessionMessage = { - id?: string - info?: { role?: string; time?: string; agent?: string } - parts?: FullSessionMessagePart[] -} - -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 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` - } else if (minutes > 0) { - return `${minutes}m ${seconds % 60}s` - } else { - return `${seconds}s` - } -} - -type ToolContextWithMetadata = { - sessionID: string - messageID: string - agent: string - abort: AbortSignal - metadata?: (input: { title?: string; metadata?: Record }) => void -} - -export function createBackgroundTask(manager: BackgroundManager): ToolDefinition { - return tool({ - description: BACKGROUND_TASK_DESCRIPTION, - args: { - description: tool.schema.string().describe("Short task description (shown in status)"), - prompt: tool.schema.string().describe("Full detailed prompt for the agent"), - agent: tool.schema.string().describe("Agent type to use (any registered agent)"), - }, - async execute(args: BackgroundTaskArgs, toolContext) { - const ctx = toolContext as ToolContextWithMetadata - - if (!args.agent || args.agent.trim() === "") { - return `[ERROR] Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)` - } - - try { - 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("[background_task] parentAgent resolution", { - sessionID: ctx.sessionID, - 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 - - const task = await manager.launch({ - description: args.description, - prompt: args.prompt, - agent: args.agent.trim(), - parentSessionID: ctx.sessionID, - parentMessageID: ctx.messageID, - parentModel, - parentAgent, - }) - - const WAIT_FOR_SESSION_INTERVAL_MS = 50 - const WAIT_FOR_SESSION_TIMEOUT_MS = 30000 - const waitStart = Date.now() - let sessionId = task.sessionID - while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) { - if (ctx.abort?.aborted) { - await manager.cancelTask(task.id) - return `Task aborted and cancelled while waiting for session to start.\n\nTask ID: ${task.id}` - } - await delay(WAIT_FOR_SESSION_INTERVAL_MS) - const updated = manager.getTask(task.id) - if (!updated || updated.status === "error") { - return `Task ${!updated ? "was deleted" : `entered error state`}.\n\nTask ID: ${task.id}` - } - sessionId = updated?.sessionID - } - - const bgMeta = { - title: args.description, - metadata: { sessionId: sessionId ?? "pending" } as Record, - } - await ctx.metadata?.(bgMeta) - const callID = (ctx as any).callID as string | undefined - if (callID) { - storeToolMetadata(ctx.sessionID, callID, bgMeta) - } - - return `Background task launched successfully. - -Task ID: ${task.id} -Session ID: ${sessionId ?? "pending"} -Description: ${task.description} -Agent: ${task.agent} -Status: ${task.status} - -The system will notify you when the task completes. -Use \`background_output\` tool with task_id="${task.id}" to check progress: -- block=false (default): Check status immediately - returns full status info -- block=true: Wait for completion (rarely needed since system notifies)` - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return `[ERROR] Failed to launch background task: ${message}` - } - }, - }) -} - -function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -function truncateText(text: string, maxLength: number): string { - if (text.length <= maxLength) return text - return text.slice(0, maxLength) + "..." -} - -function formatTaskStatus(task: BackgroundTask): string { - let duration: string - if (task.status === "pending" && task.queuedAt) { - duration = formatDuration(task.queuedAt, undefined) - } else if (task.startedAt) { - duration = formatDuration(task.startedAt, task.completedAt) - } else { - duration = "N/A" - } - const promptPreview = truncateText(task.prompt, 500) - - let progressSection = "" - if (task.progress?.lastTool) { - progressSection = `\n| Last tool | ${task.progress.lastTool} |` - } - - let lastMessageSection = "" - if (task.progress?.lastMessage) { - const truncated = truncateText(task.progress.lastMessage, 500) - const messageTime = task.progress.lastMessageAt - ? task.progress.lastMessageAt.toISOString() - : "N/A" - lastMessageSection = ` - -## Last Message (${messageTime}) - -\`\`\` -${truncated} -\`\`\`` - } - - let statusNote = "" - if (task.status === "pending") { - statusNote = ` - -> **Queued**: Task is waiting for a concurrency slot to become available.` - } else if (task.status === "running") { - statusNote = ` - -> **Note**: No need to wait explicitly - the system will notify you when this task completes.` - } else if (task.status === "error") { - statusNote = ` - -> **Failed**: The task encountered an error. Check the last message for details.` - } - - const durationLabel = task.status === "pending" ? "Queued for" : "Duration" - - return `# Task Status - -| Field | Value | -|-------|-------| -| Task ID | \`${task.id}\` | -| Description | ${task.description} | -| Agent | ${task.agent} | -| Status | **${task.status}** | -| ${durationLabel} | ${duration} | -| Session ID | \`${task.sessionID}\` |${progressSection} -${statusNote} -## Original Prompt - -\`\`\` -${promptPreview} -\`\`\`${lastMessageSection}` -} - -function getErrorMessage(value: BackgroundOutputMessagesResult): string | null { - if (Array.isArray(value)) return null - if (value.error === undefined || value.error === null) return null - if (typeof value.error === "string" && value.error.length > 0) return value.error - return String(value.error) -} - -function isSessionMessage(value: unknown): value is { - info?: { role?: string; time?: string } - parts?: Array<{ - type?: string - text?: string - content?: string | Array<{ type: string; text?: string }> - name?: string - }> -} { - return typeof value === "object" && value !== null -} - -function extractMessages(value: BackgroundOutputMessagesResult): BackgroundOutputMessage[] { - if (Array.isArray(value)) { - return value.filter(isSessionMessage) - } - if (Array.isArray(value.data)) { - return value.data.filter(isSessionMessage) - } - return [] -} - -async function formatTaskResult(task: BackgroundTask, client: BackgroundOutputClient): Promise { - if (!task.sessionID) { - return `Error: Task has no sessionID` - } - - const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ - path: { id: task.sessionID }, - }) - - const errorMessage = getErrorMessage(messagesResult) - if (errorMessage) { - return `Error fetching messages: ${errorMessage}` - } - - const messages = extractMessages(messagesResult) - - if (!Array.isArray(messages) || messages.length === 0) { - return `Task Result - -Task ID: ${task.id} -Description: ${task.description} -Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} -Session ID: ${task.sessionID} - ---- - -(No messages found)` - } - - // Include both assistant messages AND tool messages - // Tool results (grep, glob, bash output) come from role "tool" - const relevantMessages = messages.filter( - (m) => m.info?.role === "assistant" || m.info?.role === "tool" - ) - - if (relevantMessages.length === 0) { - return `Task Result - -Task ID: ${task.id} -Description: ${task.description} -Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} -Session ID: ${task.sessionID} - ---- - -(No assistant or tool response found)` - } - - // Sort by time ascending (oldest first) to process messages in order - const sortedMessages = [...relevantMessages].sort((a, b) => { - const timeA = String((a as { info?: { time?: string } }).info?.time ?? "") - const timeB = String((b as { info?: { time?: string } }).info?.time ?? "") - return timeA.localeCompare(timeB) - }) - - const newMessages = consumeNewMessages(task.sessionID, sortedMessages) - if (newMessages.length === 0) { - const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) - return `Task Result - -Task ID: ${task.id} -Description: ${task.description} -Duration: ${duration} -Session ID: ${task.sessionID} - ---- - -(No new output since last check)` - } - - // Extract content from ALL messages, not just the last one - // Tool results may be in earlier messages while the final message is empty - const extractedContent: string[] = [] - - for (const message of newMessages) { - for (const part of message.parts ?? []) { - // Handle both "text" and "reasoning" parts (thinking models use "reasoning") - if ((part.type === "text" || part.type === "reasoning") && part.text) { - extractedContent.push(part.text) - } else if (part.type === "tool_result") { - // Tool results contain the actual output from tool calls - const toolResult = part as { content?: string | Array<{ type: string; text?: string }> } - if (typeof toolResult.content === "string" && toolResult.content) { - extractedContent.push(toolResult.content) - } else if (Array.isArray(toolResult.content)) { - // Handle array of content blocks - for (const block of toolResult.content) { - // Handle both "text" and "reasoning" parts (thinking models use "reasoning") - if ((block.type === "text" || block.type === "reasoning") && block.text) { - extractedContent.push(block.text) - } - } - } - } - } - } - - const textContent = extractedContent - .filter((text) => text.length > 0) - .join("\n\n") - - const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) - - return `Task Result - -Task ID: ${task.id} -Description: ${task.description} -Duration: ${duration} -Session ID: ${task.sessionID} - ---- - -${textContent || "(No text output)"}` -} - -function extractToolResultText(part: FullSessionMessagePart): string[] { - if (typeof part.content === "string" && part.content.length > 0) { - return [part.content] - } - - if (Array.isArray(part.content)) { - const blocks = part.content - .filter((block) => (block.type === "text" || block.type === "reasoning") && block.text) - .map((block) => block.text as string) - if (blocks.length > 0) return blocks - } - - if (part.output && part.output.length > 0) { - return [part.output] - } - - return [] -} - -async function formatFullSession( - task: BackgroundTask, - client: BackgroundOutputClient, - options: { - includeThinking: boolean - messageLimit?: number - sinceMessageId?: string - includeToolResults: boolean - thinkingMaxChars?: number - } -): Promise { - if (!task.sessionID) { - return formatTaskStatus(task) - } - - const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ - path: { id: task.sessionID }, - }) - - const errorMessage = getErrorMessage(messagesResult) - if (errorMessage) { - return `Error fetching messages: ${errorMessage}` - } - - const rawMessages = extractMessages(messagesResult) - if (!Array.isArray(rawMessages)) { - return "Error fetching messages: invalid response" - } - - const sortedMessages = [...(rawMessages as FullSessionMessage[])].sort((a, b) => { - const timeA = String(a.info?.time ?? "") - const timeB = String(b.info?.time ?? "") - return timeA.localeCompare(timeB) - }) - - let filteredMessages = sortedMessages - - if (options.sinceMessageId) { - const index = filteredMessages.findIndex((message) => message.id === options.sinceMessageId) - if (index === -1) { - return `Error: since_message_id not found: ${options.sinceMessageId}` - } - filteredMessages = filteredMessages.slice(index + 1) - } - - const includeThinking = options.includeThinking - const includeToolResults = options.includeToolResults - const thinkingMaxChars = options.thinkingMaxChars ?? THINKING_MAX_CHARS - - const normalizedMessages: FullSessionMessage[] = [] - for (const message of filteredMessages) { - const parts = (message.parts ?? []).filter((part) => { - if (part.type === "thinking" || part.type === "reasoning") { - return includeThinking - } - if (part.type === "tool_result") { - return includeToolResults - } - return part.type === "text" - }) - - if (parts.length === 0) { - continue - } - - normalizedMessages.push({ ...message, parts }) - } - - const limit = typeof options.messageLimit === "number" - ? Math.min(options.messageLimit, MAX_MESSAGE_LIMIT) - : undefined - const hasMore = limit !== undefined && normalizedMessages.length > limit - const visibleMessages = limit !== undefined - ? normalizedMessages.slice(0, limit) - : normalizedMessages - - const lines: string[] = [] - lines.push("# Full Session Output") - lines.push("") - lines.push(`Task ID: ${task.id}`) - lines.push(`Description: ${task.description}`) - lines.push(`Status: ${task.status}`) - lines.push(`Session ID: ${task.sessionID}`) - lines.push(`Total messages: ${normalizedMessages.length}`) - lines.push(`Returned: ${visibleMessages.length}`) - lines.push(`Has more: ${hasMore ? "true" : "false"}`) - lines.push("") - lines.push("## Messages") - - if (visibleMessages.length === 0) { - lines.push("") - lines.push("(No messages found)") - return lines.join("\n") - } - - for (const message of visibleMessages) { - const role = message.info?.role ?? "unknown" - const agent = message.info?.agent ? ` (${message.info.agent})` : "" - const time = formatMessageTime(message.info?.time) - const idLabel = message.id ? ` id=${message.id}` : "" - lines.push("") - lines.push(`[${role}${agent}] ${time}${idLabel}`) - - for (const part of message.parts ?? []) { - if (part.type === "text" && part.text) { - lines.push(part.text.trim()) - } else if (part.type === "thinking" && part.thinking) { - lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`) - } else if (part.type === "reasoning" && part.text) { - lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`) - } else if (part.type === "tool_result") { - const toolTexts = extractToolResultText(part) - for (const toolText of toolTexts) { - lines.push(`[tool result] ${toolText}`) - } - } - } - } - - return lines.join("\n") -} - -export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition { - return tool({ - description: BACKGROUND_OUTPUT_DESCRIPTION, - args: { - task_id: tool.schema.string().describe("Task ID to get output from"), - block: tool.schema.boolean().optional().describe("Wait for completion (default: false). System notifies when done, so blocking is rarely needed."), - timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"), - full_session: tool.schema.boolean().optional().describe("Return full session messages with filters (default: false)"), - include_thinking: tool.schema.boolean().optional().describe("Include thinking/reasoning parts in full_session output (default: false)"), - message_limit: tool.schema.number().optional().describe("Max messages to return (capped at 100)"), - since_message_id: tool.schema.string().optional().describe("Return messages after this message ID (exclusive)"), - include_tool_results: tool.schema.boolean().optional().describe("Include tool results in full_session output (default: false)"), - thinking_max_chars: tool.schema.number().optional().describe("Max characters for thinking content (default: 2000)"), - }, - async execute(args: BackgroundOutputArgs) { - try { - const task = manager.getTask(args.task_id) - if (!task) { - return `Task not found: ${args.task_id}` - } - - if (args.full_session === true) { - return await formatFullSession(task, client, { - includeThinking: args.include_thinking === true, - messageLimit: args.message_limit, - sinceMessageId: args.since_message_id, - includeToolResults: args.include_tool_results === true, - thinkingMaxChars: args.thinking_max_chars, - }) - } - - const shouldBlock = args.block === true - const timeoutMs = Math.min(args.timeout ?? 60000, 600000) - - // Already completed: return result immediately (regardless of block flag) - if (task.status === "completed") { - return await formatTaskResult(task, client) - } - - // Error or cancelled: return status immediately - if (task.status === "error" || task.status === "cancelled") { - return formatTaskStatus(task) - } - - // Non-blocking and still running: return status - if (!shouldBlock) { - return formatTaskStatus(task) - } - - // Blocking: poll until completion or timeout - const startTime = Date.now() - - while (Date.now() - startTime < timeoutMs) { - await delay(1000) - - const currentTask = manager.getTask(args.task_id) - if (!currentTask) { - return `Task was deleted: ${args.task_id}` - } - - if (currentTask.status === "completed") { - return await formatTaskResult(currentTask, client) - } - - if (currentTask.status === "error" || currentTask.status === "cancelled") { - return formatTaskStatus(currentTask) - } - } - - // Timeout exceeded: return current status - const finalTask = manager.getTask(args.task_id) - if (!finalTask) { - return `Task was deleted: ${args.task_id}` - } - return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}` - } catch (error) { - return `Error getting output: ${error instanceof Error ? error.message : String(error)}` - } - }, - }) -} - -export function createBackgroundCancel(manager: BackgroundManager, client: BackgroundCancelClient): ToolDefinition { - return tool({ - description: BACKGROUND_CANCEL_DESCRIPTION, - args: { - taskId: tool.schema.string().optional().describe("Task ID to cancel (required if all=false)"), - all: tool.schema.boolean().optional().describe("Cancel all running background tasks (default: false)"), - }, - async execute(args: BackgroundCancelArgs, toolContext) { - try { - const cancelAll = args.all === true - - if (!cancelAll && !args.taskId) { - return `[ERROR] Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.` - } - - if (cancelAll) { - const tasks = manager.getAllDescendantTasks(toolContext.sessionID) - const cancellableTasks = tasks.filter(t => t.status === "running" || t.status === "pending") - - if (cancellableTasks.length === 0) { - return `No running or pending background tasks to cancel.` - } - - const cancelledInfo: Array<{ - id: string - description: string - status: string - sessionID?: string - }> = [] - - for (const task of cancellableTasks) { - const originalStatus = task.status - const cancelled = await manager.cancelTask(task.id, { - source: "background_cancel", - abortSession: originalStatus === "running", - skipNotification: true, - }) - if (!cancelled) continue - cancelledInfo.push({ - id: task.id, - description: task.description, - status: originalStatus === "pending" ? "pending" : "running", - sessionID: task.sessionID, - }) - } - - const tableRows = cancelledInfo - .map(t => `| \`${t.id}\` | ${t.description} | ${t.status} | ${t.sessionID ? `\`${t.sessionID}\`` : "(not started)"} |`) - .join("\n") - - const resumableTasks = cancelledInfo.filter(t => t.sessionID) - const resumeSection = resumableTasks.length > 0 - ? `\n## Continue Instructions - -To continue a cancelled task, use: -\`\`\` -task(session_id="", prompt="Continue: ") -\`\`\` - -Continuable sessions: -${resumableTasks.map(t => `- \`${t.sessionID}\` (${t.description})`).join("\n")}` - : "" - - return `Cancelled ${cancelledInfo.length} background task(s): - -| Task ID | Description | Status | Session ID | -|---------|-------------|--------|------------| -${tableRows} -${resumeSection}` - } - - const task = manager.getTask(args.taskId!) - if (!task) { - return `[ERROR] Task not found: ${args.taskId}` - } - - if (task.status !== "running" && task.status !== "pending") { - return `[ERROR] Cannot cancel task: current status is "${task.status}". -Only running or pending tasks can be cancelled.` - } - - const cancelled = await manager.cancelTask(task.id, { - source: "background_cancel", - abortSession: task.status === "running", - skipNotification: true, - }) - if (!cancelled) { - return `[ERROR] Failed to cancel task: ${task.id}` - } - - if (task.status === "pending") { - return `Pending task cancelled successfully - -Task ID: ${task.id} -Description: ${task.description} -Status: ${task.status}` - } - - return `Task cancelled successfully - -Task ID: ${task.id} -Description: ${task.description} -Session ID: ${task.sessionID} -Status: ${task.status}` - } catch (error) { - return `[ERROR] Error cancelling task: ${error instanceof Error ? error.message : String(error)}` - } - }, - }) -} -function formatMessageTime(value: unknown): string { - if (typeof value === "string") { - const date = new Date(value) - return Number.isNaN(date.getTime()) ? value : date.toISOString() - } - if (typeof value === "object" && value !== null) { - if ("created" in value) { - const created = (value as { created?: number }).created - if (typeof created === "number") { - return new Date(created).toISOString() - } - } - } - return "Unknown time" -} +export type { + BackgroundCancelClient, + BackgroundOutputClient, + BackgroundOutputManager, + BackgroundOutputMessage, + BackgroundOutputMessagesResult, +} from "./clients" + +export { createBackgroundTask } from "./create-background-task" +export { createBackgroundOutput } from "./create-background-output" +export { createBackgroundCancel } from "./create-background-cancel" diff --git a/src/tools/background-task/truncate-text.ts b/src/tools/background-task/truncate-text.ts new file mode 100644 index 000000000..42fd3c3a0 --- /dev/null +++ b/src/tools/background-task/truncate-text.ts @@ -0,0 +1,4 @@ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + return text.slice(0, maxLength) + "..." +} From c7122b412737bb1cfeeb435ff6dd2209d871bc07 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 15:31:32 +0900 Subject: [PATCH 03/51] fix: resolve all test failures and Cubic review issues - Fix unstable-agent-babysitter: add promptAsync to test mock - Fix claude-code-mcp-loader: isolate tests from user home configs - Fix npm-dist-tags: encode packageName for scoped packages - Fix agent-builder: clone source to prevent shared object mutation - Fix add-plugin-to-opencode-config: handle JSONC with leading comments - Fix auth-plugins/add-provider-config: error on parse failures - Fix bun-install: clear timeout on completion - Fix git-diff-stats: include untracked files in diff summary --- src/agents/agent-builder.ts | 2 +- .../add-plugin-to-opencode-config.ts | 2 +- src/cli/config-manager/add-provider-config.ts | 9 ++- src/cli/config-manager/auth-plugins.ts | 9 ++- src/cli/config-manager/bun-install.ts | 8 ++- src/cli/config-manager/npm-dist-tags.ts | 2 +- .../claude-code-mcp-loader/loader.test.ts | 58 ++++++---------- src/hooks/atlas/git-diff-stats.ts | 69 +++++++++++++------ .../unstable-agent-babysitter/index.test.ts | 3 + 9 files changed, 91 insertions(+), 71 deletions(-) diff --git a/src/agents/agent-builder.ts b/src/agents/agent-builder.ts index 459f18e07..63cf6962d 100644 --- a/src/agents/agent-builder.ts +++ b/src/agents/agent-builder.ts @@ -19,7 +19,7 @@ export function buildAgent( browserProvider?: BrowserAutomationProvider, disabledSkills?: Set ): AgentConfig { - const base = isFactory(source) ? source(model) : source + const base = isFactory(source) ? source(model) : { ...source } const categoryConfigs: Record = categories ? { ...DEFAULT_CATEGORIES, ...categories } : DEFAULT_CATEGORIES diff --git a/src/cli/config-manager/add-plugin-to-opencode-config.ts b/src/cli/config-manager/add-plugin-to-opencode-config.ts index 0262cc53f..1e8eb872d 100644 --- a/src/cli/config-manager/add-plugin-to-opencode-config.ts +++ b/src/cli/config-manager/add-plugin-to-opencode-config.ts @@ -64,7 +64,7 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`) writeFileSync(path, newContent) } else { - const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`) + const newContent = content.replace(/(\{)/, `$1\n "plugin": ["${pluginEntry}"],`) writeFileSync(path, newContent) } } else { diff --git a/src/cli/config-manager/add-provider-config.ts b/src/cli/config-manager/add-provider-config.ts index bc25c7a7a..833c9882f 100644 --- a/src/cli/config-manager/add-provider-config.ts +++ b/src/cli/config-manager/add-provider-config.ts @@ -25,10 +25,13 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult { if (format !== "none") { const parseResult = parseOpenCodeConfigFileWithError(path) if (parseResult.error && !parseResult.config) { - existingConfig = {} - } else { - existingConfig = parseResult.config + return { + success: false, + configPath: path, + error: `Failed to parse config file: ${parseResult.error}`, + } } + existingConfig = parseResult.config } const newConfig = { ...(existingConfig ?? {}) } diff --git a/src/cli/config-manager/auth-plugins.ts b/src/cli/config-manager/auth-plugins.ts index 77a38369d..7bbc8b819 100644 --- a/src/cli/config-manager/auth-plugins.ts +++ b/src/cli/config-manager/auth-plugins.ts @@ -35,10 +35,13 @@ export async function addAuthPlugins(config: InstallConfig): Promise { stderr: "pipe", }) - const timeoutPromise = new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS) - ) + let timeoutId: ReturnType + const timeoutPromise = new Promise<"timeout">((resolve) => { + timeoutId = setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS) + }) const exitPromise = proc.exited.then(() => "completed" as const) const result = await Promise.race([exitPromise, timeoutPromise]) + clearTimeout(timeoutId!) if (result === "timeout") { try { diff --git a/src/cli/config-manager/npm-dist-tags.ts b/src/cli/config-manager/npm-dist-tags.ts index f653fc2fc..67d8ca0e9 100644 --- a/src/cli/config-manager/npm-dist-tags.ts +++ b/src/cli/config-manager/npm-dist-tags.ts @@ -9,7 +9,7 @@ const NPM_FETCH_TIMEOUT_MS = 5000 export async function fetchNpmDistTags(packageName: string): Promise { try { - const res = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`, { + const res = await fetch(`https://registry.npmjs.org/-/package/${encodeURIComponent(packageName)}/dist-tags`, { signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS), }) if (!res.ok) return null diff --git a/src/features/claude-code-mcp-loader/loader.test.ts b/src/features/claude-code-mcp-loader/loader.test.ts index 8a1b9f6ea..33c5a0ae4 100644 --- a/src/features/claude-code-mcp-loader/loader.test.ts +++ b/src/features/claude-code-mcp-loader/loader.test.ts @@ -4,10 +4,19 @@ import { join } from "path" import { tmpdir } from "os" const TEST_DIR = join(tmpdir(), "mcp-loader-test-" + Date.now()) +const TEST_HOME = join(TEST_DIR, "home") describe("getSystemMcpServerNames", () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }) + mkdirSync(TEST_HOME, { recursive: true }) + mock.module("os", () => ({ + homedir: () => TEST_HOME, + tmpdir, + })) + mock.module("../../shared", () => ({ + getClaudeConfigDir: () => join(TEST_HOME, ".claude"), + })) }) afterEach(() => { @@ -162,7 +171,7 @@ describe("getSystemMcpServerNames", () => { it("reads user-level MCP config from ~/.claude.json", async () => { // given - const userConfigPath = join(TEST_DIR, ".claude.json") + const userConfigPath = join(TEST_HOME, ".claude.json") const userMcpConfig = { mcpServers: { "user-server": { @@ -171,53 +180,37 @@ describe("getSystemMcpServerNames", () => { }, }, } + writeFileSync(userConfigPath, JSON.stringify(userMcpConfig)) const originalCwd = process.cwd() process.chdir(TEST_DIR) try { - mock.module("os", () => ({ - homedir: () => TEST_DIR, - tmpdir, - })) - - writeFileSync(userConfigPath, JSON.stringify(userMcpConfig)) - + // when const { getSystemMcpServerNames } = await import("./loader") const names = getSystemMcpServerNames() + // then expect(names.has("user-server")).toBe(true) } finally { process.chdir(originalCwd) - rmSync(userConfigPath, { force: true }) } }) it("reads both ~/.claude.json and ~/.claude/.mcp.json for user scope", async () => { - // given: simulate both user-level config files - const userClaudeJson = join(TEST_DIR, ".claude.json") - const claudeDir = join(TEST_DIR, ".claude") - const claudeDirMcpJson = join(claudeDir, ".mcp.json") - + // given + const claudeDir = join(TEST_HOME, ".claude") mkdirSync(claudeDir, { recursive: true }) - // ~/.claude.json has server-a - writeFileSync(userClaudeJson, JSON.stringify({ + writeFileSync(join(TEST_HOME, ".claude.json"), JSON.stringify({ mcpServers: { - "server-from-claude-json": { - command: "npx", - args: ["server-a"], - }, + "server-from-claude-json": { command: "npx", args: ["server-a"] }, }, })) - // ~/.claude/.mcp.json has server-b (CLI-managed) - writeFileSync(claudeDirMcpJson, JSON.stringify({ + writeFileSync(join(claudeDir, ".mcp.json"), JSON.stringify({ mcpServers: { - "server-from-mcp-json": { - command: "npx", - args: ["server-b"], - }, + "server-from-mcp-json": { command: "npx", args: ["server-b"] }, }, })) @@ -225,20 +218,11 @@ describe("getSystemMcpServerNames", () => { process.chdir(TEST_DIR) try { - mock.module("os", () => ({ - homedir: () => TEST_DIR, - tmpdir, - })) - - // Also mock getClaudeConfigDir to point to our test .claude dir - mock.module("../../shared", () => ({ - getClaudeConfigDir: () => claudeDir, - })) - + // when const { getSystemMcpServerNames } = await import("./loader") const names = getSystemMcpServerNames() - // Both sources should be merged + // then expect(names.has("server-from-claude-json")).toBe(true) expect(names.has("server-from-mcp-json")).toBe(true) } finally { diff --git a/src/hooks/atlas/git-diff-stats.ts b/src/hooks/atlas/git-diff-stats.ts index 404942938..d154c50a4 100644 --- a/src/hooks/atlas/git-diff-stats.ts +++ b/src/hooks/atlas/git-diff-stats.ts @@ -9,15 +9,6 @@ interface GitFileStat { export function getGitDiffStats(directory: string): GitFileStat[] { try { - const output = execSync("git diff --numstat HEAD", { - cwd: directory, - encoding: "utf-8", - timeout: 5000, - stdio: ["pipe", "pipe", "pipe"], - }).trim() - - if (!output) return [] - const statusOutput = execSync("git status --porcelain", { cwd: directory, encoding: "utf-8", @@ -25,12 +16,18 @@ export function getGitDiffStats(directory: string): GitFileStat[] { stdio: ["pipe", "pipe", "pipe"], }).trim() + if (!statusOutput) return [] + const statusMap = new Map() + const untrackedFiles: string[] = [] for (const line of statusOutput.split("\n")) { if (!line) continue const status = line.substring(0, 2).trim() const filePath = line.substring(3) - if (status === "A" || status === "??") { + if (status === "??") { + statusMap.set(filePath, "added") + untrackedFiles.push(filePath) + } else if (status === "A") { statusMap.set(filePath, "added") } else if (status === "D") { statusMap.set(filePath, "deleted") @@ -39,21 +36,49 @@ export function getGitDiffStats(directory: string): GitFileStat[] { } } + const output = execSync("git diff --numstat HEAD", { + cwd: directory, + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim() + const stats: GitFileStat[] = [] - for (const line of output.split("\n")) { - const parts = line.split("\t") - if (parts.length < 3) continue + const trackedPaths = new Set() - const [addedStr, removedStr, path] = parts - const added = addedStr === "-" ? 0 : parseInt(addedStr, 10) - const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10) + if (output) { + for (const line of output.split("\n")) { + const parts = line.split("\t") + if (parts.length < 3) continue - stats.push({ - path, - added, - removed, - status: statusMap.get(path) ?? "modified", - }) + const [addedStr, removedStr, path] = parts + const added = addedStr === "-" ? 0 : parseInt(addedStr, 10) + const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10) + trackedPaths.add(path) + + stats.push({ + path, + added, + removed, + status: statusMap.get(path) ?? "modified", + }) + } + } + + for (const filePath of untrackedFiles) { + if (trackedPaths.has(filePath)) continue + try { + const content = execSync(`wc -l < "${filePath}"`, { + cwd: directory, + encoding: "utf-8", + timeout: 3000, + stdio: ["pipe", "pipe", "pipe"], + }).trim() + const lineCount = parseInt(content, 10) || 0 + stats.push({ path: filePath, added: lineCount, removed: 0, status: "added" }) + } catch { + stats.push({ path: filePath, added: 0, removed: 0, status: "added" }) + } } return stats diff --git a/src/hooks/unstable-agent-babysitter/index.test.ts b/src/hooks/unstable-agent-babysitter/index.test.ts index f9900e7d5..9fc309ec9 100644 --- a/src/hooks/unstable-agent-babysitter/index.test.ts +++ b/src/hooks/unstable-agent-babysitter/index.test.ts @@ -21,6 +21,9 @@ function createMockPluginInput(options: { prompt: async (input: unknown) => { promptCalls.push({ input }) }, + promptAsync: async (input: unknown) => { + promptCalls.push({ input }) + }, }, }, } From e3bd43ff643b813c786816409e575cf75e7044bb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:20:52 +0900 Subject: [PATCH 04/51] refactor(background-agent): split manager.ts into focused modules Extract 30+ single-responsibility modules from manager.ts (1556 LOC): - task lifecycle: task-starter, task-completer, task-canceller, task-resumer - task queries: task-queries, task-poller, task-queue-processor - notifications: notification-builder, notification-tracker, parent-session-notifier - session handling: session-validator, session-output-validator, session-todo-checker - spawner: spawner/ directory with focused spawn modules - utilities: duration-formatter, error-classifier, message-storage-locator - result handling: result-handler-context, background-task-completer - shutdown: background-manager-shutdown, process-signal --- .../background-event-handler.ts | 199 ++ .../background-manager-shutdown.ts | 82 + .../background-task-completer.ts | 40 + .../background-task-notification-template.ts | 46 + .../background-agent/duration-formatter.ts | 14 + .../background-agent/error-classifier.ts | 21 + .../background-agent/format-duration.ts | 14 + src/features/background-agent/manager.ts | 1644 +---------------- src/features/background-agent/message-dir.ts | 18 + .../message-storage-locator.ts | 17 + .../background-agent/notification-builder.ts | 40 + .../background-agent/notification-tracker.ts | 52 + .../background-agent/notify-parent-session.ts | 192 ++ .../background-agent/opencode-client.ts | 3 + .../parent-session-context-resolver.ts | 75 + .../parent-session-notifier.ts | 102 + .../background-agent/poll-running-tasks.ts | 178 ++ .../background-agent/process-signal.ts | 19 + .../result-handler-context.ts | 9 + .../background-agent/result-handler.ts | 283 +-- .../session-output-validator.ts | 88 + .../background-agent/session-todo-checker.ts | 33 + .../background-agent/session-validator.ts | 111 ++ src/features/background-agent/spawner.ts | 246 +-- .../spawner/background-session-creator.ts | 45 + .../concurrency-key-from-launch-input.ts | 7 + .../spawner/parent-directory-resolver.ts | 21 + .../spawner/spawner-context.ts | 12 + .../background-agent/spawner/task-factory.ts | 18 + .../background-agent/spawner/task-resumer.ts | 91 + .../background-agent/spawner/task-starter.ts | 94 + .../spawner/tmux-callback-invoker.ts | 40 + .../background-agent/stale-task-pruner.ts | 57 + src/features/background-agent/state.ts | 5 - .../background-agent/task-canceller.ts | 117 ++ .../background-agent/task-completer.ts | 68 + src/features/background-agent/task-launch.ts | 77 + src/features/background-agent/task-poller.ts | 107 ++ src/features/background-agent/task-queries.ts | 56 + .../background-agent/task-queue-processor.ts | 52 + src/features/background-agent/task-resumer.ts | 144 ++ src/features/background-agent/task-starter.ts | 190 ++ src/features/background-agent/task-tracker.ts | 97 + 43 files changed, 2753 insertions(+), 2071 deletions(-) create mode 100644 src/features/background-agent/background-event-handler.ts create mode 100644 src/features/background-agent/background-manager-shutdown.ts create mode 100644 src/features/background-agent/background-task-completer.ts create mode 100644 src/features/background-agent/background-task-notification-template.ts create mode 100644 src/features/background-agent/duration-formatter.ts create mode 100644 src/features/background-agent/error-classifier.ts create mode 100644 src/features/background-agent/format-duration.ts create mode 100644 src/features/background-agent/message-dir.ts create mode 100644 src/features/background-agent/message-storage-locator.ts create mode 100644 src/features/background-agent/notification-builder.ts create mode 100644 src/features/background-agent/notification-tracker.ts create mode 100644 src/features/background-agent/notify-parent-session.ts create mode 100644 src/features/background-agent/opencode-client.ts create mode 100644 src/features/background-agent/parent-session-context-resolver.ts create mode 100644 src/features/background-agent/parent-session-notifier.ts create mode 100644 src/features/background-agent/poll-running-tasks.ts create mode 100644 src/features/background-agent/process-signal.ts create mode 100644 src/features/background-agent/result-handler-context.ts create mode 100644 src/features/background-agent/session-output-validator.ts create mode 100644 src/features/background-agent/session-todo-checker.ts create mode 100644 src/features/background-agent/session-validator.ts create mode 100644 src/features/background-agent/spawner/background-session-creator.ts create mode 100644 src/features/background-agent/spawner/concurrency-key-from-launch-input.ts create mode 100644 src/features/background-agent/spawner/parent-directory-resolver.ts create mode 100644 src/features/background-agent/spawner/spawner-context.ts create mode 100644 src/features/background-agent/spawner/task-factory.ts create mode 100644 src/features/background-agent/spawner/task-resumer.ts create mode 100644 src/features/background-agent/spawner/task-starter.ts create mode 100644 src/features/background-agent/spawner/tmux-callback-invoker.ts create mode 100644 src/features/background-agent/stale-task-pruner.ts create mode 100644 src/features/background-agent/task-canceller.ts create mode 100644 src/features/background-agent/task-completer.ts create mode 100644 src/features/background-agent/task-launch.ts create mode 100644 src/features/background-agent/task-poller.ts create mode 100644 src/features/background-agent/task-queries.ts create mode 100644 src/features/background-agent/task-queue-processor.ts create mode 100644 src/features/background-agent/task-resumer.ts create mode 100644 src/features/background-agent/task-starter.ts create mode 100644 src/features/background-agent/task-tracker.ts diff --git a/src/features/background-agent/background-event-handler.ts b/src/features/background-agent/background-event-handler.ts new file mode 100644 index 000000000..3d6e18d9b --- /dev/null +++ b/src/features/background-agent/background-event-handler.ts @@ -0,0 +1,199 @@ +import { log } from "../../shared" +import { MIN_IDLE_TIME_MS } from "./constants" +import { subagentSessions } from "../claude-code-session-state" +import type { BackgroundTask } from "./types" + +type Event = { type: string; properties?: Record } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function getString(obj: Record, key: string): string | undefined { + const value = obj[key] + return typeof value === "string" ? value : undefined +} + +export function handleBackgroundEvent(args: { + event: Event + findBySession: (sessionID: string) => BackgroundTask | undefined + getAllDescendantTasks: (sessionID: string) => BackgroundTask[] + cancelTask: ( + taskId: string, + options: { source: string; reason: string; skipNotification: true } + ) => Promise + tryCompleteTask: (task: BackgroundTask, source: string) => Promise + validateSessionHasOutput: (sessionID: string) => Promise + checkSessionTodos: (sessionID: string) => Promise + idleDeferralTimers: Map> + completionTimers: Map> + tasks: Map + cleanupPendingByParent: (task: BackgroundTask) => void + clearNotificationsForTask: (taskId: string) => void + emitIdleEvent: (sessionID: string) => void +}): void { + const { + event, + findBySession, + getAllDescendantTasks, + cancelTask, + tryCompleteTask, + validateSessionHasOutput, + checkSessionTodos, + idleDeferralTimers, + completionTimers, + tasks, + cleanupPendingByParent, + clearNotificationsForTask, + emitIdleEvent, + } = args + + const props = event.properties + + if (event.type === "message.part.updated") { + if (!props || !isRecord(props)) return + const sessionID = getString(props, "sessionID") + if (!sessionID) return + + const task = findBySession(sessionID) + if (!task) return + + const existingTimer = idleDeferralTimers.get(task.id) + if (existingTimer) { + clearTimeout(existingTimer) + idleDeferralTimers.delete(task.id) + } + + const type = getString(props, "type") + const tool = getString(props, "tool") + + if (type === "tool" || tool) { + if (!task.progress) { + task.progress = { toolCalls: 0, lastUpdate: new Date() } + } + task.progress.toolCalls += 1 + task.progress.lastTool = tool + task.progress.lastUpdate = new Date() + } + } + + if (event.type === "session.idle") { + if (!props || !isRecord(props)) return + const sessionID = getString(props, "sessionID") + if (!sessionID) return + + const task = findBySession(sessionID) + if (!task || task.status !== "running") return + + const startedAt = task.startedAt + if (!startedAt) return + + const elapsedMs = Date.now() - startedAt.getTime() + if (elapsedMs < MIN_IDLE_TIME_MS) { + const remainingMs = MIN_IDLE_TIME_MS - elapsedMs + if (!idleDeferralTimers.has(task.id)) { + log("[background-agent] Deferring early session.idle:", { + elapsedMs, + remainingMs, + taskId: task.id, + }) + const timer = setTimeout(() => { + idleDeferralTimers.delete(task.id) + emitIdleEvent(sessionID) + }, remainingMs) + idleDeferralTimers.set(task.id, timer) + } else { + log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id }) + } + return + } + + validateSessionHasOutput(sessionID) + .then(async (hasValidOutput) => { + if (task.status !== "running") { + log("[background-agent] Task status changed during validation, skipping:", { + taskId: task.id, + status: task.status, + }) + return + } + + if (!hasValidOutput) { + log("[background-agent] Session.idle but no valid output yet, waiting:", task.id) + return + } + + const hasIncompleteTodos = await checkSessionTodos(sessionID) + + if (task.status !== "running") { + log("[background-agent] Task status changed during todo check, skipping:", { + taskId: task.id, + status: task.status, + }) + return + } + + if (hasIncompleteTodos) { + log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id) + return + } + + await tryCompleteTask(task, "session.idle event") + }) + .catch((err) => { + log("[background-agent] Error in session.idle handler:", err) + }) + } + + if (event.type === "session.deleted") { + if (!props || !isRecord(props)) return + const infoRaw = props["info"] + if (!isRecord(infoRaw)) return + const sessionID = getString(infoRaw, "id") + if (!sessionID) return + + const tasksToCancel = new Map() + const directTask = findBySession(sessionID) + if (directTask) { + tasksToCancel.set(directTask.id, directTask) + } + for (const descendant of getAllDescendantTasks(sessionID)) { + tasksToCancel.set(descendant.id, descendant) + } + if (tasksToCancel.size === 0) return + + for (const task of tasksToCancel.values()) { + if (task.status === "running" || task.status === "pending") { + void cancelTask(task.id, { + source: "session.deleted", + reason: "Session deleted", + skipNotification: true, + }).catch((err) => { + log("[background-agent] Failed to cancel task on session.deleted:", { + taskId: task.id, + error: err, + }) + }) + } + + const completionTimer = completionTimers.get(task.id) + if (completionTimer) { + clearTimeout(completionTimer) + completionTimers.delete(task.id) + } + + const idleTimer = idleDeferralTimers.get(task.id) + if (idleTimer) { + clearTimeout(idleTimer) + idleDeferralTimers.delete(task.id) + } + + cleanupPendingByParent(task) + tasks.delete(task.id) + clearNotificationsForTask(task.id) + if (task.sessionID) { + subagentSessions.delete(task.sessionID) + } + } + } +} diff --git a/src/features/background-agent/background-manager-shutdown.ts b/src/features/background-agent/background-manager-shutdown.ts new file mode 100644 index 000000000..01abd298f --- /dev/null +++ b/src/features/background-agent/background-manager-shutdown.ts @@ -0,0 +1,82 @@ +import { log } from "../../shared" + +import type { BackgroundTask, LaunchInput } from "./types" +import type { ConcurrencyManager } from "./concurrency" +import type { PluginInput } from "@opencode-ai/plugin" + +type QueueItem = { task: BackgroundTask; input: LaunchInput } + +export function shutdownBackgroundManager(args: { + shutdownTriggered: { value: boolean } + stopPolling: () => void + tasks: Map + client: PluginInput["client"] + onShutdown?: () => void + concurrencyManager: ConcurrencyManager + completionTimers: Map> + idleDeferralTimers: Map> + notifications: Map + pendingByParent: Map> + queuesByKey: Map + processingKeys: Set + unregisterProcessCleanup: () => void +}): void { + const { + shutdownTriggered, + stopPolling, + tasks, + client, + onShutdown, + concurrencyManager, + completionTimers, + idleDeferralTimers, + notifications, + pendingByParent, + queuesByKey, + processingKeys, + unregisterProcessCleanup, + } = args + + if (shutdownTriggered.value) return + shutdownTriggered.value = true + + log("[background-agent] Shutting down BackgroundManager") + stopPolling() + + for (const task of tasks.values()) { + if (task.status === "running" && task.sessionID) { + client.session.abort({ path: { id: task.sessionID } }).catch(() => {}) + } + } + + if (onShutdown) { + try { + onShutdown() + } catch (error) { + log("[background-agent] Error in onShutdown callback:", error) + } + } + + for (const task of tasks.values()) { + if (task.concurrencyKey) { + concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined + } + } + + for (const timer of completionTimers.values()) clearTimeout(timer) + completionTimers.clear() + + for (const timer of idleDeferralTimers.values()) clearTimeout(timer) + idleDeferralTimers.clear() + + concurrencyManager.clear() + tasks.clear() + notifications.clear() + pendingByParent.clear() + queuesByKey.clear() + processingKeys.clear() + unregisterProcessCleanup() + + log("[background-agent] Shutdown complete") +} diff --git a/src/features/background-agent/background-task-completer.ts b/src/features/background-agent/background-task-completer.ts new file mode 100644 index 000000000..4c105eb04 --- /dev/null +++ b/src/features/background-agent/background-task-completer.ts @@ -0,0 +1,40 @@ +import type { BackgroundTask } from "./types" +import type { ResultHandlerContext } from "./result-handler-context" +import { log } from "../../shared" +import { notifyParentSession } from "./parent-session-notifier" + +export async function tryCompleteTask( + task: BackgroundTask, + source: string, + ctx: ResultHandlerContext +): Promise { + const { concurrencyManager, state } = ctx + + if (task.status !== "running") { + log("[background-agent] Task already completed, skipping:", { + taskId: task.id, + status: task.status, + source, + }) + return false + } + + task.status = "completed" + task.completedAt = new Date() + + if (task.concurrencyKey) { + concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined + } + + state.markForNotification(task) + + try { + await notifyParentSession(task, ctx) + log(`[background-agent] Task completed via ${source}:`, task.id) + } catch (error) { + log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error }) + } + + return true +} diff --git a/src/features/background-agent/background-task-notification-template.ts b/src/features/background-agent/background-task-notification-template.ts new file mode 100644 index 000000000..c3234decc --- /dev/null +++ b/src/features/background-agent/background-task-notification-template.ts @@ -0,0 +1,46 @@ +import type { BackgroundTask } from "./types" + +export type BackgroundTaskNotificationStatus = "COMPLETED" | "CANCELLED" + +export function buildBackgroundTaskNotificationText(input: { + task: BackgroundTask + duration: string + statusText: BackgroundTaskNotificationStatus + allComplete: boolean + remainingCount: number + completedTasks: BackgroundTask[] +}): string { + const { task, duration, statusText, allComplete, remainingCount, completedTasks } = input + + const errorInfo = task.error ? `\n**Error:** ${task.error}` : "" + + if (allComplete) { + const completedTasksText = completedTasks + .map((t) => `- \`${t.id}\`: ${t.description}`) + .join("\n") + + return ` +[ALL BACKGROUND TASKS COMPLETE] + +**Completed:** +${completedTasksText || `- \`${task.id}\`: ${task.description}`} + +Use \`background_output(task_id="")\` to retrieve each result. +` + } + + const agentInfo = task.category ? `${task.agent} (${task.category})` : task.agent + + return ` +[BACKGROUND TASK ${statusText}] +**ID:** \`${task.id}\` +**Description:** ${task.description} +**Agent:** ${agentInfo} +**Duration:** ${duration}${errorInfo} + +**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete. +Do NOT poll - continue productive work. + +Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready. +` +} diff --git a/src/features/background-agent/duration-formatter.ts b/src/features/background-agent/duration-formatter.ts new file mode 100644 index 000000000..65fd8adf2 --- /dev/null +++ b/src/features/background-agent/duration-formatter.ts @@ -0,0 +1,14 @@ +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/features/background-agent/error-classifier.ts b/src/features/background-agent/error-classifier.ts new file mode 100644 index 000000000..8be1dd3d2 --- /dev/null +++ b/src/features/background-agent/error-classifier.ts @@ -0,0 +1,21 @@ +export function isAbortedSessionError(error: unknown): boolean { + const message = getErrorText(error) + return message.toLowerCase().includes("aborted") +} + +export function getErrorText(error: unknown): string { + if (!error) return "" + if (typeof error === "string") return error + if (error instanceof Error) { + return `${error.name}: ${error.message}` + } + if (typeof error === "object" && error !== null) { + if ("message" in error && typeof error.message === "string") { + return error.message + } + if ("name" in error && typeof error.name === "string") { + return error.name + } + } + return "" +} diff --git a/src/features/background-agent/format-duration.ts b/src/features/background-agent/format-duration.ts new file mode 100644 index 000000000..65fd8adf2 --- /dev/null +++ b/src/features/background-agent/format-duration.ts @@ -0,0 +1,14 @@ +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/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index e631f30a0..808221af2 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1,70 +1,32 @@ - import type { PluginInput } from "@opencode-ai/plugin" -import type { - BackgroundTask, - LaunchInput, - ResumeInput, -} from "./types" -import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared" -import { ConcurrencyManager } from "./concurrency" +import type { BackgroundTask, LaunchInput, ResumeInput } from "./types" import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" -import { isInsideTmux } from "../../shared/tmux" -import { - DEFAULT_STALE_TIMEOUT_MS, - MIN_IDLE_TIME_MS, - MIN_RUNTIME_BEFORE_STALE_MS, - MIN_STABILITY_TIME_MS, - POLLING_INTERVAL_MS, - TASK_CLEANUP_DELAY_MS, - TASK_TTL_MS, -} from "./constants" -import { subagentSessions } from "../claude-code-session-state" -import { getTaskToastManager } from "../task-toast-manager" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../hook-message-injector" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" +import { log } from "../../shared" +import { ConcurrencyManager } from "./concurrency" +import { POLLING_INTERVAL_MS } from "./constants" -type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit" +import { handleBackgroundEvent } from "./background-event-handler" +import { shutdownBackgroundManager } from "./background-manager-shutdown" +import { clearNotifications, clearNotificationsForTask, cleanupPendingByParent, getPendingNotifications, markForNotification } from "./notification-tracker" +import { notifyParentSession as notifyParentSessionInternal } from "./notify-parent-session" +import { pollRunningTasks } from "./poll-running-tasks" +import { registerProcessSignal, type ProcessCleanupEvent } from "./process-signal" +import { validateSessionHasOutput, checkSessionTodos } from "./session-validator" +import { pruneStaleState } from "./stale-task-pruner" +import { getAllDescendantTasks, getCompletedTasks, getRunningTasks, getTasksByParentSession, hasRunningTasks, findTaskBySession } from "./task-queries" +import { checkAndInterruptStaleTasks } from "./task-poller" +import { cancelBackgroundTask } from "./task-canceller" +import { tryCompleteBackgroundTask } from "./task-completer" +import { launchBackgroundTask } from "./task-launch" +import { processConcurrencyKeyQueue } from "./task-queue-processor" +import { resumeBackgroundTask } from "./task-resumer" +import { startQueuedTask } from "./task-starter" +import { trackExternalTask } from "./task-tracker" -type OpencodeClient = PluginInput["client"] - - -interface MessagePartInfo { - sessionID?: string - type?: string - tool?: string -} - -interface EventProperties { - sessionID?: string - info?: { id?: string } - [key: string]: unknown -} - -interface Event { - type: string - properties?: EventProperties -} - -interface Todo { - content: string - status: string - priority: string - id: string -} - -interface QueueItem { - task: BackgroundTask - input: LaunchInput -} - -export interface SubagentSessionCreatedEvent { - sessionID: string - parentID: string - title: string -} +type QueueItem = { task: BackgroundTask; input: LaunchInput } +export interface SubagentSessionCreatedEvent { sessionID: string; parentID: string; title: string } export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise export class BackgroundManager { @@ -72,36 +34,25 @@ export class BackgroundManager { private static cleanupRegistered = false private static cleanupHandlers = new Map void>() - private tasks: Map - private notifications: Map - private pendingByParent: Map> // Track pending tasks per parent for batching - private client: OpencodeClient + private tasks = new Map() + private notifications = new Map() + private pendingByParent = new Map>() + private queuesByKey = new Map() + private processingKeys = new Set() + private completionTimers = new Map>() + private idleDeferralTimers = new Map>() + + private client: PluginInput["client"] private directory: string private pollingInterval?: ReturnType private concurrencyManager: ConcurrencyManager - private shutdownTriggered = false + private shutdownTriggered = { value: false } private config?: BackgroundTaskConfig private tmuxEnabled: boolean private onSubagentSessionCreated?: OnSubagentSessionCreated private onShutdown?: () => void - private queuesByKey: Map = new Map() - private processingKeys: Set = new Set() - private completionTimers: Map> = new Map() - private idleDeferralTimers: Map> = new Map() - - constructor( - ctx: PluginInput, - config?: BackgroundTaskConfig, - options?: { - tmuxConfig?: TmuxConfig - onSubagentSessionCreated?: OnSubagentSessionCreated - onShutdown?: () => void - } - ) { - this.tasks = new Map() - this.notifications = new Map() - this.pendingByParent = new Map() + constructor(ctx: PluginInput, config?: BackgroundTaskConfig, options?: { tmuxConfig?: TmuxConfig; onSubagentSessionCreated?: OnSubagentSessionCreated; onShutdown?: () => void }) { this.client = ctx.client this.directory = ctx.directory this.concurrencyManager = new ConcurrencyManager(config) @@ -113,1501 +64,98 @@ export class BackgroundManager { } async launch(input: LaunchInput): Promise { - log("[background-agent] launch() called with:", { - agent: input.agent, - model: input.model, - description: input.description, - parentSessionID: input.parentSessionID, - }) - - if (!input.agent || input.agent.trim() === "") { - throw new Error("Agent parameter is required") - } - - // Create task immediately with status="pending" - const task: BackgroundTask = { - id: `bg_${crypto.randomUUID().slice(0, 8)}`, - status: "pending", - queuedAt: new Date(), - // Do NOT set startedAt - will be set when running - // Do NOT set sessionID - will be set when running - description: input.description, - prompt: input.prompt, - agent: input.agent, - parentSessionID: input.parentSessionID, - parentMessageID: input.parentMessageID, - parentModel: input.parentModel, - parentAgent: input.parentAgent, - model: input.model, - category: input.category, - } - - this.tasks.set(task.id, task) - - // Track for batched notifications immediately (pending state) - if (input.parentSessionID) { - const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() - pending.add(task.id) - this.pendingByParent.set(input.parentSessionID, pending) - } - - // Add to queue - const key = this.getConcurrencyKeyFromInput(input) - const queue = this.queuesByKey.get(key) ?? [] - queue.push({ task, input }) - this.queuesByKey.set(key, queue) - - log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length }) - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.addTask({ - id: task.id, - description: input.description, - agent: input.agent, - isBackground: true, - status: "queued", - skills: input.skills, - }) - } - - // Trigger processing (fire-and-forget) - this.processKey(key) - - return task + return launchBackgroundTask({ input, tasks: this.tasks, pendingByParent: this.pendingByParent, queuesByKey: this.queuesByKey, getConcurrencyKeyFromInput: (i) => this.getConcurrencyKeyFromInput(i), processKey: (key) => void this.processKey(key) }) } - private async processKey(key: string): Promise { - if (this.processingKeys.has(key)) { - return - } - - this.processingKeys.add(key) - - try { - const queue = this.queuesByKey.get(key) - while (queue && queue.length > 0) { - const item = queue[0] - - await this.concurrencyManager.acquire(key) - - if (item.task.status === "cancelled") { - this.concurrencyManager.release(key) - queue.shift() - continue - } - - try { - await this.startTask(item) - } catch (error) { - log("[background-agent] Error starting task:", error) - // Release concurrency slot if startTask failed and didn't release it itself - // This prevents slot leaks when errors occur after acquire but before task.concurrencyKey is set - if (!item.task.concurrencyKey) { - this.concurrencyManager.release(key) - } - } - - queue.shift() - } - } finally { - this.processingKeys.delete(key) - } - } - - private async startTask(item: QueueItem): Promise { - const { task, input } = item - - log("[background-agent] Starting task:", { - taskId: task.id, - agent: input.agent, - model: input.model, - }) - - const concurrencyKey = this.getConcurrencyKeyFromInput(input) - - const parentSession = await this.client.session.get({ - path: { id: input.parentSessionID }, - }).catch((err) => { - log(`[background-agent] Failed to get parent session: ${err}`) - return null - }) - const parentDirectory = parentSession?.data?.directory ?? this.directory - log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`) - - const createResult = await this.client.session.create({ - body: { - parentID: input.parentSessionID, - title: `${input.description} (@${input.agent} subagent)`, - permission: [ - { permission: "question", action: "deny" as const, pattern: "*" }, - ], - } as any, - query: { - directory: parentDirectory, - }, - }) - - if (createResult.error) { - throw new Error(`Failed to create background session: ${createResult.error}`) - } - - if (!createResult.data?.id) { - throw new Error("Failed to create background session: API returned no session ID") - } - - const sessionID = createResult.data.id - subagentSessions.add(sessionID) - - log("[background-agent] tmux callback check", { - hasCallback: !!this.onSubagentSessionCreated, - tmuxEnabled: this.tmuxEnabled, - isInsideTmux: isInsideTmux(), - sessionID, - parentID: input.parentSessionID, - }) - - if (this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) { - log("[background-agent] Invoking tmux callback NOW", { sessionID }) - await this.onSubagentSessionCreated({ - sessionID, - parentID: input.parentSessionID, - title: input.description, - }).catch((err) => { - log("[background-agent] Failed to spawn tmux pane:", err) - }) - log("[background-agent] tmux callback completed, waiting 200ms") - await new Promise(r => setTimeout(r, 200)) - } else { - log("[background-agent] SKIP tmux callback - conditions not met") - } - - // Update task to running state - task.status = "running" - task.startedAt = new Date() - task.sessionID = sessionID - task.progress = { - toolCalls: 0, - lastUpdate: new Date(), - } - task.concurrencyKey = concurrencyKey - task.concurrencyGroup = concurrencyKey - - this.startPolling() - - log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.updateTask(task.id, "running") - } - - log("[background-agent] Calling prompt (fire-and-forget) for launch with:", { - sessionID, - agent: input.agent, - model: input.model, - hasSkillContent: !!input.skillContent, - promptLength: input.prompt.length, - }) - - // Fire-and-forget prompt via promptAsync (no response body needed) - // Include model if caller provided one (e.g., from Sisyphus category configs) - // IMPORTANT: variant must be a top-level field in the body, NOT nested inside model - // OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" } - const launchModel = input.model - ? { providerID: input.model.providerID, modelID: input.model.modelID } - : undefined - const launchVariant = input.model?.variant - - promptWithModelSuggestionRetry(this.client, { - path: { id: sessionID }, - body: { - agent: input.agent, - ...(launchModel ? { model: launchModel } : {}), - ...(launchVariant ? { variant: launchVariant } : {}), - system: input.skillContent, - tools: { - ...getAgentToolRestrictions(input.agent), - task: false, - call_omo_agent: true, - question: false, - }, - parts: [{ type: "text", text: input.prompt }], - }, - }).catch((error) => { - log("[background-agent] promptAsync error:", error) - const existingTask = this.findBySession(sessionID) - if (existingTask) { - existingTask.status = "error" - const errorMessage = error instanceof Error ? error.message : String(error) - if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { - existingTask.error = `Agent "${input.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.` - } else { - existingTask.error = errorMessage - } - existingTask.completedAt = new Date() - if (existingTask.concurrencyKey) { - this.concurrencyManager.release(existingTask.concurrencyKey) - existingTask.concurrencyKey = undefined - } - - // Abort the session to prevent infinite polling hang - this.client.session.abort({ - path: { id: sessionID }, - }).catch(() => {}) - - this.markForNotification(existingTask) - this.cleanupPendingByParent(existingTask) - this.notifyParentSession(existingTask).catch(err => { - log("[background-agent] Failed to notify on error:", err) - }) - } - }) - } - - getTask(id: string): BackgroundTask | undefined { - return this.tasks.get(id) - } - - getTasksByParentSession(sessionID: string): BackgroundTask[] { - const result: BackgroundTask[] = [] - for (const task of this.tasks.values()) { - if (task.parentSessionID === sessionID) { - result.push(task) - } - } - return result - } - - getAllDescendantTasks(sessionID: string): BackgroundTask[] { - const result: BackgroundTask[] = [] - const directChildren = this.getTasksByParentSession(sessionID) - - for (const child of directChildren) { - result.push(child) - if (child.sessionID) { - const descendants = this.getAllDescendantTasks(child.sessionID) - result.push(...descendants) - } - } - - return result - } - - findBySession(sessionID: string): BackgroundTask | undefined { - for (const task of this.tasks.values()) { - if (task.sessionID === sessionID) { - return task - } - } - return undefined - } - - private getConcurrencyKeyFromInput(input: LaunchInput): string { - if (input.model) { - return `${input.model.providerID}/${input.model.modelID}` - } - return input.agent - } - - /** - * Track a task created elsewhere (e.g., from task) for notification tracking. - * This allows tasks created by other tools to receive the same toast/prompt notifications. - */ - async trackTask(input: { - taskId: string - sessionID: string - parentSessionID: string - description: string - agent?: string - parentAgent?: string - concurrencyKey?: string - }): Promise { - const existingTask = this.tasks.get(input.taskId) - if (existingTask) { - // P2 fix: Clean up old parent's pending set BEFORE changing parent - // Otherwise cleanupPendingByParent would use the new parent ID - const parentChanged = input.parentSessionID !== existingTask.parentSessionID - if (parentChanged) { - this.cleanupPendingByParent(existingTask) // Clean from OLD parent - existingTask.parentSessionID = input.parentSessionID - } - if (input.parentAgent !== undefined) { - existingTask.parentAgent = input.parentAgent - } - if (!existingTask.concurrencyGroup) { - existingTask.concurrencyGroup = input.concurrencyKey ?? existingTask.agent - } - - if (existingTask.sessionID) { - subagentSessions.add(existingTask.sessionID) - } - this.startPolling() - - // Track for batched notifications if task is pending or running - if (existingTask.status === "pending" || existingTask.status === "running") { - const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() - pending.add(existingTask.id) - this.pendingByParent.set(input.parentSessionID, pending) - } else if (!parentChanged) { - // Only clean up if parent didn't change (already cleaned above if it did) - this.cleanupPendingByParent(existingTask) - } - - log("[background-agent] External task already registered:", { taskId: existingTask.id, sessionID: existingTask.sessionID, status: existingTask.status }) - - return existingTask - } - - const concurrencyGroup = input.concurrencyKey ?? input.agent ?? "task" - - // Acquire concurrency slot if a key is provided - if (input.concurrencyKey) { - await this.concurrencyManager.acquire(input.concurrencyKey) - } - - const task: BackgroundTask = { - id: input.taskId, - sessionID: input.sessionID, - parentSessionID: input.parentSessionID, - parentMessageID: "", - description: input.description, - prompt: "", - agent: input.agent || "task", - status: "running", - startedAt: new Date(), - progress: { - toolCalls: 0, - lastUpdate: new Date(), - }, - parentAgent: input.parentAgent, - concurrencyKey: input.concurrencyKey, - concurrencyGroup, - } - - this.tasks.set(task.id, task) - subagentSessions.add(input.sessionID) - this.startPolling() - - if (input.parentSessionID) { - const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() - pending.add(task.id) - this.pendingByParent.set(input.parentSessionID, pending) - } - - log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID }) - - return task + async trackTask(input: { taskId: string; sessionID: string; parentSessionID: string; description: string; agent?: string; parentAgent?: string; concurrencyKey?: string }): Promise { + return trackExternalTask({ input, tasks: this.tasks, pendingByParent: this.pendingByParent, concurrencyManager: this.concurrencyManager, startPolling: () => this.startPolling(), cleanupPendingByParent: (task) => this.cleanupPendingByParent(task) }) } async resume(input: ResumeInput): Promise { - const existingTask = this.findBySession(input.sessionId) - if (!existingTask) { - throw new Error(`Task not found for session: ${input.sessionId}`) - } - - if (!existingTask.sessionID) { - throw new Error(`Task has no sessionID: ${existingTask.id}`) - } - - if (existingTask.status === "running") { - log("[background-agent] Resume skipped - task already running:", { - taskId: existingTask.id, - sessionID: existingTask.sessionID, - }) - return existingTask - } - - // Re-acquire concurrency using the persisted concurrency group - const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent - await this.concurrencyManager.acquire(concurrencyKey) - existingTask.concurrencyKey = concurrencyKey - existingTask.concurrencyGroup = concurrencyKey - - - existingTask.status = "running" - existingTask.completedAt = undefined - existingTask.error = undefined - existingTask.parentSessionID = input.parentSessionID - existingTask.parentMessageID = input.parentMessageID - existingTask.parentModel = input.parentModel - existingTask.parentAgent = input.parentAgent - // Reset startedAt on resume to prevent immediate completion - // The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing - existingTask.startedAt = new Date() - - existingTask.progress = { - toolCalls: existingTask.progress?.toolCalls ?? 0, - lastUpdate: new Date(), - } - - this.startPolling() - if (existingTask.sessionID) { - subagentSessions.add(existingTask.sessionID) - } - - if (input.parentSessionID) { - const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() - pending.add(existingTask.id) - this.pendingByParent.set(input.parentSessionID, pending) - } - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.addTask({ - id: existingTask.id, - description: existingTask.description, - agent: existingTask.agent, - isBackground: true, - }) - } - - log("[background-agent] Resuming task:", { taskId: existingTask.id, sessionID: existingTask.sessionID }) - - log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", { - sessionID: existingTask.sessionID, - agent: existingTask.agent, - model: existingTask.model, - promptLength: input.prompt.length, - }) - - // Fire-and-forget prompt via promptAsync (no response body needed) - // Include model if task has one (preserved from original launch with category config) - // variant must be top-level in body, not nested inside model (OpenCode PromptInput schema) - const resumeModel = existingTask.model - ? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID } - : undefined - const resumeVariant = existingTask.model?.variant - - this.client.session.promptAsync({ - path: { id: existingTask.sessionID }, - body: { - agent: existingTask.agent, - ...(resumeModel ? { model: resumeModel } : {}), - ...(resumeVariant ? { variant: resumeVariant } : {}), - tools: { - ...getAgentToolRestrictions(existingTask.agent), - task: false, - call_omo_agent: true, - question: false, - }, - parts: [{ type: "text", text: input.prompt }], - }, - }).catch((error) => { - log("[background-agent] resume prompt error:", error) - existingTask.status = "error" - const errorMessage = error instanceof Error ? error.message : String(error) - existingTask.error = errorMessage - existingTask.completedAt = new Date() - - // Release concurrency on error to prevent slot leaks - if (existingTask.concurrencyKey) { - this.concurrencyManager.release(existingTask.concurrencyKey) - existingTask.concurrencyKey = undefined - } - - // Abort the session to prevent infinite polling hang - if (existingTask.sessionID) { - this.client.session.abort({ - path: { id: existingTask.sessionID }, - }).catch(() => {}) - } - - this.markForNotification(existingTask) - this.cleanupPendingByParent(existingTask) - this.notifyParentSession(existingTask).catch(err => { - log("[background-agent] Failed to notify on resume error:", err) - }) - }) - - return existingTask + return resumeBackgroundTask({ input, findBySession: (id) => this.findBySession(id), client: this.client, concurrencyManager: this.concurrencyManager, pendingByParent: this.pendingByParent, startPolling: () => this.startPolling(), markForNotification: (task) => this.markForNotification(task), cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), notifyParentSession: (task) => this.notifyParentSession(task) }) } - private async checkSessionTodos(sessionID: string): Promise { - try { - const response = await this.client.session.todo({ - path: { id: sessionID }, - }) - const todos = (response.data ?? response) as Todo[] - if (!todos || todos.length === 0) return false + getTask(id: string): BackgroundTask | undefined { return this.tasks.get(id) } + getTasksByParentSession(sessionID: string): BackgroundTask[] { return getTasksByParentSession(this.tasks.values(), sessionID) } + getAllDescendantTasks(sessionID: string): BackgroundTask[] { return getAllDescendantTasks((id) => this.getTasksByParentSession(id), sessionID) } + findBySession(sessionID: string): BackgroundTask | undefined { return findTaskBySession(this.tasks.values(), sessionID) } + getRunningTasks(): BackgroundTask[] { return getRunningTasks(this.tasks.values()) } + getCompletedTasks(): BackgroundTask[] { return getCompletedTasks(this.tasks.values()) } - const incomplete = todos.filter( - (t) => t.status !== "completed" && t.status !== "cancelled" - ) - return incomplete.length > 0 - } catch { - return false - } - } + markForNotification(task: BackgroundTask): void { markForNotification(this.notifications, task) } + getPendingNotifications(sessionID: string): BackgroundTask[] { return getPendingNotifications(this.notifications, sessionID) } + clearNotifications(sessionID: string): void { clearNotifications(this.notifications, sessionID) } - handleEvent(event: Event): void { - const props = event.properties - - if (event.type === "message.part.updated") { - if (!props || typeof props !== "object" || !("sessionID" in props)) return - const partInfo = props as unknown as MessagePartInfo - const sessionID = partInfo?.sessionID - if (!sessionID) return - - const task = this.findBySession(sessionID) - if (!task) return - - // Clear any pending idle deferral timer since the task is still active - const existingTimer = this.idleDeferralTimers.get(task.id) - if (existingTimer) { - clearTimeout(existingTimer) - this.idleDeferralTimers.delete(task.id) - } - - if (partInfo?.type === "tool" || partInfo?.tool) { - if (!task.progress) { - task.progress = { - toolCalls: 0, - lastUpdate: new Date(), - } - } - task.progress.toolCalls += 1 - task.progress.lastTool = partInfo.tool - task.progress.lastUpdate = new Date() - } - } - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const task = this.findBySession(sessionID) - if (!task || task.status !== "running") return - - const startedAt = task.startedAt - if (!startedAt) return - - // Edge guard: Require minimum elapsed time (5 seconds) before accepting idle - const elapsedMs = Date.now() - startedAt.getTime() - if (elapsedMs < MIN_IDLE_TIME_MS) { - const remainingMs = MIN_IDLE_TIME_MS - elapsedMs - if (!this.idleDeferralTimers.has(task.id)) { - log("[background-agent] Deferring early session.idle:", { elapsedMs, remainingMs, taskId: task.id }) - const timer = setTimeout(() => { - this.idleDeferralTimers.delete(task.id) - this.handleEvent({ type: "session.idle", properties: { sessionID } }) - }, remainingMs) - this.idleDeferralTimers.set(task.id, timer) - } else { - log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id }) - } - return - } - - // Edge guard: Verify session has actual assistant output before completing - this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => { - // Re-check status after async operation (could have been completed by polling) - if (task.status !== "running") { - log("[background-agent] Task status changed during validation, skipping:", { taskId: task.id, status: task.status }) - return - } - - if (!hasValidOutput) { - log("[background-agent] Session.idle but no valid output yet, waiting:", task.id) - return - } - - const hasIncompleteTodos = await this.checkSessionTodos(sessionID) - - // Re-check status after async operation again - if (task.status !== "running") { - log("[background-agent] Task status changed during todo check, skipping:", { taskId: task.id, status: task.status }) - return - } - - if (hasIncompleteTodos) { - log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id) - return - } - - await this.tryCompleteTask(task, "session.idle event") - }).catch(err => { - log("[background-agent] Error in session.idle handler:", err) - }) - } - - if (event.type === "session.deleted") { - const info = props?.info - if (!info || typeof info.id !== "string") return - const sessionID = info.id - - const tasksToCancel = new Map() - const directTask = this.findBySession(sessionID) - if (directTask) { - tasksToCancel.set(directTask.id, directTask) - } - for (const descendant of this.getAllDescendantTasks(sessionID)) { - tasksToCancel.set(descendant.id, descendant) - } - - if (tasksToCancel.size === 0) return - - for (const task of tasksToCancel.values()) { - if (task.status === "running" || task.status === "pending") { - void this.cancelTask(task.id, { - source: "session.deleted", - reason: "Session deleted", - skipNotification: true, - }).catch(err => { - log("[background-agent] Failed to cancel task on session.deleted:", { taskId: task.id, error: err }) - }) - } - - const existingTimer = this.completionTimers.get(task.id) - if (existingTimer) { - clearTimeout(existingTimer) - this.completionTimers.delete(task.id) - } - - const idleTimer = this.idleDeferralTimers.get(task.id) - if (idleTimer) { - clearTimeout(idleTimer) - this.idleDeferralTimers.delete(task.id) - } - - this.cleanupPendingByParent(task) - this.tasks.delete(task.id) - this.clearNotificationsForTask(task.id) - if (task.sessionID) { - subagentSessions.delete(task.sessionID) - } - } - } - } - - markForNotification(task: BackgroundTask): void { - const queue = this.notifications.get(task.parentSessionID) ?? [] - queue.push(task) - this.notifications.set(task.parentSessionID, queue) - } - - getPendingNotifications(sessionID: string): BackgroundTask[] { - return this.notifications.get(sessionID) ?? [] - } - - clearNotifications(sessionID: string): void { - this.notifications.delete(sessionID) - } - - /** - * Validates that a session has actual assistant/tool output before marking complete. - * Prevents premature completion when session.idle fires before agent responds. - */ - private async validateSessionHasOutput(sessionID: string): Promise { - try { - const response = await this.client.session.messages({ - path: { id: sessionID }, - }) - - const messages = response.data ?? [] - - // Check for at least one assistant or tool message - const hasAssistantOrToolMessage = messages.some( - (m: { info?: { role?: string } }) => - m.info?.role === "assistant" || m.info?.role === "tool" - ) - - if (!hasAssistantOrToolMessage) { - log("[background-agent] No assistant/tool messages found in session:", sessionID) - return false - } - - // Additionally check that at least one message has content (not just empty) - // OpenCode API uses different part types than Anthropic's API: - // - "reasoning" with .text property (thinking/reasoning content) - // - "tool" with .state.output property (tool call results) - // - "text" with .text property (final text output) - // - "step-start"/"step-finish" (metadata, no content) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const hasContent = messages.some((m: any) => { - if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false - const parts = m.parts ?? [] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return parts.some((p: any) => - // Text content (final output) - (p.type === "text" && p.text && p.text.trim().length > 0) || - // Reasoning content (thinking blocks) - (p.type === "reasoning" && p.text && p.text.trim().length > 0) || - // Tool calls (indicates work was done) - p.type === "tool" || - // Tool results (output from executed tools) - important for tool-only tasks - (p.type === "tool_result" && p.content && - (typeof p.content === "string" ? p.content.trim().length > 0 : p.content.length > 0)) - ) - }) - - if (!hasContent) { - log("[background-agent] Messages exist but no content found in session:", sessionID) - return false - } - - return true - } catch (error) { - log("[background-agent] Error validating session output:", error) - // On error, allow completion to proceed (don't block indefinitely) - return true - } - } - - private clearNotificationsForTask(taskId: string): void { - for (const [sessionID, tasks] of this.notifications.entries()) { - const filtered = tasks.filter((t) => t.id !== taskId) - if (filtered.length === 0) { - this.notifications.delete(sessionID) - } else { - this.notifications.set(sessionID, filtered) - } - } - } - - /** - * Remove task from pending tracking for its parent session. - * Cleans up the parent entry if no pending tasks remain. - */ - private cleanupPendingByParent(task: BackgroundTask): void { - if (!task.parentSessionID) return - const pending = this.pendingByParent.get(task.parentSessionID) - if (pending) { - pending.delete(task.id) - if (pending.size === 0) { - this.pendingByParent.delete(task.parentSessionID) - } - } - } - - async cancelTask( - taskId: string, - options?: { source?: string; reason?: string; abortSession?: boolean; skipNotification?: boolean } - ): Promise { - const task = this.tasks.get(taskId) - if (!task || (task.status !== "running" && task.status !== "pending")) { - return false - } - - const source = options?.source ?? "cancel" - const abortSession = options?.abortSession !== false - const reason = options?.reason - - if (task.status === "pending") { - const key = task.model - ? `${task.model.providerID}/${task.model.modelID}` - : task.agent - const queue = this.queuesByKey.get(key) - if (queue) { - const index = queue.findIndex(item => item.task.id === taskId) - if (index !== -1) { - queue.splice(index, 1) - if (queue.length === 0) { - this.queuesByKey.delete(key) - } - } - } - log("[background-agent] Cancelled pending task:", { taskId, key }) - } - - task.status = "cancelled" - task.completedAt = new Date() - if (reason) { - task.error = reason - } - - if (task.concurrencyKey) { - this.concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - - const existingTimer = this.completionTimers.get(task.id) - if (existingTimer) { - clearTimeout(existingTimer) - this.completionTimers.delete(task.id) - } - - const idleTimer = this.idleDeferralTimers.get(task.id) - if (idleTimer) { - clearTimeout(idleTimer) - this.idleDeferralTimers.delete(task.id) - } - - this.cleanupPendingByParent(task) - - if (abortSession && task.sessionID) { - this.client.session.abort({ - path: { id: task.sessionID }, - }).catch(() => {}) - } - - if (options?.skipNotification) { - log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id) - return true - } - - this.markForNotification(task) - - try { - await this.notifyParentSession(task) - log(`[background-agent] Task cancelled via ${source}:`, task.id) - } catch (err) { - log("[background-agent] Error in notifyParentSession for cancelled task:", { taskId: task.id, error: err }) - } - - return true - } - - /** - * Cancels a pending task by removing it from queue and marking as cancelled. - * Does NOT abort session (no session exists yet) or release concurrency slot (wasn't acquired). - */ cancelPendingTask(taskId: string): boolean { const task = this.tasks.get(taskId) - if (!task || task.status !== "pending") { - return false - } - + if (!task || task.status !== "pending") return false void this.cancelTask(taskId, { source: "cancelPendingTask", abortSession: false }) return true } + async cancelTask(taskId: string, options?: { source?: string; reason?: string; abortSession?: boolean; skipNotification?: boolean }): Promise { + return cancelBackgroundTask({ taskId, options, tasks: this.tasks, queuesByKey: this.queuesByKey, completionTimers: this.completionTimers, idleDeferralTimers: this.idleDeferralTimers, concurrencyManager: this.concurrencyManager, client: this.client, cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), markForNotification: (task) => this.markForNotification(task), notifyParentSession: (task) => this.notifyParentSession(task) }) + } + + handleEvent(event: { type: string; properties?: Record }): void { + handleBackgroundEvent({ event, findBySession: (id) => this.findBySession(id), getAllDescendantTasks: (id) => this.getAllDescendantTasks(id), cancelTask: (id, opts) => this.cancelTask(id, opts), tryCompleteTask: (task, source) => this.tryCompleteTask(task, source), validateSessionHasOutput: (id) => this.validateSessionHasOutput(id), checkSessionTodos: (id) => this.checkSessionTodos(id), idleDeferralTimers: this.idleDeferralTimers, completionTimers: this.completionTimers, tasks: this.tasks, cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), clearNotificationsForTask: (id) => this.clearNotificationsForTask(id), emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }) }) + } + + shutdown(): void { + shutdownBackgroundManager({ shutdownTriggered: this.shutdownTriggered, stopPolling: () => this.stopPolling(), tasks: this.tasks, client: this.client, onShutdown: this.onShutdown, concurrencyManager: this.concurrencyManager, completionTimers: this.completionTimers, idleDeferralTimers: this.idleDeferralTimers, notifications: this.notifications, pendingByParent: this.pendingByParent, queuesByKey: this.queuesByKey, processingKeys: this.processingKeys, unregisterProcessCleanup: () => this.unregisterProcessCleanup() }) + } + + private getConcurrencyKeyFromInput(input: LaunchInput): string { return input.model ? `${input.model.providerID}/${input.model.modelID}` : input.agent } + private async processKey(key: string): Promise { await processConcurrencyKeyQueue({ key, queuesByKey: this.queuesByKey, processingKeys: this.processingKeys, concurrencyManager: this.concurrencyManager, startTask: (item) => this.startTask(item) }) } + private async startTask(item: QueueItem): Promise { + await startQueuedTask({ item, client: this.client, defaultDirectory: this.directory, tmuxEnabled: this.tmuxEnabled, onSubagentSessionCreated: this.onSubagentSessionCreated, startPolling: () => this.startPolling(), getConcurrencyKeyFromInput: (i) => this.getConcurrencyKeyFromInput(i), concurrencyManager: this.concurrencyManager, findBySession: (id) => this.findBySession(id), markForNotification: (task) => this.markForNotification(task), cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), notifyParentSession: (task) => this.notifyParentSession(task) }) + } + private startPolling(): void { if (this.pollingInterval) return - - this.pollingInterval = setInterval(() => { - this.pollRunningTasks() - }, POLLING_INTERVAL_MS) + this.pollingInterval = setInterval(() => void this.pollRunningTasks(), POLLING_INTERVAL_MS) this.pollingInterval.unref() } + private stopPolling(): void { if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = undefined } } - private stopPolling(): void { - if (this.pollingInterval) { - clearInterval(this.pollingInterval) - this.pollingInterval = undefined - } + private async pollRunningTasks(): Promise { + await pollRunningTasks({ tasks: this.tasks.values(), client: this.client, pruneStaleTasksAndNotifications: () => this.pruneStaleTasksAndNotifications(), checkAndInterruptStaleTasks: () => this.checkAndInterruptStaleTasks(), validateSessionHasOutput: (id) => this.validateSessionHasOutput(id), checkSessionTodos: (id) => this.checkSessionTodos(id), tryCompleteTask: (task, source) => this.tryCompleteTask(task, source), hasRunningTasks: () => this.hasRunningTasks(), stopPolling: () => this.stopPolling() }) } + private pruneStaleTasksAndNotifications(): void { + pruneStaleState({ tasks: this.tasks, notifications: this.notifications, concurrencyManager: this.concurrencyManager, cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), clearNotificationsForTask: (id) => this.clearNotificationsForTask(id) }) + } + private async checkAndInterruptStaleTasks(): Promise { + await checkAndInterruptStaleTasks({ tasks: this.tasks.values(), client: this.client, config: this.config, concurrencyManager: this.concurrencyManager, notifyParentSession: (task) => this.notifyParentSession(task) }) + } + + private hasRunningTasks(): boolean { return hasRunningTasks(this.tasks.values()) } + private async tryCompleteTask(task: BackgroundTask, source: string): Promise { + return tryCompleteBackgroundTask({ task, source, concurrencyManager: this.concurrencyManager, idleDeferralTimers: this.idleDeferralTimers, client: this.client, markForNotification: (t) => this.markForNotification(t), cleanupPendingByParent: (t) => this.cleanupPendingByParent(t), notifyParentSession: (t) => this.notifyParentSession(t) }) + } + private async notifyParentSession(task: BackgroundTask): Promise { + await notifyParentSessionInternal({ task, tasks: this.tasks, pendingByParent: this.pendingByParent, completionTimers: this.completionTimers, clearNotificationsForTask: (id) => this.clearNotificationsForTask(id), client: this.client }) + } + + private async validateSessionHasOutput(sessionID: string): Promise { return validateSessionHasOutput(this.client, sessionID) } + private async checkSessionTodos(sessionID: string): Promise { return checkSessionTodos(this.client, sessionID) } + private clearNotificationsForTask(taskId: string): void { clearNotificationsForTask(this.notifications, taskId) } + private cleanupPendingByParent(task: BackgroundTask): void { cleanupPendingByParent(this.pendingByParent, task) } + private registerProcessCleanup(): void { BackgroundManager.cleanupManagers.add(this) - if (BackgroundManager.cleanupRegistered) return BackgroundManager.cleanupRegistered = true - - const cleanupAll = () => { - for (const manager of BackgroundManager.cleanupManagers) { - try { - manager.shutdown() - } catch (error) { - log("[background-agent] Error during shutdown cleanup:", error) - } - } - } - - const registerSignal = (signal: ProcessCleanupEvent, exitAfter: boolean): void => { - const listener = registerProcessSignal(signal, cleanupAll, exitAfter) - BackgroundManager.cleanupHandlers.set(signal, listener) - } - - registerSignal("SIGINT", true) - registerSignal("SIGTERM", true) - if (process.platform === "win32") { - registerSignal("SIGBREAK", true) - } - registerSignal("beforeExit", false) - registerSignal("exit", false) + const cleanupAll = () => { for (const manager of BackgroundManager.cleanupManagers) { try { manager.shutdown() } catch (error) { log("[background-agent] Error during shutdown cleanup:", error) } } } + const registerSignal = (signal: ProcessCleanupEvent, exitAfter: boolean) => { const listener = registerProcessSignal(signal, cleanupAll, exitAfter); BackgroundManager.cleanupHandlers.set(signal, listener) } + registerSignal("SIGINT", true); registerSignal("SIGTERM", true); if (process.platform === "win32") registerSignal("SIGBREAK", true) + registerSignal("beforeExit", false); registerSignal("exit", false) } private unregisterProcessCleanup(): void { BackgroundManager.cleanupManagers.delete(this) - if (BackgroundManager.cleanupManagers.size > 0) return - - for (const [signal, listener] of BackgroundManager.cleanupHandlers.entries()) { - process.off(signal, listener) - } - BackgroundManager.cleanupHandlers.clear() - BackgroundManager.cleanupRegistered = false - } - - - /** - * Get all running tasks (for compaction hook) - */ - getRunningTasks(): BackgroundTask[] { - return Array.from(this.tasks.values()).filter(t => t.status === "running") - } - - /** - * Get all completed tasks still in memory (for compaction hook) - */ - getCompletedTasks(): BackgroundTask[] { - return Array.from(this.tasks.values()).filter(t => t.status !== "running") - } - - /** - * Safely complete a task with race condition protection. - * Returns true if task was successfully completed, false if already completed by another path. - */ - private async tryCompleteTask(task: BackgroundTask, source: string): Promise { - // Guard: Check if task is still running (could have been completed by another path) - if (task.status !== "running") { - log("[background-agent] Task already completed, skipping:", { taskId: task.id, status: task.status, source }) - return false - } - - // Atomically mark as completed to prevent race conditions - task.status = "completed" - task.completedAt = new Date() - - // Release concurrency BEFORE any async operations to prevent slot leaks - if (task.concurrencyKey) { - this.concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - - this.markForNotification(task) - - // Ensure pending tracking is cleaned up even if notification fails - this.cleanupPendingByParent(task) - - const idleTimer = this.idleDeferralTimers.get(task.id) - if (idleTimer) { - clearTimeout(idleTimer) - this.idleDeferralTimers.delete(task.id) - } - - if (task.sessionID) { - this.client.session.abort({ - path: { id: task.sessionID }, - }).catch(() => {}) - } - - try { - await this.notifyParentSession(task) - log(`[background-agent] Task completed via ${source}:`, task.id) - } catch (err) { - log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error: err }) - // Concurrency already released, notification failed but task is complete - } - - return true - } - - private async notifyParentSession(task: BackgroundTask): Promise { - // Note: Callers must release concurrency before calling this method - // to ensure slots are freed even if notification fails - - const duration = this.formatDuration(task.startedAt ?? new Date(), task.completedAt) - - log("[background-agent] notifyParentSession called for task:", task.id) - - // Show toast notification - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.showCompletionToast({ - id: task.id, - description: task.description, - duration, - }) - } - - // Update pending tracking and check if all tasks complete - const pendingSet = this.pendingByParent.get(task.parentSessionID) - if (pendingSet) { - pendingSet.delete(task.id) - if (pendingSet.size === 0) { - this.pendingByParent.delete(task.parentSessionID) - } - } - - const allComplete = !pendingSet || pendingSet.size === 0 - const remainingCount = pendingSet?.size ?? 0 - - const statusText = task.status === "completed" ? "COMPLETED" : "CANCELLED" - const errorInfo = task.error ? `\n**Error:** ${task.error}` : "" - - let notification: string - let completedTasks: BackgroundTask[] = [] - if (allComplete) { - completedTasks = Array.from(this.tasks.values()) - .filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending") - const completedTasksText = completedTasks - .map(t => `- \`${t.id}\`: ${t.description}`) - .join("\n") - - notification = ` -[ALL BACKGROUND TASKS COMPLETE] - -**Completed:** -${completedTasksText || `- \`${task.id}\`: ${task.description}`} - -Use \`background_output(task_id="")\` to retrieve each result. -` - } else { - // Individual completion - silent notification - notification = ` -[BACKGROUND TASK ${statusText}] -**ID:** \`${task.id}\` -**Description:** ${task.description} -**Duration:** ${duration}${errorInfo} - -**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete. -Do NOT poll - continue productive work. - -Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready. -` - } - - let agent: string | undefined = task.parentAgent - let model: { providerID: string; modelID: string } | undefined - - try { - const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - agent = info.agent ?? task.parentAgent - model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) - break - } - } - } catch (error) { - if (this.isAbortedSessionError(error)) { - log("[background-agent] Parent session aborted, skipping notification:", { - taskId: task.id, - parentSessionID: task.parentSessionID, - }) - return - } - const messageDir = getMessageDir(task.parentSessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agent = currentMessage?.agent ?? task.parentAgent - model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - } - - log("[background-agent] notifyParentSession context:", { - taskId: task.id, - resolvedAgent: agent, - resolvedModel: model, - }) - - try { - await this.client.session.promptAsync({ - path: { id: task.parentSessionID }, - body: { - noReply: !allComplete, - ...(agent !== undefined ? { agent } : {}), - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: notification }], - }, - }) - log("[background-agent] Sent notification to parent session:", { - taskId: task.id, - allComplete, - noReply: !allComplete, - }) - } catch (error) { - if (this.isAbortedSessionError(error)) { - log("[background-agent] Parent session aborted, skipping notification:", { - taskId: task.id, - parentSessionID: task.parentSessionID, - }) - return - } - log("[background-agent] Failed to send notification:", error) - } - - if (allComplete) { - for (const completedTask of completedTasks) { - const taskId = completedTask.id - const existingTimer = this.completionTimers.get(taskId) - if (existingTimer) { - clearTimeout(existingTimer) - this.completionTimers.delete(taskId) - } - const timer = setTimeout(() => { - this.completionTimers.delete(taskId) - if (this.tasks.has(taskId)) { - this.clearNotificationsForTask(taskId) - this.tasks.delete(taskId) - log("[background-agent] Removed completed task from memory:", taskId) - } - }, TASK_CLEANUP_DELAY_MS) - this.completionTimers.set(taskId, timer) - } - } - } - - private 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` - } else if (minutes > 0) { - return `${minutes}m ${seconds % 60}s` - } - return `${seconds}s` - } - - private isAbortedSessionError(error: unknown): boolean { - const message = this.getErrorText(error) - return message.toLowerCase().includes("aborted") - } - - private getErrorText(error: unknown): string { - if (!error) return "" - if (typeof error === "string") return error - if (error instanceof Error) { - return `${error.name}: ${error.message}` - } - if (typeof error === "object" && error !== null) { - if ("message" in error && typeof error.message === "string") { - return error.message - } - if ("name" in error && typeof error.name === "string") { - return error.name - } - } - return "" - } - - private hasRunningTasks(): boolean { - for (const task of this.tasks.values()) { - if (task.status === "running") return true - } - return false - } - - private pruneStaleTasksAndNotifications(): void { - const now = Date.now() - - for (const [taskId, task] of this.tasks.entries()) { - const timestamp = task.status === "pending" - ? task.queuedAt?.getTime() - : task.startedAt?.getTime() - - if (!timestamp) { - continue - } - - const age = now - timestamp - if (age > TASK_TTL_MS) { - const errorMessage = task.status === "pending" - ? "Task timed out while queued (30 minutes)" - : "Task timed out after 30 minutes" - - log("[background-agent] Pruning stale task:", { taskId, status: task.status, age: Math.round(age / 1000) + "s" }) - task.status = "error" - task.error = errorMessage - task.completedAt = new Date() - if (task.concurrencyKey) { - this.concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - // Clean up pendingByParent to prevent stale entries - this.cleanupPendingByParent(task) - this.clearNotificationsForTask(taskId) - this.tasks.delete(taskId) - if (task.sessionID) { - subagentSessions.delete(task.sessionID) - } - } - } - - for (const [sessionID, notifications] of this.notifications.entries()) { - if (notifications.length === 0) { - this.notifications.delete(sessionID) - continue - } - const validNotifications = notifications.filter((task) => { - if (!task.startedAt) return false - const age = now - task.startedAt.getTime() - return age <= TASK_TTL_MS - }) - if (validNotifications.length === 0) { - this.notifications.delete(sessionID) - } else if (validNotifications.length !== notifications.length) { - this.notifications.set(sessionID, validNotifications) - } - } - } - - private async checkAndInterruptStaleTasks(): Promise { - const staleTimeoutMs = this.config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS - const now = Date.now() - - for (const task of this.tasks.values()) { - if (task.status !== "running") continue - if (!task.progress?.lastUpdate) continue - - const startedAt = task.startedAt - const sessionID = task.sessionID - if (!startedAt || !sessionID) continue - - const runtime = now - startedAt.getTime() - if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue - - const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime() - if (timeSinceLastUpdate <= staleTimeoutMs) continue - - if (task.status !== "running") continue - - const staleMinutes = Math.round(timeSinceLastUpdate / 60000) - task.status = "cancelled" - task.error = `Stale timeout (no activity for ${staleMinutes}min)` - task.completedAt = new Date() - - if (task.concurrencyKey) { - this.concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - - this.client.session.abort({ - path: { id: sessionID }, - }).catch(() => {}) - - log(`[background-agent] Task ${task.id} interrupted: stale timeout`) - - try { - await this.notifyParentSession(task) - } catch (err) { - log("[background-agent] Error in notifyParentSession for stale task:", { taskId: task.id, error: err }) - } - } - } - - private async pollRunningTasks(): Promise { - this.pruneStaleTasksAndNotifications() - await this.checkAndInterruptStaleTasks() - - const statusResult = await this.client.session.status() - const allStatuses = (statusResult.data ?? {}) as Record - - for (const task of this.tasks.values()) { - if (task.status !== "running") continue - - const sessionID = task.sessionID - if (!sessionID) continue - - try { - const sessionStatus = allStatuses[sessionID] - - // Don't skip if session not in status - fall through to message-based detection - if (sessionStatus?.type === "idle") { - // Edge guard: Validate session has actual output before completing - const hasValidOutput = await this.validateSessionHasOutput(sessionID) - if (!hasValidOutput) { - log("[background-agent] Polling idle but no valid output yet, waiting:", task.id) - continue - } - - // Re-check status after async operation - if (task.status !== "running") continue - - const hasIncompleteTodos = await this.checkSessionTodos(sessionID) - if (hasIncompleteTodos) { - log("[background-agent] Task has incomplete todos via polling, waiting:", task.id) - continue - } - - await this.tryCompleteTask(task, "polling (idle status)") - continue - } - - const messagesResult = await this.client.session.messages({ - path: { id: sessionID }, - }) - - if (!messagesResult.error && messagesResult.data) { - const messages = messagesResult.data as Array<{ - info?: { role?: string } - parts?: Array<{ type?: string; tool?: string; name?: string; text?: string }> - }> - const assistantMsgs = messages.filter( - (m) => m.info?.role === "assistant" - ) - - let toolCalls = 0 - let lastTool: string | undefined - let lastMessage: string | undefined - - for (const msg of assistantMsgs) { - const parts = msg.parts ?? [] - for (const part of parts) { - if (part.type === "tool_use" || part.tool) { - toolCalls++ - lastTool = part.tool || part.name || "unknown" - } - if (part.type === "text" && part.text) { - lastMessage = part.text - } - } - } - - if (!task.progress) { - task.progress = { toolCalls: 0, lastUpdate: new Date() } - } - task.progress.toolCalls = toolCalls - task.progress.lastTool = lastTool - task.progress.lastUpdate = new Date() - if (lastMessage) { - task.progress.lastMessage = lastMessage - task.progress.lastMessageAt = new Date() - } - - // Stability detection: complete when message count unchanged for 3 polls - const currentMsgCount = messages.length - const startedAt = task.startedAt - if (!startedAt) continue - - const elapsedMs = Date.now() - startedAt.getTime() - - if (elapsedMs >= MIN_STABILITY_TIME_MS) { - if (task.lastMsgCount === currentMsgCount) { - task.stablePolls = (task.stablePolls ?? 0) + 1 - if (task.stablePolls >= 3) { - // Re-fetch session status to confirm agent is truly idle - const recheckStatus = await this.client.session.status() - const recheckData = (recheckStatus.data ?? {}) as Record - const currentStatus = recheckData[sessionID] - - if (currentStatus?.type !== "idle") { - log("[background-agent] Stability reached but session not idle, resetting:", { - taskId: task.id, - sessionStatus: currentStatus?.type ?? "not_in_status" - }) - task.stablePolls = 0 - continue - } - - // Edge guard: Validate session has actual output before completing - const hasValidOutput = await this.validateSessionHasOutput(sessionID) - if (!hasValidOutput) { - log("[background-agent] Stability reached but no valid output, waiting:", task.id) - continue - } - - // Re-check status after async operation - if (task.status !== "running") continue - - const hasIncompleteTodos = await this.checkSessionTodos(sessionID) - if (!hasIncompleteTodos) { - await this.tryCompleteTask(task, "stability detection") - continue - } - } - } else { - task.stablePolls = 0 - } - } - task.lastMsgCount = currentMsgCount - } - } catch (error) { - log("[background-agent] Poll error for task:", { taskId: task.id, error }) - } - } - - if (!this.hasRunningTasks()) { - this.stopPolling() - } - } - - /** - * Shutdown the manager gracefully. - * Cancels all pending concurrency waiters and clears timers. - * Should be called when the plugin is unloaded. - */ - shutdown(): void { - if (this.shutdownTriggered) return - this.shutdownTriggered = true - log("[background-agent] Shutting down BackgroundManager") - this.stopPolling() - - // Abort all running sessions to prevent zombie processes (#1240) - for (const task of this.tasks.values()) { - if (task.status === "running" && task.sessionID) { - this.client.session.abort({ - path: { id: task.sessionID }, - }).catch(() => {}) - } - } - - // Notify shutdown listeners (e.g., tmux cleanup) - if (this.onShutdown) { - try { - this.onShutdown() - } catch (error) { - log("[background-agent] Error in onShutdown callback:", error) - } - } - - // Release concurrency for all running tasks - for (const task of this.tasks.values()) { - if (task.concurrencyKey) { - this.concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - } - - for (const timer of this.completionTimers.values()) { - clearTimeout(timer) - } - this.completionTimers.clear() - - for (const timer of this.idleDeferralTimers.values()) { - clearTimeout(timer) - } - this.idleDeferralTimers.clear() - - this.concurrencyManager.clear() - this.tasks.clear() - this.notifications.clear() - this.pendingByParent.clear() - this.queuesByKey.clear() - this.processingKeys.clear() - this.unregisterProcessCleanup() - log("[background-agent] Shutdown complete") - + for (const [signal, listener] of BackgroundManager.cleanupHandlers.entries()) process.off(signal, listener) + BackgroundManager.cleanupHandlers.clear(); BackgroundManager.cleanupRegistered = false } } - -function registerProcessSignal( - signal: ProcessCleanupEvent, - handler: () => void, - exitAfter: boolean -): () => void { - const listener = () => { - handler() - if (exitAfter) { - // Set exitCode and schedule exit after delay to allow other handlers to complete async cleanup - // Use 6s delay to accommodate LSP cleanup (5s timeout + 1s SIGKILL wait) - process.exitCode = 0 - setTimeout(() => process.exit(), 6000) - } - } - process.on(signal, listener) - return listener -} - - -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 -} diff --git a/src/features/background-agent/message-dir.ts b/src/features/background-agent/message-dir.ts new file mode 100644 index 000000000..3e8f56a47 --- /dev/null +++ b/src/features/background-agent/message-dir.ts @@ -0,0 +1,18 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" + +import { MESSAGE_STORAGE } from "../hook-message-injector" + +export 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 +} diff --git a/src/features/background-agent/message-storage-locator.ts b/src/features/background-agent/message-storage-locator.ts new file mode 100644 index 000000000..ceecd329c --- /dev/null +++ b/src/features/background-agent/message-storage-locator.ts @@ -0,0 +1,17 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "../hook-message-injector" + +export 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 +} diff --git a/src/features/background-agent/notification-builder.ts b/src/features/background-agent/notification-builder.ts new file mode 100644 index 000000000..e16d2b4e5 --- /dev/null +++ b/src/features/background-agent/notification-builder.ts @@ -0,0 +1,40 @@ +import type { BackgroundTask } from "./types" + +export function buildBackgroundTaskNotificationText(args: { + task: BackgroundTask + duration: string + allComplete: boolean + remainingCount: number + completedTasks: BackgroundTask[] +}): string { + const { task, duration, allComplete, remainingCount, completedTasks } = args + const statusText = task.status === "completed" ? "COMPLETED" : "CANCELLED" + const errorInfo = task.error ? `\n**Error:** ${task.error}` : "" + + if (allComplete) { + const completedTasksText = completedTasks + .map((t) => `- \`${t.id}\`: ${t.description}`) + .join("\n") + + return ` +[ALL BACKGROUND TASKS COMPLETE] + +**Completed:** +${completedTasksText || `- \`${task.id}\`: ${task.description}`} + +Use \`background_output(task_id="")\` to retrieve each result. +` + } + + return ` +[BACKGROUND TASK ${statusText}] +**ID:** \`${task.id}\` +**Description:** ${task.description} +**Duration:** ${duration}${errorInfo} + +**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete. +Do NOT poll - continue productive work. + +Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready. +` +} diff --git a/src/features/background-agent/notification-tracker.ts b/src/features/background-agent/notification-tracker.ts new file mode 100644 index 000000000..722c27300 --- /dev/null +++ b/src/features/background-agent/notification-tracker.ts @@ -0,0 +1,52 @@ +import type { BackgroundTask } from "./types" + +export function markForNotification( + notifications: Map, + task: BackgroundTask +): void { + const queue = notifications.get(task.parentSessionID) ?? [] + queue.push(task) + notifications.set(task.parentSessionID, queue) +} + +export function getPendingNotifications( + notifications: Map, + sessionID: string +): BackgroundTask[] { + return notifications.get(sessionID) ?? [] +} + +export function clearNotifications( + notifications: Map, + sessionID: string +): void { + notifications.delete(sessionID) +} + +export function clearNotificationsForTask( + notifications: Map, + taskId: string +): void { + for (const [sessionID, tasks] of notifications.entries()) { + const filtered = tasks.filter((t) => t.id !== taskId) + if (filtered.length === 0) { + notifications.delete(sessionID) + } else { + notifications.set(sessionID, filtered) + } + } +} + +export function cleanupPendingByParent( + pendingByParent: Map>, + task: BackgroundTask +): void { + if (!task.parentSessionID) return + const pending = pendingByParent.get(task.parentSessionID) + if (!pending) return + + pending.delete(task.id) + if (pending.size === 0) { + pendingByParent.delete(task.parentSessionID) + } +} diff --git a/src/features/background-agent/notify-parent-session.ts b/src/features/background-agent/notify-parent-session.ts new file mode 100644 index 000000000..ea28f025f --- /dev/null +++ b/src/features/background-agent/notify-parent-session.ts @@ -0,0 +1,192 @@ +import { log } from "../../shared" + +import { findNearestMessageWithFields } from "../hook-message-injector" +import { getTaskToastManager } from "../task-toast-manager" + +import { TASK_CLEANUP_DELAY_MS } from "./constants" +import { formatDuration } from "./format-duration" +import { isAbortedSessionError } from "./error-classifier" +import { getMessageDir } from "./message-dir" +import { buildBackgroundTaskNotificationText } from "./notification-builder" + +import type { BackgroundTask } from "./types" +import type { OpencodeClient } from "./opencode-client" + +type AgentModel = { providerID: string; modelID: string } + +type MessageInfo = { + agent?: string + model?: AgentModel + providerID?: string + modelID?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function extractMessageInfo(message: unknown): MessageInfo { + if (!isRecord(message)) return {} + const info = message["info"] + if (!isRecord(info)) return {} + + const agent = typeof info["agent"] === "string" ? info["agent"] : undefined + const modelObj = info["model"] + if (isRecord(modelObj)) { + const providerID = modelObj["providerID"] + const modelID = modelObj["modelID"] + if (typeof providerID === "string" && typeof modelID === "string") { + return { agent, model: { providerID, modelID } } + } + } + + const providerID = info["providerID"] + const modelID = info["modelID"] + if (typeof providerID === "string" && typeof modelID === "string") { + return { agent, model: { providerID, modelID } } + } + + return { agent } +} + +export async function notifyParentSession(args: { + task: BackgroundTask + tasks: Map + pendingByParent: Map> + completionTimers: Map> + clearNotificationsForTask: (taskId: string) => void + client: OpencodeClient +}): Promise { + const { task, tasks, pendingByParent, completionTimers, clearNotificationsForTask, client } = args + + const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) + log("[background-agent] notifyParentSession called for task:", task.id) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.showCompletionToast({ + id: task.id, + description: task.description, + duration, + }) + } + + const pendingSet = pendingByParent.get(task.parentSessionID) + if (pendingSet) { + pendingSet.delete(task.id) + if (pendingSet.size === 0) { + pendingByParent.delete(task.parentSessionID) + } + } + + const allComplete = !pendingSet || pendingSet.size === 0 + const remainingCount = pendingSet?.size ?? 0 + + const completedTasks = allComplete + ? Array.from(tasks.values()).filter( + (t) => + t.parentSessionID === task.parentSessionID && + t.status !== "running" && + t.status !== "pending" + ) + : [] + + const notification = buildBackgroundTaskNotificationText({ + task, + duration, + allComplete, + remainingCount, + completedTasks, + }) + + let agent: string | undefined = task.parentAgent + let model: AgentModel | undefined + + try { + const messagesResp = await client.session.messages({ + path: { id: task.parentSessionID }, + }) + const raw = (messagesResp as { data?: unknown }).data ?? [] + const messages = Array.isArray(raw) ? raw : [] + + for (let i = messages.length - 1; i >= 0; i--) { + const extracted = extractMessageInfo(messages[i]) + if (extracted.agent || extracted.model) { + agent = extracted.agent ?? task.parentAgent + model = extracted.model + break + } + } + } catch (error) { + if (isAbortedSessionError(error)) { + log("[background-agent] Parent session aborted, skipping notification:", { + taskId: task.id, + parentSessionID: task.parentSessionID, + }) + return + } + + const messageDir = getMessageDir(task.parentSessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agent = currentMessage?.agent ?? task.parentAgent + model = + currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined + } + + log("[background-agent] notifyParentSession context:", { + taskId: task.id, + resolvedAgent: agent, + resolvedModel: model, + }) + + try { + await client.session.promptAsync({ + path: { id: task.parentSessionID }, + body: { + noReply: !allComplete, + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), + parts: [{ type: "text", text: notification }], + }, + }) + + log("[background-agent] Sent notification to parent session:", { + taskId: task.id, + allComplete, + noReply: !allComplete, + }) + } catch (error) { + if (isAbortedSessionError(error)) { + log("[background-agent] Parent session aborted, skipping notification:", { + taskId: task.id, + parentSessionID: task.parentSessionID, + }) + return + } + log("[background-agent] Failed to send notification:", error) + } + + if (!allComplete) return + + for (const completedTask of completedTasks) { + const taskId = completedTask.id + const existingTimer = completionTimers.get(taskId) + if (existingTimer) { + clearTimeout(existingTimer) + completionTimers.delete(taskId) + } + + const timer = setTimeout(() => { + completionTimers.delete(taskId) + if (tasks.has(taskId)) { + clearNotificationsForTask(taskId) + tasks.delete(taskId) + log("[background-agent] Removed completed task from memory:", taskId) + } + }, TASK_CLEANUP_DELAY_MS) + + completionTimers.set(taskId, timer) + } +} diff --git a/src/features/background-agent/opencode-client.ts b/src/features/background-agent/opencode-client.ts new file mode 100644 index 000000000..6f314fcca --- /dev/null +++ b/src/features/background-agent/opencode-client.ts @@ -0,0 +1,3 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +export type OpencodeClient = PluginInput["client"] diff --git a/src/features/background-agent/parent-session-context-resolver.ts b/src/features/background-agent/parent-session-context-resolver.ts new file mode 100644 index 000000000..d27dd375e --- /dev/null +++ b/src/features/background-agent/parent-session-context-resolver.ts @@ -0,0 +1,75 @@ +import type { OpencodeClient } from "./constants" +import type { BackgroundTask } from "./types" +import { findNearestMessageWithFields } from "../hook-message-injector" +import { getMessageDir } from "./message-storage-locator" + +type AgentModel = { providerID: string; modelID: string } + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function extractAgentAndModelFromMessage(message: unknown): { + agent?: string + model?: AgentModel +} { + if (!isObject(message)) return {} + const info = message["info"] + if (!isObject(info)) return {} + + const agent = typeof info["agent"] === "string" ? info["agent"] : undefined + const modelObj = info["model"] + if (isObject(modelObj)) { + const providerID = modelObj["providerID"] + const modelID = modelObj["modelID"] + if (typeof providerID === "string" && typeof modelID === "string") { + return { agent, model: { providerID, modelID } } + } + } + + const providerID = info["providerID"] + const modelID = info["modelID"] + if (typeof providerID === "string" && typeof modelID === "string") { + return { agent, model: { providerID, modelID } } + } + + return { agent } +} + +export async function resolveParentSessionAgentAndModel(input: { + client: OpencodeClient + task: BackgroundTask +}): Promise<{ agent?: string; model?: AgentModel }> { + const { client, task } = input + + let agent: string | undefined = task.parentAgent + let model: AgentModel | undefined + + try { + const messagesResp = await client.session.messages({ + path: { id: task.parentSessionID }, + }) + + const messagesRaw = "data" in messagesResp ? messagesResp.data : [] + const messages = Array.isArray(messagesRaw) ? messagesRaw : [] + + for (let i = messages.length - 1; i >= 0; i--) { + const extracted = extractAgentAndModelFromMessage(messages[i]) + if (extracted.agent || extracted.model) { + agent = extracted.agent ?? task.parentAgent + model = extracted.model + break + } + } + } catch { + const messageDir = getMessageDir(task.parentSessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agent = currentMessage?.agent ?? task.parentAgent + model = + currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined + } + + return { agent, model } +} diff --git a/src/features/background-agent/parent-session-notifier.ts b/src/features/background-agent/parent-session-notifier.ts new file mode 100644 index 000000000..9d4c1ac08 --- /dev/null +++ b/src/features/background-agent/parent-session-notifier.ts @@ -0,0 +1,102 @@ +import type { BackgroundTask } from "./types" +import type { ResultHandlerContext } from "./result-handler-context" +import { TASK_CLEANUP_DELAY_MS } from "./constants" +import { log } from "../../shared" +import { getTaskToastManager } from "../task-toast-manager" +import { formatDuration } from "./duration-formatter" +import { buildBackgroundTaskNotificationText } from "./background-task-notification-template" +import { resolveParentSessionAgentAndModel } from "./parent-session-context-resolver" + +export async function notifyParentSession( + task: BackgroundTask, + ctx: ResultHandlerContext +): Promise { + const { client, state } = ctx + + const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) + log("[background-agent] notifyParentSession called for task:", task.id) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.showCompletionToast({ + id: task.id, + description: task.description, + duration, + }) + } + + const pendingSet = state.pendingByParent.get(task.parentSessionID) + if (pendingSet) { + pendingSet.delete(task.id) + if (pendingSet.size === 0) { + state.pendingByParent.delete(task.parentSessionID) + } + } + + const allComplete = !pendingSet || pendingSet.size === 0 + const remainingCount = pendingSet?.size ?? 0 + + const statusText = task.status === "completed" ? "COMPLETED" : "CANCELLED" + + const completedTasks = allComplete + ? Array.from(state.tasks.values()).filter( + (t) => + t.parentSessionID === task.parentSessionID && + t.status !== "running" && + t.status !== "pending" + ) + : [] + + const notification = buildBackgroundTaskNotificationText({ + task, + duration, + statusText, + allComplete, + remainingCount, + completedTasks, + }) + + const { agent, model } = await resolveParentSessionAgentAndModel({ client, task }) + + log("[background-agent] notifyParentSession context:", { + taskId: task.id, + resolvedAgent: agent, + resolvedModel: model, + }) + + try { + await client.session.promptAsync({ + path: { id: task.parentSessionID }, + body: { + noReply: !allComplete, + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), + parts: [{ type: "text", text: notification }], + }, + }) + + log("[background-agent] Sent notification to parent session:", { + taskId: task.id, + allComplete, + noReply: !allComplete, + }) + } catch (error) { + log("[background-agent] Failed to send notification:", error) + } + + if (!allComplete) return + + for (const completedTask of completedTasks) { + const taskId = completedTask.id + state.clearCompletionTimer(taskId) + const timer = setTimeout(() => { + state.completionTimers.delete(taskId) + if (state.tasks.has(taskId)) { + state.clearNotificationsForTask(taskId) + state.tasks.delete(taskId) + log("[background-agent] Removed completed task from memory:", taskId) + } + }, TASK_CLEANUP_DELAY_MS) + state.setCompletionTimer(taskId, timer) + } +} diff --git a/src/features/background-agent/poll-running-tasks.ts b/src/features/background-agent/poll-running-tasks.ts new file mode 100644 index 000000000..688bba6f8 --- /dev/null +++ b/src/features/background-agent/poll-running-tasks.ts @@ -0,0 +1,178 @@ +import { log } from "../../shared" + +import { + MIN_STABILITY_TIME_MS, +} from "./constants" + +import type { BackgroundTask } from "./types" +import type { OpencodeClient } from "./opencode-client" + +type SessionStatusMap = Record + +type MessagePart = { + type?: string + tool?: string + name?: string + text?: string +} + +type SessionMessage = { + info?: { role?: string } + parts?: MessagePart[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function asSessionMessages(value: unknown): SessionMessage[] { + if (!Array.isArray(value)) return [] + return value.filter(isRecord) as SessionMessage[] +} + +export async function pollRunningTasks(args: { + tasks: Iterable + client: OpencodeClient + pruneStaleTasksAndNotifications: () => void + checkAndInterruptStaleTasks: () => Promise + validateSessionHasOutput: (sessionID: string) => Promise + checkSessionTodos: (sessionID: string) => Promise + tryCompleteTask: (task: BackgroundTask, source: string) => Promise + hasRunningTasks: () => boolean + stopPolling: () => void +}): Promise { + const { + tasks, + client, + pruneStaleTasksAndNotifications, + checkAndInterruptStaleTasks, + validateSessionHasOutput, + checkSessionTodos, + tryCompleteTask, + hasRunningTasks, + stopPolling, + } = args + + pruneStaleTasksAndNotifications() + await checkAndInterruptStaleTasks() + + const statusResult = await client.session.status() + const allStatuses = ((statusResult as { data?: unknown }).data ?? {}) as SessionStatusMap + + for (const task of tasks) { + if (task.status !== "running") continue + + const sessionID = task.sessionID + if (!sessionID) continue + + try { + const sessionStatus = allStatuses[sessionID] + if (sessionStatus?.type === "idle") { + const hasValidOutput = await validateSessionHasOutput(sessionID) + if (!hasValidOutput) { + log("[background-agent] Polling idle but no valid output yet, waiting:", task.id) + continue + } + + if (task.status !== "running") continue + + const hasIncompleteTodos = await checkSessionTodos(sessionID) + if (hasIncompleteTodos) { + log("[background-agent] Task has incomplete todos via polling, waiting:", task.id) + continue + } + + await tryCompleteTask(task, "polling (idle status)") + continue + } + + const messagesResult = await client.session.messages({ + path: { id: sessionID }, + }) + + if ((messagesResult as { error?: unknown }).error) { + continue + } + + const messages = asSessionMessages((messagesResult as { data?: unknown }).data) + const assistantMsgs = messages.filter((m) => m.info?.role === "assistant") + + let toolCalls = 0 + let lastTool: string | undefined + let lastMessage: string | undefined + + for (const msg of assistantMsgs) { + const parts = msg.parts ?? [] + for (const part of parts) { + if (part.type === "tool_use" || part.tool) { + toolCalls += 1 + lastTool = part.tool || part.name || "unknown" + } + if (part.type === "text" && part.text) { + lastMessage = part.text + } + } + } + + if (!task.progress) { + task.progress = { toolCalls: 0, lastUpdate: new Date() } + } + task.progress.toolCalls = toolCalls + task.progress.lastTool = lastTool + task.progress.lastUpdate = new Date() + if (lastMessage) { + task.progress.lastMessage = lastMessage + task.progress.lastMessageAt = new Date() + } + + const currentMsgCount = messages.length + const startedAt = task.startedAt + if (!startedAt) continue + + const elapsedMs = Date.now() - startedAt.getTime() + if (elapsedMs >= MIN_STABILITY_TIME_MS) { + if (task.lastMsgCount === currentMsgCount) { + task.stablePolls = (task.stablePolls ?? 0) + 1 + if (task.stablePolls >= 3) { + const recheckStatus = await client.session.status() + const recheckData = ((recheckStatus as { data?: unknown }).data ?? {}) as SessionStatusMap + const currentStatus = recheckData[sessionID] + + if (currentStatus?.type !== "idle") { + log("[background-agent] Stability reached but session not idle, resetting:", { + taskId: task.id, + sessionStatus: currentStatus?.type ?? "not_in_status", + }) + task.stablePolls = 0 + continue + } + + const hasValidOutput = await validateSessionHasOutput(sessionID) + if (!hasValidOutput) { + log("[background-agent] Stability reached but no valid output, waiting:", task.id) + continue + } + + if (task.status !== "running") continue + + const hasIncompleteTodos = await checkSessionTodos(sessionID) + if (!hasIncompleteTodos) { + await tryCompleteTask(task, "stability detection") + continue + } + } + } else { + task.stablePolls = 0 + } + } + + task.lastMsgCount = currentMsgCount + } catch (error) { + log("[background-agent] Poll error for task:", { taskId: task.id, error }) + } + } + + if (!hasRunningTasks()) { + stopPolling() + } +} diff --git a/src/features/background-agent/process-signal.ts b/src/features/background-agent/process-signal.ts new file mode 100644 index 000000000..94f1b9db5 --- /dev/null +++ b/src/features/background-agent/process-signal.ts @@ -0,0 +1,19 @@ +export type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit" + +export function registerProcessSignal( + signal: ProcessCleanupEvent, + handler: () => void, + exitAfter: boolean +): () => void { + const listener = () => { + handler() + if (exitAfter) { + // Set exitCode and schedule exit after delay to allow other handlers to complete async cleanup + // Use 6s delay to accommodate LSP cleanup (5s timeout + 1s SIGKILL wait) + process.exitCode = 0 + setTimeout(() => process.exit(), 6000) + } + } + process.on(signal, listener) + return listener +} diff --git a/src/features/background-agent/result-handler-context.ts b/src/features/background-agent/result-handler-context.ts new file mode 100644 index 000000000..7aa629542 --- /dev/null +++ b/src/features/background-agent/result-handler-context.ts @@ -0,0 +1,9 @@ +import type { OpencodeClient } from "./constants" +import type { ConcurrencyManager } from "./concurrency" +import type { TaskStateManager } from "./state" + +export interface ResultHandlerContext { + client: OpencodeClient + concurrencyManager: ConcurrencyManager + state: TaskStateManager +} diff --git a/src/features/background-agent/result-handler.ts b/src/features/background-agent/result-handler.ts index 84cb90843..ccc365c8d 100644 --- a/src/features/background-agent/result-handler.ts +++ b/src/features/background-agent/result-handler.ts @@ -1,276 +1,7 @@ -import type { BackgroundTask } from "./types" -import type { OpencodeClient, Todo } from "./constants" -import { TASK_CLEANUP_DELAY_MS } from "./constants" -import { log } from "../../shared" -import { getTaskToastManager } from "../task-toast-manager" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../hook-message-injector" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import type { ConcurrencyManager } from "./concurrency" -import type { TaskStateManager } from "./state" - -export interface ResultHandlerContext { - client: OpencodeClient - concurrencyManager: ConcurrencyManager - state: TaskStateManager -} - -export async function checkSessionTodos( - client: OpencodeClient, - sessionID: string -): Promise { - try { - const response = await client.session.todo({ - path: { id: sessionID }, - }) - const todos = (response.data ?? response) as Todo[] - if (!todos || todos.length === 0) return false - - const incomplete = todos.filter( - (t) => t.status !== "completed" && t.status !== "cancelled" - ) - return incomplete.length > 0 - } catch { - return false - } -} - -export async function validateSessionHasOutput( - client: OpencodeClient, - sessionID: string -): Promise { - try { - const response = await client.session.messages({ - path: { id: sessionID }, - }) - - const messages = response.data ?? [] - - const hasAssistantOrToolMessage = messages.some( - (m: { info?: { role?: string } }) => - m.info?.role === "assistant" || m.info?.role === "tool" - ) - - if (!hasAssistantOrToolMessage) { - log("[background-agent] No assistant/tool messages found in session:", sessionID) - return false - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const hasContent = messages.some((m: any) => { - if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false - const parts = m.parts ?? [] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return parts.some((p: any) => - (p.type === "text" && p.text && p.text.trim().length > 0) || - (p.type === "reasoning" && p.text && p.text.trim().length > 0) || - p.type === "tool" || - (p.type === "tool_result" && p.content && - (typeof p.content === "string" ? p.content.trim().length > 0 : p.content.length > 0)) - ) - }) - - if (!hasContent) { - log("[background-agent] Messages exist but no content found in session:", sessionID) - return false - } - - return true - } catch (error) { - log("[background-agent] Error validating session output:", error) - return true - } -} - -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` - } else if (minutes > 0) { - return `${minutes}m ${seconds % 60}s` - } - return `${seconds}s` -} - -export 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 -} - -export async function tryCompleteTask( - task: BackgroundTask, - source: string, - ctx: ResultHandlerContext -): Promise { - const { concurrencyManager, state } = ctx - - if (task.status !== "running") { - log("[background-agent] Task already completed, skipping:", { taskId: task.id, status: task.status, source }) - return false - } - - task.status = "completed" - task.completedAt = new Date() - - if (task.concurrencyKey) { - concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - - state.markForNotification(task) - - try { - await notifyParentSession(task, ctx) - log(`[background-agent] Task completed via ${source}:`, task.id) - } catch (err) { - log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error: err }) - } - - return true -} - -export async function notifyParentSession( - task: BackgroundTask, - ctx: ResultHandlerContext -): Promise { - const { client, state } = ctx - const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) - - log("[background-agent] notifyParentSession called for task:", task.id) - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.showCompletionToast({ - id: task.id, - description: task.description, - duration, - }) - } - - const pendingSet = state.pendingByParent.get(task.parentSessionID) - if (pendingSet) { - pendingSet.delete(task.id) - if (pendingSet.size === 0) { - state.pendingByParent.delete(task.parentSessionID) - } - } - - const allComplete = !pendingSet || pendingSet.size === 0 - const remainingCount = pendingSet?.size ?? 0 - - const statusText = task.status === "completed" ? "COMPLETED" : "CANCELLED" - const errorInfo = task.error ? `\n**Error:** ${task.error}` : "" - - let notification: string - let completedTasks: BackgroundTask[] = [] - if (allComplete) { - completedTasks = Array.from(state.tasks.values()) - .filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending") - const completedTasksText = completedTasks - .map(t => `- \`${t.id}\`: ${t.description}`) - .join("\n") - - notification = ` -[ALL BACKGROUND TASKS COMPLETE] - -**Completed:** -${completedTasksText || `- \`${task.id}\`: ${task.description}`} - -Use \`background_output(task_id="")\` to retrieve each result. -` - } else { - const agentInfo = task.category - ? `${task.agent} (${task.category})` - : task.agent - notification = ` -[BACKGROUND TASK ${statusText}] -**ID:** \`${task.id}\` -**Description:** ${task.description} -**Agent:** ${agentInfo} -**Duration:** ${duration}${errorInfo} - -**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete. -Do NOT poll - continue productive work. - -Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready. -` - } - - let agent: string | undefined = task.parentAgent - let model: { providerID: string; modelID: string } | undefined - - try { - const messagesResp = await client.session.messages({ path: { id: task.parentSessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - agent = info.agent ?? task.parentAgent - model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) - break - } - } - } catch { - const messageDir = getMessageDir(task.parentSessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agent = currentMessage?.agent ?? task.parentAgent - model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - } - - log("[background-agent] notifyParentSession context:", { - taskId: task.id, - resolvedAgent: agent, - resolvedModel: model, - }) - - try { - await client.session.promptAsync({ - path: { id: task.parentSessionID }, - body: { - noReply: !allComplete, - ...(agent !== undefined ? { agent } : {}), - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: notification }], - }, - }) - log("[background-agent] Sent notification to parent session:", { - taskId: task.id, - allComplete, - noReply: !allComplete, - }) - } catch (error) { - log("[background-agent] Failed to send notification:", error) - } - - if (allComplete) { - for (const completedTask of completedTasks) { - const taskId = completedTask.id - state.clearCompletionTimer(taskId) - const timer = setTimeout(() => { - state.completionTimers.delete(taskId) - if (state.tasks.has(taskId)) { - state.clearNotificationsForTask(taskId) - state.tasks.delete(taskId) - log("[background-agent] Removed completed task from memory:", taskId) - } - }, TASK_CLEANUP_DELAY_MS) - state.setCompletionTimer(taskId, timer) - } - } -} +export type { ResultHandlerContext } from "./result-handler-context" +export { formatDuration } from "./duration-formatter" +export { getMessageDir } from "./message-storage-locator" +export { checkSessionTodos } from "./session-todo-checker" +export { validateSessionHasOutput } from "./session-output-validator" +export { tryCompleteTask } from "./background-task-completer" +export { notifyParentSession } from "./parent-session-notifier" diff --git a/src/features/background-agent/session-output-validator.ts b/src/features/background-agent/session-output-validator.ts new file mode 100644 index 000000000..136bcc41c --- /dev/null +++ b/src/features/background-agent/session-output-validator.ts @@ -0,0 +1,88 @@ +import type { OpencodeClient } from "./constants" +import { log } from "../../shared" + +type SessionMessagePart = { + type?: string + text?: string + content?: unknown +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function getMessageRole(message: unknown): string | undefined { + if (!isObject(message)) return undefined + const info = message["info"] + if (!isObject(info)) return undefined + const role = info["role"] + return typeof role === "string" ? role : undefined +} + +function getMessageParts(message: unknown): SessionMessagePart[] { + if (!isObject(message)) return [] + const parts = message["parts"] + if (!Array.isArray(parts)) return [] + + return parts + .filter((part): part is SessionMessagePart => isObject(part)) + .map((part) => ({ + type: typeof part["type"] === "string" ? part["type"] : undefined, + text: typeof part["text"] === "string" ? part["text"] : undefined, + content: part["content"], + })) +} + +function partHasContent(part: SessionMessagePart): boolean { + if (part.type === "text" || part.type === "reasoning") { + return Boolean(part.text && part.text.trim().length > 0) + } + if (part.type === "tool") return true + if (part.type === "tool_result") { + if (typeof part.content === "string") return part.content.trim().length > 0 + if (Array.isArray(part.content)) return part.content.length > 0 + return Boolean(part.content) + } + return false +} + +export async function validateSessionHasOutput( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ + path: { id: sessionID }, + }) + + const messagesRaw = "data" in response ? response.data : [] + const messages = Array.isArray(messagesRaw) ? messagesRaw : [] + + const hasAssistantOrToolMessage = messages.some((message) => { + const role = getMessageRole(message) + return role === "assistant" || role === "tool" + }) + + if (!hasAssistantOrToolMessage) { + log("[background-agent] No assistant/tool messages found in session:", sessionID) + return false + } + + const hasContent = messages.some((message) => { + const role = getMessageRole(message) + if (role !== "assistant" && role !== "tool") return false + const parts = getMessageParts(message) + return parts.some(partHasContent) + }) + + if (!hasContent) { + log("[background-agent] Messages exist but no content found in session:", sessionID) + return false + } + + return true + } catch (error) { + log("[background-agent] Error validating session output:", error) + return true + } +} diff --git a/src/features/background-agent/session-todo-checker.ts b/src/features/background-agent/session-todo-checker.ts new file mode 100644 index 000000000..3feaedbf7 --- /dev/null +++ b/src/features/background-agent/session-todo-checker.ts @@ -0,0 +1,33 @@ +import type { OpencodeClient, Todo } from "./constants" + +function isTodo(value: unknown): value is Todo { + if (typeof value !== "object" || value === null) return false + const todo = value as Record + return ( + typeof todo["id"] === "string" && + typeof todo["content"] === "string" && + typeof todo["status"] === "string" && + typeof todo["priority"] === "string" + ) +} + +export async function checkSessionTodos( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.todo({ + path: { id: sessionID }, + }) + + const todosRaw = "data" in response ? response.data : response + if (!Array.isArray(todosRaw) || todosRaw.length === 0) return false + + const incomplete = todosRaw + .filter(isTodo) + .filter((todo) => todo.status !== "completed" && todo.status !== "cancelled") + return incomplete.length > 0 + } catch { + return false + } +} diff --git a/src/features/background-agent/session-validator.ts b/src/features/background-agent/session-validator.ts new file mode 100644 index 000000000..6181dec9b --- /dev/null +++ b/src/features/background-agent/session-validator.ts @@ -0,0 +1,111 @@ +import { log } from "../../shared" + +import type { OpencodeClient } from "./opencode-client" + +type Todo = { + content: string + status: string + priority: string + id: string +} + +type SessionMessage = { + info?: { role?: string } + parts?: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function asSessionMessages(value: unknown): SessionMessage[] { + if (!Array.isArray(value)) return [] + return value as SessionMessage[] +} + +function asParts(value: unknown): Array> { + if (!Array.isArray(value)) return [] + return value.filter(isRecord) +} + +function hasNonEmptyText(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0 +} + +function isToolResultContentNonEmpty(content: unknown): boolean { + if (typeof content === "string") return content.trim().length > 0 + if (Array.isArray(content)) return content.length > 0 + return false +} + +/** + * Validates that a session has actual assistant/tool output before marking complete. + * Prevents premature completion when session.idle fires before agent responds. + */ +export async function validateSessionHasOutput( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.messages({ + path: { id: sessionID }, + }) + + const messages = asSessionMessages((response as { data?: unknown }).data ?? response) + + const hasAssistantOrToolMessage = messages.some( + (m) => m.info?.role === "assistant" || m.info?.role === "tool" + ) + if (!hasAssistantOrToolMessage) { + log("[background-agent] No assistant/tool messages found in session:", sessionID) + return false + } + + const hasContent = messages.some((m) => { + if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false + + const parts = asParts(m.parts) + return parts.some((part) => { + const type = part.type + if (type === "tool") return true + if (type === "text" && hasNonEmptyText(part.text)) return true + if (type === "reasoning" && hasNonEmptyText(part.text)) return true + if (type === "tool_result" && isToolResultContentNonEmpty(part.content)) return true + return false + }) + }) + + if (!hasContent) { + log("[background-agent] Messages exist but no content found in session:", sessionID) + return false + } + + return true + } catch (error) { + log("[background-agent] Error validating session output:", error) + // On error, allow completion to proceed (don't block indefinitely) + return true + } +} + +export async function checkSessionTodos( + client: OpencodeClient, + sessionID: string +): Promise { + try { + const response = await client.session.todo({ + path: { id: sessionID }, + }) + + const raw = (response as { data?: unknown }).data ?? response + const todos = Array.isArray(raw) ? (raw as Todo[]) : [] + if (todos.length === 0) return false + + const incomplete = todos.filter( + (t) => t.status !== "completed" && t.status !== "cancelled" + ) + return incomplete.length > 0 + } catch { + return false + } +} diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index 477aafc18..ddf0e1534 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -1,242 +1,4 @@ -import type { BackgroundTask, LaunchInput, ResumeInput } from "./types" -import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants" -import { TMUX_CALLBACK_DELAY_MS } from "./constants" -import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared" -import { subagentSessions } from "../claude-code-session-state" -import { getTaskToastManager } from "../task-toast-manager" -import { isInsideTmux } from "../../shared/tmux" -import type { ConcurrencyManager } from "./concurrency" - -export interface SpawnerContext { - client: OpencodeClient - directory: string - concurrencyManager: ConcurrencyManager - tmuxEnabled: boolean - onSubagentSessionCreated?: OnSubagentSessionCreated - onTaskError: (task: BackgroundTask, error: Error) => void -} - -export function createTask(input: LaunchInput): BackgroundTask { - return { - id: `bg_${crypto.randomUUID().slice(0, 8)}`, - status: "pending", - queuedAt: new Date(), - description: input.description, - prompt: input.prompt, - agent: input.agent, - parentSessionID: input.parentSessionID, - parentMessageID: input.parentMessageID, - parentModel: input.parentModel, - parentAgent: input.parentAgent, - model: input.model, - } -} - -export async function startTask( - item: QueueItem, - ctx: SpawnerContext -): Promise { - const { task, input } = item - const { client, directory, concurrencyManager, tmuxEnabled, onSubagentSessionCreated, onTaskError } = ctx - - log("[background-agent] Starting task:", { - taskId: task.id, - agent: input.agent, - model: input.model, - }) - - const concurrencyKey = input.model - ? `${input.model.providerID}/${input.model.modelID}` - : input.agent - - const parentSession = await client.session.get({ - path: { id: input.parentSessionID }, - }).catch((err) => { - log(`[background-agent] Failed to get parent session: ${err}`) - return null - }) - const parentDirectory = parentSession?.data?.directory ?? directory - log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`) - - const createResult = await client.session.create({ - body: { - parentID: input.parentSessionID, - title: `Background: ${input.description}`, - permission: [ - { permission: "question", action: "deny" as const, pattern: "*" }, - ], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - query: { - directory: parentDirectory, - }, - }).catch((error) => { - concurrencyManager.release(concurrencyKey) - throw error - }) - - if (createResult.error) { - concurrencyManager.release(concurrencyKey) - throw new Error(`Failed to create background session: ${createResult.error}`) - } - - const sessionID = createResult.data.id - subagentSessions.add(sessionID) - - log("[background-agent] tmux callback check", { - hasCallback: !!onSubagentSessionCreated, - tmuxEnabled, - isInsideTmux: isInsideTmux(), - sessionID, - parentID: input.parentSessionID, - }) - - if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) { - log("[background-agent] Invoking tmux callback NOW", { sessionID }) - await onSubagentSessionCreated({ - sessionID, - parentID: input.parentSessionID, - title: input.description, - }).catch((err) => { - log("[background-agent] Failed to spawn tmux pane:", err) - }) - log("[background-agent] tmux callback completed, waiting") - await new Promise(r => setTimeout(r, TMUX_CALLBACK_DELAY_MS)) - } else { - log("[background-agent] SKIP tmux callback - conditions not met") - } - - task.status = "running" - task.startedAt = new Date() - task.sessionID = sessionID - task.progress = { - toolCalls: 0, - lastUpdate: new Date(), - } - task.concurrencyKey = concurrencyKey - task.concurrencyGroup = concurrencyKey - - log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.updateTask(task.id, "running") - } - - log("[background-agent] Calling prompt (fire-and-forget) for launch with:", { - sessionID, - agent: input.agent, - model: input.model, - hasSkillContent: !!input.skillContent, - promptLength: input.prompt.length, - }) - - const launchModel = input.model - ? { providerID: input.model.providerID, modelID: input.model.modelID } - : undefined - const launchVariant = input.model?.variant - - promptWithModelSuggestionRetry(client, { - path: { id: sessionID }, - body: { - agent: input.agent, - ...(launchModel ? { model: launchModel } : {}), - ...(launchVariant ? { variant: launchVariant } : {}), - system: input.skillContent, - tools: { - ...getAgentToolRestrictions(input.agent), - task: false, - call_omo_agent: true, - question: false, - }, - parts: [{ type: "text", text: input.prompt }], - }, - }).catch((error) => { - log("[background-agent] promptAsync error:", error) - onTaskError(task, error instanceof Error ? error : new Error(String(error))) - }) -} - -export async function resumeTask( - task: BackgroundTask, - input: ResumeInput, - ctx: Pick -): Promise { - const { client, concurrencyManager, onTaskError } = ctx - - if (!task.sessionID) { - throw new Error(`Task has no sessionID: ${task.id}`) - } - - if (task.status === "running") { - log("[background-agent] Resume skipped - task already running:", { - taskId: task.id, - sessionID: task.sessionID, - }) - return - } - - const concurrencyKey = task.concurrencyGroup ?? task.agent - await concurrencyManager.acquire(concurrencyKey) - task.concurrencyKey = concurrencyKey - task.concurrencyGroup = concurrencyKey - - task.status = "running" - task.completedAt = undefined - task.error = undefined - task.parentSessionID = input.parentSessionID - task.parentMessageID = input.parentMessageID - task.parentModel = input.parentModel - task.parentAgent = input.parentAgent - task.startedAt = new Date() - - task.progress = { - toolCalls: task.progress?.toolCalls ?? 0, - lastUpdate: new Date(), - } - - subagentSessions.add(task.sessionID) - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.addTask({ - id: task.id, - description: task.description, - agent: task.agent, - isBackground: true, - }) - } - - log("[background-agent] Resuming task:", { taskId: task.id, sessionID: task.sessionID }) - - log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", { - sessionID: task.sessionID, - agent: task.agent, - model: task.model, - promptLength: input.prompt.length, - }) - - const resumeModel = task.model - ? { providerID: task.model.providerID, modelID: task.model.modelID } - : undefined - const resumeVariant = task.model?.variant - - client.session.promptAsync({ - path: { id: task.sessionID }, - body: { - agent: task.agent, - ...(resumeModel ? { model: resumeModel } : {}), - ...(resumeVariant ? { variant: resumeVariant } : {}), - tools: { - ...getAgentToolRestrictions(task.agent), - task: false, - call_omo_agent: true, - question: false, - }, - parts: [{ type: "text", text: input.prompt }], - }, - }).catch((error) => { - log("[background-agent] resume prompt error:", error) - onTaskError(task, error instanceof Error ? error : new Error(String(error))) - }) -} +export type { SpawnerContext } from "./spawner/spawner-context" +export { createTask } from "./spawner/task-factory" +export { startTask } from "./spawner/task-starter" +export { resumeTask } from "./spawner/task-resumer" diff --git a/src/features/background-agent/spawner/background-session-creator.ts b/src/features/background-agent/spawner/background-session-creator.ts new file mode 100644 index 000000000..9b27d869d --- /dev/null +++ b/src/features/background-agent/spawner/background-session-creator.ts @@ -0,0 +1,45 @@ +import type { OpencodeClient } from "../constants" +import type { ConcurrencyManager } from "../concurrency" +import type { LaunchInput } from "../types" +import { log } from "../../../shared" + +export async function createBackgroundSession(options: { + client: OpencodeClient + input: LaunchInput + parentDirectory: string + concurrencyManager: ConcurrencyManager + concurrencyKey: string +}): Promise { + const { client, input, parentDirectory, concurrencyManager, concurrencyKey } = options + + const body = { + parentID: input.parentSessionID, + title: `Background: ${input.description}`, + } + + const createResult = await client.session + .create({ + body, + query: { + directory: parentDirectory, + }, + }) + .catch((error: unknown) => { + concurrencyManager.release(concurrencyKey) + throw error + }) + + if (createResult.error) { + concurrencyManager.release(concurrencyKey) + throw new Error(`Failed to create background session: ${createResult.error}`) + } + + if (!createResult.data?.id) { + concurrencyManager.release(concurrencyKey) + throw new Error("Failed to create background session: API returned no session ID") + } + + const sessionID = createResult.data.id + log("[background-agent] Background session created", { sessionID }) + return sessionID +} diff --git a/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts b/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts new file mode 100644 index 000000000..7165877cc --- /dev/null +++ b/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts @@ -0,0 +1,7 @@ +import type { LaunchInput } from "../types" + +export function getConcurrencyKeyFromLaunchInput(input: LaunchInput): string { + return input.model + ? `${input.model.providerID}/${input.model.modelID}` + : input.agent +} diff --git a/src/features/background-agent/spawner/parent-directory-resolver.ts b/src/features/background-agent/spawner/parent-directory-resolver.ts new file mode 100644 index 000000000..7e527551f --- /dev/null +++ b/src/features/background-agent/spawner/parent-directory-resolver.ts @@ -0,0 +1,21 @@ +import type { OpencodeClient } from "../constants" +import { log } from "../../../shared" + +export async function resolveParentDirectory(options: { + client: OpencodeClient + parentSessionID: string + defaultDirectory: string +}): Promise { + const { client, parentSessionID, defaultDirectory } = options + + const parentSession = await client.session + .get({ path: { id: parentSessionID } }) + .catch((error: unknown) => { + log(`[background-agent] Failed to get parent session: ${error}`) + return null + }) + + const parentDirectory = parentSession?.data?.directory ?? defaultDirectory + log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`) + return parentDirectory +} diff --git a/src/features/background-agent/spawner/spawner-context.ts b/src/features/background-agent/spawner/spawner-context.ts new file mode 100644 index 000000000..3b6bb1484 --- /dev/null +++ b/src/features/background-agent/spawner/spawner-context.ts @@ -0,0 +1,12 @@ +import type { BackgroundTask } from "../types" +import type { ConcurrencyManager } from "../concurrency" +import type { OpencodeClient, OnSubagentSessionCreated } from "../constants" + +export interface SpawnerContext { + client: OpencodeClient + directory: string + concurrencyManager: ConcurrencyManager + tmuxEnabled: boolean + onSubagentSessionCreated?: OnSubagentSessionCreated + onTaskError: (task: BackgroundTask, error: Error) => void +} diff --git a/src/features/background-agent/spawner/task-factory.ts b/src/features/background-agent/spawner/task-factory.ts new file mode 100644 index 000000000..7af433d41 --- /dev/null +++ b/src/features/background-agent/spawner/task-factory.ts @@ -0,0 +1,18 @@ +import { randomUUID } from "crypto" +import type { BackgroundTask, LaunchInput } from "../types" + +export function createTask(input: LaunchInput): BackgroundTask { + return { + id: `bg_${randomUUID().slice(0, 8)}`, + status: "pending", + queuedAt: new Date(), + description: input.description, + prompt: input.prompt, + agent: input.agent, + parentSessionID: input.parentSessionID, + parentMessageID: input.parentMessageID, + parentModel: input.parentModel, + parentAgent: input.parentAgent, + model: input.model, + } +} diff --git a/src/features/background-agent/spawner/task-resumer.ts b/src/features/background-agent/spawner/task-resumer.ts new file mode 100644 index 000000000..4a517a651 --- /dev/null +++ b/src/features/background-agent/spawner/task-resumer.ts @@ -0,0 +1,91 @@ +import type { BackgroundTask, ResumeInput } from "../types" +import { log, getAgentToolRestrictions } from "../../../shared" +import type { SpawnerContext } from "./spawner-context" +import { subagentSessions } from "../../claude-code-session-state" +import { getTaskToastManager } from "../../task-toast-manager" + +export async function resumeTask( + task: BackgroundTask, + input: ResumeInput, + ctx: Pick +): Promise { + const { client, concurrencyManager, onTaskError } = ctx + + if (!task.sessionID) { + throw new Error(`Task has no sessionID: ${task.id}`) + } + + if (task.status === "running") { + log("[background-agent] Resume skipped - task already running:", { + taskId: task.id, + sessionID: task.sessionID, + }) + return + } + + const concurrencyKey = task.concurrencyGroup ?? task.agent + await concurrencyManager.acquire(concurrencyKey) + task.concurrencyKey = concurrencyKey + task.concurrencyGroup = concurrencyKey + + task.status = "running" + task.completedAt = undefined + task.error = undefined + task.parentSessionID = input.parentSessionID + task.parentMessageID = input.parentMessageID + task.parentModel = input.parentModel + task.parentAgent = input.parentAgent + task.startedAt = new Date() + + task.progress = { + toolCalls: task.progress?.toolCalls ?? 0, + lastUpdate: new Date(), + } + + subagentSessions.add(task.sessionID) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.addTask({ + id: task.id, + description: task.description, + agent: task.agent, + isBackground: true, + }) + } + + log("[background-agent] Resuming task:", { taskId: task.id, sessionID: task.sessionID }) + + log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", { + sessionID: task.sessionID, + agent: task.agent, + model: task.model, + promptLength: input.prompt.length, + }) + + const resumeModel = task.model + ? { providerID: task.model.providerID, modelID: task.model.modelID } + : undefined + const resumeVariant = task.model?.variant + + client.session + .promptAsync({ + path: { id: task.sessionID }, + body: { + agent: task.agent, + ...(resumeModel ? { model: resumeModel } : {}), + ...(resumeVariant ? { variant: resumeVariant } : {}), + tools: { + ...getAgentToolRestrictions(task.agent), + task: false, + call_omo_agent: true, + question: false, + }, + parts: [{ type: "text", text: input.prompt }], + }, + }) + .catch((error: unknown) => { + log("[background-agent] resume prompt error:", error) + onTaskError(task, error instanceof Error ? error : new Error(String(error))) + }) +} diff --git a/src/features/background-agent/spawner/task-starter.ts b/src/features/background-agent/spawner/task-starter.ts new file mode 100644 index 000000000..4dfb48d1f --- /dev/null +++ b/src/features/background-agent/spawner/task-starter.ts @@ -0,0 +1,94 @@ +import type { QueueItem } from "../constants" +import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../../shared" +import { subagentSessions } from "../../claude-code-session-state" +import { getTaskToastManager } from "../../task-toast-manager" +import { createBackgroundSession } from "./background-session-creator" +import { getConcurrencyKeyFromLaunchInput } from "./concurrency-key-from-launch-input" +import { resolveParentDirectory } from "./parent-directory-resolver" +import type { SpawnerContext } from "./spawner-context" +import { maybeInvokeTmuxCallback } from "./tmux-callback-invoker" + +export async function startTask(item: QueueItem, ctx: SpawnerContext): Promise { + const { task, input } = item + const { client, directory, concurrencyManager, tmuxEnabled, onSubagentSessionCreated, onTaskError } = ctx + + log("[background-agent] Starting task:", { + taskId: task.id, + agent: input.agent, + model: input.model, + }) + + const concurrencyKey = getConcurrencyKeyFromLaunchInput(input) + const parentDirectory = await resolveParentDirectory({ + client, + parentSessionID: input.parentSessionID, + defaultDirectory: directory, + }) + + const sessionID = await createBackgroundSession({ + client, + input, + parentDirectory, + concurrencyManager, + concurrencyKey, + }) + subagentSessions.add(sessionID) + + await maybeInvokeTmuxCallback({ + onSubagentSessionCreated, + tmuxEnabled, + sessionID, + parentID: input.parentSessionID, + title: input.description, + }) + + task.status = "running" + task.startedAt = new Date() + task.sessionID = sessionID + task.progress = { + toolCalls: 0, + lastUpdate: new Date(), + } + task.concurrencyKey = concurrencyKey + task.concurrencyGroup = concurrencyKey + + log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.updateTask(task.id, "running") + } + + log("[background-agent] Calling prompt (fire-and-forget) for launch with:", { + sessionID, + agent: input.agent, + model: input.model, + hasSkillContent: !!input.skillContent, + promptLength: input.prompt.length, + }) + + const launchModel = input.model + ? { providerID: input.model.providerID, modelID: input.model.modelID } + : undefined + const launchVariant = input.model?.variant + + promptWithModelSuggestionRetry(client, { + path: { id: sessionID }, + body: { + agent: input.agent, + ...(launchModel ? { model: launchModel } : {}), + ...(launchVariant ? { variant: launchVariant } : {}), + system: input.skillContent, + tools: { + ...getAgentToolRestrictions(input.agent), + task: false, + call_omo_agent: true, + question: false, + }, + parts: [{ type: "text", text: input.prompt }], + }, + }).catch((error: unknown) => { + log("[background-agent] promptAsync error:", error) + onTaskError(task, error instanceof Error ? error : new Error(String(error))) + }) +} diff --git a/src/features/background-agent/spawner/tmux-callback-invoker.ts b/src/features/background-agent/spawner/tmux-callback-invoker.ts new file mode 100644 index 000000000..139dd8b71 --- /dev/null +++ b/src/features/background-agent/spawner/tmux-callback-invoker.ts @@ -0,0 +1,40 @@ +import { setTimeout } from "timers/promises" +import type { OnSubagentSessionCreated } from "../constants" +import { TMUX_CALLBACK_DELAY_MS } from "../constants" +import { log } from "../../../shared" +import { isInsideTmux } from "../../../shared/tmux" + +export async function maybeInvokeTmuxCallback(options: { + onSubagentSessionCreated?: OnSubagentSessionCreated + tmuxEnabled: boolean + sessionID: string + parentID: string + title: string +}): Promise { + const { onSubagentSessionCreated, tmuxEnabled, sessionID, parentID, title } = options + + log("[background-agent] tmux callback check", { + hasCallback: !!onSubagentSessionCreated, + tmuxEnabled, + isInsideTmux: isInsideTmux(), + sessionID, + parentID, + }) + + if (!onSubagentSessionCreated || !tmuxEnabled || !isInsideTmux()) { + log("[background-agent] SKIP tmux callback - conditions not met") + return + } + + log("[background-agent] Invoking tmux callback NOW", { sessionID }) + await onSubagentSessionCreated({ + sessionID, + parentID, + title, + }).catch((error: unknown) => { + log("[background-agent] Failed to spawn tmux pane:", error) + }) + + log("[background-agent] tmux callback completed, waiting") + await setTimeout(TMUX_CALLBACK_DELAY_MS) +} diff --git a/src/features/background-agent/stale-task-pruner.ts b/src/features/background-agent/stale-task-pruner.ts new file mode 100644 index 000000000..160f73138 --- /dev/null +++ b/src/features/background-agent/stale-task-pruner.ts @@ -0,0 +1,57 @@ +import { log } from "../../shared" + +import { TASK_TTL_MS } from "./constants" +import { subagentSessions } from "../claude-code-session-state" +import { pruneStaleTasksAndNotifications } from "./task-poller" + +import type { BackgroundTask } from "./types" +import type { ConcurrencyManager } from "./concurrency" + +export function pruneStaleState(args: { + tasks: Map + notifications: Map + concurrencyManager: ConcurrencyManager + cleanupPendingByParent: (task: BackgroundTask) => void + clearNotificationsForTask: (taskId: string) => void +}): void { + const { + tasks, + notifications, + concurrencyManager, + cleanupPendingByParent, + clearNotificationsForTask, + } = args + + pruneStaleTasksAndNotifications({ + tasks, + notifications, + onTaskPruned: (taskId, task, errorMessage) => { + const now = Date.now() + const timestamp = task.status === "pending" + ? task.queuedAt?.getTime() + : task.startedAt?.getTime() + const age = timestamp ? now - timestamp : TASK_TTL_MS + + log("[background-agent] Pruning stale task:", { + taskId, + status: task.status, + age: Math.round(age / 1000) + "s", + }) + + task.status = "error" + task.error = errorMessage + task.completedAt = new Date() + if (task.concurrencyKey) { + concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined + } + + cleanupPendingByParent(task) + clearNotificationsForTask(taskId) + tasks.delete(taskId) + if (task.sessionID) { + subagentSessions.delete(task.sessionID) + } + }, + }) +} diff --git a/src/features/background-agent/state.ts b/src/features/background-agent/state.ts index 3997dcf6f..e3a1cd57b 100644 --- a/src/features/background-agent/state.ts +++ b/src/features/background-agent/state.ts @@ -2,7 +2,6 @@ import type { BackgroundTask, LaunchInput } from "./types" import type { QueueItem } from "./constants" import { log } from "../../shared" import { subagentSessions } from "../claude-code-session-state" - export class TaskStateManager { readonly tasks: Map = new Map() readonly notifications: Map = new Map() @@ -10,11 +9,9 @@ export class TaskStateManager { readonly queuesByKey: Map = new Map() readonly processingKeys: Set = new Set() readonly completionTimers: Map> = new Map() - getTask(id: string): BackgroundTask | undefined { return this.tasks.get(id) } - findBySession(sessionID: string): BackgroundTask | undefined { for (const task of this.tasks.values()) { if (task.sessionID === sessionID) { @@ -23,7 +20,6 @@ export class TaskStateManager { } return undefined } - getTasksByParentSession(sessionID: string): BackgroundTask[] { const result: BackgroundTask[] = [] for (const task of this.tasks.values()) { @@ -52,7 +48,6 @@ export class TaskStateManager { getRunningTasks(): BackgroundTask[] { return Array.from(this.tasks.values()).filter(t => t.status === "running") } - getCompletedTasks(): BackgroundTask[] { return Array.from(this.tasks.values()).filter(t => t.status !== "running") } diff --git a/src/features/background-agent/task-canceller.ts b/src/features/background-agent/task-canceller.ts new file mode 100644 index 000000000..f4aa940fb --- /dev/null +++ b/src/features/background-agent/task-canceller.ts @@ -0,0 +1,117 @@ +import { log } from "../../shared" + +import type { BackgroundTask } from "./types" +import type { LaunchInput } from "./types" +import type { ConcurrencyManager } from "./concurrency" +import type { OpencodeClient } from "./opencode-client" + +type QueueItem = { task: BackgroundTask; input: LaunchInput } + +export async function cancelBackgroundTask(args: { + taskId: string + options?: { + source?: string + reason?: string + abortSession?: boolean + skipNotification?: boolean + } + tasks: Map + queuesByKey: Map + completionTimers: Map> + idleDeferralTimers: Map> + concurrencyManager: ConcurrencyManager + client: OpencodeClient + cleanupPendingByParent: (task: BackgroundTask) => void + markForNotification: (task: BackgroundTask) => void + notifyParentSession: (task: BackgroundTask) => Promise +}): Promise { + const { + taskId, + options, + tasks, + queuesByKey, + completionTimers, + idleDeferralTimers, + concurrencyManager, + client, + cleanupPendingByParent, + markForNotification, + notifyParentSession, + } = args + + const task = tasks.get(taskId) + if (!task || (task.status !== "running" && task.status !== "pending")) { + return false + } + + const source = options?.source ?? "cancel" + const abortSession = options?.abortSession !== false + const reason = options?.reason + + if (task.status === "pending") { + const key = task.model + ? `${task.model.providerID}/${task.model.modelID}` + : task.agent + const queue = queuesByKey.get(key) + if (queue) { + const index = queue.findIndex((item) => item.task.id === taskId) + if (index !== -1) { + queue.splice(index, 1) + if (queue.length === 0) { + queuesByKey.delete(key) + } + } + } + log("[background-agent] Cancelled pending task:", { taskId, key }) + } + + task.status = "cancelled" + task.completedAt = new Date() + if (reason) { + task.error = reason + } + + if (task.concurrencyKey) { + concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined + } + + const completionTimer = completionTimers.get(task.id) + if (completionTimer) { + clearTimeout(completionTimer) + completionTimers.delete(task.id) + } + + const idleTimer = idleDeferralTimers.get(task.id) + if (idleTimer) { + clearTimeout(idleTimer) + idleDeferralTimers.delete(task.id) + } + + cleanupPendingByParent(task) + + if (abortSession && task.sessionID) { + client.session.abort({ + path: { id: task.sessionID }, + }).catch(() => {}) + } + + if (options?.skipNotification) { + log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id) + return true + } + + markForNotification(task) + + try { + await notifyParentSession(task) + log(`[background-agent] Task cancelled via ${source}:`, task.id) + } catch (err) { + log("[background-agent] Error in notifyParentSession for cancelled task:", { + taskId: task.id, + error: err, + }) + } + + return true +} diff --git a/src/features/background-agent/task-completer.ts b/src/features/background-agent/task-completer.ts new file mode 100644 index 000000000..028c8534c --- /dev/null +++ b/src/features/background-agent/task-completer.ts @@ -0,0 +1,68 @@ +import { log } from "../../shared" + +import type { BackgroundTask } from "./types" +import type { ConcurrencyManager } from "./concurrency" +import type { OpencodeClient } from "./opencode-client" + +export async function tryCompleteBackgroundTask(args: { + task: BackgroundTask + source: string + concurrencyManager: ConcurrencyManager + idleDeferralTimers: Map> + client: OpencodeClient + markForNotification: (task: BackgroundTask) => void + cleanupPendingByParent: (task: BackgroundTask) => void + notifyParentSession: (task: BackgroundTask) => Promise +}): Promise { + const { + task, + source, + concurrencyManager, + idleDeferralTimers, + client, + markForNotification, + cleanupPendingByParent, + notifyParentSession, + } = args + + if (task.status !== "running") { + log("[background-agent] Task already completed, skipping:", { + taskId: task.id, + status: task.status, + source, + }) + return false + } + + task.status = "completed" + task.completedAt = new Date() + + if (task.concurrencyKey) { + concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined + } + + markForNotification(task) + cleanupPendingByParent(task) + + const idleTimer = idleDeferralTimers.get(task.id) + if (idleTimer) { + clearTimeout(idleTimer) + idleDeferralTimers.delete(task.id) + } + + if (task.sessionID) { + client.session.abort({ + path: { id: task.sessionID }, + }).catch(() => {}) + } + + try { + await notifyParentSession(task) + log(`[background-agent] Task completed via ${source}:`, task.id) + } catch (err) { + log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error: err }) + } + + return true +} diff --git a/src/features/background-agent/task-launch.ts b/src/features/background-agent/task-launch.ts new file mode 100644 index 000000000..37a7785b9 --- /dev/null +++ b/src/features/background-agent/task-launch.ts @@ -0,0 +1,77 @@ +import { getTaskToastManager } from "../task-toast-manager" +import { log } from "../../shared" + +import type { BackgroundTask } from "./types" +import type { LaunchInput } from "./types" + +type QueueItem = { + task: BackgroundTask + input: LaunchInput +} + +export function launchBackgroundTask(args: { + input: LaunchInput + tasks: Map + pendingByParent: Map> + queuesByKey: Map + getConcurrencyKeyFromInput: (input: LaunchInput) => string + processKey: (key: string) => void +}): BackgroundTask { + const { input, tasks, pendingByParent, queuesByKey, getConcurrencyKeyFromInput, processKey } = args + + log("[background-agent] launch() called with:", { + agent: input.agent, + model: input.model, + description: input.description, + parentSessionID: input.parentSessionID, + }) + + if (!input.agent || input.agent.trim() === "") { + throw new Error("Agent parameter is required") + } + + const task: BackgroundTask = { + id: `bg_${crypto.randomUUID().slice(0, 8)}`, + status: "pending", + queuedAt: new Date(), + description: input.description, + prompt: input.prompt, + agent: input.agent, + parentSessionID: input.parentSessionID, + parentMessageID: input.parentMessageID, + parentModel: input.parentModel, + parentAgent: input.parentAgent, + model: input.model, + category: input.category, + } + + tasks.set(task.id, task) + + if (input.parentSessionID) { + const pending = pendingByParent.get(input.parentSessionID) ?? new Set() + pending.add(task.id) + pendingByParent.set(input.parentSessionID, pending) + } + + const key = getConcurrencyKeyFromInput(input) + const queue = queuesByKey.get(key) ?? [] + queue.push({ task, input }) + queuesByKey.set(key, queue) + + log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length }) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.addTask({ + id: task.id, + description: input.description, + agent: input.agent, + isBackground: true, + status: "queued", + skills: input.skills, + }) + } + + processKey(key) + return task +} diff --git a/src/features/background-agent/task-poller.ts b/src/features/background-agent/task-poller.ts new file mode 100644 index 000000000..a3b48b8c0 --- /dev/null +++ b/src/features/background-agent/task-poller.ts @@ -0,0 +1,107 @@ +import { log } from "../../shared" + +import type { BackgroundTaskConfig } from "../../config/schema" +import type { BackgroundTask } from "./types" +import type { ConcurrencyManager } from "./concurrency" +import type { OpencodeClient } from "./opencode-client" + +import { + DEFAULT_STALE_TIMEOUT_MS, + MIN_RUNTIME_BEFORE_STALE_MS, + TASK_TTL_MS, +} from "./constants" + +export function pruneStaleTasksAndNotifications(args: { + tasks: Map + notifications: Map + onTaskPruned: (taskId: string, task: BackgroundTask, errorMessage: string) => void +}): void { + const { tasks, notifications, onTaskPruned } = args + const now = Date.now() + + for (const [taskId, task] of tasks.entries()) { + const timestamp = task.status === "pending" + ? task.queuedAt?.getTime() + : task.startedAt?.getTime() + + if (!timestamp) continue + + const age = now - timestamp + if (age <= TASK_TTL_MS) continue + + const errorMessage = task.status === "pending" + ? "Task timed out while queued (30 minutes)" + : "Task timed out after 30 minutes" + + onTaskPruned(taskId, task, errorMessage) + } + + for (const [sessionID, queued] of notifications.entries()) { + if (queued.length === 0) { + notifications.delete(sessionID) + continue + } + + const validNotifications = queued.filter((task) => { + if (!task.startedAt) return false + const age = now - task.startedAt.getTime() + return age <= TASK_TTL_MS + }) + + if (validNotifications.length === 0) { + notifications.delete(sessionID) + } else if (validNotifications.length !== queued.length) { + notifications.set(sessionID, validNotifications) + } + } +} + +export async function checkAndInterruptStaleTasks(args: { + tasks: Iterable + client: OpencodeClient + config: BackgroundTaskConfig | undefined + concurrencyManager: ConcurrencyManager + notifyParentSession: (task: BackgroundTask) => Promise +}): Promise { + const { tasks, client, config, concurrencyManager, notifyParentSession } = args + const staleTimeoutMs = config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS + const now = Date.now() + + for (const task of tasks) { + if (task.status !== "running") continue + if (!task.progress?.lastUpdate) continue + + const startedAt = task.startedAt + const sessionID = task.sessionID + if (!startedAt || !sessionID) continue + + const runtime = now - startedAt.getTime() + if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue + + const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime() + if (timeSinceLastUpdate <= staleTimeoutMs) continue + if (task.status !== "running") continue + + const staleMinutes = Math.round(timeSinceLastUpdate / 60000) + task.status = "cancelled" + task.error = `Stale timeout (no activity for ${staleMinutes}min)` + task.completedAt = new Date() + + if (task.concurrencyKey) { + concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined + } + + client.session.abort({ + path: { id: sessionID }, + }).catch(() => {}) + + log(`[background-agent] Task ${task.id} interrupted: stale timeout`) + + try { + await notifyParentSession(task) + } catch (err) { + log("[background-agent] Error in notifyParentSession for stale task:", { taskId: task.id, error: err }) + } + } +} diff --git a/src/features/background-agent/task-queries.ts b/src/features/background-agent/task-queries.ts new file mode 100644 index 000000000..d53c6f901 --- /dev/null +++ b/src/features/background-agent/task-queries.ts @@ -0,0 +1,56 @@ +import type { BackgroundTask } from "./types" + +export function getTasksByParentSession( + tasks: Iterable, + sessionID: string +): BackgroundTask[] { + const result: BackgroundTask[] = [] + for (const task of tasks) { + if (task.parentSessionID === sessionID) { + result.push(task) + } + } + return result +} + +export function getAllDescendantTasks( + tasksByParent: (sessionID: string) => BackgroundTask[], + sessionID: string +): BackgroundTask[] { + const result: BackgroundTask[] = [] + const directChildren = tasksByParent(sessionID) + + for (const child of directChildren) { + result.push(child) + if (child.sessionID) { + result.push(...getAllDescendantTasks(tasksByParent, child.sessionID)) + } + } + + return result +} + +export function findTaskBySession( + tasks: Iterable, + sessionID: string +): BackgroundTask | undefined { + for (const task of tasks) { + if (task.sessionID === sessionID) return task + } + return undefined +} + +export function getRunningTasks(tasks: Iterable): BackgroundTask[] { + return Array.from(tasks).filter((t) => t.status === "running") +} + +export function getCompletedTasks(tasks: Iterable): BackgroundTask[] { + return Array.from(tasks).filter((t) => t.status !== "running") +} + +export function hasRunningTasks(tasks: Iterable): boolean { + for (const task of tasks) { + if (task.status === "running") return true + } + return false +} diff --git a/src/features/background-agent/task-queue-processor.ts b/src/features/background-agent/task-queue-processor.ts new file mode 100644 index 000000000..64568eab4 --- /dev/null +++ b/src/features/background-agent/task-queue-processor.ts @@ -0,0 +1,52 @@ +import { log } from "../../shared" + +import type { BackgroundTask } from "./types" +import type { ConcurrencyManager } from "./concurrency" + +type QueueItem = { + task: BackgroundTask + input: import("./types").LaunchInput +} + +export async function processConcurrencyKeyQueue(args: { + key: string + queuesByKey: Map + processingKeys: Set + concurrencyManager: ConcurrencyManager + startTask: (item: QueueItem) => Promise +}): Promise { + const { key, queuesByKey, processingKeys, concurrencyManager, startTask } = args + + if (processingKeys.has(key)) return + processingKeys.add(key) + + try { + const queue = queuesByKey.get(key) + while (queue && queue.length > 0) { + const item = queue[0] + + await concurrencyManager.acquire(key) + + if (item.task.status === "cancelled") { + concurrencyManager.release(key) + queue.shift() + continue + } + + try { + await startTask(item) + } catch (error) { + log("[background-agent] Error starting task:", error) + // Release concurrency slot if startTask failed and didn't release it itself + // This prevents slot leaks when errors occur after acquire but before task.concurrencyKey is set + if (!item.task.concurrencyKey) { + concurrencyManager.release(key) + } + } + + queue.shift() + } + } finally { + processingKeys.delete(key) + } +} diff --git a/src/features/background-agent/task-resumer.ts b/src/features/background-agent/task-resumer.ts new file mode 100644 index 000000000..e09b12768 --- /dev/null +++ b/src/features/background-agent/task-resumer.ts @@ -0,0 +1,144 @@ +import { log, getAgentToolRestrictions } from "../../shared" +import { subagentSessions } from "../claude-code-session-state" +import { getTaskToastManager } from "../task-toast-manager" + +import type { BackgroundTask, ResumeInput } from "./types" +import type { ConcurrencyManager } from "./concurrency" +import type { OpencodeClient } from "./opencode-client" + +type ModelRef = { providerID: string; modelID: string } + +export async function resumeBackgroundTask(args: { + input: ResumeInput + findBySession: (sessionID: string) => BackgroundTask | undefined + client: OpencodeClient + concurrencyManager: ConcurrencyManager + pendingByParent: Map> + startPolling: () => void + markForNotification: (task: BackgroundTask) => void + cleanupPendingByParent: (task: BackgroundTask) => void + notifyParentSession: (task: BackgroundTask) => Promise +}): Promise { + const { + input, + findBySession, + client, + concurrencyManager, + pendingByParent, + startPolling, + markForNotification, + cleanupPendingByParent, + notifyParentSession, + } = args + + const existingTask = findBySession(input.sessionId) + if (!existingTask) { + throw new Error(`Task not found for session: ${input.sessionId}`) + } + + if (!existingTask.sessionID) { + throw new Error(`Task has no sessionID: ${existingTask.id}`) + } + + if (existingTask.status === "running") { + log("[background-agent] Resume skipped - task already running:", { + taskId: existingTask.id, + sessionID: existingTask.sessionID, + }) + return existingTask + } + + const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent + await concurrencyManager.acquire(concurrencyKey) + existingTask.concurrencyKey = concurrencyKey + existingTask.concurrencyGroup = concurrencyKey + + existingTask.status = "running" + existingTask.completedAt = undefined + existingTask.error = undefined + existingTask.parentSessionID = input.parentSessionID + existingTask.parentMessageID = input.parentMessageID + existingTask.parentModel = input.parentModel + existingTask.parentAgent = input.parentAgent + existingTask.startedAt = new Date() + + existingTask.progress = { + toolCalls: existingTask.progress?.toolCalls ?? 0, + lastUpdate: new Date(), + } + + startPolling() + if (existingTask.sessionID) { + subagentSessions.add(existingTask.sessionID) + } + + if (input.parentSessionID) { + const pending = pendingByParent.get(input.parentSessionID) ?? new Set() + pending.add(existingTask.id) + pendingByParent.set(input.parentSessionID, pending) + } + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.addTask({ + id: existingTask.id, + description: existingTask.description, + agent: existingTask.agent, + isBackground: true, + }) + } + + log("[background-agent] Resuming task:", { taskId: existingTask.id, sessionID: existingTask.sessionID }) + log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", { + sessionID: existingTask.sessionID, + agent: existingTask.agent, + model: existingTask.model, + promptLength: input.prompt.length, + }) + + const resumeModel: ModelRef | undefined = existingTask.model + ? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID } + : undefined + const resumeVariant = existingTask.model?.variant + + client.session.promptAsync({ + path: { id: existingTask.sessionID }, + body: { + agent: existingTask.agent, + ...(resumeModel ? { model: resumeModel } : {}), + ...(resumeVariant ? { variant: resumeVariant } : {}), + tools: { + ...getAgentToolRestrictions(existingTask.agent), + task: false, + call_omo_agent: true, + question: false, + }, + parts: [{ type: "text", text: input.prompt }], + }, + }).catch((error) => { + log("[background-agent] resume prompt error:", error) + existingTask.status = "error" + const errorMessage = error instanceof Error ? error.message : String(error) + existingTask.error = errorMessage + existingTask.completedAt = new Date() + + if (existingTask.concurrencyKey) { + concurrencyManager.release(existingTask.concurrencyKey) + existingTask.concurrencyKey = undefined + } + + if (existingTask.sessionID) { + client.session.abort({ + path: { id: existingTask.sessionID }, + }).catch(() => {}) + } + + markForNotification(existingTask) + cleanupPendingByParent(existingTask) + notifyParentSession(existingTask).catch((err) => { + log("[background-agent] Failed to notify on resume error:", err) + }) + }) + + return existingTask +} diff --git a/src/features/background-agent/task-starter.ts b/src/features/background-agent/task-starter.ts new file mode 100644 index 000000000..6083d15a0 --- /dev/null +++ b/src/features/background-agent/task-starter.ts @@ -0,0 +1,190 @@ +import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared" +import { isInsideTmux } from "../../shared/tmux" + +import { subagentSessions } from "../claude-code-session-state" +import { getTaskToastManager } from "../task-toast-manager" + +import type { BackgroundTask } from "./types" +import type { LaunchInput } from "./types" +import type { ConcurrencyManager } from "./concurrency" +import type { OpencodeClient } from "./opencode-client" + +type QueueItem = { + task: BackgroundTask + input: LaunchInput +} + +type ModelRef = { providerID: string; modelID: string } + +export async function startQueuedTask(args: { + item: QueueItem + client: OpencodeClient + defaultDirectory: string + tmuxEnabled: boolean + onSubagentSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise + startPolling: () => void + getConcurrencyKeyFromInput: (input: LaunchInput) => string + concurrencyManager: ConcurrencyManager + findBySession: (sessionID: string) => BackgroundTask | undefined + markForNotification: (task: BackgroundTask) => void + cleanupPendingByParent: (task: BackgroundTask) => void + notifyParentSession: (task: BackgroundTask) => Promise +}): Promise { + const { + item, + client, + defaultDirectory, + tmuxEnabled, + onSubagentSessionCreated, + startPolling, + getConcurrencyKeyFromInput, + concurrencyManager, + findBySession, + markForNotification, + cleanupPendingByParent, + notifyParentSession, + } = args + + const { task, input } = item + + log("[background-agent] Starting task:", { + taskId: task.id, + agent: input.agent, + model: input.model, + }) + + const concurrencyKey = getConcurrencyKeyFromInput(input) + + const parentSession = await client.session.get({ + path: { id: input.parentSessionID }, + }).catch((err) => { + log(`[background-agent] Failed to get parent session: ${err}`) + return null + }) + + const parentDirectory = parentSession?.data?.directory ?? defaultDirectory + log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`) + + const createResult = await client.session.create({ + body: { + parentID: input.parentSessionID, + title: `${input.description} (@${input.agent} subagent)`, + }, + query: { + directory: parentDirectory, + }, + }) + + if (createResult.error) { + throw new Error(`Failed to create background session: ${createResult.error}`) + } + + if (!createResult.data?.id) { + throw new Error("Failed to create background session: API returned no session ID") + } + + const sessionID = createResult.data.id + subagentSessions.add(sessionID) + + log("[background-agent] tmux callback check", { + hasCallback: !!onSubagentSessionCreated, + tmuxEnabled, + isInsideTmux: isInsideTmux(), + sessionID, + parentID: input.parentSessionID, + }) + + if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) { + log("[background-agent] Invoking tmux callback NOW", { sessionID }) + await onSubagentSessionCreated({ + sessionID, + parentID: input.parentSessionID, + title: input.description, + }).catch((err) => { + log("[background-agent] Failed to spawn tmux pane:", err) + }) + log("[background-agent] tmux callback completed, waiting 200ms") + await new Promise((resolve) => { + setTimeout(() => resolve(), 200) + }) + } else { + log("[background-agent] SKIP tmux callback - conditions not met") + } + + task.status = "running" + task.startedAt = new Date() + task.sessionID = sessionID + task.progress = { + toolCalls: 0, + lastUpdate: new Date(), + } + task.concurrencyKey = concurrencyKey + task.concurrencyGroup = concurrencyKey + + startPolling() + + log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.updateTask(task.id, "running") + } + + log("[background-agent] Calling prompt (fire-and-forget) for launch with:", { + sessionID, + agent: input.agent, + model: input.model, + hasSkillContent: !!input.skillContent, + promptLength: input.prompt.length, + }) + + const launchModel: ModelRef | undefined = input.model + ? { providerID: input.model.providerID, modelID: input.model.modelID } + : undefined + const launchVariant = input.model?.variant + + promptWithModelSuggestionRetry(client, { + path: { id: sessionID }, + body: { + agent: input.agent, + ...(launchModel ? { model: launchModel } : {}), + ...(launchVariant ? { variant: launchVariant } : {}), + system: input.skillContent, + tools: { + ...getAgentToolRestrictions(input.agent), + task: false, + call_omo_agent: true, + question: false, + }, + parts: [{ type: "text", text: input.prompt }], + }, + }).catch((error) => { + log("[background-agent] promptAsync error:", error) + const existingTask = findBySession(sessionID) + if (!existingTask) return + + existingTask.status = "error" + const errorMessage = error instanceof Error ? error.message : String(error) + if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { + existingTask.error = `Agent "${input.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.` + } else { + existingTask.error = errorMessage + } + existingTask.completedAt = new Date() + + if (existingTask.concurrencyKey) { + concurrencyManager.release(existingTask.concurrencyKey) + existingTask.concurrencyKey = undefined + } + + client.session.abort({ + path: { id: sessionID }, + }).catch(() => {}) + + markForNotification(existingTask) + cleanupPendingByParent(existingTask) + notifyParentSession(existingTask).catch((err) => { + log("[background-agent] Failed to notify on error:", err) + }) + }) +} diff --git a/src/features/background-agent/task-tracker.ts b/src/features/background-agent/task-tracker.ts new file mode 100644 index 000000000..6c78d50f7 --- /dev/null +++ b/src/features/background-agent/task-tracker.ts @@ -0,0 +1,97 @@ +import { log } from "../../shared" +import { subagentSessions } from "../claude-code-session-state" + +import type { BackgroundTask } from "./types" +import type { ConcurrencyManager } from "./concurrency" + +export async function trackExternalTask(args: { + input: { + taskId: string + sessionID: string + parentSessionID: string + description: string + agent?: string + parentAgent?: string + concurrencyKey?: string + } + tasks: Map + pendingByParent: Map> + concurrencyManager: ConcurrencyManager + startPolling: () => void + cleanupPendingByParent: (task: BackgroundTask) => void +}): Promise { + const { input, tasks, pendingByParent, concurrencyManager, startPolling, cleanupPendingByParent } = args + + const existingTask = tasks.get(input.taskId) + if (existingTask) { + const parentChanged = input.parentSessionID !== existingTask.parentSessionID + if (parentChanged) { + cleanupPendingByParent(existingTask) + existingTask.parentSessionID = input.parentSessionID + } + if (input.parentAgent !== undefined) { + existingTask.parentAgent = input.parentAgent + } + if (!existingTask.concurrencyGroup) { + existingTask.concurrencyGroup = input.concurrencyKey ?? existingTask.agent + } + + if (existingTask.sessionID) { + subagentSessions.add(existingTask.sessionID) + } + startPolling() + + if (existingTask.status === "pending" || existingTask.status === "running") { + const pending = pendingByParent.get(input.parentSessionID) ?? new Set() + pending.add(existingTask.id) + pendingByParent.set(input.parentSessionID, pending) + } else if (!parentChanged) { + cleanupPendingByParent(existingTask) + } + + log("[background-agent] External task already registered:", { + taskId: existingTask.id, + sessionID: existingTask.sessionID, + status: existingTask.status, + }) + + return existingTask + } + + const concurrencyGroup = input.concurrencyKey ?? input.agent ?? "task" + if (input.concurrencyKey) { + await concurrencyManager.acquire(input.concurrencyKey) + } + + const task: BackgroundTask = { + id: input.taskId, + sessionID: input.sessionID, + parentSessionID: input.parentSessionID, + parentMessageID: "", + description: input.description, + prompt: "", + agent: input.agent || "task", + status: "running", + startedAt: new Date(), + progress: { + toolCalls: 0, + lastUpdate: new Date(), + }, + parentAgent: input.parentAgent, + concurrencyKey: input.concurrencyKey, + concurrencyGroup, + } + + tasks.set(task.id, task) + subagentSessions.add(input.sessionID) + startPolling() + + if (input.parentSessionID) { + const pending = pendingByParent.get(input.parentSessionID) ?? new Set() + pending.add(task.id) + pendingByParent.set(input.parentSessionID, pending) + } + + log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID }) + return task +} From f8b57714435663bb802ba877428b6deef0de791d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:21:04 +0900 Subject: [PATCH 05/51] refactor(tmux-subagent): split manager and decision-engine into focused modules Extract session lifecycle, polling, grid planning, and event handling: - polling.ts: session polling controller with stability detection - event-handlers.ts: session created/deleted handlers - grid-planning.ts, spawn-action-decider.ts, spawn-target-finder.ts - session-status-parser.ts, session-message-count.ts - cleanup.ts, polling-constants.ts, tmux-grid-constants.ts --- src/features/tmux-subagent/cleanup.ts | 42 ++ src/features/tmux-subagent/decision-engine.ts | 402 +--------------- src/features/tmux-subagent/event-handlers.ts | 6 + src/features/tmux-subagent/grid-planning.ts | 107 +++++ src/features/tmux-subagent/index.ts | 10 + src/features/tmux-subagent/manager.ts | 438 +++--------------- .../tmux-subagent/oldest-agent-pane.ts | 37 ++ .../tmux-subagent/pane-split-availability.ts | 60 +++ .../tmux-subagent/polling-constants.ts | 6 + src/features/tmux-subagent/polling.ts | 183 ++++++++ .../tmux-subagent/session-created-event.ts | 44 ++ .../tmux-subagent/session-created-handler.ts | 163 +++++++ .../tmux-subagent/session-deleted-handler.ts | 50 ++ .../tmux-subagent/session-message-count.ts | 3 + .../tmux-subagent/session-ready-waiter.ts | 44 ++ .../tmux-subagent/session-status-parser.ts | 17 + .../tmux-subagent/spawn-action-decider.ts | 135 ++++++ .../tmux-subagent/spawn-target-finder.ts | 86 ++++ .../tmux-subagent/tmux-grid-constants.ts | 10 + 19 files changed, 1080 insertions(+), 763 deletions(-) create mode 100644 src/features/tmux-subagent/cleanup.ts create mode 100644 src/features/tmux-subagent/event-handlers.ts create mode 100644 src/features/tmux-subagent/grid-planning.ts create mode 100644 src/features/tmux-subagent/oldest-agent-pane.ts create mode 100644 src/features/tmux-subagent/pane-split-availability.ts create mode 100644 src/features/tmux-subagent/polling-constants.ts create mode 100644 src/features/tmux-subagent/polling.ts create mode 100644 src/features/tmux-subagent/session-created-event.ts create mode 100644 src/features/tmux-subagent/session-created-handler.ts create mode 100644 src/features/tmux-subagent/session-deleted-handler.ts create mode 100644 src/features/tmux-subagent/session-message-count.ts create mode 100644 src/features/tmux-subagent/session-ready-waiter.ts create mode 100644 src/features/tmux-subagent/session-status-parser.ts create mode 100644 src/features/tmux-subagent/spawn-action-decider.ts create mode 100644 src/features/tmux-subagent/spawn-target-finder.ts create mode 100644 src/features/tmux-subagent/tmux-grid-constants.ts diff --git a/src/features/tmux-subagent/cleanup.ts b/src/features/tmux-subagent/cleanup.ts new file mode 100644 index 000000000..414ad00bc --- /dev/null +++ b/src/features/tmux-subagent/cleanup.ts @@ -0,0 +1,42 @@ +import type { TmuxConfig } from "../../config/schema" +import { log } from "../../shared" +import type { TrackedSession } from "./types" +import { queryWindowState } from "./pane-state-querier" +import { executeAction } from "./action-executor" + +export async function cleanupTmuxSessions(params: { + tmuxConfig: TmuxConfig + serverUrl: string + sourcePaneId: string | undefined + sessions: Map + stopPolling: () => void +}): Promise { + params.stopPolling() + + if (params.sessions.size === 0) { + log("[tmux-session-manager] cleanup complete") + return + } + + log("[tmux-session-manager] closing all panes", { count: params.sessions.size }) + const state = params.sourcePaneId ? await queryWindowState(params.sourcePaneId) : null + + if (state) { + const closePromises = Array.from(params.sessions.values()).map((tracked) => + executeAction( + { type: "close", paneId: tracked.paneId, sessionId: tracked.sessionId }, + { config: params.tmuxConfig, serverUrl: params.serverUrl, windowState: state }, + ).catch((error) => + log("[tmux-session-manager] cleanup error for pane", { + paneId: tracked.paneId, + error: String(error), + }), + ), + ) + + await Promise.all(closePromises) + } + + params.sessions.clear() + log("[tmux-session-manager] cleanup complete") +} diff --git a/src/features/tmux-subagent/decision-engine.ts b/src/features/tmux-subagent/decision-engine.ts index b6761bf65..c820468e5 100644 --- a/src/features/tmux-subagent/decision-engine.ts +++ b/src/features/tmux-subagent/decision-engine.ts @@ -1,386 +1,22 @@ -import type { WindowState, PaneAction, SpawnDecision, CapacityConfig, TmuxPaneInfo, SplitDirection } from "./types" -import { MIN_PANE_WIDTH, MIN_PANE_HEIGHT } from "./types" +export type { SessionMapping } from "./oldest-agent-pane" +export type { GridCapacity, GridPlan, GridSlot } from "./grid-planning" +export type { SpawnTarget } from "./spawn-target-finder" -export interface SessionMapping { - sessionId: string - paneId: string - createdAt: Date -} +export { + calculateCapacity, + computeGridPlan, + mapPaneToSlot, +} from "./grid-planning" -export interface GridCapacity { - cols: number - rows: number - total: number -} +export { + canSplitPane, + canSplitPaneAnyDirection, + findMinimalEvictions, + getBestSplitDirection, + getColumnCount, + getColumnWidth, + isSplittableAtCount, +} from "./pane-split-availability" -export interface GridSlot { - row: number - col: number -} - -export interface GridPlan { - cols: number - rows: number - slotWidth: number - slotHeight: number -} - -export interface SpawnTarget { - targetPaneId: string - splitDirection: SplitDirection -} - -const MAIN_PANE_RATIO = 0.5 -const MAX_COLS = 2 -const MAX_ROWS = 3 -const MAX_GRID_SIZE = 4 -const DIVIDER_SIZE = 1 -const MIN_SPLIT_WIDTH = 2 * MIN_PANE_WIDTH + DIVIDER_SIZE -const MIN_SPLIT_HEIGHT = 2 * MIN_PANE_HEIGHT + DIVIDER_SIZE - -export function getColumnCount(paneCount: number): number { - if (paneCount <= 0) return 1 - return Math.min(MAX_COLS, Math.max(1, Math.ceil(paneCount / MAX_ROWS))) -} - -export function getColumnWidth(agentAreaWidth: number, paneCount: number): number { - const cols = getColumnCount(paneCount) - const dividersWidth = (cols - 1) * DIVIDER_SIZE - return Math.floor((agentAreaWidth - dividersWidth) / cols) -} - -export function isSplittableAtCount(agentAreaWidth: number, paneCount: number): boolean { - const columnWidth = getColumnWidth(agentAreaWidth, paneCount) - return columnWidth >= MIN_SPLIT_WIDTH -} - -export function findMinimalEvictions(agentAreaWidth: number, currentCount: number): number | null { - for (let k = 1; k <= currentCount; k++) { - if (isSplittableAtCount(agentAreaWidth, currentCount - k)) { - return k - } - } - return null -} - -export function canSplitPane(pane: TmuxPaneInfo, direction: SplitDirection): boolean { - if (direction === "-h") { - return pane.width >= MIN_SPLIT_WIDTH - } - return pane.height >= MIN_SPLIT_HEIGHT -} - -export function canSplitPaneAnyDirection(pane: TmuxPaneInfo): boolean { - return pane.width >= MIN_SPLIT_WIDTH || pane.height >= MIN_SPLIT_HEIGHT -} - -export function getBestSplitDirection(pane: TmuxPaneInfo): SplitDirection | null { - const canH = pane.width >= MIN_SPLIT_WIDTH - const canV = pane.height >= MIN_SPLIT_HEIGHT - - if (!canH && !canV) return null - if (canH && !canV) return "-h" - if (!canH && canV) return "-v" - return pane.width >= pane.height ? "-h" : "-v" -} - -export function calculateCapacity( - windowWidth: number, - windowHeight: number -): GridCapacity { - const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO)) - const cols = Math.min(MAX_GRID_SIZE, Math.max(0, Math.floor((availableWidth + DIVIDER_SIZE) / (MIN_PANE_WIDTH + DIVIDER_SIZE)))) - const rows = Math.min(MAX_GRID_SIZE, Math.max(0, Math.floor((windowHeight + DIVIDER_SIZE) / (MIN_PANE_HEIGHT + DIVIDER_SIZE)))) - const total = cols * rows - return { cols, rows, total } -} - -export function computeGridPlan( - windowWidth: number, - windowHeight: number, - paneCount: number -): GridPlan { - const capacity = calculateCapacity(windowWidth, windowHeight) - const { cols: maxCols, rows: maxRows } = capacity - - if (maxCols === 0 || maxRows === 0 || paneCount === 0) { - return { cols: 1, rows: 1, slotWidth: 0, slotHeight: 0 } - } - - let bestCols = 1 - let bestRows = 1 - let bestArea = Infinity - - for (let rows = 1; rows <= maxRows; rows++) { - for (let cols = 1; cols <= maxCols; cols++) { - if (cols * rows >= paneCount) { - const area = cols * rows - if (area < bestArea || (area === bestArea && rows < bestRows)) { - bestCols = cols - bestRows = rows - bestArea = area - } - } - } - } - - const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO)) - const slotWidth = Math.floor(availableWidth / bestCols) - const slotHeight = Math.floor(windowHeight / bestRows) - - return { cols: bestCols, rows: bestRows, slotWidth, slotHeight } -} - -export function mapPaneToSlot( - pane: TmuxPaneInfo, - plan: GridPlan, - mainPaneWidth: number -): GridSlot { - const rightAreaX = mainPaneWidth - const relativeX = Math.max(0, pane.left - rightAreaX) - const relativeY = pane.top - - const col = plan.slotWidth > 0 - ? Math.min(plan.cols - 1, Math.floor(relativeX / plan.slotWidth)) - : 0 - const row = plan.slotHeight > 0 - ? Math.min(plan.rows - 1, Math.floor(relativeY / plan.slotHeight)) - : 0 - - return { row, col } -} - -function buildOccupancy( - agentPanes: TmuxPaneInfo[], - plan: GridPlan, - mainPaneWidth: number -): Map { - const occupancy = new Map() - for (const pane of agentPanes) { - const slot = mapPaneToSlot(pane, plan, mainPaneWidth) - const key = `${slot.row}:${slot.col}` - occupancy.set(key, pane) - } - return occupancy -} - -function findFirstEmptySlot( - occupancy: Map, - plan: GridPlan -): GridSlot { - for (let row = 0; row < plan.rows; row++) { - for (let col = 0; col < plan.cols; col++) { - const key = `${row}:${col}` - if (!occupancy.has(key)) { - return { row, col } - } - } - } - return { row: plan.rows - 1, col: plan.cols - 1 } -} - -function findSplittableTarget( - state: WindowState, - preferredDirection?: SplitDirection -): SpawnTarget | null { - if (!state.mainPane) return null - - const existingCount = state.agentPanes.length - - if (existingCount === 0) { - const virtualMainPane: TmuxPaneInfo = { - ...state.mainPane, - width: state.windowWidth, - } - if (canSplitPane(virtualMainPane, "-h")) { - return { targetPaneId: state.mainPane.paneId, splitDirection: "-h" } - } - return null - } - - const plan = computeGridPlan(state.windowWidth, state.windowHeight, existingCount + 1) - const mainPaneWidth = Math.floor(state.windowWidth * MAIN_PANE_RATIO) - const occupancy = buildOccupancy(state.agentPanes, plan, mainPaneWidth) - const targetSlot = findFirstEmptySlot(occupancy, plan) - - const leftKey = `${targetSlot.row}:${targetSlot.col - 1}` - const leftPane = occupancy.get(leftKey) - if (leftPane && canSplitPane(leftPane, "-h")) { - return { targetPaneId: leftPane.paneId, splitDirection: "-h" } - } - - const aboveKey = `${targetSlot.row - 1}:${targetSlot.col}` - const abovePane = occupancy.get(aboveKey) - if (abovePane && canSplitPane(abovePane, "-v")) { - return { targetPaneId: abovePane.paneId, splitDirection: "-v" } - } - - const splittablePanes = state.agentPanes - .map(p => ({ pane: p, direction: getBestSplitDirection(p) })) - .filter(({ direction }) => direction !== null) - .sort((a, b) => (b.pane.width * b.pane.height) - (a.pane.width * a.pane.height)) - - if (splittablePanes.length > 0) { - const best = splittablePanes[0] - return { targetPaneId: best.pane.paneId, splitDirection: best.direction! } - } - - return null -} - -export function findSpawnTarget(state: WindowState): SpawnTarget | null { - return findSplittableTarget(state) -} - -function findOldestSession(mappings: SessionMapping[]): SessionMapping | null { - if (mappings.length === 0) return null - return mappings.reduce((oldest, current) => - current.createdAt < oldest.createdAt ? current : oldest - ) -} - -function findOldestAgentPane( - agentPanes: TmuxPaneInfo[], - sessionMappings: SessionMapping[] -): TmuxPaneInfo | null { - if (agentPanes.length === 0) return null - - const paneIdToAge = new Map() - for (const mapping of sessionMappings) { - paneIdToAge.set(mapping.paneId, mapping.createdAt) - } - - const panesWithAge = agentPanes - .map(p => ({ pane: p, age: paneIdToAge.get(p.paneId) })) - .filter(({ age }) => age !== undefined) - .sort((a, b) => a.age!.getTime() - b.age!.getTime()) - - if (panesWithAge.length > 0) { - return panesWithAge[0].pane - } - - return agentPanes.reduce((oldest, p) => { - if (p.top < oldest.top || (p.top === oldest.top && p.left < oldest.left)) { - return p - } - return oldest - }) -} - -export function decideSpawnActions( - state: WindowState, - sessionId: string, - description: string, - _config: CapacityConfig, - sessionMappings: SessionMapping[] -): SpawnDecision { - if (!state.mainPane) { - return { canSpawn: false, actions: [], reason: "no main pane found" } - } - - const agentAreaWidth = Math.floor(state.windowWidth * (1 - MAIN_PANE_RATIO)) - const currentCount = state.agentPanes.length - - if (agentAreaWidth < MIN_PANE_WIDTH) { - return { - canSpawn: false, - actions: [], - reason: `window too small for agent panes: ${state.windowWidth}x${state.windowHeight}`, - } - } - - const oldestPane = findOldestAgentPane(state.agentPanes, sessionMappings) - const oldestMapping = oldestPane - ? sessionMappings.find(m => m.paneId === oldestPane.paneId) - : null - - if (currentCount === 0) { - const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth } - if (canSplitPane(virtualMainPane, "-h")) { - return { - canSpawn: true, - actions: [{ - type: "spawn", - sessionId, - description, - targetPaneId: state.mainPane.paneId, - splitDirection: "-h" - }] - } - } - return { canSpawn: false, actions: [], reason: "mainPane too small to split" } - } - - if (isSplittableAtCount(agentAreaWidth, currentCount)) { - const spawnTarget = findSplittableTarget(state) - if (spawnTarget) { - return { - canSpawn: true, - actions: [{ - type: "spawn", - sessionId, - description, - targetPaneId: spawnTarget.targetPaneId, - splitDirection: spawnTarget.splitDirection - }] - } - } - } - - const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount) - - if (minEvictions === 1 && oldestPane) { - return { - canSpawn: true, - actions: [ - { - type: "close", - paneId: oldestPane.paneId, - sessionId: oldestMapping?.sessionId || "" - }, - { - type: "spawn", - sessionId, - description, - targetPaneId: state.mainPane.paneId, - splitDirection: "-h" - } - ], - reason: "closed 1 pane to make room for split" - } - } - - if (oldestPane) { - return { - canSpawn: true, - actions: [{ - type: "replace", - paneId: oldestPane.paneId, - oldSessionId: oldestMapping?.sessionId || "", - newSessionId: sessionId, - description - }], - reason: "replaced oldest pane (no split possible)" - } - } - - return { - canSpawn: false, - actions: [], - reason: "no pane available to replace" - } -} - -export function decideCloseAction( - state: WindowState, - sessionId: string, - sessionMappings: SessionMapping[] -): PaneAction | null { - const mapping = sessionMappings.find((m) => m.sessionId === sessionId) - if (!mapping) return null - - const paneExists = state.agentPanes.some((p) => p.paneId === mapping.paneId) - if (!paneExists) return null - - return { type: "close", paneId: mapping.paneId, sessionId } -} +export { findSpawnTarget } from "./spawn-target-finder" +export { decideCloseAction, decideSpawnActions } from "./spawn-action-decider" diff --git a/src/features/tmux-subagent/event-handlers.ts b/src/features/tmux-subagent/event-handlers.ts new file mode 100644 index 000000000..0991d10e2 --- /dev/null +++ b/src/features/tmux-subagent/event-handlers.ts @@ -0,0 +1,6 @@ +export { coerceSessionCreatedEvent } from "./session-created-event" +export type { SessionCreatedEvent } from "./session-created-event" +export { handleSessionCreated } from "./session-created-handler" +export type { SessionCreatedHandlerDeps } from "./session-created-handler" +export { handleSessionDeleted } from "./session-deleted-handler" +export type { SessionDeletedHandlerDeps } from "./session-deleted-handler" diff --git a/src/features/tmux-subagent/grid-planning.ts b/src/features/tmux-subagent/grid-planning.ts new file mode 100644 index 000000000..9e0fcb91d --- /dev/null +++ b/src/features/tmux-subagent/grid-planning.ts @@ -0,0 +1,107 @@ +import type { TmuxPaneInfo } from "./types" +import { + DIVIDER_SIZE, + MAIN_PANE_RATIO, + MAX_GRID_SIZE, +} from "./tmux-grid-constants" +import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types" + +export interface GridCapacity { + cols: number + rows: number + total: number +} + +export interface GridSlot { + row: number + col: number +} + +export interface GridPlan { + cols: number + rows: number + slotWidth: number + slotHeight: number +} + +export function calculateCapacity( + windowWidth: number, + windowHeight: number, +): GridCapacity { + const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO)) + const cols = Math.min( + MAX_GRID_SIZE, + Math.max( + 0, + Math.floor( + (availableWidth + DIVIDER_SIZE) / (MIN_PANE_WIDTH + DIVIDER_SIZE), + ), + ), + ) + const rows = Math.min( + MAX_GRID_SIZE, + Math.max( + 0, + Math.floor( + (windowHeight + DIVIDER_SIZE) / (MIN_PANE_HEIGHT + DIVIDER_SIZE), + ), + ), + ) + return { cols, rows, total: cols * rows } +} + +export function computeGridPlan( + windowWidth: number, + windowHeight: number, + paneCount: number, +): GridPlan { + const capacity = calculateCapacity(windowWidth, windowHeight) + const { cols: maxCols, rows: maxRows } = capacity + + if (maxCols === 0 || maxRows === 0 || paneCount === 0) { + return { cols: 1, rows: 1, slotWidth: 0, slotHeight: 0 } + } + + let bestCols = 1 + let bestRows = 1 + let bestArea = Infinity + + for (let rows = 1; rows <= maxRows; rows++) { + for (let cols = 1; cols <= maxCols; cols++) { + if (cols * rows < paneCount) continue + const area = cols * rows + if (area < bestArea || (area === bestArea && rows < bestRows)) { + bestCols = cols + bestRows = rows + bestArea = area + } + } + } + + const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO)) + const slotWidth = Math.floor(availableWidth / bestCols) + const slotHeight = Math.floor(windowHeight / bestRows) + + return { cols: bestCols, rows: bestRows, slotWidth, slotHeight } +} + +export function mapPaneToSlot( + pane: TmuxPaneInfo, + plan: GridPlan, + mainPaneWidth: number, +): GridSlot { + const rightAreaX = mainPaneWidth + const relativeX = Math.max(0, pane.left - rightAreaX) + const relativeY = pane.top + + const col = + plan.slotWidth > 0 + ? Math.min(plan.cols - 1, Math.floor(relativeX / plan.slotWidth)) + : 0 + const row = + plan.slotHeight > 0 + ? Math.min(plan.rows - 1, Math.floor(relativeY / plan.slotHeight)) + : 0 + + return { row, col } +} diff --git a/src/features/tmux-subagent/index.ts b/src/features/tmux-subagent/index.ts index 2b250087c..254edac93 100644 --- a/src/features/tmux-subagent/index.ts +++ b/src/features/tmux-subagent/index.ts @@ -1,4 +1,14 @@ export * from "./manager" +export * from "./event-handlers" +export * from "./polling" +export * from "./cleanup" +export * from "./session-created-event" +export * from "./session-created-handler" +export * from "./session-deleted-handler" +export * from "./polling-constants" +export * from "./session-status-parser" +export * from "./session-message-count" +export * from "./session-ready-waiter" export * from "./types" export * from "./pane-state-querier" export * from "./decision-engine" diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index ad600dc5d..bc973eec1 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -4,23 +4,20 @@ import type { TrackedSession, CapacityConfig } from "./types" import { isInsideTmux as defaultIsInsideTmux, getCurrentPaneId as defaultGetCurrentPaneId, - POLL_INTERVAL_BACKGROUND_MS, - SESSION_MISSING_GRACE_MS, - SESSION_READY_POLL_INTERVAL_MS, - SESSION_READY_TIMEOUT_MS, } from "../../shared/tmux" import { log } from "../../shared" -import { queryWindowState } from "./pane-state-querier" -import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine" -import { executeActions, executeAction } from "./action-executor" +import type { SessionMapping } from "./decision-engine" +import { + coerceSessionCreatedEvent, + handleSessionCreated, + handleSessionDeleted, + type SessionCreatedEvent, +} from "./event-handlers" +import { createSessionPollingController, type SessionPollingController } from "./polling" +import { cleanupTmuxSessions } from "./cleanup" type OpencodeClient = PluginInput["client"] -interface SessionCreatedEvent { - type: string - properties?: { info?: { id?: string; parentID?: string; title?: string } } -} - export interface TmuxUtilDeps { isInsideTmux: () => boolean getCurrentPaneId: () => string | undefined @@ -31,13 +28,6 @@ const defaultTmuxDeps: TmuxUtilDeps = { getCurrentPaneId: defaultGetCurrentPaneId, } -const SESSION_TIMEOUT_MS = 10 * 60 * 1000 - -// Stability detection constants (prevents premature closure - see issue #1330) -// Mirrors the proven pattern from background-agent/manager.ts -const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in -const STABLE_POLLS_REQUIRED = 3 // 3 consecutive idle polls (~6s with 2s poll interval) - /** * State-first Tmux Session Manager * @@ -57,8 +47,8 @@ export class TmuxSessionManager { private sourcePaneId: string | undefined private sessions = new Map() private pendingSessions = new Set() - private pollInterval?: ReturnType private deps: TmuxUtilDeps + private polling: SessionPollingController constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) { this.client = ctx.client @@ -68,6 +58,14 @@ export class TmuxSessionManager { this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}` this.sourcePaneId = deps.getCurrentPaneId() + this.polling = createSessionPollingController({ + client: this.client, + tmuxConfig: this.tmuxConfig, + serverUrl: this.serverUrl, + sourcePaneId: this.sourcePaneId, + sessions: this.sessions, + }) + log("[tmux-session-manager] initialized", { configEnabled: this.tmuxConfig.enabled, tmuxConfig: this.tmuxConfig, @@ -95,378 +93,58 @@ export class TmuxSessionManager { })) } - private async waitForSessionReady(sessionId: string): Promise { - const startTime = Date.now() - - while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) { - try { - const statusResult = await this.client.session.status({ path: undefined }) - const allStatuses = (statusResult.data ?? {}) as Record - - if (allStatuses[sessionId]) { - log("[tmux-session-manager] session ready", { - sessionId, - status: allStatuses[sessionId].type, - waitedMs: Date.now() - startTime, - }) - return true - } - } catch (err) { - log("[tmux-session-manager] session status check error", { error: String(err) }) - } - - await new Promise((resolve) => setTimeout(resolve, SESSION_READY_POLL_INTERVAL_MS)) - } - - log("[tmux-session-manager] session ready timeout", { - sessionId, - timeoutMs: SESSION_READY_TIMEOUT_MS, - }) - return false - } - async onSessionCreated(event: SessionCreatedEvent): Promise { - const enabled = this.isEnabled() - log("[tmux-session-manager] onSessionCreated called", { - enabled, - tmuxConfigEnabled: this.tmuxConfig.enabled, - isInsideTmux: this.deps.isInsideTmux(), - eventType: event.type, - infoId: event.properties?.info?.id, - infoParentID: event.properties?.info?.parentID, - }) - - if (!enabled) return - if (event.type !== "session.created") return - - const info = event.properties?.info - if (!info?.id || !info?.parentID) return - - const sessionId = info.id - const title = info.title ?? "Subagent" - - if (this.sessions.has(sessionId) || this.pendingSessions.has(sessionId)) { - log("[tmux-session-manager] session already tracked or pending", { sessionId }) - return - } - - if (!this.sourcePaneId) { - log("[tmux-session-manager] no source pane id") - return - } - - this.pendingSessions.add(sessionId) - - try { - const state = await queryWindowState(this.sourcePaneId) - if (!state) { - log("[tmux-session-manager] failed to query window state") - return - } - - log("[tmux-session-manager] window state queried", { - windowWidth: state.windowWidth, - mainPane: state.mainPane?.paneId, - agentPaneCount: state.agentPanes.length, - agentPanes: state.agentPanes.map((p) => p.paneId), - }) - - const decision = decideSpawnActions( - state, - sessionId, - title, - this.getCapacityConfig(), - this.getSessionMappings() - ) - - log("[tmux-session-manager] spawn decision", { - canSpawn: decision.canSpawn, - reason: decision.reason, - actionCount: decision.actions.length, - actions: decision.actions.map((a) => { - if (a.type === "close") return { type: "close", paneId: a.paneId } - if (a.type === "replace") return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId } - return { type: "spawn", sessionId: a.sessionId } - }), - }) - - if (!decision.canSpawn) { - log("[tmux-session-manager] cannot spawn", { reason: decision.reason }) - return - } - - const result = await executeActions( - decision.actions, - { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state } - ) - - for (const { action, result: actionResult } of result.results) { - if (action.type === "close" && actionResult.success) { - this.sessions.delete(action.sessionId) - log("[tmux-session-manager] removed closed session from cache", { - sessionId: action.sessionId, - }) - } - if (action.type === "replace" && actionResult.success) { - this.sessions.delete(action.oldSessionId) - log("[tmux-session-manager] removed replaced session from cache", { - oldSessionId: action.oldSessionId, - newSessionId: action.newSessionId, - }) - } - } - - if (result.success && result.spawnedPaneId) { - const sessionReady = await this.waitForSessionReady(sessionId) - - if (!sessionReady) { - log("[tmux-session-manager] session not ready after timeout, tracking anyway", { - sessionId, - paneId: result.spawnedPaneId, - }) - } - - const now = Date.now() - this.sessions.set(sessionId, { - sessionId, - paneId: result.spawnedPaneId, - description: title, - createdAt: new Date(now), - lastSeenAt: new Date(now), - }) - log("[tmux-session-manager] pane spawned and tracked", { - sessionId, - paneId: result.spawnedPaneId, - sessionReady, - }) - this.startPolling() - } else { - log("[tmux-session-manager] spawn failed", { - success: result.success, - results: result.results.map((r) => ({ - type: r.action.type, - success: r.result.success, - error: r.result.error, - })), - }) - } - } finally { - this.pendingSessions.delete(sessionId) - } + await handleSessionCreated( + { + client: this.client, + tmuxConfig: this.tmuxConfig, + serverUrl: this.serverUrl, + sourcePaneId: this.sourcePaneId, + sessions: this.sessions, + pendingSessions: this.pendingSessions, + isInsideTmux: this.deps.isInsideTmux, + isEnabled: () => this.isEnabled(), + getCapacityConfig: () => this.getCapacityConfig(), + getSessionMappings: () => this.getSessionMappings(), + waitForSessionReady: (sessionId) => this.polling.waitForSessionReady(sessionId), + startPolling: () => this.polling.startPolling(), + }, + event, + ) } async onSessionDeleted(event: { sessionID: string }): Promise { - if (!this.isEnabled()) return - if (!this.sourcePaneId) return - - const tracked = this.sessions.get(event.sessionID) - if (!tracked) return - - log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID }) - - const state = await queryWindowState(this.sourcePaneId) - if (!state) { - this.sessions.delete(event.sessionID) - return - } - - const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings()) - if (closeAction) { - await executeAction(closeAction, { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }) - } - - this.sessions.delete(event.sessionID) - - if (this.sessions.size === 0) { - this.stopPolling() - } - } - - private startPolling(): void { - if (this.pollInterval) return - - this.pollInterval = setInterval( - () => this.pollSessions(), - POLL_INTERVAL_BACKGROUND_MS, + await handleSessionDeleted( + { + tmuxConfig: this.tmuxConfig, + serverUrl: this.serverUrl, + sourcePaneId: this.sourcePaneId, + sessions: this.sessions, + isEnabled: () => this.isEnabled(), + getSessionMappings: () => this.getSessionMappings(), + stopPolling: () => this.polling.stopPolling(), + }, + event, ) - log("[tmux-session-manager] polling started") - } - - private stopPolling(): void { - if (this.pollInterval) { - clearInterval(this.pollInterval) - this.pollInterval = undefined - log("[tmux-session-manager] polling stopped") - } - } - - private async pollSessions(): Promise { - if (this.sessions.size === 0) { - this.stopPolling() - return - } - - try { - const statusResult = await this.client.session.status({ path: undefined }) - const allStatuses = (statusResult.data ?? {}) as Record - - log("[tmux-session-manager] pollSessions", { - trackedSessions: Array.from(this.sessions.keys()), - allStatusKeys: Object.keys(allStatuses), - }) - - const now = Date.now() - const sessionsToClose: string[] = [] - - for (const [sessionId, tracked] of this.sessions.entries()) { - const status = allStatuses[sessionId] - const isIdle = status?.type === "idle" - - if (status) { - tracked.lastSeenAt = new Date(now) - } - - const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0 - const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS - const isTimedOut = now - tracked.createdAt.getTime() > SESSION_TIMEOUT_MS - const elapsedMs = now - tracked.createdAt.getTime() - - // Stability detection: Don't close immediately on idle - // Wait for STABLE_POLLS_REQUIRED consecutive polls with same message count - let shouldCloseViaStability = false - - if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) { - // Fetch message count to detect if agent is still producing output - try { - const messagesResult = await this.client.session.messages({ - path: { id: sessionId } - }) - const currentMsgCount = Array.isArray(messagesResult.data) - ? messagesResult.data.length - : 0 - - if (tracked.lastMessageCount === currentMsgCount) { - // Message count unchanged - increment stable polls - tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1 - - if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) { - // Double-check status before closing - const recheckResult = await this.client.session.status({ path: undefined }) - const recheckStatuses = (recheckResult.data ?? {}) as Record - const recheckStatus = recheckStatuses[sessionId] - - if (recheckStatus?.type === "idle") { - shouldCloseViaStability = true - } else { - // Status changed - reset stability counter - tracked.stableIdlePolls = 0 - log("[tmux-session-manager] stability reached but session not idle on recheck, resetting", { - sessionId, - recheckStatus: recheckStatus?.type, - }) - } - } - } else { - // New messages - agent is still working, reset stability counter - tracked.stableIdlePolls = 0 - } - - tracked.lastMessageCount = currentMsgCount - } catch (msgErr) { - log("[tmux-session-manager] failed to fetch messages for stability check", { - sessionId, - error: String(msgErr), - }) - // On error, don't close - be conservative - } - } else if (!isIdle) { - // Not idle - reset stability counter - tracked.stableIdlePolls = 0 - } - - log("[tmux-session-manager] session check", { - sessionId, - statusType: status?.type, - isIdle, - elapsedMs, - stableIdlePolls: tracked.stableIdlePolls, - lastMessageCount: tracked.lastMessageCount, - missingSince, - missingTooLong, - isTimedOut, - shouldCloseViaStability, - }) - - // Close if: stability detection confirmed OR missing too long OR timed out - // Note: We no longer close immediately on idle - stability detection handles that - if (shouldCloseViaStability || missingTooLong || isTimedOut) { - sessionsToClose.push(sessionId) - } - } - - for (const sessionId of sessionsToClose) { - log("[tmux-session-manager] closing session due to poll", { sessionId }) - await this.closeSessionById(sessionId) - } - } catch (err) { - log("[tmux-session-manager] poll error", { error: String(err) }) - } - } - - private async closeSessionById(sessionId: string): Promise { - const tracked = this.sessions.get(sessionId) - if (!tracked) return - - log("[tmux-session-manager] closing session pane", { - sessionId, - paneId: tracked.paneId, - }) - - const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null - if (state) { - await executeAction( - { type: "close", paneId: tracked.paneId, sessionId }, - { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state } - ) - } - - this.sessions.delete(sessionId) - - if (this.sessions.size === 0) { - this.stopPolling() - } } createEventHandler(): (input: { event: { type: string; properties?: unknown } }) => Promise { return async (input) => { - await this.onSessionCreated(input.event as SessionCreatedEvent) + await this.onSessionCreated(coerceSessionCreatedEvent(input.event)) } } + async pollSessions(): Promise { + return this.polling.pollSessions() + } + async cleanup(): Promise { - this.stopPolling() - - if (this.sessions.size > 0) { - log("[tmux-session-manager] closing all panes", { count: this.sessions.size }) - const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null - - if (state) { - const closePromises = Array.from(this.sessions.values()).map((s) => - executeAction( - { type: "close", paneId: s.paneId, sessionId: s.sessionId }, - { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state } - ).catch((err) => - log("[tmux-session-manager] cleanup error for pane", { - paneId: s.paneId, - error: String(err), - }), - ), - ) - await Promise.all(closePromises) - } - this.sessions.clear() - } - - log("[tmux-session-manager] cleanup complete") + await cleanupTmuxSessions({ + tmuxConfig: this.tmuxConfig, + serverUrl: this.serverUrl, + sourcePaneId: this.sourcePaneId, + sessions: this.sessions, + stopPolling: () => this.polling.stopPolling(), + }) } } diff --git a/src/features/tmux-subagent/oldest-agent-pane.ts b/src/features/tmux-subagent/oldest-agent-pane.ts new file mode 100644 index 000000000..e48ba0154 --- /dev/null +++ b/src/features/tmux-subagent/oldest-agent-pane.ts @@ -0,0 +1,37 @@ +import type { TmuxPaneInfo } from "./types" + +export interface SessionMapping { + sessionId: string + paneId: string + createdAt: Date +} + +export function findOldestAgentPane( + agentPanes: TmuxPaneInfo[], + sessionMappings: SessionMapping[], +): TmuxPaneInfo | null { + if (agentPanes.length === 0) return null + + const paneIdToAge = new Map() + for (const mapping of sessionMappings) { + paneIdToAge.set(mapping.paneId, mapping.createdAt) + } + + const panesWithAge = agentPanes + .map((pane) => ({ pane, age: paneIdToAge.get(pane.paneId) })) + .filter( + (item): item is { pane: TmuxPaneInfo; age: Date } => item.age !== undefined, + ) + .sort((a, b) => a.age.getTime() - b.age.getTime()) + + if (panesWithAge.length > 0) { + return panesWithAge[0].pane + } + + return agentPanes.reduce((oldest, pane) => { + if (pane.top < oldest.top || (pane.top === oldest.top && pane.left < oldest.left)) { + return pane + } + return oldest + }) +} diff --git a/src/features/tmux-subagent/pane-split-availability.ts b/src/features/tmux-subagent/pane-split-availability.ts new file mode 100644 index 000000000..fd9d34ec7 --- /dev/null +++ b/src/features/tmux-subagent/pane-split-availability.ts @@ -0,0 +1,60 @@ +import type { SplitDirection, TmuxPaneInfo } from "./types" +import { + DIVIDER_SIZE, + MAX_COLS, + MAX_ROWS, + MIN_SPLIT_HEIGHT, + MIN_SPLIT_WIDTH, +} from "./tmux-grid-constants" + +export function getColumnCount(paneCount: number): number { + if (paneCount <= 0) return 1 + return Math.min(MAX_COLS, Math.max(1, Math.ceil(paneCount / MAX_ROWS))) +} + +export function getColumnWidth(agentAreaWidth: number, paneCount: number): number { + const cols = getColumnCount(paneCount) + const dividersWidth = (cols - 1) * DIVIDER_SIZE + return Math.floor((agentAreaWidth - dividersWidth) / cols) +} + +export function isSplittableAtCount( + agentAreaWidth: number, + paneCount: number, +): boolean { + const columnWidth = getColumnWidth(agentAreaWidth, paneCount) + return columnWidth >= MIN_SPLIT_WIDTH +} + +export function findMinimalEvictions( + agentAreaWidth: number, + currentCount: number, +): number | null { + for (let k = 1; k <= currentCount; k++) { + if (isSplittableAtCount(agentAreaWidth, currentCount - k)) { + return k + } + } + return null +} + +export function canSplitPane(pane: TmuxPaneInfo, direction: SplitDirection): boolean { + if (direction === "-h") { + return pane.width >= MIN_SPLIT_WIDTH + } + return pane.height >= MIN_SPLIT_HEIGHT +} + +export function canSplitPaneAnyDirection(pane: TmuxPaneInfo): boolean { + return pane.width >= MIN_SPLIT_WIDTH || pane.height >= MIN_SPLIT_HEIGHT +} + +export function getBestSplitDirection(pane: TmuxPaneInfo): SplitDirection | null { + const canH = pane.width >= MIN_SPLIT_WIDTH + const canV = pane.height >= MIN_SPLIT_HEIGHT + + if (!canH && !canV) return null + if (canH && !canV) return "-h" + if (!canH && canV) return "-v" + return pane.width >= pane.height ? "-h" : "-v" +} diff --git a/src/features/tmux-subagent/polling-constants.ts b/src/features/tmux-subagent/polling-constants.ts new file mode 100644 index 000000000..bf9f4b8e6 --- /dev/null +++ b/src/features/tmux-subagent/polling-constants.ts @@ -0,0 +1,6 @@ +export const SESSION_TIMEOUT_MS = 10 * 60 * 1000 + +// Stability detection constants (prevents premature closure - see issue #1330) +// Mirrors the proven pattern from background-agent/manager.ts +export const MIN_STABILITY_TIME_MS = 10 * 1000 +export const STABLE_POLLS_REQUIRED = 3 diff --git a/src/features/tmux-subagent/polling.ts b/src/features/tmux-subagent/polling.ts new file mode 100644 index 000000000..a438be488 --- /dev/null +++ b/src/features/tmux-subagent/polling.ts @@ -0,0 +1,183 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { TmuxConfig } from "../../config/schema" +import { + POLL_INTERVAL_BACKGROUND_MS, + SESSION_MISSING_GRACE_MS, +} from "../../shared/tmux" +import { log } from "../../shared" +import type { TrackedSession } from "./types" +import { queryWindowState } from "./pane-state-querier" +import { executeAction } from "./action-executor" +import { + MIN_STABILITY_TIME_MS, + SESSION_TIMEOUT_MS, + STABLE_POLLS_REQUIRED, +} from "./polling-constants" +import { parseSessionStatusMap } from "./session-status-parser" +import { getMessageCount } from "./session-message-count" +import { waitForSessionReady as waitForSessionReadyFromClient } from "./session-ready-waiter" + +type OpencodeClient = PluginInput["client"] + +export interface SessionPollingController { + startPolling: () => void + stopPolling: () => void + closeSessionById: (sessionId: string) => Promise + waitForSessionReady: (sessionId: string) => Promise + pollSessions: () => Promise +} + +export function createSessionPollingController(params: { + client: OpencodeClient + tmuxConfig: TmuxConfig + serverUrl: string + sourcePaneId: string | undefined + sessions: Map +}): SessionPollingController { + let pollInterval: ReturnType | undefined + + async function closeSessionById(sessionId: string): Promise { + const tracked = params.sessions.get(sessionId) + if (!tracked) return + + log("[tmux-session-manager] closing session pane", { + sessionId, + paneId: tracked.paneId, + }) + + const state = params.sourcePaneId ? await queryWindowState(params.sourcePaneId) : null + if (state) { + await executeAction( + { type: "close", paneId: tracked.paneId, sessionId }, + { config: params.tmuxConfig, serverUrl: params.serverUrl, windowState: state }, + ) + } + + params.sessions.delete(sessionId) + + if (params.sessions.size === 0) { + stopPolling() + } + } + + async function pollSessions(): Promise { + if (params.sessions.size === 0) { + stopPolling() + return + } + + try { + const statusResult = await params.client.session.status({ path: undefined }) + const allStatuses = parseSessionStatusMap(statusResult.data) + + log("[tmux-session-manager] pollSessions", { + trackedSessions: Array.from(params.sessions.keys()), + allStatusKeys: Object.keys(allStatuses), + }) + + const now = Date.now() + const sessionsToClose: string[] = [] + + for (const [sessionId, tracked] of params.sessions.entries()) { + const status = allStatuses[sessionId] + const isIdle = status?.type === "idle" + + if (status) { + tracked.lastSeenAt = new Date(now) + } + + const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0 + const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS + const isTimedOut = now - tracked.createdAt.getTime() > SESSION_TIMEOUT_MS + const elapsedMs = now - tracked.createdAt.getTime() + + let shouldCloseViaStability = false + + if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) { + try { + const messagesResult = await params.client.session.messages({ + path: { id: sessionId }, + }) + const currentMessageCount = getMessageCount(messagesResult.data) + + if (tracked.lastMessageCount === currentMessageCount) { + tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1 + + if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) { + const recheckResult = await params.client.session.status({ path: undefined }) + const recheckStatuses = parseSessionStatusMap(recheckResult.data) + const recheckStatus = recheckStatuses[sessionId] + + if (recheckStatus?.type === "idle") { + shouldCloseViaStability = true + } else { + tracked.stableIdlePolls = 0 + log( + "[tmux-session-manager] stability reached but session not idle on recheck, resetting", + { sessionId, recheckStatus: recheckStatus?.type }, + ) + } + } + } else { + tracked.stableIdlePolls = 0 + } + + tracked.lastMessageCount = currentMessageCount + } catch (messageError) { + log("[tmux-session-manager] failed to fetch messages for stability check", { + sessionId, + error: String(messageError), + }) + } + } else if (!isIdle) { + tracked.stableIdlePolls = 0 + } + + log("[tmux-session-manager] session check", { + sessionId, + statusType: status?.type, + isIdle, + elapsedMs, + stableIdlePolls: tracked.stableIdlePolls, + lastMessageCount: tracked.lastMessageCount, + missingSince, + missingTooLong, + isTimedOut, + shouldCloseViaStability, + }) + + if (shouldCloseViaStability || missingTooLong || isTimedOut) { + sessionsToClose.push(sessionId) + } + } + + for (const sessionId of sessionsToClose) { + log("[tmux-session-manager] closing session due to poll", { sessionId }) + await closeSessionById(sessionId) + } + } catch (error) { + log("[tmux-session-manager] poll error", { error: String(error) }) + } + } + + function startPolling(): void { + if (pollInterval) return + pollInterval = setInterval(() => { + void pollSessions() + }, POLL_INTERVAL_BACKGROUND_MS) + log("[tmux-session-manager] polling started") + } + + function stopPolling(): void { + if (!pollInterval) return + clearInterval(pollInterval) + pollInterval = undefined + log("[tmux-session-manager] polling stopped") + } + + async function waitForSessionReady(sessionId: string): Promise { + return waitForSessionReadyFromClient({ client: params.client, sessionId }) + } + + return { startPolling, stopPolling, closeSessionById, waitForSessionReady, pollSessions } +} diff --git a/src/features/tmux-subagent/session-created-event.ts b/src/features/tmux-subagent/session-created-event.ts new file mode 100644 index 000000000..53440a2de --- /dev/null +++ b/src/features/tmux-subagent/session-created-event.ts @@ -0,0 +1,44 @@ +type UnknownRecord = Record + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null +} + +function getNestedRecord(value: unknown, key: string): UnknownRecord | undefined { + if (!isRecord(value)) return undefined + const nested = value[key] + return isRecord(nested) ? nested : undefined +} + +function getNestedString(value: unknown, key: string): string | undefined { + if (!isRecord(value)) return undefined + const nested = value[key] + return typeof nested === "string" ? nested : undefined +} + +export interface SessionCreatedEvent { + type: string + properties?: { info?: { id?: string; parentID?: string; title?: string } } +} + +export function coerceSessionCreatedEvent(input: { + type: string + properties?: unknown +}): SessionCreatedEvent { + const properties = isRecord(input.properties) ? input.properties : undefined + const info = getNestedRecord(properties, "info") + + return { + type: input.type, + properties: + info || properties + ? { + info: { + id: getNestedString(info, "id"), + parentID: getNestedString(info, "parentID"), + title: getNestedString(info, "title"), + }, + } + : undefined, + } +} diff --git a/src/features/tmux-subagent/session-created-handler.ts b/src/features/tmux-subagent/session-created-handler.ts new file mode 100644 index 000000000..18afb0d94 --- /dev/null +++ b/src/features/tmux-subagent/session-created-handler.ts @@ -0,0 +1,163 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { TmuxConfig } from "../../config/schema" +import type { CapacityConfig, TrackedSession } from "./types" +import { log } from "../../shared" +import { queryWindowState } from "./pane-state-querier" +import { decideSpawnActions, type SessionMapping } from "./decision-engine" +import { executeActions } from "./action-executor" +import type { SessionCreatedEvent } from "./session-created-event" + +type OpencodeClient = PluginInput["client"] + +export interface SessionCreatedHandlerDeps { + client: OpencodeClient + tmuxConfig: TmuxConfig + serverUrl: string + sourcePaneId: string | undefined + sessions: Map + pendingSessions: Set + isInsideTmux: () => boolean + isEnabled: () => boolean + getCapacityConfig: () => CapacityConfig + getSessionMappings: () => SessionMapping[] + waitForSessionReady: (sessionId: string) => Promise + startPolling: () => void +} + +export async function handleSessionCreated( + deps: SessionCreatedHandlerDeps, + event: SessionCreatedEvent, +): Promise { + const enabled = deps.isEnabled() + log("[tmux-session-manager] onSessionCreated called", { + enabled, + tmuxConfigEnabled: deps.tmuxConfig.enabled, + isInsideTmux: deps.isInsideTmux(), + eventType: event.type, + infoId: event.properties?.info?.id, + infoParentID: event.properties?.info?.parentID, + }) + + if (!enabled) return + if (event.type !== "session.created") return + + const info = event.properties?.info + if (!info?.id || !info?.parentID) return + + const sessionId = info.id + const title = info.title ?? "Subagent" + + if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) { + log("[tmux-session-manager] session already tracked or pending", { sessionId }) + return + } + + if (!deps.sourcePaneId) { + log("[tmux-session-manager] no source pane id") + return + } + + deps.pendingSessions.add(sessionId) + + try { + const state = await queryWindowState(deps.sourcePaneId) + if (!state) { + log("[tmux-session-manager] failed to query window state") + return + } + + log("[tmux-session-manager] window state queried", { + windowWidth: state.windowWidth, + mainPane: state.mainPane?.paneId, + agentPaneCount: state.agentPanes.length, + agentPanes: state.agentPanes.map((p) => p.paneId), + }) + + const decision = decideSpawnActions( + state, + sessionId, + title, + deps.getCapacityConfig(), + deps.getSessionMappings(), + ) + + log("[tmux-session-manager] spawn decision", { + canSpawn: decision.canSpawn, + reason: decision.reason, + actionCount: decision.actions.length, + actions: decision.actions.map((a) => { + if (a.type === "close") return { type: "close", paneId: a.paneId } + if (a.type === "replace") { + return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId } + } + return { type: "spawn", sessionId: a.sessionId } + }), + }) + + if (!decision.canSpawn) { + log("[tmux-session-manager] cannot spawn", { reason: decision.reason }) + return + } + + const result = await executeActions(decision.actions, { + config: deps.tmuxConfig, + serverUrl: deps.serverUrl, + windowState: state, + }) + + for (const { action, result: actionResult } of result.results) { + if (action.type === "close" && actionResult.success) { + deps.sessions.delete(action.sessionId) + log("[tmux-session-manager] removed closed session from cache", { + sessionId: action.sessionId, + }) + } + if (action.type === "replace" && actionResult.success) { + deps.sessions.delete(action.oldSessionId) + log("[tmux-session-manager] removed replaced session from cache", { + oldSessionId: action.oldSessionId, + newSessionId: action.newSessionId, + }) + } + } + + if (!result.success || !result.spawnedPaneId) { + log("[tmux-session-manager] spawn failed", { + success: result.success, + results: result.results.map((r) => ({ + type: r.action.type, + success: r.result.success, + error: r.result.error, + })), + }) + return + } + + const sessionReady = await deps.waitForSessionReady(sessionId) + if (!sessionReady) { + log("[tmux-session-manager] session not ready after timeout, tracking anyway", { + sessionId, + paneId: result.spawnedPaneId, + }) + } + + const now = Date.now() + deps.sessions.set(sessionId, { + sessionId, + paneId: result.spawnedPaneId, + description: title, + createdAt: new Date(now), + lastSeenAt: new Date(now), + }) + + log("[tmux-session-manager] pane spawned and tracked", { + sessionId, + paneId: result.spawnedPaneId, + sessionReady, + }) + + deps.startPolling() + } finally { + deps.pendingSessions.delete(sessionId) + } +} diff --git a/src/features/tmux-subagent/session-deleted-handler.ts b/src/features/tmux-subagent/session-deleted-handler.ts new file mode 100644 index 000000000..f832cf481 --- /dev/null +++ b/src/features/tmux-subagent/session-deleted-handler.ts @@ -0,0 +1,50 @@ +import type { TmuxConfig } from "../../config/schema" +import type { TrackedSession } from "./types" +import { log } from "../../shared" +import { queryWindowState } from "./pane-state-querier" +import { decideCloseAction, type SessionMapping } from "./decision-engine" +import { executeAction } from "./action-executor" + +export interface SessionDeletedHandlerDeps { + tmuxConfig: TmuxConfig + serverUrl: string + sourcePaneId: string | undefined + sessions: Map + isEnabled: () => boolean + getSessionMappings: () => SessionMapping[] + stopPolling: () => void +} + +export async function handleSessionDeleted( + deps: SessionDeletedHandlerDeps, + event: { sessionID: string }, +): Promise { + if (!deps.isEnabled()) return + if (!deps.sourcePaneId) return + + const tracked = deps.sessions.get(event.sessionID) + if (!tracked) return + + log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID }) + + const state = await queryWindowState(deps.sourcePaneId) + if (!state) { + deps.sessions.delete(event.sessionID) + return + } + + const closeAction = decideCloseAction(state, event.sessionID, deps.getSessionMappings()) + if (closeAction) { + await executeAction(closeAction, { + config: deps.tmuxConfig, + serverUrl: deps.serverUrl, + windowState: state, + }) + } + + deps.sessions.delete(event.sessionID) + + if (deps.sessions.size === 0) { + deps.stopPolling() + } +} diff --git a/src/features/tmux-subagent/session-message-count.ts b/src/features/tmux-subagent/session-message-count.ts new file mode 100644 index 000000000..a634208c9 --- /dev/null +++ b/src/features/tmux-subagent/session-message-count.ts @@ -0,0 +1,3 @@ +export function getMessageCount(data: unknown): number { + return Array.isArray(data) ? data.length : 0 +} diff --git a/src/features/tmux-subagent/session-ready-waiter.ts b/src/features/tmux-subagent/session-ready-waiter.ts new file mode 100644 index 000000000..d98757c5d --- /dev/null +++ b/src/features/tmux-subagent/session-ready-waiter.ts @@ -0,0 +1,44 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { + SESSION_READY_POLL_INTERVAL_MS, + SESSION_READY_TIMEOUT_MS, +} from "../../shared/tmux" +import { log } from "../../shared" +import { parseSessionStatusMap } from "./session-status-parser" + +type OpencodeClient = PluginInput["client"] + +export async function waitForSessionReady(params: { + client: OpencodeClient + sessionId: string +}): Promise { + const startTime = Date.now() + + while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) { + try { + const statusResult = await params.client.session.status({ path: undefined }) + const allStatuses = parseSessionStatusMap(statusResult.data) + + if (allStatuses[params.sessionId]) { + log("[tmux-session-manager] session ready", { + sessionId: params.sessionId, + status: allStatuses[params.sessionId].type, + waitedMs: Date.now() - startTime, + }) + return true + } + } catch (error) { + log("[tmux-session-manager] session status check error", { error: String(error) }) + } + + await new Promise((resolve) => { + setTimeout(resolve, SESSION_READY_POLL_INTERVAL_MS) + }) + } + + log("[tmux-session-manager] session ready timeout", { + sessionId: params.sessionId, + timeoutMs: SESSION_READY_TIMEOUT_MS, + }) + return false +} diff --git a/src/features/tmux-subagent/session-status-parser.ts b/src/features/tmux-subagent/session-status-parser.ts new file mode 100644 index 000000000..7c401d148 --- /dev/null +++ b/src/features/tmux-subagent/session-status-parser.ts @@ -0,0 +1,17 @@ +type SessionStatus = { type: string } + +export function parseSessionStatusMap(data: unknown): Record { + if (typeof data !== "object" || data === null) return {} + const record = data as Record + + const result: Record = {} + for (const [sessionId, value] of Object.entries(record)) { + if (typeof value !== "object" || value === null) continue + const valueRecord = value as Record + const type = valueRecord["type"] + if (typeof type !== "string") continue + result[sessionId] = { type } + } + + return result +} diff --git a/src/features/tmux-subagent/spawn-action-decider.ts b/src/features/tmux-subagent/spawn-action-decider.ts new file mode 100644 index 000000000..1a279b65b --- /dev/null +++ b/src/features/tmux-subagent/spawn-action-decider.ts @@ -0,0 +1,135 @@ +import type { + CapacityConfig, + PaneAction, + SpawnDecision, + TmuxPaneInfo, + WindowState, +} from "./types" +import { MAIN_PANE_RATIO } from "./tmux-grid-constants" +import { + canSplitPane, + findMinimalEvictions, + isSplittableAtCount, +} from "./pane-split-availability" +import { findSpawnTarget } from "./spawn-target-finder" +import { findOldestAgentPane, type SessionMapping } from "./oldest-agent-pane" +import { MIN_PANE_WIDTH } from "./types" + +export function decideSpawnActions( + state: WindowState, + sessionId: string, + description: string, + _config: CapacityConfig, + sessionMappings: SessionMapping[], +): SpawnDecision { + if (!state.mainPane) { + return { canSpawn: false, actions: [], reason: "no main pane found" } + } + + const agentAreaWidth = Math.floor(state.windowWidth * (1 - MAIN_PANE_RATIO)) + const currentCount = state.agentPanes.length + + if (agentAreaWidth < MIN_PANE_WIDTH) { + return { + canSpawn: false, + actions: [], + reason: `window too small for agent panes: ${state.windowWidth}x${state.windowHeight}`, + } + } + + const oldestPane = findOldestAgentPane(state.agentPanes, sessionMappings) + const oldestMapping = oldestPane + ? sessionMappings.find((m) => m.paneId === oldestPane.paneId) ?? null + : null + + if (currentCount === 0) { + const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth } + if (canSplitPane(virtualMainPane, "-h")) { + return { + canSpawn: true, + actions: [ + { + type: "spawn", + sessionId, + description, + targetPaneId: state.mainPane.paneId, + splitDirection: "-h", + }, + ], + } + } + return { canSpawn: false, actions: [], reason: "mainPane too small to split" } + } + + if (isSplittableAtCount(agentAreaWidth, currentCount)) { + const spawnTarget = findSpawnTarget(state) + if (spawnTarget) { + return { + canSpawn: true, + actions: [ + { + type: "spawn", + sessionId, + description, + targetPaneId: spawnTarget.targetPaneId, + splitDirection: spawnTarget.splitDirection, + }, + ], + } + } + } + + const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount) + if (minEvictions === 1 && oldestPane) { + return { + canSpawn: true, + actions: [ + { + type: "close", + paneId: oldestPane.paneId, + sessionId: oldestMapping?.sessionId || "", + }, + { + type: "spawn", + sessionId, + description, + targetPaneId: state.mainPane.paneId, + splitDirection: "-h", + }, + ], + reason: "closed 1 pane to make room for split", + } + } + + if (oldestPane) { + return { + canSpawn: true, + actions: [ + { + type: "replace", + paneId: oldestPane.paneId, + oldSessionId: oldestMapping?.sessionId || "", + newSessionId: sessionId, + description, + }, + ], + reason: "replaced oldest pane (no split possible)", + } + } + + return { canSpawn: false, actions: [], reason: "no pane available to replace" } +} + +export function decideCloseAction( + state: WindowState, + sessionId: string, + sessionMappings: SessionMapping[], +): PaneAction | null { + const mapping = sessionMappings.find((m) => m.sessionId === sessionId) + if (!mapping) return null + + const paneExists = state.agentPanes.some((pane) => pane.paneId === mapping.paneId) + if (!paneExists) return null + + return { type: "close", paneId: mapping.paneId, sessionId } +} diff --git a/src/features/tmux-subagent/spawn-target-finder.ts b/src/features/tmux-subagent/spawn-target-finder.ts new file mode 100644 index 000000000..592f4c2fb --- /dev/null +++ b/src/features/tmux-subagent/spawn-target-finder.ts @@ -0,0 +1,86 @@ +import type { SplitDirection, TmuxPaneInfo, WindowState } from "./types" +import { MAIN_PANE_RATIO } from "./tmux-grid-constants" +import { computeGridPlan, mapPaneToSlot } from "./grid-planning" +import { canSplitPane, getBestSplitDirection } from "./pane-split-availability" + +export interface SpawnTarget { + targetPaneId: string + splitDirection: SplitDirection +} + +function buildOccupancy( + agentPanes: TmuxPaneInfo[], + plan: ReturnType, + mainPaneWidth: number, +): Map { + const occupancy = new Map() + for (const pane of agentPanes) { + const slot = mapPaneToSlot(pane, plan, mainPaneWidth) + occupancy.set(`${slot.row}:${slot.col}`, pane) + } + return occupancy +} + +function findFirstEmptySlot( + occupancy: Map, + plan: ReturnType, +): { row: number; col: number } { + for (let row = 0; row < plan.rows; row++) { + for (let col = 0; col < plan.cols; col++) { + if (!occupancy.has(`${row}:${col}`)) { + return { row, col } + } + } + } + return { row: plan.rows - 1, col: plan.cols - 1 } +} + +function findSplittableTarget( + state: WindowState, + _preferredDirection?: SplitDirection, +): SpawnTarget | null { + if (!state.mainPane) return null + const existingCount = state.agentPanes.length + + if (existingCount === 0) { + const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth } + if (canSplitPane(virtualMainPane, "-h")) { + return { targetPaneId: state.mainPane.paneId, splitDirection: "-h" } + } + return null + } + + const plan = computeGridPlan(state.windowWidth, state.windowHeight, existingCount + 1) + const mainPaneWidth = Math.floor(state.windowWidth * MAIN_PANE_RATIO) + const occupancy = buildOccupancy(state.agentPanes, plan, mainPaneWidth) + const targetSlot = findFirstEmptySlot(occupancy, plan) + + const leftPane = occupancy.get(`${targetSlot.row}:${targetSlot.col - 1}`) + if (leftPane && canSplitPane(leftPane, "-h")) { + return { targetPaneId: leftPane.paneId, splitDirection: "-h" } + } + + const abovePane = occupancy.get(`${targetSlot.row - 1}:${targetSlot.col}`) + if (abovePane && canSplitPane(abovePane, "-v")) { + return { targetPaneId: abovePane.paneId, splitDirection: "-v" } + } + + const splittablePanes = state.agentPanes + .map((pane) => ({ pane, direction: getBestSplitDirection(pane) })) + .filter( + (item): item is { pane: TmuxPaneInfo; direction: SplitDirection } => + item.direction !== null, + ) + .sort((a, b) => b.pane.width * b.pane.height - a.pane.width * a.pane.height) + + const best = splittablePanes[0] + if (best) { + return { targetPaneId: best.pane.paneId, splitDirection: best.direction } + } + + return null +} + +export function findSpawnTarget(state: WindowState): SpawnTarget | null { + return findSplittableTarget(state) +} diff --git a/src/features/tmux-subagent/tmux-grid-constants.ts b/src/features/tmux-subagent/tmux-grid-constants.ts new file mode 100644 index 000000000..778c5e3ae --- /dev/null +++ b/src/features/tmux-subagent/tmux-grid-constants.ts @@ -0,0 +1,10 @@ +import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types" + +export const MAIN_PANE_RATIO = 0.5 +export const MAX_COLS = 2 +export const MAX_ROWS = 3 +export const MAX_GRID_SIZE = 4 +export const DIVIDER_SIZE = 1 + +export const MIN_SPLIT_WIDTH = 2 * MIN_PANE_WIDTH + DIVIDER_SIZE +export const MIN_SPLIT_HEIGHT = 2 * MIN_PANE_HEIGHT + DIVIDER_SIZE From 51ced65b5f22893985d58af181b2c59319b383c5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:21:19 +0900 Subject: [PATCH 06/51] refactor(opencode-skill-loader): split loader and merger into focused modules Extract skill loading pipeline into single-responsibility modules: - skill-discovery.ts, skill-directory-loader.ts, skill-deduplication.ts - loaded-skill-from-path.ts, loaded-skill-template-extractor.ts - skill-template-resolver.ts, skill-definition-record.ts - git-master-template-injection.ts, allowed-tools-parser.ts - skill-mcp-config.ts, skill-resolution-options.ts - merger/ directory for skill merging logic --- .../allowed-tools-parser.ts | 9 + .../git-master-template-injection.ts | 81 ++++++ src/features/opencode-skill-loader/index.ts | 12 + .../loaded-skill-from-path.ts | 71 +++++ .../loaded-skill-template-extractor.ts | 12 + src/features/opencode-skill-loader/loader.ts | 254 ++--------------- src/features/opencode-skill-loader/merger.ts | 201 +------------- .../merger/builtin-skill-converter.ts | 26 ++ .../merger/config-skill-entry-loader.ts | 103 +++++++ .../merger/scope-priority.ts | 10 + .../merger/skill-definition-merger.ts | 31 +++ .../merger/skills-config-normalizer.ts | 19 ++ .../opencode-skill-loader/skill-content.ts | 259 +----------------- .../skill-deduplication.ts | 13 + .../skill-definition-record.ts | 11 + .../skill-directory-loader.ts | 106 +++++++ .../opencode-skill-loader/skill-discovery.ts | 76 +++++ .../opencode-skill-loader/skill-mcp-config.ts | 45 +++ .../skill-resolution-options.ts | 7 + .../skill-template-resolver.ts | 97 +++++++ 20 files changed, 769 insertions(+), 674 deletions(-) create mode 100644 src/features/opencode-skill-loader/allowed-tools-parser.ts create mode 100644 src/features/opencode-skill-loader/git-master-template-injection.ts create mode 100644 src/features/opencode-skill-loader/loaded-skill-from-path.ts create mode 100644 src/features/opencode-skill-loader/loaded-skill-template-extractor.ts create mode 100644 src/features/opencode-skill-loader/merger/builtin-skill-converter.ts create mode 100644 src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts create mode 100644 src/features/opencode-skill-loader/merger/scope-priority.ts create mode 100644 src/features/opencode-skill-loader/merger/skill-definition-merger.ts create mode 100644 src/features/opencode-skill-loader/merger/skills-config-normalizer.ts create mode 100644 src/features/opencode-skill-loader/skill-deduplication.ts create mode 100644 src/features/opencode-skill-loader/skill-definition-record.ts create mode 100644 src/features/opencode-skill-loader/skill-directory-loader.ts create mode 100644 src/features/opencode-skill-loader/skill-discovery.ts create mode 100644 src/features/opencode-skill-loader/skill-mcp-config.ts create mode 100644 src/features/opencode-skill-loader/skill-resolution-options.ts create mode 100644 src/features/opencode-skill-loader/skill-template-resolver.ts diff --git a/src/features/opencode-skill-loader/allowed-tools-parser.ts b/src/features/opencode-skill-loader/allowed-tools-parser.ts new file mode 100644 index 000000000..0bf1354d0 --- /dev/null +++ b/src/features/opencode-skill-loader/allowed-tools-parser.ts @@ -0,0 +1,9 @@ +export function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined { + if (!allowedTools) return undefined + + if (Array.isArray(allowedTools)) { + return allowedTools.map((tool) => tool.trim()).filter(Boolean) + } + + return allowedTools.split(/\s+/).filter(Boolean) +} diff --git a/src/features/opencode-skill-loader/git-master-template-injection.ts b/src/features/opencode-skill-loader/git-master-template-injection.ts new file mode 100644 index 000000000..f6815798c --- /dev/null +++ b/src/features/opencode-skill-loader/git-master-template-injection.ts @@ -0,0 +1,81 @@ +import type { GitMasterConfig } from "../../config/schema" + +export function injectGitMasterConfig(template: string, config?: GitMasterConfig): string { + const commitFooter = config?.commit_footer ?? true + const includeCoAuthoredBy = config?.include_co_authored_by ?? true + + if (!commitFooter && !includeCoAuthoredBy) { + return template + } + + const sections: string[] = [] + + sections.push("### 5.5 Commit Footer & Co-Author") + sections.push("") + sections.push("Add Sisyphus attribution to EVERY commit:") + sections.push("") + + if (commitFooter) { + const footerText = + typeof commitFooter === "string" + ? commitFooter + : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" + sections.push("1. **Footer in commit body:**") + sections.push("```") + sections.push(footerText) + sections.push("```") + sections.push("") + } + + if (includeCoAuthoredBy) { + sections.push(`${commitFooter ? "2" : "1"}. **Co-authored-by trailer:**`) + sections.push("```") + sections.push("Co-authored-by: Sisyphus ") + sections.push("```") + sections.push("") + } + + if (commitFooter && includeCoAuthoredBy) { + const footerText = + typeof commitFooter === "string" + ? commitFooter + : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" + sections.push("**Example (both enabled):**") + sections.push("```bash") + sections.push( + `git commit -m "{Commit Message}" -m "${footerText}" -m "Co-authored-by: Sisyphus "` + ) + sections.push("```") + } else if (commitFooter) { + const footerText = + typeof commitFooter === "string" + ? commitFooter + : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" + sections.push("**Example:**") + sections.push("```bash") + sections.push(`git commit -m "{Commit Message}" -m "${footerText}"`) + sections.push("```") + } else if (includeCoAuthoredBy) { + sections.push("**Example:**") + sections.push("```bash") + sections.push( + "git commit -m \"{Commit Message}\" -m \"Co-authored-by: Sisyphus \"" + ) + sections.push("```") + } + + const injection = sections.join("\n") + + const insertionPoint = template.indexOf("```\n") + if (insertionPoint !== -1) { + return ( + template.slice(0, insertionPoint) + + "```\n\n" + + injection + + "\n" + + template.slice(insertionPoint + "```\n".length) + ) + } + + return template + "\n\n" + injection +} diff --git a/src/features/opencode-skill-loader/index.ts b/src/features/opencode-skill-loader/index.ts index cb4646289..68c556245 100644 --- a/src/features/opencode-skill-loader/index.ts +++ b/src/features/opencode-skill-loader/index.ts @@ -2,3 +2,15 @@ export * from "./types" export * from "./loader" export * from "./merger" export * from "./skill-content" + +export * from "./skill-directory-loader" +export * from "./loaded-skill-from-path" +export * from "./skill-mcp-config" +export * from "./skill-deduplication" +export * from "./skill-definition-record" + +export * from "./git-master-template-injection" +export * from "./skill-discovery" +export * from "./skill-resolution-options" +export * from "./loaded-skill-template-extractor" +export * from "./skill-template-resolver" diff --git a/src/features/opencode-skill-loader/loaded-skill-from-path.ts b/src/features/opencode-skill-loader/loaded-skill-from-path.ts new file mode 100644 index 000000000..7d3f646e2 --- /dev/null +++ b/src/features/opencode-skill-loader/loaded-skill-from-path.ts @@ -0,0 +1,71 @@ +import { promises as fs } from "fs" +import { basename } from "path" +import { parseFrontmatter } from "../../shared/frontmatter" +import { sanitizeModelField } from "../../shared/model-sanitizer" +import type { CommandDefinition } from "../claude-code-command-loader/types" +import { parseAllowedTools } from "./allowed-tools-parser" +import { loadMcpJsonFromDir, parseSkillMcpConfigFromFrontmatter } from "./skill-mcp-config" +import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types" + +export async function loadSkillFromPath(options: { + skillPath: string + resolvedPath: string + defaultName: string + scope: SkillScope + namePrefix?: string +}): Promise { + const namePrefix = options.namePrefix ?? "" + + try { + const content = await fs.readFile(options.skillPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content) + const mcpJsonMcp = await loadMcpJsonFromDir(options.resolvedPath) + const mcpConfig = mcpJsonMcp || frontmatterMcp + + const baseName = data.name || options.defaultName + const skillName = namePrefix ? `${namePrefix}/${baseName}` : baseName + const originalDescription = data.description || "" + const isOpencodeSource = options.scope === "opencode" || options.scope === "opencode-project" + const formattedDescription = `(${options.scope} - Skill) ${originalDescription}` + + const templateContent = `\nBase directory for this skill: ${options.resolvedPath}/\nFile references (@path) in this skill are relative to this directory.\n\n${body.trim()}\n\n\n\n$ARGUMENTS\n` + + const eagerLoader: LazyContentLoader = { + loaded: true, + content: templateContent, + load: async () => templateContent, + } + + const definition: CommandDefinition = { + name: skillName, + description: formattedDescription, + template: templateContent, + model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), + agent: data.agent, + subtask: data.subtask, + argumentHint: data["argument-hint"], + } + + return { + name: skillName, + path: options.skillPath, + resolvedPath: options.resolvedPath, + definition, + scope: options.scope, + license: data.license, + compatibility: data.compatibility, + metadata: data.metadata, + allowedTools: parseAllowedTools(data["allowed-tools"]), + mcpConfig, + lazyContent: eagerLoader, + } + } catch { + return null + } +} + +export function inferSkillNameFromFileName(filePath: string): string { + return basename(filePath, ".md") +} diff --git a/src/features/opencode-skill-loader/loaded-skill-template-extractor.ts b/src/features/opencode-skill-loader/loaded-skill-template-extractor.ts new file mode 100644 index 000000000..ba20552e5 --- /dev/null +++ b/src/features/opencode-skill-loader/loaded-skill-template-extractor.ts @@ -0,0 +1,12 @@ +import { readFileSync } from "node:fs" +import { parseFrontmatter } from "../../shared/frontmatter" +import type { LoadedSkill } from "./types" + +export function extractSkillTemplate(skill: LoadedSkill): string { + if (skill.path) { + const content = readFileSync(skill.path, "utf-8") + const { body } = parseFrontmatter(content) + return body.trim() + } + return skill.definition.template || "" +} diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index 595224751..6caf4e73d 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -1,255 +1,41 @@ -import { promises as fs } from "fs" -import { join, basename } from "path" -import yaml from "js-yaml" -import { parseFrontmatter } from "../../shared/frontmatter" -import { sanitizeModelField } from "../../shared/model-sanitizer" -import { resolveSymlinkAsync, isMarkdownFile } from "../../shared/file-utils" +import { join } from "path" import { getClaudeConfigDir } from "../../shared" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" import type { CommandDefinition } from "../claude-code-command-loader/types" -import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types" -import type { SkillMcpConfig } from "../skill-mcp-manager/types" - -function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined { - const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) - if (!frontmatterMatch) return undefined - - try { - const parsed = yaml.load(frontmatterMatch[1]) as Record - if (parsed && typeof parsed === "object" && "mcp" in parsed && parsed.mcp) { - return parsed.mcp as SkillMcpConfig - } - } catch { - return undefined - } - return undefined -} - -async function loadMcpJsonFromDir(skillDir: string): Promise { - const mcpJsonPath = join(skillDir, "mcp.json") - - try { - const content = await fs.readFile(mcpJsonPath, "utf-8") - const parsed = JSON.parse(content) as Record - - if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) { - return parsed.mcpServers as SkillMcpConfig - } - - if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) { - const hasCommandField = Object.values(parsed).some( - (v) => v && typeof v === "object" && "command" in (v as Record) - ) - if (hasCommandField) { - return parsed as SkillMcpConfig - } - } - } catch { - return undefined - } - return undefined -} - -function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined { - if (!allowedTools) return undefined - - // Handle YAML array format: already parsed as string[] - if (Array.isArray(allowedTools)) { - return allowedTools.map(t => t.trim()).filter(Boolean) - } - - // Handle space-separated string format: "Read Write Edit Bash" - return allowedTools.split(/\s+/).filter(Boolean) -} - -async function loadSkillFromPath( - skillPath: string, - resolvedPath: string, - defaultName: string, - scope: SkillScope, - namePrefix: string = "" -): Promise { - try { - const content = await fs.readFile(skillPath, "utf-8") - const { data, body } = parseFrontmatter(content) - const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content) - const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath) - const mcpConfig = mcpJsonMcp || frontmatterMcp - - // For nested skills, use the full path as the name (e.g., "superpowers/brainstorming") - // For flat skills, use frontmatter name or directory name - const baseName = data.name || defaultName - const skillName = namePrefix ? `${namePrefix}/${baseName}` : baseName - const originalDescription = data.description || "" - const isOpencodeSource = scope === "opencode" || scope === "opencode-project" - const formattedDescription = `(${scope} - Skill) ${originalDescription}` - - const templateContent = ` -Base directory for this skill: ${resolvedPath}/ -File references (@path) in this skill are relative to this directory. - -${body.trim()} - - - -$ARGUMENTS -` - - // RATIONALE: We read the file eagerly to ensure atomic consistency between - // metadata and body. We maintain the LazyContentLoader interface for - // compatibility, but the state is effectively eager. - const eagerLoader: LazyContentLoader = { - loaded: true, - content: templateContent, - load: async () => templateContent, - } - - const definition: CommandDefinition = { - name: skillName, - description: formattedDescription, - template: templateContent, - model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), - agent: data.agent, - subtask: data.subtask, - argumentHint: data["argument-hint"], - } - - return { - name: skillName, - path: skillPath, - resolvedPath, - definition, - scope, - license: data.license, - compatibility: data.compatibility, - metadata: data.metadata, - allowedTools: parseAllowedTools(data["allowed-tools"]), - mcpConfig, - lazyContent: eagerLoader, - } - } catch { - return null - } -} - -async function loadSkillsFromDir( - skillsDir: string, - scope: SkillScope, - namePrefix: string = "", - depth: number = 0, - maxDepth: number = 2 -): Promise { - const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []) - const skillMap = new Map() - - const directories = entries.filter(e => !e.name.startsWith(".") && (e.isDirectory() || e.isSymbolicLink())) - const files = entries.filter(e => !e.name.startsWith(".") && !e.isDirectory() && !e.isSymbolicLink() && isMarkdownFile(e)) - - for (const entry of directories) { - const entryPath = join(skillsDir, entry.name) - const resolvedPath = await resolveSymlinkAsync(entryPath) - const dirName = entry.name - - const skillMdPath = join(resolvedPath, "SKILL.md") - try { - await fs.access(skillMdPath) - const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope, namePrefix) - if (skill && !skillMap.has(skill.name)) { - skillMap.set(skill.name, skill) - } - continue - } catch { - } - - const namedSkillMdPath = join(resolvedPath, `${dirName}.md`) - try { - await fs.access(namedSkillMdPath) - const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope, namePrefix) - if (skill && !skillMap.has(skill.name)) { - skillMap.set(skill.name, skill) - } - continue - } catch { - } - - if (depth < maxDepth) { - const newPrefix = namePrefix ? `${namePrefix}/${dirName}` : dirName - const nestedSkills = await loadSkillsFromDir(resolvedPath, scope, newPrefix, depth + 1, maxDepth) - for (const nestedSkill of nestedSkills) { - if (!skillMap.has(nestedSkill.name)) { - skillMap.set(nestedSkill.name, nestedSkill) - } - } - } - } - - for (const entry of files) { - const entryPath = join(skillsDir, entry.name) - const baseName = basename(entry.name, ".md") - const skill = await loadSkillFromPath(entryPath, skillsDir, baseName, scope, namePrefix) - if (skill && !skillMap.has(skill.name)) { - skillMap.set(skill.name, skill) - } - } - - return Array.from(skillMap.values()) -} - -function skillsToRecord(skills: LoadedSkill[]): Record { - const result: Record = {} - for (const skill of skills) { - const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = skill.definition - result[skill.name] = openCodeCompatible as CommandDefinition - } - return result -} +import type { LoadedSkill } from "./types" +import { skillsToCommandDefinitionRecord } from "./skill-definition-record" +import { deduplicateSkillsByName } from "./skill-deduplication" +import { loadSkillsFromDir } from "./skill-directory-loader" export async function loadUserSkills(): Promise> { const userSkillsDir = join(getClaudeConfigDir(), "skills") - const skills = await loadSkillsFromDir(userSkillsDir, "user") - return skillsToRecord(skills) + const skills = await loadSkillsFromDir({ skillsDir: userSkillsDir, scope: "user" }) + return skillsToCommandDefinitionRecord(skills) } export async function loadProjectSkills(): Promise> { const projectSkillsDir = join(process.cwd(), ".claude", "skills") - const skills = await loadSkillsFromDir(projectSkillsDir, "project") - return skillsToRecord(skills) + const skills = await loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" }) + return skillsToCommandDefinitionRecord(skills) } export async function loadOpencodeGlobalSkills(): Promise> { const configDir = getOpenCodeConfigDir({ binary: "opencode" }) const opencodeSkillsDir = join(configDir, "skills") - const skills = await loadSkillsFromDir(opencodeSkillsDir, "opencode") - return skillsToRecord(skills) + const skills = await loadSkillsFromDir({ skillsDir: opencodeSkillsDir, scope: "opencode" }) + return skillsToCommandDefinitionRecord(skills) } export async function loadOpencodeProjectSkills(): Promise> { const opencodeProjectDir = join(process.cwd(), ".opencode", "skills") - const skills = await loadSkillsFromDir(opencodeProjectDir, "opencode-project") - return skillsToRecord(skills) + const skills = await loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" }) + return skillsToCommandDefinitionRecord(skills) } export interface DiscoverSkillsOptions { includeClaudeCodePaths?: boolean } -/** - * Deduplicates skills by name, keeping the first occurrence (higher priority). - * Priority order: opencode-project > opencode > project > user - * (OpenCode Global skills take precedence over legacy Claude project skills) - */ -function deduplicateSkills(skills: LoadedSkill[]): LoadedSkill[] { - const seen = new Set() - const result: LoadedSkill[] = [] - for (const skill of skills) { - if (!seen.has(skill.name)) { - seen.add(skill.name) - result.push(skill) - } - } - return result -} - export async function discoverAllSkills(): Promise { const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([ discoverOpencodeProjectSkills(), @@ -259,7 +45,7 @@ export async function discoverAllSkills(): Promise { ]) // Priority: opencode-project > opencode > project > user - return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills]) + return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills]) } export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise { @@ -272,7 +58,7 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi if (!includeClaudeCodePaths) { // Priority: opencode-project > opencode - return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills]) + return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills]) } const [projectSkills, userSkills] = await Promise.all([ @@ -281,7 +67,7 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi ]) // Priority: opencode-project > opencode > project > user - return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills]) + return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills]) } export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise { @@ -291,21 +77,21 @@ export async function getSkillByName(name: string, options: DiscoverSkillsOption export async function discoverUserClaudeSkills(): Promise { const userSkillsDir = join(getClaudeConfigDir(), "skills") - return loadSkillsFromDir(userSkillsDir, "user") + return loadSkillsFromDir({ skillsDir: userSkillsDir, scope: "user" }) } export async function discoverProjectClaudeSkills(): Promise { const projectSkillsDir = join(process.cwd(), ".claude", "skills") - return loadSkillsFromDir(projectSkillsDir, "project") + return loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" }) } export async function discoverOpencodeGlobalSkills(): Promise { const configDir = getOpenCodeConfigDir({ binary: "opencode" }) const opencodeSkillsDir = join(configDir, "skills") - return loadSkillsFromDir(opencodeSkillsDir, "opencode") + return loadSkillsFromDir({ skillsDir: opencodeSkillsDir, scope: "opencode" }) } export async function discoverOpencodeProjectSkills(): Promise { const opencodeProjectDir = join(process.cwd(), ".opencode", "skills") - return loadSkillsFromDir(opencodeProjectDir, "opencode-project") + return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" }) } diff --git a/src/features/opencode-skill-loader/merger.ts b/src/features/opencode-skill-loader/merger.ts index cace1a22a..c5598ca5d 100644 --- a/src/features/opencode-skill-loader/merger.ts +++ b/src/features/opencode-skill-loader/merger.ts @@ -1,192 +1,11 @@ -import type { LoadedSkill, SkillScope, SkillMetadata } from "./types" -import type { SkillsConfig, SkillDefinition } from "../../config/schema" +import type { LoadedSkill } from "./types" +import type { SkillsConfig } from "../../config/schema" import type { BuiltinSkill } from "../builtin-skills/types" -import type { CommandDefinition } from "../claude-code-command-loader/types" -import { readFileSync, existsSync } from "fs" -import { dirname, resolve, isAbsolute } from "path" -import { homedir } from "os" -import { parseFrontmatter } from "../../shared/frontmatter" -import { sanitizeModelField } from "../../shared/model-sanitizer" -import { deepMerge } from "../../shared/deep-merge" - -function parseAllowedToolsFromMetadata(allowedTools: string | string[] | undefined): string[] | undefined { - if (!allowedTools) return undefined - if (Array.isArray(allowedTools)) { - return allowedTools.map(t => t.trim()).filter(Boolean) - } - return allowedTools.split(/\s+/).filter(Boolean) -} - -const SCOPE_PRIORITY: Record = { - builtin: 1, - config: 2, - user: 3, - opencode: 4, - project: 5, - "opencode-project": 6, -} - -function builtinToLoaded(builtin: BuiltinSkill): LoadedSkill { - const definition: CommandDefinition = { - name: builtin.name, - description: `(opencode - Skill) ${builtin.description}`, - template: builtin.template, - model: builtin.model, - agent: builtin.agent, - subtask: builtin.subtask, - argumentHint: builtin.argumentHint, - } - - return { - name: builtin.name, - definition, - scope: "builtin", - license: builtin.license, - compatibility: builtin.compatibility, - metadata: builtin.metadata as Record | undefined, - allowedTools: builtin.allowedTools, - mcpConfig: builtin.mcpConfig, - } -} - -function resolveFilePath(from: string, configDir?: string): string { - let filePath = from - - if (filePath.startsWith("{file:") && filePath.endsWith("}")) { - filePath = filePath.slice(6, -1) - } - - if (filePath.startsWith("~/")) { - return resolve(homedir(), filePath.slice(2)) - } - - if (isAbsolute(filePath)) { - return filePath - } - - const baseDir = configDir || process.cwd() - return resolve(baseDir, filePath) -} - -function loadSkillFromFile(filePath: string): { template: string; metadata: SkillMetadata } | null { - try { - if (!existsSync(filePath)) return null - const content = readFileSync(filePath, "utf-8") - const { data, body } = parseFrontmatter(content) - return { template: body, metadata: data } - } catch { - return null - } -} - -function configEntryToLoaded( - name: string, - entry: SkillDefinition, - configDir?: string -): LoadedSkill | null { - let template = entry.template || "" - let fileMetadata: SkillMetadata = {} - - if (entry.from) { - const filePath = resolveFilePath(entry.from, configDir) - const loaded = loadSkillFromFile(filePath) - if (loaded) { - template = loaded.template - fileMetadata = loaded.metadata - } else { - return null - } - } - - if (!template && !entry.from) { - return null - } - - const description = entry.description || fileMetadata.description || "" - const resolvedPath = entry.from ? dirname(resolveFilePath(entry.from, configDir)) : configDir || process.cwd() - - const wrappedTemplate = ` -Base directory for this skill: ${resolvedPath}/ -File references (@path) in this skill are relative to this directory. - -${template.trim()} - - - -$ARGUMENTS -` - - const definition: CommandDefinition = { - name, - description: `(config - Skill) ${description}`, - template: wrappedTemplate, - model: sanitizeModelField(entry.model || fileMetadata.model, "opencode"), - agent: entry.agent || fileMetadata.agent, - subtask: entry.subtask ?? fileMetadata.subtask, - argumentHint: entry["argument-hint"] || fileMetadata["argument-hint"], - } - - const allowedTools = entry["allowed-tools"] || - (fileMetadata["allowed-tools"] ? parseAllowedToolsFromMetadata(fileMetadata["allowed-tools"]) : undefined) - - return { - name, - path: entry.from ? resolveFilePath(entry.from, configDir) : undefined, - resolvedPath, - definition, - scope: "config", - license: entry.license || fileMetadata.license, - compatibility: entry.compatibility || fileMetadata.compatibility, - metadata: entry.metadata as Record | undefined || fileMetadata.metadata, - allowedTools, - } -} - -function normalizeConfig(config: SkillsConfig | undefined): { - sources: Array - enable: string[] - disable: string[] - entries: Record -} { - if (!config) { - return { sources: [], enable: [], disable: [], entries: {} } - } - - if (Array.isArray(config)) { - return { sources: [], enable: config, disable: [], entries: {} } - } - - const { sources = [], enable = [], disable = [], ...entries } = config - return { sources, enable, disable, entries } -} - -function mergeSkillDefinitions(base: LoadedSkill, patch: SkillDefinition): LoadedSkill { - const mergedMetadata = base.metadata || patch.metadata - ? deepMerge(base.metadata || {}, (patch.metadata as Record) || {}) - : undefined - - const mergedTools = base.allowedTools || patch["allowed-tools"] - ? [...(base.allowedTools || []), ...(patch["allowed-tools"] || [])] - : undefined - - const description = patch.description || base.definition.description?.replace(/^\([^)]+\) /, "") - - return { - ...base, - definition: { - ...base.definition, - description: `(${base.scope} - Skill) ${description}`, - model: patch.model || base.definition.model, - agent: patch.agent || base.definition.agent, - subtask: patch.subtask ?? base.definition.subtask, - argumentHint: patch["argument-hint"] || base.definition.argumentHint, - }, - license: patch.license || base.license, - compatibility: patch.compatibility || base.compatibility, - metadata: mergedMetadata as Record | undefined, - allowedTools: mergedTools ? [...new Set(mergedTools)] : undefined, - } -} +import { builtinToLoadedSkill } from "./merger/builtin-skill-converter" +import { configEntryToLoadedSkill } from "./merger/config-skill-entry-loader" +import { mergeSkillDefinitions } from "./merger/skill-definition-merger" +import { normalizeSkillsConfig } from "./merger/skills-config-normalizer" +import { SCOPE_PRIORITY } from "./merger/scope-priority" export interface MergeSkillsOptions { configDir?: string @@ -204,11 +23,11 @@ export function mergeSkills( const skillMap = new Map() for (const builtin of builtinSkills) { - const loaded = builtinToLoaded(builtin) + const loaded = builtinToLoadedSkill(builtin) skillMap.set(loaded.name, loaded) } - const normalizedConfig = normalizeConfig(config) + const normalizedConfig = normalizeSkillsConfig(config) for (const [name, entry] of Object.entries(normalizedConfig.entries)) { if (entry === false) continue @@ -216,7 +35,7 @@ export function mergeSkills( if (entry.disable) continue - const loaded = configEntryToLoaded(name, entry, options.configDir) + const loaded = configEntryToLoadedSkill(name, entry, options.configDir) if (loaded) { const existing = skillMap.get(name) if (existing && !entry.template && !entry.from) { diff --git a/src/features/opencode-skill-loader/merger/builtin-skill-converter.ts b/src/features/opencode-skill-loader/merger/builtin-skill-converter.ts new file mode 100644 index 000000000..da445c5d4 --- /dev/null +++ b/src/features/opencode-skill-loader/merger/builtin-skill-converter.ts @@ -0,0 +1,26 @@ +import type { BuiltinSkill } from "../../builtin-skills/types" +import type { CommandDefinition } from "../../claude-code-command-loader/types" +import type { LoadedSkill } from "../types" + +export function builtinToLoadedSkill(builtin: BuiltinSkill): LoadedSkill { + const definition: CommandDefinition = { + name: builtin.name, + description: `(opencode - Skill) ${builtin.description}`, + template: builtin.template, + model: builtin.model, + agent: builtin.agent, + subtask: builtin.subtask, + argumentHint: builtin.argumentHint, + } + + return { + name: builtin.name, + definition, + scope: "builtin", + license: builtin.license, + compatibility: builtin.compatibility, + metadata: builtin.metadata as Record | undefined, + allowedTools: builtin.allowedTools, + mcpConfig: builtin.mcpConfig, + } +} diff --git a/src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts b/src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts new file mode 100644 index 000000000..74d9772b8 --- /dev/null +++ b/src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts @@ -0,0 +1,103 @@ +import type { LoadedSkill, SkillMetadata } from "../types" +import type { SkillDefinition } from "../../../config/schema" +import type { CommandDefinition } from "../../claude-code-command-loader/types" +import { existsSync, readFileSync } from "fs" +import { dirname, isAbsolute, resolve } from "path" +import { homedir } from "os" +import { parseFrontmatter } from "../../../shared/frontmatter" +import { sanitizeModelField } from "../../../shared/model-sanitizer" +import { parseAllowedTools } from "../allowed-tools-parser" + +function resolveFilePath(from: string, configDir?: string): string { + let filePath = from + + if (filePath.startsWith("{file:") && filePath.endsWith("}")) { + filePath = filePath.slice(6, -1) + } + + if (filePath.startsWith("~/")) { + return resolve(homedir(), filePath.slice(2)) + } + + if (isAbsolute(filePath)) { + return filePath + } + + const baseDir = configDir || process.cwd() + return resolve(baseDir, filePath) +} + +function loadSkillFromFile(filePath: string): { template: string; metadata: SkillMetadata } | null { + try { + if (!existsSync(filePath)) return null + const content = readFileSync(filePath, "utf-8") + const { data, body } = parseFrontmatter(content) + return { template: body, metadata: data } + } catch { + return null + } +} + +export function configEntryToLoadedSkill( + name: string, + entry: SkillDefinition, + configDir?: string +): LoadedSkill | null { + let template = entry.template || "" + let fileMetadata: SkillMetadata = {} + + if (entry.from) { + const filePath = resolveFilePath(entry.from, configDir) + const loaded = loadSkillFromFile(filePath) + if (loaded) { + template = loaded.template + fileMetadata = loaded.metadata + } else { + return null + } + } + + if (!template && !entry.from) { + return null + } + + const description = entry.description || fileMetadata.description || "" + const resolvedPath = entry.from + ? dirname(resolveFilePath(entry.from, configDir)) + : configDir || process.cwd() + + const wrappedTemplate = ` +Base directory for this skill: ${resolvedPath}/ +File references (@path) in this skill are relative to this directory. + +${template.trim()} + + + +$ARGUMENTS +` + + const definition: CommandDefinition = { + name, + description: `(config - Skill) ${description}`, + template: wrappedTemplate, + model: sanitizeModelField(entry.model || fileMetadata.model, "opencode"), + agent: entry.agent || fileMetadata.agent, + subtask: entry.subtask ?? fileMetadata.subtask, + argumentHint: entry["argument-hint"] || fileMetadata["argument-hint"], + } + + const allowedTools = entry["allowed-tools"] || parseAllowedTools(fileMetadata["allowed-tools"]) + + return { + name, + path: entry.from ? resolveFilePath(entry.from, configDir) : undefined, + resolvedPath, + definition, + scope: "config", + license: entry.license || fileMetadata.license, + compatibility: entry.compatibility || fileMetadata.compatibility, + metadata: (entry.metadata as Record | undefined) || fileMetadata.metadata, + allowedTools, + } +} diff --git a/src/features/opencode-skill-loader/merger/scope-priority.ts b/src/features/opencode-skill-loader/merger/scope-priority.ts new file mode 100644 index 000000000..c665d700c --- /dev/null +++ b/src/features/opencode-skill-loader/merger/scope-priority.ts @@ -0,0 +1,10 @@ +import type { SkillScope } from "../types" + +export const SCOPE_PRIORITY: Record = { + builtin: 1, + config: 2, + user: 3, + opencode: 4, + project: 5, + "opencode-project": 6, +} diff --git a/src/features/opencode-skill-loader/merger/skill-definition-merger.ts b/src/features/opencode-skill-loader/merger/skill-definition-merger.ts new file mode 100644 index 000000000..691be1c3b --- /dev/null +++ b/src/features/opencode-skill-loader/merger/skill-definition-merger.ts @@ -0,0 +1,31 @@ +import type { LoadedSkill } from "../types" +import type { SkillDefinition } from "../../../config/schema" +import { deepMerge } from "../../../shared/deep-merge" + +export function mergeSkillDefinitions(base: LoadedSkill, patch: SkillDefinition): LoadedSkill { + const mergedMetadata = base.metadata || patch.metadata + ? deepMerge(base.metadata || {}, (patch.metadata as Record) || {}) + : undefined + + const mergedTools = base.allowedTools || patch["allowed-tools"] + ? [...(base.allowedTools || []), ...(patch["allowed-tools"] || [])] + : undefined + + const description = patch.description || base.definition.description?.replace(/^\([^)]+\) /, "") + + return { + ...base, + definition: { + ...base.definition, + description: `(${base.scope} - Skill) ${description}`, + model: patch.model || base.definition.model, + agent: patch.agent || base.definition.agent, + subtask: patch.subtask ?? base.definition.subtask, + argumentHint: patch["argument-hint"] || base.definition.argumentHint, + }, + license: patch.license || base.license, + compatibility: patch.compatibility || base.compatibility, + metadata: mergedMetadata as Record | undefined, + allowedTools: mergedTools ? [...new Set(mergedTools)] : undefined, + } +} diff --git a/src/features/opencode-skill-loader/merger/skills-config-normalizer.ts b/src/features/opencode-skill-loader/merger/skills-config-normalizer.ts new file mode 100644 index 000000000..94043521b --- /dev/null +++ b/src/features/opencode-skill-loader/merger/skills-config-normalizer.ts @@ -0,0 +1,19 @@ +import type { SkillsConfig, SkillDefinition } from "../../../config/schema" + +export function normalizeSkillsConfig(config: SkillsConfig | undefined): { + sources: Array + enable: string[] + disable: string[] + entries: Record +} { + if (!config) { + return { sources: [], enable: [], disable: [], entries: {} } + } + + if (Array.isArray(config)) { + return { sources: [], enable: config, disable: [], entries: {} } + } + + const { sources = [], enable = [], disable = [], ...entries } = config + return { sources, enable, disable, entries } +} diff --git a/src/features/opencode-skill-loader/skill-content.ts b/src/features/opencode-skill-loader/skill-content.ts index 5441e10a6..27a8e0f0b 100644 --- a/src/features/opencode-skill-loader/skill-content.ts +++ b/src/features/opencode-skill-loader/skill-content.ts @@ -1,250 +1,11 @@ -import { createBuiltinSkills } from "../builtin-skills/skills" -import { discoverSkills } from "./loader" -import type { LoadedSkill } from "./types" -import { parseFrontmatter } from "../../shared/frontmatter" -import { readFileSync } from "node:fs" -import type { GitMasterConfig, BrowserAutomationProvider } from "../../config/schema" +export type { SkillResolutionOptions } from "./skill-resolution-options" -export interface SkillResolutionOptions { - gitMasterConfig?: GitMasterConfig - browserProvider?: BrowserAutomationProvider - disabledSkills?: Set -} - -const cachedSkillsByProvider = new Map() - -function clearSkillCache(): void { - cachedSkillsByProvider.clear() -} - -async function getAllSkills(options?: SkillResolutionOptions): Promise { - const cacheKey = options?.browserProvider ?? "playwright" - const hasDisabledSkills = options?.disabledSkills && options.disabledSkills.size > 0 - - // Skip cache if disabledSkills is provided (varies between calls) - if (!hasDisabledSkills) { - const cached = cachedSkillsByProvider.get(cacheKey) - if (cached) return cached - } - - const [discoveredSkills, builtinSkillDefs] = await Promise.all([ - discoverSkills({ includeClaudeCodePaths: true }), - Promise.resolve( - createBuiltinSkills({ - browserProvider: options?.browserProvider, - disabledSkills: options?.disabledSkills, - }) - ), - ]) - - const builtinSkillsAsLoaded: LoadedSkill[] = builtinSkillDefs.map((skill) => ({ - name: skill.name, - definition: { - name: skill.name, - description: skill.description, - template: skill.template, - model: skill.model, - agent: skill.agent, - subtask: skill.subtask, - }, - scope: "builtin" as const, - license: skill.license, - compatibility: skill.compatibility, - metadata: skill.metadata as Record | undefined, - allowedTools: skill.allowedTools, - mcpConfig: skill.mcpConfig, - })) - - // Provider-gated skill names that should be filtered based on browserProvider - const providerGatedSkillNames = new Set(["agent-browser", "playwright"]) - const browserProvider = options?.browserProvider ?? "playwright" - - // Filter discovered skills to exclude provider-gated names that don't match the selected provider - const filteredDiscoveredSkills = discoveredSkills.filter((skill) => { - if (!providerGatedSkillNames.has(skill.name)) { - return true - } - // For provider-gated skills, only include if it matches the selected provider - return skill.name === browserProvider - }) - - const discoveredNames = new Set(filteredDiscoveredSkills.map((s) => s.name)) - const uniqueBuiltins = builtinSkillsAsLoaded.filter((s) => !discoveredNames.has(s.name)) - - let allSkills = [...filteredDiscoveredSkills, ...uniqueBuiltins] - - // Filter discovered skills by disabledSkills (builtin skills are already filtered by createBuiltinSkills) - if (hasDisabledSkills) { - allSkills = allSkills.filter((s) => !options!.disabledSkills!.has(s.name)) - } else { - cachedSkillsByProvider.set(cacheKey, allSkills) - } - - return allSkills -} - -async function extractSkillTemplate(skill: LoadedSkill): Promise { - if (skill.path) { - const content = readFileSync(skill.path, "utf-8") - const { body } = parseFrontmatter(content) - return body.trim() - } - return skill.definition.template || "" -} - -export { clearSkillCache, getAllSkills, extractSkillTemplate } - -export function injectGitMasterConfig(template: string, config?: GitMasterConfig): string { - const commitFooter = config?.commit_footer ?? true - const includeCoAuthoredBy = config?.include_co_authored_by ?? true - - if (!commitFooter && !includeCoAuthoredBy) { - return template - } - - const sections: string[] = [] - - sections.push(`### 5.5 Commit Footer & Co-Author`) - sections.push(``) - sections.push(`Add Sisyphus attribution to EVERY commit:`) - sections.push(``) - - if (commitFooter) { - const footerText = typeof commitFooter === "string" ? commitFooter : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" - sections.push(`1. **Footer in commit body:**`) - sections.push("```") - sections.push(footerText) - sections.push("```") - sections.push(``) - } - - if (includeCoAuthoredBy) { - sections.push(`${commitFooter ? "2" : "1"}. **Co-authored-by trailer:**`) - sections.push("```") - sections.push(`Co-authored-by: Sisyphus `) - sections.push("```") - sections.push(``) - } - - if (commitFooter && includeCoAuthoredBy) { - const footerText = typeof commitFooter === "string" ? commitFooter : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" - sections.push(`**Example (both enabled):**`) - sections.push("```bash") - sections.push(`git commit -m "{Commit Message}" -m "${footerText}" -m "Co-authored-by: Sisyphus "`) - sections.push("```") - } else if (commitFooter) { - const footerText = typeof commitFooter === "string" ? commitFooter : "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" - sections.push(`**Example:**`) - sections.push("```bash") - sections.push(`git commit -m "{Commit Message}" -m "${footerText}"`) - sections.push("```") - } else if (includeCoAuthoredBy) { - sections.push(`**Example:**`) - sections.push("```bash") - sections.push(`git commit -m "{Commit Message}" -m "Co-authored-by: Sisyphus "`) - sections.push("```") - } - - const injection = sections.join("\n") - - const insertionPoint = template.indexOf("```\n") - if (insertionPoint !== -1) { - return template.slice(0, insertionPoint) + "```\n\n" + injection + "\n" + template.slice(insertionPoint + "```\n".length) - } - - return template + "\n\n" + injection -} - -export function resolveSkillContent(skillName: string, options?: SkillResolutionOptions): string | null { - const skills = createBuiltinSkills({ - browserProvider: options?.browserProvider, - disabledSkills: options?.disabledSkills, - }) - const skill = skills.find((s) => s.name === skillName) - if (!skill) return null - - if (skillName === "git-master") { - return injectGitMasterConfig(skill.template, options?.gitMasterConfig) - } - - return skill.template -} - -export function resolveMultipleSkills(skillNames: string[], options?: SkillResolutionOptions): { - resolved: Map - notFound: string[] -} { - const skills = createBuiltinSkills({ - browserProvider: options?.browserProvider, - disabledSkills: options?.disabledSkills, - }) - const skillMap = new Map(skills.map((s) => [s.name, s.template])) - - const resolved = new Map() - const notFound: string[] = [] - - for (const name of skillNames) { - const template = skillMap.get(name) - if (template) { - if (name === "git-master") { - resolved.set(name, injectGitMasterConfig(template, options?.gitMasterConfig)) - } else { - resolved.set(name, template) - } - } else { - notFound.push(name) - } - } - - return { resolved, notFound } -} - -export async function resolveSkillContentAsync( - skillName: string, - options?: SkillResolutionOptions -): Promise { - const allSkills = await getAllSkills(options) - const skill = allSkills.find((s) => s.name === skillName) - if (!skill) return null - - const template = await extractSkillTemplate(skill) - - if (skillName === "git-master") { - return injectGitMasterConfig(template, options?.gitMasterConfig) - } - - return template -} - -export async function resolveMultipleSkillsAsync( - skillNames: string[], - options?: SkillResolutionOptions -): Promise<{ - resolved: Map - notFound: string[] -}> { - const allSkills = await getAllSkills(options) - const skillMap = new Map() - for (const skill of allSkills) { - skillMap.set(skill.name, skill) - } - - const resolved = new Map() - const notFound: string[] = [] - - for (const name of skillNames) { - const skill = skillMap.get(name) - if (skill) { - const template = await extractSkillTemplate(skill) - if (name === "git-master") { - resolved.set(name, injectGitMasterConfig(template, options?.gitMasterConfig)) - } else { - resolved.set(name, template) - } - } else { - notFound.push(name) - } - } - - return { resolved, notFound } -} +export { clearSkillCache, getAllSkills } from "./skill-discovery" +export { extractSkillTemplate } from "./loaded-skill-template-extractor" +export { injectGitMasterConfig } from "./git-master-template-injection" +export { + resolveSkillContent, + resolveMultipleSkills, + resolveSkillContentAsync, + resolveMultipleSkillsAsync, +} from "./skill-template-resolver" diff --git a/src/features/opencode-skill-loader/skill-deduplication.ts b/src/features/opencode-skill-loader/skill-deduplication.ts new file mode 100644 index 000000000..1c3c6a405 --- /dev/null +++ b/src/features/opencode-skill-loader/skill-deduplication.ts @@ -0,0 +1,13 @@ +import type { LoadedSkill } from "./types" + +export function deduplicateSkillsByName(skills: LoadedSkill[]): LoadedSkill[] { + const seen = new Set() + const result: LoadedSkill[] = [] + for (const skill of skills) { + if (!seen.has(skill.name)) { + seen.add(skill.name) + result.push(skill) + } + } + return result +} diff --git a/src/features/opencode-skill-loader/skill-definition-record.ts b/src/features/opencode-skill-loader/skill-definition-record.ts new file mode 100644 index 000000000..71b811c2a --- /dev/null +++ b/src/features/opencode-skill-loader/skill-definition-record.ts @@ -0,0 +1,11 @@ +import type { CommandDefinition } from "../claude-code-command-loader/types" +import type { LoadedSkill } from "./types" + +export function skillsToCommandDefinitionRecord(skills: LoadedSkill[]): Record { + const result: Record = {} + for (const skill of skills) { + const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = skill.definition + result[skill.name] = openCodeCompatible as CommandDefinition + } + return result +} diff --git a/src/features/opencode-skill-loader/skill-directory-loader.ts b/src/features/opencode-skill-loader/skill-directory-loader.ts new file mode 100644 index 000000000..13c859d6b --- /dev/null +++ b/src/features/opencode-skill-loader/skill-directory-loader.ts @@ -0,0 +1,106 @@ +import { promises as fs } from "fs" +import { join } from "path" +import { resolveSymlinkAsync, isMarkdownFile } from "../../shared/file-utils" +import type { LoadedSkill, SkillScope } from "./types" +import { inferSkillNameFromFileName, loadSkillFromPath } from "./loaded-skill-from-path" + +export async function loadSkillsFromDir(options: { + skillsDir: string + scope: SkillScope + namePrefix?: string + depth?: number + maxDepth?: number +}): Promise { + const namePrefix = options.namePrefix ?? "" + const depth = options.depth ?? 0 + const maxDepth = options.maxDepth ?? 2 + + const entries = await fs.readdir(options.skillsDir, { withFileTypes: true }).catch(() => []) + const skillMap = new Map() + + const directories = entries.filter( + (entry) => !entry.name.startsWith(".") && (entry.isDirectory() || entry.isSymbolicLink()) + ) + const files = entries.filter( + (entry) => + !entry.name.startsWith(".") && + !entry.isDirectory() && + !entry.isSymbolicLink() && + isMarkdownFile(entry) + ) + + for (const entry of directories) { + const entryPath = join(options.skillsDir, entry.name) + const resolvedPath = await resolveSymlinkAsync(entryPath) + const dirName = entry.name + + const skillMdPath = join(resolvedPath, "SKILL.md") + try { + await fs.access(skillMdPath) + const skill = await loadSkillFromPath({ + skillPath: skillMdPath, + resolvedPath, + defaultName: dirName, + scope: options.scope, + namePrefix, + }) + if (skill && !skillMap.has(skill.name)) { + skillMap.set(skill.name, skill) + } + continue + } catch { + // no SKILL.md + } + + const namedSkillMdPath = join(resolvedPath, `${dirName}.md`) + try { + await fs.access(namedSkillMdPath) + const skill = await loadSkillFromPath({ + skillPath: namedSkillMdPath, + resolvedPath, + defaultName: dirName, + scope: options.scope, + namePrefix, + }) + if (skill && !skillMap.has(skill.name)) { + skillMap.set(skill.name, skill) + } + continue + } catch { + // no named md + } + + if (depth < maxDepth) { + const newPrefix = namePrefix ? `${namePrefix}/${dirName}` : dirName + const nestedSkills = await loadSkillsFromDir({ + skillsDir: resolvedPath, + scope: options.scope, + namePrefix: newPrefix, + depth: depth + 1, + maxDepth, + }) + for (const nestedSkill of nestedSkills) { + if (!skillMap.has(nestedSkill.name)) { + skillMap.set(nestedSkill.name, nestedSkill) + } + } + } + } + + for (const entry of files) { + const entryPath = join(options.skillsDir, entry.name) + const baseName = inferSkillNameFromFileName(entryPath) + const skill = await loadSkillFromPath({ + skillPath: entryPath, + resolvedPath: options.skillsDir, + defaultName: baseName, + scope: options.scope, + namePrefix, + }) + if (skill && !skillMap.has(skill.name)) { + skillMap.set(skill.name, skill) + } + } + + return Array.from(skillMap.values()) +} diff --git a/src/features/opencode-skill-loader/skill-discovery.ts b/src/features/opencode-skill-loader/skill-discovery.ts new file mode 100644 index 000000000..2154b06ea --- /dev/null +++ b/src/features/opencode-skill-loader/skill-discovery.ts @@ -0,0 +1,76 @@ +import { createBuiltinSkills } from "../builtin-skills/skills" +import { discoverSkills } from "./loader" +import type { LoadedSkill } from "./types" +import type { SkillResolutionOptions } from "./skill-resolution-options" + +const cachedSkillsByProvider = new Map() + +export function clearSkillCache(): void { + cachedSkillsByProvider.clear() +} + +export async function getAllSkills(options?: SkillResolutionOptions): Promise { + const cacheKey = options?.browserProvider ?? "playwright" + const hasDisabledSkills = options?.disabledSkills && options.disabledSkills.size > 0 + + // Skip cache if disabledSkills is provided (varies between calls) + if (!hasDisabledSkills) { + const cached = cachedSkillsByProvider.get(cacheKey) + if (cached) return cached + } + + const [discoveredSkills, builtinSkillDefinitions] = await Promise.all([ + discoverSkills({ includeClaudeCodePaths: true }), + Promise.resolve( + createBuiltinSkills({ + browserProvider: options?.browserProvider, + disabledSkills: options?.disabledSkills, + }) + ), + ]) + + const builtinSkillsAsLoaded: LoadedSkill[] = builtinSkillDefinitions.map((skill) => ({ + name: skill.name, + definition: { + name: skill.name, + description: skill.description, + template: skill.template, + model: skill.model, + agent: skill.agent, + subtask: skill.subtask, + }, + scope: "builtin" as const, + license: skill.license, + compatibility: skill.compatibility, + metadata: skill.metadata as Record | undefined, + allowedTools: skill.allowedTools, + mcpConfig: skill.mcpConfig, + })) + + // Provider-gated skill names that should be filtered based on browserProvider + const providerGatedSkillNames = new Set(["agent-browser", "playwright"]) + const browserProvider = options?.browserProvider ?? "playwright" + + // Filter discovered skills to exclude provider-gated names that don't match the selected provider + const filteredDiscoveredSkills = discoveredSkills.filter((skill) => { + if (!providerGatedSkillNames.has(skill.name)) { + return true + } + // For provider-gated skills, only include if it matches the selected provider + return skill.name === browserProvider + }) + + const discoveredNames = new Set(filteredDiscoveredSkills.map((skill) => skill.name)) + const uniqueBuiltins = builtinSkillsAsLoaded.filter((skill) => !discoveredNames.has(skill.name)) + + let allSkills = [...filteredDiscoveredSkills, ...uniqueBuiltins] + + // Filter discovered skills by disabledSkills (builtin skills are already filtered by createBuiltinSkills) + if (hasDisabledSkills) { + allSkills = allSkills.filter((skill) => !options!.disabledSkills!.has(skill.name)) + } else { + cachedSkillsByProvider.set(cacheKey, allSkills) + } + + return allSkills +} diff --git a/src/features/opencode-skill-loader/skill-mcp-config.ts b/src/features/opencode-skill-loader/skill-mcp-config.ts new file mode 100644 index 000000000..211940f46 --- /dev/null +++ b/src/features/opencode-skill-loader/skill-mcp-config.ts @@ -0,0 +1,45 @@ +import { promises as fs } from "fs" +import { join } from "path" +import yaml from "js-yaml" +import type { SkillMcpConfig } from "../skill-mcp-manager/types" + +export function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined { + const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!frontmatterMatch) return undefined + + try { + const parsed = yaml.load(frontmatterMatch[1]) as Record + if (parsed && typeof parsed === "object" && "mcp" in parsed && parsed.mcp) { + return parsed.mcp as SkillMcpConfig + } + } catch { + return undefined + } + return undefined +} + +export async function loadMcpJsonFromDir(skillDir: string): Promise { + const mcpJsonPath = join(skillDir, "mcp.json") + + try { + const content = await fs.readFile(mcpJsonPath, "utf-8") + const parsed = JSON.parse(content) as Record + + if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) { + return parsed.mcpServers as SkillMcpConfig + } + + if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) { + const hasCommandField = Object.values(parsed).some( + (value) => value && typeof value === "object" && "command" in (value as Record) + ) + if (hasCommandField) { + return parsed as SkillMcpConfig + } + } + } catch { + return undefined + } + + return undefined +} diff --git a/src/features/opencode-skill-loader/skill-resolution-options.ts b/src/features/opencode-skill-loader/skill-resolution-options.ts new file mode 100644 index 000000000..3955aa5ff --- /dev/null +++ b/src/features/opencode-skill-loader/skill-resolution-options.ts @@ -0,0 +1,7 @@ +import type { BrowserAutomationProvider, GitMasterConfig } from "../../config/schema" + +export interface SkillResolutionOptions { + gitMasterConfig?: GitMasterConfig + browserProvider?: BrowserAutomationProvider + disabledSkills?: Set +} diff --git a/src/features/opencode-skill-loader/skill-template-resolver.ts b/src/features/opencode-skill-loader/skill-template-resolver.ts new file mode 100644 index 000000000..046256c37 --- /dev/null +++ b/src/features/opencode-skill-loader/skill-template-resolver.ts @@ -0,0 +1,97 @@ +import { createBuiltinSkills } from "../builtin-skills/skills" +import type { LoadedSkill } from "./types" +import type { SkillResolutionOptions } from "./skill-resolution-options" +import { injectGitMasterConfig } from "./git-master-template-injection" +import { getAllSkills } from "./skill-discovery" +import { extractSkillTemplate } from "./loaded-skill-template-extractor" + +export function resolveSkillContent(skillName: string, options?: SkillResolutionOptions): string | null { + const skills = createBuiltinSkills({ + browserProvider: options?.browserProvider, + disabledSkills: options?.disabledSkills, + }) + const skill = skills.find((builtinSkill) => builtinSkill.name === skillName) + if (!skill) return null + + if (skillName === "git-master") { + return injectGitMasterConfig(skill.template, options?.gitMasterConfig) + } + + return skill.template +} + +export function resolveMultipleSkills( + skillNames: string[], + options?: SkillResolutionOptions +): { resolved: Map; notFound: string[] } { + const skills = createBuiltinSkills({ + browserProvider: options?.browserProvider, + disabledSkills: options?.disabledSkills, + }) + const skillMap = new Map(skills.map((skill) => [skill.name, skill.template])) + + const resolved = new Map() + const notFound: string[] = [] + + for (const name of skillNames) { + const template = skillMap.get(name) + if (template) { + if (name === "git-master") { + resolved.set(name, injectGitMasterConfig(template, options?.gitMasterConfig)) + } else { + resolved.set(name, template) + } + } else { + notFound.push(name) + } + } + + return { resolved, notFound } +} + +export async function resolveSkillContentAsync( + skillName: string, + options?: SkillResolutionOptions +): Promise { + const allSkills = await getAllSkills(options) + const skill = allSkills.find((loadedSkill) => loadedSkill.name === skillName) + if (!skill) return null + + const template = await extractSkillTemplate(skill) + + if (skillName === "git-master") { + return injectGitMasterConfig(template, options?.gitMasterConfig) + } + + return template +} + +export async function resolveMultipleSkillsAsync( + skillNames: string[], + options?: SkillResolutionOptions +): Promise<{ resolved: Map; notFound: string[] }> { + const allSkills = await getAllSkills(options) + const skillMap = new Map() + for (const skill of allSkills) { + skillMap.set(skill.name, skill) + } + + const resolved = new Map() + const notFound: string[] = [] + + for (const name of skillNames) { + const skill = skillMap.get(name) + if (skill) { + const template = await extractSkillTemplate(skill) + if (name === "git-master") { + resolved.set(name, injectGitMasterConfig(template, options?.gitMasterConfig)) + } else { + resolved.set(name, template) + } + } else { + notFound.push(name) + } + } + + return { resolved, notFound } +} From 46969935cd01adf6662d9fb827fea71b23685f27 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:21:28 +0900 Subject: [PATCH 07/51] refactor(skill-mcp-manager): split manager.ts into connection and client modules Extract MCP client lifecycle management: - connection.ts: getOrCreateClientWithRetry logic - stdio-client.ts, http-client.ts: transport-specific creation - oauth-handler.ts: OAuth token management - cleanup.ts: session and global cleanup - connection-type.ts: connection type detection --- src/features/skill-mcp-manager/cleanup.ts | 129 ++++ .../skill-mcp-manager/connection-type.ts | 26 + src/features/skill-mcp-manager/connection.ts | 95 +++ src/features/skill-mcp-manager/http-client.ts | 68 ++ src/features/skill-mcp-manager/manager.ts | 592 ++---------------- .../skill-mcp-manager/oauth-handler.ts | 100 +++ .../skill-mcp-manager/stdio-client.ts | 69 ++ src/features/skill-mcp-manager/types.ts | 52 ++ 8 files changed, 590 insertions(+), 541 deletions(-) create mode 100644 src/features/skill-mcp-manager/cleanup.ts create mode 100644 src/features/skill-mcp-manager/connection-type.ts create mode 100644 src/features/skill-mcp-manager/connection.ts create mode 100644 src/features/skill-mcp-manager/http-client.ts create mode 100644 src/features/skill-mcp-manager/oauth-handler.ts create mode 100644 src/features/skill-mcp-manager/stdio-client.ts diff --git a/src/features/skill-mcp-manager/cleanup.ts b/src/features/skill-mcp-manager/cleanup.ts new file mode 100644 index 000000000..805c8c506 --- /dev/null +++ b/src/features/skill-mcp-manager/cleanup.ts @@ -0,0 +1,129 @@ +import type { ManagedClient, SkillMcpManagerState } from "./types" + +async function closeManagedClient(managed: ManagedClient): Promise { + try { + await managed.client.close() + } catch { + // Ignore close errors - process may already be terminated + } + + try { + await managed.transport.close() + } catch { + // Transport may already be terminated + } +} + +export function registerProcessCleanup(state: SkillMcpManagerState): void { + if (state.cleanupRegistered) return + state.cleanupRegistered = true + + const cleanup = async (): Promise => { + for (const managed of state.clients.values()) { + await closeManagedClient(managed) + } + state.clients.clear() + state.pendingConnections.clear() + } + + // Note: Node's 'exit' event is synchronous-only, so we rely on signal handlers for async cleanup. + // Signal handlers invoke the async cleanup function and ignore errors so they don't block or throw. + // Don't call process.exit() here - let the background-agent manager handle the final process exit. + // Use void + catch to trigger async cleanup without awaiting it in the signal handler. + + const register = (signal: NodeJS.Signals) => { + const listener = () => void cleanup().catch(() => {}) + state.cleanupHandlers.push({ signal, listener }) + process.on(signal, listener) + } + + register("SIGINT") + register("SIGTERM") + if (process.platform === "win32") { + register("SIGBREAK") + } +} + +export function unregisterProcessCleanup(state: SkillMcpManagerState): void { + if (!state.cleanupRegistered) return + for (const { signal, listener } of state.cleanupHandlers) { + process.off(signal, listener) + } + state.cleanupHandlers = [] + state.cleanupRegistered = false +} + +export function startCleanupTimer(state: SkillMcpManagerState): void { + if (state.cleanupInterval) return + + state.cleanupInterval = setInterval(() => { + void cleanupIdleClients(state).catch(() => {}) + }, 60_000) + + state.cleanupInterval.unref() +} + +export function stopCleanupTimer(state: SkillMcpManagerState): void { + if (!state.cleanupInterval) return + clearInterval(state.cleanupInterval) + state.cleanupInterval = null +} + +async function cleanupIdleClients(state: SkillMcpManagerState): Promise { + const now = Date.now() + + for (const [key, managed] of state.clients) { + if (now - managed.lastUsedAt > state.idleTimeoutMs) { + state.clients.delete(key) + await closeManagedClient(managed) + } + } + + if (state.clients.size === 0) { + stopCleanupTimer(state) + } +} + +export async function disconnectSession(state: SkillMcpManagerState, sessionID: string): Promise { + const keysToRemove: string[] = [] + + for (const [key, managed] of state.clients.entries()) { + if (key.startsWith(`${sessionID}:`)) { + keysToRemove.push(key) + // Delete from map first to prevent re-entrancy during async close + state.clients.delete(key) + await closeManagedClient(managed) + } + } + + for (const key of keysToRemove) { + state.pendingConnections.delete(key) + } + + if (state.clients.size === 0) { + stopCleanupTimer(state) + } +} + +export async function disconnectAll(state: SkillMcpManagerState): Promise { + stopCleanupTimer(state) + unregisterProcessCleanup(state) + + const clients = Array.from(state.clients.values()) + state.clients.clear() + state.pendingConnections.clear() + state.authProviders.clear() + + for (const managed of clients) { + await closeManagedClient(managed) + } +} + +export async function forceReconnect(state: SkillMcpManagerState, clientKey: string): Promise { + const existing = state.clients.get(clientKey) + if (!existing) return false + + state.clients.delete(clientKey) + await closeManagedClient(existing) + return true +} diff --git a/src/features/skill-mcp-manager/connection-type.ts b/src/features/skill-mcp-manager/connection-type.ts new file mode 100644 index 000000000..64e1b59d8 --- /dev/null +++ b/src/features/skill-mcp-manager/connection-type.ts @@ -0,0 +1,26 @@ +import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" +import type { ConnectionType } from "./types" + +/** + * Determines connection type from MCP server configuration. + * Priority: explicit type field > url presence > command presence + */ +export function getConnectionType(config: ClaudeCodeMcpServer): ConnectionType | null { + // Explicit type takes priority + if (config.type === "http" || config.type === "sse") { + return "http" + } + if (config.type === "stdio") { + return "stdio" + } + + // Infer from available fields + if (config.url) { + return "http" + } + if (config.command) { + return "stdio" + } + + return null +} diff --git a/src/features/skill-mcp-manager/connection.ts b/src/features/skill-mcp-manager/connection.ts new file mode 100644 index 000000000..1a3559d06 --- /dev/null +++ b/src/features/skill-mcp-manager/connection.ts @@ -0,0 +1,95 @@ +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" +import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander" +import { forceReconnect } from "./cleanup" +import { getConnectionType } from "./connection-type" +import { createHttpClient } from "./http-client" +import { createStdioClient } from "./stdio-client" +import type { SkillMcpClientConnectionParams, SkillMcpClientInfo, SkillMcpManagerState } from "./types" + +export async function getOrCreateClient(params: { + state: SkillMcpManagerState + clientKey: string + info: SkillMcpClientInfo + config: ClaudeCodeMcpServer +}): Promise { + const { state, clientKey, info, config } = params + + const existing = state.clients.get(clientKey) + if (existing) { + existing.lastUsedAt = Date.now() + return existing.client + } + + // Prevent race condition: if a connection is already in progress, wait for it + const pending = state.pendingConnections.get(clientKey) + if (pending) { + return pending + } + + const expandedConfig = expandEnvVarsInObject(config) + const connectionPromise = createClient({ state, clientKey, info, config: expandedConfig }) + state.pendingConnections.set(clientKey, connectionPromise) + + try { + const client = await connectionPromise + return client + } finally { + state.pendingConnections.delete(clientKey) + } +} + +export async function getOrCreateClientWithRetryImpl(params: { + state: SkillMcpManagerState + clientKey: string + info: SkillMcpClientInfo + config: ClaudeCodeMcpServer +}): Promise { + const { state, clientKey } = params + + try { + return await getOrCreateClient(params) + } catch (error) { + const didReconnect = await forceReconnect(state, clientKey) + if (!didReconnect) { + throw error + } + return await getOrCreateClient(params) + } +} + +async function createClient(params: { + state: SkillMcpManagerState + clientKey: string + info: SkillMcpClientInfo + config: ClaudeCodeMcpServer +}): Promise { + const { info, config } = params + const connectionType = getConnectionType(config) + + if (!connectionType) { + throw new Error( + `MCP server "${info.serverName}" has no valid connection configuration.\n\n` + + `The MCP configuration in skill "${info.skillName}" must specify either:\n` + + ` - A URL for HTTP connection (remote MCP server)\n` + + ` - A command for stdio connection (local MCP process)\n\n` + + `Examples:\n` + + ` HTTP:\n` + + ` mcp:\n` + + ` ${info.serverName}:\n` + + ` url: https://mcp.example.com/mcp\n` + + ` headers:\n` + + " Authorization: Bearer ${API_KEY}\n\n" + + ` Stdio:\n` + + ` mcp:\n` + + ` ${info.serverName}:\n` + + ` command: npx\n` + + ` args: [-y, @some/mcp-server]` + ) + } + + if (connectionType === "http") { + return await createHttpClient(params satisfies SkillMcpClientConnectionParams) + } + return await createStdioClient(params satisfies SkillMcpClientConnectionParams) +} diff --git a/src/features/skill-mcp-manager/http-client.ts b/src/features/skill-mcp-manager/http-client.ts new file mode 100644 index 000000000..308e9a6ba --- /dev/null +++ b/src/features/skill-mcp-manager/http-client.ts @@ -0,0 +1,68 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import { registerProcessCleanup, startCleanupTimer } from "./cleanup" +import { buildHttpRequestInit } from "./oauth-handler" +import type { ManagedClient, SkillMcpClientConnectionParams } from "./types" + +export async function createHttpClient(params: SkillMcpClientConnectionParams): Promise { + const { state, clientKey, info, config } = params + + if (!config.url) { + throw new Error(`MCP server "${info.serverName}" is configured for HTTP but missing 'url' field.`) + } + + let url: URL + try { + url = new URL(config.url) + } catch { + throw new Error( + `MCP server "${info.serverName}" has invalid URL: ${config.url}\n\n` + + `Expected a valid URL like: https://mcp.example.com/mcp` + ) + } + + registerProcessCleanup(state) + + const requestInit = await buildHttpRequestInit(config, state.authProviders) + const transport = new StreamableHTTPClientTransport(url, { + requestInit, + }) + + const client = new Client( + { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" }, + { capabilities: {} } + ) + + try { + await client.connect(transport) + } catch (error) { + try { + await transport.close() + } catch { + // Transport may already be closed + } + + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to connect to MCP server "${info.serverName}".\n\n` + + `URL: ${config.url}\n` + + `Reason: ${errorMessage}\n\n` + + `Hints:\n` + + ` - Verify the URL is correct and the server is running\n` + + ` - Check if authentication headers are required\n` + + ` - Ensure the server supports MCP over HTTP` + ) + } + + const managedClient = { + client, + transport, + skillName: info.skillName, + lastUsedAt: Date.now(), + connectionType: "http", + } satisfies ManagedClient + + state.clients.set(clientKey, managedClient) + startCleanupTimer(state) + return client +} diff --git a/src/features/skill-mcp-manager/manager.ts b/src/features/skill-mcp-manager/manager.ts index 43cb3dd89..71d3cf781 100644 --- a/src/features/skill-mcp-manager/manager.ts +++ b/src/features/skill-mcp-manager/manager.ts @@ -1,480 +1,57 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" -import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js" +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import type { Prompt, Resource, Tool } from "@modelcontextprotocol/sdk/types.js" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" -import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander" -import { McpOAuthProvider } from "../mcp-oauth/provider" -import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up" -import { createCleanMcpEnvironment } from "./env-cleaner" -import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types" - -/** - * Connection type for a managed MCP client. - * - "stdio": Local process via stdin/stdout - * - "http": Remote server via HTTP (Streamable HTTP transport) - */ -type ConnectionType = "stdio" | "http" - -interface ManagedClientBase { - client: Client - skillName: string - lastUsedAt: number - connectionType: ConnectionType -} - -interface ManagedStdioClient extends ManagedClientBase { - connectionType: "stdio" - transport: StdioClientTransport -} - -interface ManagedHttpClient extends ManagedClientBase { - connectionType: "http" - transport: StreamableHTTPClientTransport -} - -type ManagedClient = ManagedStdioClient | ManagedHttpClient - -/** - * Determines connection type from MCP server configuration. - * Priority: explicit type field > url presence > command presence - */ -function getConnectionType(config: ClaudeCodeMcpServer): ConnectionType | null { - // Explicit type takes priority - if (config.type === "http" || config.type === "sse") { - return "http" - } - if (config.type === "stdio") { - return "stdio" - } - - // Infer from available fields - if (config.url) { - return "http" - } - if (config.command) { - return "stdio" - } - - return null -} +import { disconnectAll, disconnectSession, forceReconnect } from "./cleanup" +import { getOrCreateClient, getOrCreateClientWithRetryImpl } from "./connection" +import { handleStepUpIfNeeded } from "./oauth-handler" +import type { SkillMcpClientInfo, SkillMcpManagerState, SkillMcpServerContext } from "./types" export class SkillMcpManager { - private clients: Map = new Map() - private pendingConnections: Map> = new Map() - private authProviders: Map = new Map() - private cleanupRegistered = false - private cleanupInterval: ReturnType | null = null - private cleanupHandlers: Array<{ signal: NodeJS.Signals; listener: () => void }> = [] - private readonly IDLE_TIMEOUT = 5 * 60 * 1000 + private readonly state: SkillMcpManagerState = { + clients: new Map(), + pendingConnections: new Map(), + authProviders: new Map(), + cleanupRegistered: false, + cleanupInterval: null, + cleanupHandlers: [], + idleTimeoutMs: 5 * 60 * 1000, + } private getClientKey(info: SkillMcpClientInfo): string { return `${info.sessionID}:${info.skillName}:${info.serverName}` } - /** - * Get or create an McpOAuthProvider for a given server URL + oauth config. - * Providers are cached by server URL to reuse tokens across reconnections. - */ - private getOrCreateAuthProvider( - serverUrl: string, - oauth: NonNullable - ): McpOAuthProvider { - const existing = this.authProviders.get(serverUrl) - if (existing) { - return existing - } - - const provider = new McpOAuthProvider({ - serverUrl, - clientId: oauth.clientId, - scopes: oauth.scopes, + async getOrCreateClient(info: SkillMcpClientInfo, config: ClaudeCodeMcpServer): Promise { + const clientKey = this.getClientKey(info) + return await getOrCreateClient({ + state: this.state, + clientKey, + info, + config, }) - this.authProviders.set(serverUrl, provider) - return provider - } - - private registerProcessCleanup(): void { - if (this.cleanupRegistered) return - this.cleanupRegistered = true - - const cleanup = async () => { - for (const [, managed] of this.clients) { - try { - await managed.client.close() - } catch { - // Ignore errors during cleanup - } - try { - await managed.transport.close() - } catch { - // Transport may already be terminated - } - } - this.clients.clear() - this.pendingConnections.clear() - } - - // Note: Node's 'exit' event is synchronous-only, so we rely on signal handlers for async cleanup. - // Signal handlers invoke the async cleanup function and ignore errors so they don't block or throw. - // Don't call process.exit() here - let the background-agent manager handle the final process exit. - // Use void + catch to trigger async cleanup without awaiting it in the signal handler. - - const register = (signal: NodeJS.Signals) => { - const listener = () => void cleanup().catch(() => {}) - this.cleanupHandlers.push({ signal, listener }) - process.on(signal, listener) - } - - register("SIGINT") - register("SIGTERM") - if (process.platform === "win32") { - register("SIGBREAK") - } - } - - private unregisterProcessCleanup(): void { - if (!this.cleanupRegistered) return - for (const { signal, listener } of this.cleanupHandlers) { - process.off(signal, listener) - } - this.cleanupHandlers = [] - this.cleanupRegistered = false - } - - async getOrCreateClient( - info: SkillMcpClientInfo, - config: ClaudeCodeMcpServer - ): Promise { - const key = this.getClientKey(info) - const existing = this.clients.get(key) - - if (existing) { - existing.lastUsedAt = Date.now() - return existing.client - } - - // Prevent race condition: if a connection is already in progress, wait for it - const pending = this.pendingConnections.get(key) - if (pending) { - return pending - } - - const expandedConfig = expandEnvVarsInObject(config) - const connectionPromise = this.createClient(info, expandedConfig) - this.pendingConnections.set(key, connectionPromise) - - try { - const client = await connectionPromise - return client - } finally { - this.pendingConnections.delete(key) - } - } - - private async createClient( - info: SkillMcpClientInfo, - config: ClaudeCodeMcpServer - ): Promise { - const connectionType = getConnectionType(config) - - if (!connectionType) { - throw new Error( - `MCP server "${info.serverName}" has no valid connection configuration.\n\n` + - `The MCP configuration in skill "${info.skillName}" must specify either:\n` + - ` - A URL for HTTP connection (remote MCP server)\n` + - ` - A command for stdio connection (local MCP process)\n\n` + - `Examples:\n` + - ` HTTP:\n` + - ` mcp:\n` + - ` ${info.serverName}:\n` + - ` url: https://mcp.example.com/mcp\n` + - ` headers:\n` + - ` Authorization: Bearer \${API_KEY}\n\n` + - ` Stdio:\n` + - ` mcp:\n` + - ` ${info.serverName}:\n` + - ` command: npx\n` + - ` args: [-y, @some/mcp-server]` - ) - } - - if (connectionType === "http") { - return this.createHttpClient(info, config) - } else { - return this.createStdioClient(info, config) - } - } - - /** - * Create an HTTP-based MCP client using StreamableHTTPClientTransport. - * Supports remote MCP servers with optional authentication headers. - */ - private async createHttpClient( - info: SkillMcpClientInfo, - config: ClaudeCodeMcpServer - ): Promise { - const key = this.getClientKey(info) - - if (!config.url) { - throw new Error( - `MCP server "${info.serverName}" is configured for HTTP but missing 'url' field.` - ) - } - - let url: URL - try { - url = new URL(config.url) - } catch { - throw new Error( - `MCP server "${info.serverName}" has invalid URL: ${config.url}\n\n` + - `Expected a valid URL like: https://mcp.example.com/mcp` - ) - } - - this.registerProcessCleanup() - - // Build request init with headers if provided - const requestInit: RequestInit = {} - if (config.headers && Object.keys(config.headers).length > 0) { - requestInit.headers = { ...config.headers } - } - - let authProvider: McpOAuthProvider | undefined - if (config.oauth) { - authProvider = this.getOrCreateAuthProvider(config.url, config.oauth) - let tokenData = authProvider.tokens() - - const isExpired = tokenData?.expiresAt != null && tokenData.expiresAt < Math.floor(Date.now() / 1000) - if (!tokenData || isExpired) { - try { - tokenData = await authProvider.login() - } catch { - // Login failed — proceed without auth header - } - } - - if (tokenData) { - const existingHeaders = (requestInit.headers ?? {}) as Record - requestInit.headers = { - ...existingHeaders, - Authorization: `Bearer ${tokenData.accessToken}`, - } - } - } - - const transport = new StreamableHTTPClientTransport(url, { - requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined, - }) - - const client = new Client( - { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" }, - { capabilities: {} } - ) - - try { - await client.connect(transport) - } catch (error) { - try { - await transport.close() - } catch { - // Transport may already be closed - } - const errorMessage = error instanceof Error ? error.message : String(error) - throw new Error( - `Failed to connect to MCP server "${info.serverName}".\n\n` + - `URL: ${config.url}\n` + - `Reason: ${errorMessage}\n\n` + - `Hints:\n` + - ` - Verify the URL is correct and the server is running\n` + - ` - Check if authentication headers are required\n` + - ` - Ensure the server supports MCP over HTTP` - ) - } - - const managedClient: ManagedHttpClient = { - client, - transport, - skillName: info.skillName, - lastUsedAt: Date.now(), - connectionType: "http", - } - this.clients.set(key, managedClient) - this.startCleanupTimer() - return client - } - - /** - * Create a stdio-based MCP client using StdioClientTransport. - * Spawns a local process and communicates via stdin/stdout. - */ - private async createStdioClient( - info: SkillMcpClientInfo, - config: ClaudeCodeMcpServer - ): Promise { - const key = this.getClientKey(info) - - if (!config.command) { - throw new Error( - `MCP server "${info.serverName}" is configured for stdio but missing 'command' field.` - ) - } - - const command = config.command - const args = config.args || [] - - const mergedEnv = createCleanMcpEnvironment(config.env) - - this.registerProcessCleanup() - - const transport = new StdioClientTransport({ - command, - args, - env: mergedEnv, - stderr: "ignore", - }) - - const client = new Client( - { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" }, - { capabilities: {} } - ) - - try { - await client.connect(transport) - } catch (error) { - // Close transport to prevent orphaned MCP process on connection failure - try { - await transport.close() - } catch { - // Process may already be terminated - } - const errorMessage = error instanceof Error ? error.message : String(error) - throw new Error( - `Failed to connect to MCP server "${info.serverName}".\n\n` + - `Command: ${command} ${args.join(" ")}\n` + - `Reason: ${errorMessage}\n\n` + - `Hints:\n` + - ` - Ensure the command is installed and available in PATH\n` + - ` - Check if the MCP server package exists\n` + - ` - Verify the args are correct for this server` - ) - } - - const managedClient: ManagedStdioClient = { - client, - transport, - skillName: info.skillName, - lastUsedAt: Date.now(), - connectionType: "stdio", - } - this.clients.set(key, managedClient) - this.startCleanupTimer() - return client } async disconnectSession(sessionID: string): Promise { - const keysToRemove: string[] = [] - - for (const [key, managed] of this.clients.entries()) { - if (key.startsWith(`${sessionID}:`)) { - keysToRemove.push(key) - // Delete from map first to prevent re-entrancy during async close - this.clients.delete(key) - try { - await managed.client.close() - } catch { - // Ignore close errors - process may already be terminated - } - try { - await managed.transport.close() - } catch { - // Transport may already be terminated - } - } - } - - for (const key of keysToRemove) { - this.pendingConnections.delete(key) - } - - if (this.clients.size === 0) { - this.stopCleanupTimer() - } + await disconnectSession(this.state, sessionID) } async disconnectAll(): Promise { - this.stopCleanupTimer() - this.unregisterProcessCleanup() - const clients = Array.from(this.clients.values()) - this.clients.clear() - this.pendingConnections.clear() - this.authProviders.clear() - for (const managed of clients) { - try { - await managed.client.close() - } catch { /* process may already be terminated */ } - try { - await managed.transport.close() - } catch { /* transport may already be terminated */ } - } + await disconnectAll(this.state) } - private startCleanupTimer(): void { - if (this.cleanupInterval) return - this.cleanupInterval = setInterval(() => { - this.cleanupIdleClients() - }, 60_000) - this.cleanupInterval.unref() - } - - private stopCleanupTimer(): void { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval) - this.cleanupInterval = null - } - } - - private async cleanupIdleClients(): Promise { - const now = Date.now() - for (const [key, managed] of this.clients) { - if (now - managed.lastUsedAt > this.IDLE_TIMEOUT) { - this.clients.delete(key) - try { - await managed.client.close() - } catch { /* process may already be terminated */ } - try { - await managed.transport.close() - } catch { /* transport may already be terminated */ } - } - } - - if (this.clients.size === 0) { - this.stopCleanupTimer() - } - } - - async listTools( - info: SkillMcpClientInfo, - context: SkillMcpServerContext - ): Promise { + async listTools(info: SkillMcpClientInfo, context: SkillMcpServerContext): Promise { const client = await this.getOrCreateClientWithRetry(info, context.config) const result = await client.listTools() return result.tools } - async listResources( - info: SkillMcpClientInfo, - context: SkillMcpServerContext - ): Promise { + async listResources(info: SkillMcpClientInfo, context: SkillMcpServerContext): Promise { const client = await this.getOrCreateClientWithRetry(info, context.config) const result = await client.listResources() return result.resources } - async listPrompts( - info: SkillMcpClientInfo, - context: SkillMcpServerContext - ): Promise { + async listPrompts(info: SkillMcpClientInfo, context: SkillMcpServerContext): Promise { const client = await this.getOrCreateClientWithRetry(info, context.config) const result = await client.listPrompts() return result.prompts @@ -486,18 +63,14 @@ export class SkillMcpManager { name: string, args: Record ): Promise { - return this.withOperationRetry(info, context.config, async (client) => { + return await this.withOperationRetry(info, context.config, async (client) => { const result = await client.callTool({ name, arguments: args }) return result.content }) } - async readResource( - info: SkillMcpClientInfo, - context: SkillMcpServerContext, - uri: string - ): Promise { - return this.withOperationRetry(info, context.config, async (client) => { + async readResource(info: SkillMcpClientInfo, context: SkillMcpServerContext, uri: string): Promise { + return await this.withOperationRetry(info, context.config, async (client) => { const result = await client.readResource({ uri }) return result.contents }) @@ -509,7 +82,7 @@ export class SkillMcpManager { name: string, args: Record ): Promise { - return this.withOperationRetry(info, context.config, async (client) => { + return await this.withOperationRetry(info, context.config, async (client) => { const result = await client.getPrompt({ name, arguments: args }) return result.messages }) @@ -531,9 +104,13 @@ export class SkillMcpManager { lastError = error instanceof Error ? error : new Error(String(error)) const errorMessage = lastError.message.toLowerCase() - const stepUpHandled = await this.handleStepUpIfNeeded(lastError, config) + const stepUpHandled = await handleStepUpIfNeeded({ + error: lastError, + config, + authProviders: this.state.authProviders, + }) if (stepUpHandled) { - await this.forceReconnect(info) + await forceReconnect(this.state, this.getClientKey(info)) continue } @@ -542,99 +119,32 @@ export class SkillMcpManager { } if (attempt === maxRetries) { - throw new Error( - `Failed after ${maxRetries} reconnection attempts: ${lastError.message}` - ) + throw new Error(`Failed after ${maxRetries} reconnection attempts: ${lastError.message}`) } - await this.forceReconnect(info) + await forceReconnect(this.state, this.getClientKey(info)) } } - throw lastError || new Error("Operation failed with unknown error") + throw lastError ?? new Error("Operation failed with unknown error") } - private async handleStepUpIfNeeded( - error: Error, - config: ClaudeCodeMcpServer - ): Promise { - if (!config.oauth || !config.url) { - return false - } - - const statusMatch = /\b403\b/.exec(error.message) - if (!statusMatch) { - return false - } - - const headers: Record = {} - const wwwAuthMatch = /WWW-Authenticate:\s*(.+)/i.exec(error.message) - if (wwwAuthMatch?.[1]) { - headers["www-authenticate"] = wwwAuthMatch[1] - } - - const stepUp = isStepUpRequired(403, headers) - if (!stepUp) { - return false - } - - const currentScopes = config.oauth.scopes ?? [] - const merged = mergeScopes(currentScopes, stepUp.requiredScopes) - config.oauth.scopes = merged - - this.authProviders.delete(config.url) - const provider = this.getOrCreateAuthProvider(config.url, config.oauth) - - try { - await provider.login() - return true - } catch { - return false - } - } - - private async forceReconnect(info: SkillMcpClientInfo): Promise { - const key = this.getClientKey(info) - const existing = this.clients.get(key) - if (existing) { - this.clients.delete(key) - try { - await existing.client.close() - } catch { /* process may already be terminated */ } - try { - await existing.transport.close() - } catch { /* transport may already be terminated */ } - } - } - - private async getOrCreateClientWithRetry( - info: SkillMcpClientInfo, - config: ClaudeCodeMcpServer - ): Promise { - try { - return await this.getOrCreateClient(info, config) - } catch (error) { - const key = this.getClientKey(info) - const existing = this.clients.get(key) - if (existing) { - this.clients.delete(key) - try { - await existing.client.close() - } catch { /* process may already be terminated */ } - try { - await existing.transport.close() - } catch { /* transport may already be terminated */ } - return await this.getOrCreateClient(info, config) - } - throw error - } + // NOTE: tests spy on this exact method name via `spyOn(manager as any, 'getOrCreateClientWithRetry')`. + private async getOrCreateClientWithRetry(info: SkillMcpClientInfo, config: ClaudeCodeMcpServer): Promise { + const clientKey = this.getClientKey(info) + return await getOrCreateClientWithRetryImpl({ + state: this.state, + clientKey, + info, + config, + }) } getConnectedServers(): string[] { - return Array.from(this.clients.keys()) + return Array.from(this.state.clients.keys()) } isConnected(info: SkillMcpClientInfo): boolean { - return this.clients.has(this.getClientKey(info)) + return this.state.clients.has(this.getClientKey(info)) } } diff --git a/src/features/skill-mcp-manager/oauth-handler.ts b/src/features/skill-mcp-manager/oauth-handler.ts new file mode 100644 index 000000000..66e12b3e6 --- /dev/null +++ b/src/features/skill-mcp-manager/oauth-handler.ts @@ -0,0 +1,100 @@ +import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" +import { McpOAuthProvider } from "../mcp-oauth/provider" +import type { OAuthTokenData } from "../mcp-oauth/storage" +import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up" + +export function getOrCreateAuthProvider( + authProviders: Map, + serverUrl: string, + oauth: NonNullable +): McpOAuthProvider { + const existing = authProviders.get(serverUrl) + if (existing) return existing + + const provider = new McpOAuthProvider({ + serverUrl, + clientId: oauth.clientId, + scopes: oauth.scopes, + }) + authProviders.set(serverUrl, provider) + return provider +} + +function isTokenExpired(tokenData: OAuthTokenData): boolean { + if (tokenData.expiresAt == null) return false + return tokenData.expiresAt < Math.floor(Date.now() / 1000) +} + +export async function buildHttpRequestInit( + config: ClaudeCodeMcpServer, + authProviders: Map +): Promise { + const headers: Record = {} + + if (config.headers) { + for (const [key, value] of Object.entries(config.headers)) { + headers[key] = value + } + } + + if (config.oauth && config.url) { + const provider = getOrCreateAuthProvider(authProviders, config.url, config.oauth) + let tokenData = provider.tokens() + + if (!tokenData || isTokenExpired(tokenData)) { + try { + tokenData = await provider.login() + } catch { + tokenData = null + } + } + + if (tokenData) { + headers.Authorization = `Bearer ${tokenData.accessToken}` + } + } + + return Object.keys(headers).length > 0 ? { headers } : undefined +} + +export async function handleStepUpIfNeeded(params: { + error: Error + config: ClaudeCodeMcpServer + authProviders: Map +}): Promise { + const { error, config, authProviders } = params + + if (!config.oauth || !config.url) { + return false + } + + const statusMatch = /\b403\b/.exec(error.message) + if (!statusMatch) { + return false + } + + const headers: Record = {} + const wwwAuthMatch = /WWW-Authenticate:\s*(.+)/i.exec(error.message) + if (wwwAuthMatch?.[1]) { + headers["www-authenticate"] = wwwAuthMatch[1] + } + + const stepUp = isStepUpRequired(403, headers) + if (!stepUp) { + return false + } + + const currentScopes = config.oauth.scopes ?? [] + const mergedScopes = mergeScopes(currentScopes, stepUp.requiredScopes) + config.oauth.scopes = mergedScopes + + authProviders.delete(config.url) + const provider = getOrCreateAuthProvider(authProviders, config.url, config.oauth) + + try { + await provider.login() + return true + } catch { + return false + } +} diff --git a/src/features/skill-mcp-manager/stdio-client.ts b/src/features/skill-mcp-manager/stdio-client.ts new file mode 100644 index 000000000..56b8bb51f --- /dev/null +++ b/src/features/skill-mcp-manager/stdio-client.ts @@ -0,0 +1,69 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" +import { createCleanMcpEnvironment } from "./env-cleaner" +import { registerProcessCleanup, startCleanupTimer } from "./cleanup" +import type { ManagedClient, SkillMcpClientConnectionParams } from "./types" + +function getStdioCommand(config: ClaudeCodeMcpServer, serverName: string): string { + if (!config.command) { + throw new Error(`MCP server "${serverName}" is configured for stdio but missing 'command' field.`) + } + return config.command +} + +export async function createStdioClient(params: SkillMcpClientConnectionParams): Promise { + const { state, clientKey, info, config } = params + + const command = getStdioCommand(config, info.serverName) + const args = config.args ?? [] + const mergedEnv = createCleanMcpEnvironment(config.env) + + registerProcessCleanup(state) + + const transport = new StdioClientTransport({ + command, + args, + env: mergedEnv, + stderr: "ignore", + }) + + const client = new Client( + { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" }, + { capabilities: {} } + ) + + try { + await client.connect(transport) + } catch (error) { + // Close transport to prevent orphaned MCP process on connection failure + try { + await transport.close() + } catch { + // Process may already be terminated + } + + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to connect to MCP server "${info.serverName}".\n\n` + + `Command: ${command} ${args.join(" ")}\n` + + `Reason: ${errorMessage}\n\n` + + `Hints:\n` + + ` - Ensure the command is installed and available in PATH\n` + + ` - Check if the MCP server package exists\n` + + ` - Verify the args are correct for this server` + ) + } + + const managedClient = { + client, + transport, + skillName: info.skillName, + lastUsedAt: Date.now(), + connectionType: "stdio", + } satisfies ManagedClient + + state.clients.set(clientKey, managedClient) + startCleanupTimer(state) + return client +} diff --git a/src/features/skill-mcp-manager/types.ts b/src/features/skill-mcp-manager/types.ts index bed9dbcbd..b7a9f46ca 100644 --- a/src/features/skill-mcp-manager/types.ts +++ b/src/features/skill-mcp-manager/types.ts @@ -1,4 +1,8 @@ +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" +import type { McpOAuthProvider } from "../mcp-oauth/provider" export type SkillMcpConfig = Record @@ -12,3 +16,51 @@ export interface SkillMcpServerContext { config: ClaudeCodeMcpServer skillName: string } + +/** + * Connection type for a managed MCP client. + * - "stdio": Local process via stdin/stdout + * - "http": Remote server via HTTP (Streamable HTTP transport) + */ +export type ConnectionType = "stdio" | "http" + +export interface ManagedClientBase { + client: Client + skillName: string + lastUsedAt: number + connectionType: ConnectionType +} + +export interface ManagedStdioClient extends ManagedClientBase { + connectionType: "stdio" + transport: StdioClientTransport +} + +export interface ManagedHttpClient extends ManagedClientBase { + connectionType: "http" + transport: StreamableHTTPClientTransport +} + +export type ManagedClient = ManagedStdioClient | ManagedHttpClient + +export interface ProcessCleanupHandler { + signal: NodeJS.Signals + listener: () => void +} + +export interface SkillMcpManagerState { + clients: Map + pendingConnections: Map> + authProviders: Map + cleanupRegistered: boolean + cleanupInterval: ReturnType | null + cleanupHandlers: ProcessCleanupHandler[] + idleTimeoutMs: number +} + +export interface SkillMcpClientConnectionParams { + state: SkillMcpManagerState + clientKey: string + info: SkillMcpClientInfo + config: ClaudeCodeMcpServer +} From 39dc62c62af5de806409b2df62e6f10ce11bf40c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:21:37 +0900 Subject: [PATCH 08/51] refactor(claude-code-plugin-loader): split loader.ts into per-type loaders Extract plugin component loading into dedicated modules: - discovery.ts: plugin directory detection - plugin-path-resolver.ts: path resolution logic - agent-loader.ts, command-loader.ts, hook-loader.ts - mcp-server-loader.ts, skill-loader.ts --- .../claude-code-plugin-loader/agent-loader.ts | 69 +++ .../command-loader.ts | 53 ++ .../claude-code-plugin-loader/discovery.ts | 180 +++++++ .../claude-code-plugin-loader/hook-loader.ts | 26 + .../claude-code-plugin-loader/index.ts | 7 + .../claude-code-plugin-loader/loader.ts | 468 +----------------- .../mcp-server-loader.ts | 48 ++ .../plugin-path-resolver.ts | 23 + .../claude-code-plugin-loader/skill-loader.ts | 60 +++ 9 files changed, 483 insertions(+), 451 deletions(-) create mode 100644 src/features/claude-code-plugin-loader/agent-loader.ts create mode 100644 src/features/claude-code-plugin-loader/command-loader.ts create mode 100644 src/features/claude-code-plugin-loader/discovery.ts create mode 100644 src/features/claude-code-plugin-loader/hook-loader.ts create mode 100644 src/features/claude-code-plugin-loader/mcp-server-loader.ts create mode 100644 src/features/claude-code-plugin-loader/plugin-path-resolver.ts create mode 100644 src/features/claude-code-plugin-loader/skill-loader.ts diff --git a/src/features/claude-code-plugin-loader/agent-loader.ts b/src/features/claude-code-plugin-loader/agent-loader.ts new file mode 100644 index 000000000..0f52dac52 --- /dev/null +++ b/src/features/claude-code-plugin-loader/agent-loader.ts @@ -0,0 +1,69 @@ +import { existsSync, readdirSync, readFileSync } from "fs" +import { basename, join } from "path" +import type { AgentConfig } from "@opencode-ai/sdk" +import { parseFrontmatter } from "../../shared/frontmatter" +import { isMarkdownFile } from "../../shared/file-utils" +import { log } from "../../shared/logger" +import type { AgentFrontmatter } from "../claude-code-agent-loader/types" +import type { LoadedPlugin } from "./types" + +function parseToolsConfig(toolsStr?: string): Record | undefined { + if (!toolsStr) return undefined + + const tools = toolsStr + .split(",") + .map((tool) => tool.trim()) + .filter(Boolean) + + if (tools.length === 0) return undefined + + const result: Record = {} + for (const tool of tools) { + result[tool.toLowerCase()] = true + } + return result +} + +export function loadPluginAgents(plugins: LoadedPlugin[]): Record { + const agents: Record = {} + + for (const plugin of plugins) { + if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue + + const entries = readdirSync(plugin.agentsDir, { withFileTypes: true }) + + for (const entry of entries) { + if (!isMarkdownFile(entry)) continue + + const agentPath = join(plugin.agentsDir, entry.name) + const agentName = basename(entry.name, ".md") + const namespacedName = `${plugin.name}:${agentName}` + + try { + const content = readFileSync(agentPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const originalDescription = data.description || "" + const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}` + + const config: AgentConfig = { + description: formattedDescription, + mode: "subagent", + prompt: body.trim(), + } + + const toolsConfig = parseToolsConfig(data.tools) + if (toolsConfig) { + config.tools = toolsConfig + } + + agents[namespacedName] = config + log(`Loaded plugin agent: ${namespacedName}`, { path: agentPath }) + } catch (error) { + log(`Failed to load plugin agent: ${agentPath}`, error) + } + } + } + + return agents +} diff --git a/src/features/claude-code-plugin-loader/command-loader.ts b/src/features/claude-code-plugin-loader/command-loader.ts new file mode 100644 index 000000000..9b55cd6b0 --- /dev/null +++ b/src/features/claude-code-plugin-loader/command-loader.ts @@ -0,0 +1,53 @@ +import { existsSync, readdirSync, readFileSync } from "fs" +import { basename, join } from "path" +import { parseFrontmatter } from "../../shared/frontmatter" +import { isMarkdownFile } from "../../shared/file-utils" +import { sanitizeModelField } from "../../shared/model-sanitizer" +import { log } from "../../shared/logger" +import type { CommandDefinition, CommandFrontmatter } from "../claude-code-command-loader/types" +import type { LoadedPlugin } from "./types" + +export function loadPluginCommands(plugins: LoadedPlugin[]): Record { + const commands: Record = {} + + for (const plugin of plugins) { + if (!plugin.commandsDir || !existsSync(plugin.commandsDir)) continue + + const entries = readdirSync(plugin.commandsDir, { withFileTypes: true }) + + for (const entry of entries) { + if (!isMarkdownFile(entry)) continue + + const commandPath = join(plugin.commandsDir, entry.name) + const commandName = basename(entry.name, ".md") + const namespacedName = `${plugin.name}:${commandName}` + + try { + const content = readFileSync(commandPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const wrappedTemplate = `\n${body.trim()}\n\n\n\n$ARGUMENTS\n` + const formattedDescription = `(plugin: ${plugin.name}) ${data.description || ""}` + + const definition = { + name: namespacedName, + description: formattedDescription, + template: wrappedTemplate, + agent: data.agent, + model: sanitizeModelField(data.model, "claude-code"), + subtask: data.subtask, + argumentHint: data["argument-hint"], + } + + const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = definition + commands[namespacedName] = openCodeCompatible as CommandDefinition + + log(`Loaded plugin command: ${namespacedName}`, { path: commandPath }) + } catch (error) { + log(`Failed to load plugin command: ${commandPath}`, error) + } + } + } + + return commands +} diff --git a/src/features/claude-code-plugin-loader/discovery.ts b/src/features/claude-code-plugin-loader/discovery.ts new file mode 100644 index 000000000..c7c46a803 --- /dev/null +++ b/src/features/claude-code-plugin-loader/discovery.ts @@ -0,0 +1,180 @@ +import { existsSync, readFileSync } from "fs" +import { homedir } from "os" +import { join } from "path" +import { log } from "../../shared/logger" +import type { + InstalledPluginsDatabase, + PluginInstallation, + PluginManifest, + LoadedPlugin, + PluginLoadResult, + PluginLoadError, + PluginScope, + ClaudeSettings, + PluginLoaderOptions, +} from "./types" + +function getPluginsBaseDir(): string { + if (process.env.CLAUDE_PLUGINS_HOME) { + return process.env.CLAUDE_PLUGINS_HOME + } + return join(homedir(), ".claude", "plugins") +} + +function getInstalledPluginsPath(): string { + return join(getPluginsBaseDir(), "installed_plugins.json") +} + +function loadInstalledPlugins(): InstalledPluginsDatabase | null { + const dbPath = getInstalledPluginsPath() + if (!existsSync(dbPath)) { + return null + } + + try { + const content = readFileSync(dbPath, "utf-8") + return JSON.parse(content) as InstalledPluginsDatabase + } catch (error) { + log("Failed to load installed plugins database", error) + return null + } +} + +function getClaudeSettingsPath(): string { + if (process.env.CLAUDE_SETTINGS_PATH) { + return process.env.CLAUDE_SETTINGS_PATH + } + return join(homedir(), ".claude", "settings.json") +} + +function loadClaudeSettings(): ClaudeSettings | null { + const settingsPath = getClaudeSettingsPath() + if (!existsSync(settingsPath)) { + return null + } + + try { + const content = readFileSync(settingsPath, "utf-8") + return JSON.parse(content) as ClaudeSettings + } catch (error) { + log("Failed to load Claude settings", error) + return null + } +} + +function loadPluginManifest(installPath: string): PluginManifest | null { + const manifestPath = join(installPath, ".claude-plugin", "plugin.json") + if (!existsSync(manifestPath)) { + return null + } + + try { + const content = readFileSync(manifestPath, "utf-8") + return JSON.parse(content) as PluginManifest + } catch (error) { + log(`Failed to load plugin manifest from ${manifestPath}`, error) + return null + } +} + +function derivePluginNameFromKey(pluginKey: string): string { + const atIndex = pluginKey.indexOf("@") + return atIndex > 0 ? pluginKey.substring(0, atIndex) : pluginKey +} + +function isPluginEnabled( + pluginKey: string, + settingsEnabledPlugins: Record | undefined, + overrideEnabledPlugins: Record | undefined, +): boolean { + if (overrideEnabledPlugins && pluginKey in overrideEnabledPlugins) { + return overrideEnabledPlugins[pluginKey] + } + if (settingsEnabledPlugins && pluginKey in settingsEnabledPlugins) { + return settingsEnabledPlugins[pluginKey] + } + return true +} + +function extractPluginEntries( + db: InstalledPluginsDatabase, +): Array<[string, PluginInstallation | undefined]> { + if (db.version === 1) { + return Object.entries(db.plugins).map(([key, installation]) => [key, installation]) + } + return Object.entries(db.plugins).map(([key, installations]) => [key, installations[0]]) +} + +export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginLoadResult { + const db = loadInstalledPlugins() + const settings = loadClaudeSettings() + const plugins: LoadedPlugin[] = [] + const errors: PluginLoadError[] = [] + + if (!db || !db.plugins) { + return { plugins, errors } + } + + const settingsEnabledPlugins = settings?.enabledPlugins + const overrideEnabledPlugins = options?.enabledPluginsOverride + + for (const [pluginKey, installation] of extractPluginEntries(db)) { + if (!installation) continue + + if (!isPluginEnabled(pluginKey, settingsEnabledPlugins, overrideEnabledPlugins)) { + log(`Plugin disabled: ${pluginKey}`) + continue + } + + const { installPath, scope, version } = installation + + if (!existsSync(installPath)) { + errors.push({ + pluginKey, + installPath, + error: "Plugin installation path does not exist", + }) + continue + } + + const manifest = loadPluginManifest(installPath) + const pluginName = manifest?.name || derivePluginNameFromKey(pluginKey) + + const loadedPlugin: LoadedPlugin = { + name: pluginName, + version: version || manifest?.version || "unknown", + scope: scope as PluginScope, + installPath, + pluginKey, + manifest: manifest ?? undefined, + } + + if (existsSync(join(installPath, "commands"))) { + loadedPlugin.commandsDir = join(installPath, "commands") + } + if (existsSync(join(installPath, "agents"))) { + loadedPlugin.agentsDir = join(installPath, "agents") + } + if (existsSync(join(installPath, "skills"))) { + loadedPlugin.skillsDir = join(installPath, "skills") + } + + const hooksPath = join(installPath, "hooks", "hooks.json") + if (existsSync(hooksPath)) { + loadedPlugin.hooksPath = hooksPath + } + + const mcpPath = join(installPath, ".mcp.json") + if (existsSync(mcpPath)) { + loadedPlugin.mcpPath = mcpPath + } + + plugins.push(loadedPlugin) + log(`Discovered plugin: ${pluginName}@${version} (${scope})`, { + installPath, + hasManifest: !!manifest, + }) + } + + return { plugins, errors } +} diff --git a/src/features/claude-code-plugin-loader/hook-loader.ts b/src/features/claude-code-plugin-loader/hook-loader.ts new file mode 100644 index 000000000..8f2a8c4c0 --- /dev/null +++ b/src/features/claude-code-plugin-loader/hook-loader.ts @@ -0,0 +1,26 @@ +import { existsSync, readFileSync } from "fs" +import { log } from "../../shared/logger" +import type { HooksConfig, LoadedPlugin } from "./types" +import { resolvePluginPaths } from "./plugin-path-resolver" + +export function loadPluginHooksConfigs(plugins: LoadedPlugin[]): HooksConfig[] { + const configs: HooksConfig[] = [] + + for (const plugin of plugins) { + if (!plugin.hooksPath || !existsSync(plugin.hooksPath)) continue + + try { + const content = readFileSync(plugin.hooksPath, "utf-8") + let config = JSON.parse(content) as HooksConfig + + config = resolvePluginPaths(config, plugin.installPath) + + configs.push(config) + log(`Loaded plugin hooks config from ${plugin.name}`, { path: plugin.hooksPath }) + } catch (error) { + log(`Failed to load plugin hooks config: ${plugin.hooksPath}`, error) + } + } + + return configs +} diff --git a/src/features/claude-code-plugin-loader/index.ts b/src/features/claude-code-plugin-loader/index.ts index e95b6a4e3..142986fee 100644 --- a/src/features/claude-code-plugin-loader/index.ts +++ b/src/features/claude-code-plugin-loader/index.ts @@ -1,3 +1,10 @@ export * from "./types" export * from "./loader" +export * from "./discovery" +export * from "./plugin-path-resolver" +export * from "./command-loader" +export * from "./skill-loader" +export * from "./agent-loader" +export * from "./mcp-server-loader" +export * from "./hook-loader" export type { PluginLoaderOptions, ClaudeSettings } from "./types" diff --git a/src/features/claude-code-plugin-loader/loader.ts b/src/features/claude-code-plugin-loader/loader.ts index 16771ad94..7c027cf39 100644 --- a/src/features/claude-code-plugin-loader/loader.ts +++ b/src/features/claude-code-plugin-loader/loader.ts @@ -1,455 +1,21 @@ -import { existsSync, readdirSync, readFileSync } from "fs" -import { homedir } from "os" -import { join, basename } from "path" -import type { AgentConfig } from "@opencode-ai/sdk" -import { parseFrontmatter } from "../../shared/frontmatter" -import { sanitizeModelField } from "../../shared/model-sanitizer" -import { isMarkdownFile, resolveSymlink } from "../../shared/file-utils" import { log } from "../../shared/logger" -import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander" -import { transformMcpServer } from "../claude-code-mcp-loader/transformer" -import type { CommandDefinition, CommandFrontmatter } from "../claude-code-command-loader/types" -import type { SkillMetadata } from "../opencode-skill-loader/types" -import type { AgentFrontmatter } from "../claude-code-agent-loader/types" -import type { ClaudeCodeMcpConfig, McpServerConfig } from "../claude-code-mcp-loader/types" -import type { - InstalledPluginsDatabase, - PluginInstallation, - PluginManifest, - LoadedPlugin, - PluginLoadResult, - PluginLoadError, - PluginScope, - HooksConfig, - ClaudeSettings, - PluginLoaderOptions, -} from "./types" - -const CLAUDE_PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}" - -function getPluginsBaseDir(): string { - // Allow override for testing - if (process.env.CLAUDE_PLUGINS_HOME) { - return process.env.CLAUDE_PLUGINS_HOME - } - return join(homedir(), ".claude", "plugins") -} - -function getInstalledPluginsPath(): string { - return join(getPluginsBaseDir(), "installed_plugins.json") -} - -function resolvePluginPath(path: string, pluginRoot: string): string { - return path.replace(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot) -} - -function resolvePluginPaths(obj: T, pluginRoot: string): T { - if (obj === null || obj === undefined) return obj - if (typeof obj === "string") { - return resolvePluginPath(obj, pluginRoot) as T - } - if (Array.isArray(obj)) { - return obj.map((item) => resolvePluginPaths(item, pluginRoot)) as T - } - if (typeof obj === "object") { - const result: Record = {} - for (const [key, value] of Object.entries(obj)) { - result[key] = resolvePluginPaths(value, pluginRoot) - } - return result as T - } - return obj -} - -function loadInstalledPlugins(): InstalledPluginsDatabase | null { - const dbPath = getInstalledPluginsPath() - if (!existsSync(dbPath)) { - return null - } - - try { - const content = readFileSync(dbPath, "utf-8") - return JSON.parse(content) as InstalledPluginsDatabase - } catch (error) { - log("Failed to load installed plugins database", error) - return null - } -} - -function getClaudeSettingsPath(): string { - if (process.env.CLAUDE_SETTINGS_PATH) { - return process.env.CLAUDE_SETTINGS_PATH - } - return join(homedir(), ".claude", "settings.json") -} - -function loadClaudeSettings(): ClaudeSettings | null { - const settingsPath = getClaudeSettingsPath() - if (!existsSync(settingsPath)) { - return null - } - - try { - const content = readFileSync(settingsPath, "utf-8") - return JSON.parse(content) as ClaudeSettings - } catch (error) { - log("Failed to load Claude settings", error) - return null - } -} - -function loadPluginManifest(installPath: string): PluginManifest | null { - const manifestPath = join(installPath, ".claude-plugin", "plugin.json") - if (!existsSync(manifestPath)) { - return null - } - - try { - const content = readFileSync(manifestPath, "utf-8") - return JSON.parse(content) as PluginManifest - } catch (error) { - log(`Failed to load plugin manifest from ${manifestPath}`, error) - return null - } -} - -function derivePluginNameFromKey(pluginKey: string): string { - const atIndex = pluginKey.indexOf("@") - if (atIndex > 0) { - return pluginKey.substring(0, atIndex) - } - return pluginKey -} - -function isPluginEnabled( - pluginKey: string, - settingsEnabledPlugins: Record | undefined, - overrideEnabledPlugins: Record | undefined -): boolean { - if (overrideEnabledPlugins && pluginKey in overrideEnabledPlugins) { - return overrideEnabledPlugins[pluginKey] - } - if (settingsEnabledPlugins && pluginKey in settingsEnabledPlugins) { - return settingsEnabledPlugins[pluginKey] - } - return true -} - -function extractPluginEntries( - db: InstalledPluginsDatabase -): Array<[string, PluginInstallation | undefined]> { - if (db.version === 1) { - return Object.entries(db.plugins).map(([key, installation]) => [key, installation]) - } - return Object.entries(db.plugins).map(([key, installations]) => [key, installations[0]]) -} - -export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginLoadResult { - const db = loadInstalledPlugins() - const settings = loadClaudeSettings() - const plugins: LoadedPlugin[] = [] - const errors: PluginLoadError[] = [] - - if (!db || !db.plugins) { - return { plugins, errors } - } - - const settingsEnabledPlugins = settings?.enabledPlugins - const overrideEnabledPlugins = options?.enabledPluginsOverride - - for (const [pluginKey, installation] of extractPluginEntries(db)) { - if (!installation) continue - - if (!isPluginEnabled(pluginKey, settingsEnabledPlugins, overrideEnabledPlugins)) { - log(`Plugin disabled: ${pluginKey}`) - continue - } - - const { installPath, scope, version } = installation - - if (!existsSync(installPath)) { - errors.push({ - pluginKey, - installPath, - error: "Plugin installation path does not exist", - }) - continue - } - - const manifest = loadPluginManifest(installPath) - const pluginName = manifest?.name || derivePluginNameFromKey(pluginKey) - - const loadedPlugin: LoadedPlugin = { - name: pluginName, - version: version || manifest?.version || "unknown", - scope: scope as PluginScope, - installPath, - pluginKey, - manifest: manifest ?? undefined, - } - - if (existsSync(join(installPath, "commands"))) { - loadedPlugin.commandsDir = join(installPath, "commands") - } - if (existsSync(join(installPath, "agents"))) { - loadedPlugin.agentsDir = join(installPath, "agents") - } - if (existsSync(join(installPath, "skills"))) { - loadedPlugin.skillsDir = join(installPath, "skills") - } - - const hooksPath = join(installPath, "hooks", "hooks.json") - if (existsSync(hooksPath)) { - loadedPlugin.hooksPath = hooksPath - } - - const mcpPath = join(installPath, ".mcp.json") - if (existsSync(mcpPath)) { - loadedPlugin.mcpPath = mcpPath - } - - plugins.push(loadedPlugin) - log(`Discovered plugin: ${pluginName}@${version} (${scope})`, { installPath, hasManifest: !!manifest }) - } - - return { plugins, errors } -} - -export function loadPluginCommands( - plugins: LoadedPlugin[] -): Record { - const commands: Record = {} - - for (const plugin of plugins) { - if (!plugin.commandsDir || !existsSync(plugin.commandsDir)) continue - - const entries = readdirSync(plugin.commandsDir, { withFileTypes: true }) - - for (const entry of entries) { - if (!isMarkdownFile(entry)) continue - - const commandPath = join(plugin.commandsDir, entry.name) - const commandName = basename(entry.name, ".md") - const namespacedName = `${plugin.name}:${commandName}` - - try { - const content = readFileSync(commandPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const wrappedTemplate = ` -${body.trim()} - - - -$ARGUMENTS -` - - const formattedDescription = `(plugin: ${plugin.name}) ${data.description || ""}` - - const definition = { - name: namespacedName, - description: formattedDescription, - template: wrappedTemplate, - agent: data.agent, - model: sanitizeModelField(data.model, "claude-code"), - subtask: data.subtask, - argumentHint: data["argument-hint"], - } - const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = definition - commands[namespacedName] = openCodeCompatible as CommandDefinition - - log(`Loaded plugin command: ${namespacedName}`, { path: commandPath }) - } catch (error) { - log(`Failed to load plugin command: ${commandPath}`, error) - } - } - } - - return commands -} - -export function loadPluginSkillsAsCommands( - plugins: LoadedPlugin[] -): Record { - const skills: Record = {} - - for (const plugin of plugins) { - if (!plugin.skillsDir || !existsSync(plugin.skillsDir)) continue - - const entries = readdirSync(plugin.skillsDir, { withFileTypes: true }) - - for (const entry of entries) { - if (entry.name.startsWith(".")) continue - - const skillPath = join(plugin.skillsDir, entry.name) - if (!entry.isDirectory() && !entry.isSymbolicLink()) continue - - const resolvedPath = resolveSymlink(skillPath) - const skillMdPath = join(resolvedPath, "SKILL.md") - if (!existsSync(skillMdPath)) continue - - try { - const content = readFileSync(skillMdPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const skillName = data.name || entry.name - const namespacedName = `${plugin.name}:${skillName}` - const originalDescription = data.description || "" - const formattedDescription = `(plugin: ${plugin.name} - Skill) ${originalDescription}` - - const wrappedTemplate = ` -Base directory for this skill: ${resolvedPath}/ -File references (@path) in this skill are relative to this directory. - -${body.trim()} - - - -$ARGUMENTS -` - - const definition = { - name: namespacedName, - description: formattedDescription, - template: wrappedTemplate, - model: sanitizeModelField(data.model), - } - const { name: _name, ...openCodeCompatible } = definition - skills[namespacedName] = openCodeCompatible as CommandDefinition - - log(`Loaded plugin skill: ${namespacedName}`, { path: resolvedPath }) - } catch (error) { - log(`Failed to load plugin skill: ${skillPath}`, error) - } - } - } - - return skills -} - -function parseToolsConfig(toolsStr?: string): Record | undefined { - if (!toolsStr) return undefined - - const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean) - if (tools.length === 0) return undefined - - const result: Record = {} - for (const tool of tools) { - result[tool.toLowerCase()] = true - } - return result -} - -export function loadPluginAgents( - plugins: LoadedPlugin[] -): Record { - const agents: Record = {} - - for (const plugin of plugins) { - if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue - - const entries = readdirSync(plugin.agentsDir, { withFileTypes: true }) - - for (const entry of entries) { - if (!isMarkdownFile(entry)) continue - - const agentPath = join(plugin.agentsDir, entry.name) - const agentName = basename(entry.name, ".md") - const namespacedName = `${plugin.name}:${agentName}` - - try { - const content = readFileSync(agentPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const name = data.name || agentName - const originalDescription = data.description || "" - const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}` - - const config: AgentConfig = { - description: formattedDescription, - mode: "subagent", - prompt: body.trim(), - } - - const toolsConfig = parseToolsConfig(data.tools) - if (toolsConfig) { - config.tools = toolsConfig - } - - agents[namespacedName] = config - log(`Loaded plugin agent: ${namespacedName}`, { path: agentPath }) - } catch (error) { - log(`Failed to load plugin agent: ${agentPath}`, error) - } - } - } - - return agents -} - -export async function loadPluginMcpServers( - plugins: LoadedPlugin[] -): Promise> { - const servers: Record = {} - - for (const plugin of plugins) { - if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue - - try { - const content = await Bun.file(plugin.mcpPath).text() - let config = JSON.parse(content) as ClaudeCodeMcpConfig - - config = resolvePluginPaths(config, plugin.installPath) - config = expandEnvVarsInObject(config) - - if (!config.mcpServers) continue - - for (const [name, serverConfig] of Object.entries(config.mcpServers)) { - if (serverConfig.disabled) { - log(`Skipping disabled MCP server "${name}" from plugin ${plugin.name}`) - continue - } - - try { - const transformed = transformMcpServer(name, serverConfig) - const namespacedName = `${plugin.name}:${name}` - servers[namespacedName] = transformed - log(`Loaded plugin MCP server: ${namespacedName}`, { path: plugin.mcpPath }) - } catch (error) { - log(`Failed to transform plugin MCP server "${name}"`, error) - } - } - } catch (error) { - log(`Failed to load plugin MCP config: ${plugin.mcpPath}`, error) - } - } - - return servers -} - -export function loadPluginHooksConfigs( - plugins: LoadedPlugin[] -): HooksConfig[] { - const configs: HooksConfig[] = [] - - for (const plugin of plugins) { - if (!plugin.hooksPath || !existsSync(plugin.hooksPath)) continue - - try { - const content = readFileSync(plugin.hooksPath, "utf-8") - let config = JSON.parse(content) as HooksConfig - - config = resolvePluginPaths(config, plugin.installPath) - - configs.push(config) - log(`Loaded plugin hooks config from ${plugin.name}`, { path: plugin.hooksPath }) - } catch (error) { - log(`Failed to load plugin hooks config: ${plugin.hooksPath}`, error) - } - } - - return configs -} +import type { AgentConfig } from "@opencode-ai/sdk" +import type { CommandDefinition } from "../claude-code-command-loader/types" +import type { McpServerConfig } from "../claude-code-mcp-loader/types" +import type { HooksConfig, LoadedPlugin, PluginLoadError, PluginLoaderOptions } from "./types" +import { discoverInstalledPlugins } from "./discovery" +import { loadPluginCommands } from "./command-loader" +import { loadPluginSkillsAsCommands } from "./skill-loader" +import { loadPluginAgents } from "./agent-loader" +import { loadPluginMcpServers } from "./mcp-server-loader" +import { loadPluginHooksConfigs } from "./hook-loader" + +export { discoverInstalledPlugins } from "./discovery" +export { loadPluginCommands } from "./command-loader" +export { loadPluginSkillsAsCommands } from "./skill-loader" +export { loadPluginAgents } from "./agent-loader" +export { loadPluginMcpServers } from "./mcp-server-loader" +export { loadPluginHooksConfigs } from "./hook-loader" export interface PluginComponentsResult { commands: Record diff --git a/src/features/claude-code-plugin-loader/mcp-server-loader.ts b/src/features/claude-code-plugin-loader/mcp-server-loader.ts new file mode 100644 index 000000000..9fcfba231 --- /dev/null +++ b/src/features/claude-code-plugin-loader/mcp-server-loader.ts @@ -0,0 +1,48 @@ +import { existsSync } from "fs" +import type { McpServerConfig } from "../claude-code-mcp-loader/types" +import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander" +import { transformMcpServer } from "../claude-code-mcp-loader/transformer" +import type { ClaudeCodeMcpConfig } from "../claude-code-mcp-loader/types" +import { log } from "../../shared/logger" +import type { LoadedPlugin } from "./types" +import { resolvePluginPaths } from "./plugin-path-resolver" + +export async function loadPluginMcpServers( + plugins: LoadedPlugin[], +): Promise> { + const servers: Record = {} + + for (const plugin of plugins) { + if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue + + try { + const content = await Bun.file(plugin.mcpPath).text() + let config = JSON.parse(content) as ClaudeCodeMcpConfig + + config = resolvePluginPaths(config, plugin.installPath) + config = expandEnvVarsInObject(config) + + if (!config.mcpServers) continue + + for (const [name, serverConfig] of Object.entries(config.mcpServers)) { + if (serverConfig.disabled) { + log(`Skipping disabled MCP server "${name}" from plugin ${plugin.name}`) + continue + } + + try { + const transformed = transformMcpServer(name, serverConfig) + const namespacedName = `${plugin.name}:${name}` + servers[namespacedName] = transformed + log(`Loaded plugin MCP server: ${namespacedName}`, { path: plugin.mcpPath }) + } catch (error) { + log(`Failed to transform plugin MCP server "${name}"`, error) + } + } + } catch (error) { + log(`Failed to load plugin MCP config: ${plugin.mcpPath}`, error) + } + } + + return servers +} diff --git a/src/features/claude-code-plugin-loader/plugin-path-resolver.ts b/src/features/claude-code-plugin-loader/plugin-path-resolver.ts new file mode 100644 index 000000000..c8806aa58 --- /dev/null +++ b/src/features/claude-code-plugin-loader/plugin-path-resolver.ts @@ -0,0 +1,23 @@ +const CLAUDE_PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}" + +export function resolvePluginPath(path: string, pluginRoot: string): string { + return path.replace(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot) +} + +export function resolvePluginPaths(obj: T, pluginRoot: string): T { + if (obj === null || obj === undefined) return obj + if (typeof obj === "string") { + return resolvePluginPath(obj, pluginRoot) as T + } + if (Array.isArray(obj)) { + return obj.map((item) => resolvePluginPaths(item, pluginRoot)) as T + } + if (typeof obj === "object") { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + result[key] = resolvePluginPaths(value, pluginRoot) + } + return result as T + } + return obj +} diff --git a/src/features/claude-code-plugin-loader/skill-loader.ts b/src/features/claude-code-plugin-loader/skill-loader.ts new file mode 100644 index 000000000..391f07601 --- /dev/null +++ b/src/features/claude-code-plugin-loader/skill-loader.ts @@ -0,0 +1,60 @@ +import { existsSync, readdirSync, readFileSync } from "fs" +import { join } from "path" +import { parseFrontmatter } from "../../shared/frontmatter" +import { resolveSymlink } from "../../shared/file-utils" +import { sanitizeModelField } from "../../shared/model-sanitizer" +import { log } from "../../shared/logger" +import type { CommandDefinition } from "../claude-code-command-loader/types" +import type { SkillMetadata } from "../opencode-skill-loader/types" +import type { LoadedPlugin } from "./types" + +export function loadPluginSkillsAsCommands( + plugins: LoadedPlugin[], +): Record { + const skills: Record = {} + + for (const plugin of plugins) { + if (!plugin.skillsDir || !existsSync(plugin.skillsDir)) continue + + const entries = readdirSync(plugin.skillsDir, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.name.startsWith(".")) continue + + const skillPath = join(plugin.skillsDir, entry.name) + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue + + const resolvedPath = resolveSymlink(skillPath) + const skillMdPath = join(resolvedPath, "SKILL.md") + if (!existsSync(skillMdPath)) continue + + try { + const content = readFileSync(skillMdPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const skillName = data.name || entry.name + const namespacedName = `${plugin.name}:${skillName}` + const originalDescription = data.description || "" + const formattedDescription = `(plugin: ${plugin.name} - Skill) ${originalDescription}` + + const wrappedTemplate = `\nBase directory for this skill: ${resolvedPath}/\nFile references (@path) in this skill are relative to this directory.\n\n${body.trim()}\n\n\n\n$ARGUMENTS\n` + + const definition = { + name: namespacedName, + description: formattedDescription, + template: wrappedTemplate, + model: sanitizeModelField(data.model), + } + + const { name: _name, ...openCodeCompatible } = definition + skills[namespacedName] = openCodeCompatible as CommandDefinition + + log(`Loaded plugin skill: ${namespacedName}`, { path: resolvedPath }) + } catch (error) { + log(`Failed to load plugin skill: ${skillPath}`, error) + } + } + } + + return skills +} From 9b841c6edc339fe1c344a0d6bc47839ead37498c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:21:43 +0900 Subject: [PATCH 09/51] refactor(mcp-oauth): extract OAuth authorization flow from provider.ts Split provider.ts into focused modules: - oauth-authorization-flow.ts: OAuth2 authorization code flow logic --- src/features/mcp-oauth/index.ts | 2 + .../mcp-oauth/oauth-authorization-flow.ts | 150 ++++++++++++++++++ src/features/mcp-oauth/provider.ts | 149 ++--------------- 3 files changed, 165 insertions(+), 136 deletions(-) create mode 100644 src/features/mcp-oauth/oauth-authorization-flow.ts diff --git a/src/features/mcp-oauth/index.ts b/src/features/mcp-oauth/index.ts index 06861aae9..cf042d888 100644 --- a/src/features/mcp-oauth/index.ts +++ b/src/features/mcp-oauth/index.ts @@ -1 +1,3 @@ export * from "./schema" +export * from "./oauth-authorization-flow" +export * from "./provider" diff --git a/src/features/mcp-oauth/oauth-authorization-flow.ts b/src/features/mcp-oauth/oauth-authorization-flow.ts new file mode 100644 index 000000000..26f7d31ac --- /dev/null +++ b/src/features/mcp-oauth/oauth-authorization-flow.ts @@ -0,0 +1,150 @@ +import { spawn } from "node:child_process" +import { createHash, randomBytes } from "node:crypto" +import { createServer } from "node:http" + +export type OAuthCallbackResult = { + code: string + state: string +} + +export function generateCodeVerifier(): string { + return randomBytes(32).toString("base64url") +} + +export function generateCodeChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url") +} + +export function buildAuthorizationUrl( + authorizationEndpoint: string, + options: { + clientId: string + redirectUri: string + codeChallenge: string + state: string + scopes?: string[] + resource?: string + } +): string { + const url = new URL(authorizationEndpoint) + url.searchParams.set("response_type", "code") + url.searchParams.set("client_id", options.clientId) + url.searchParams.set("redirect_uri", options.redirectUri) + url.searchParams.set("code_challenge", options.codeChallenge) + url.searchParams.set("code_challenge_method", "S256") + url.searchParams.set("state", options.state) + if (options.scopes && options.scopes.length > 0) { + url.searchParams.set("scope", options.scopes.join(" ")) + } + if (options.resource) { + url.searchParams.set("resource", options.resource) + } + return url.toString() +} + +const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 + +export function startCallbackServer(port: number): Promise { + return new Promise((resolve, reject) => { + let timeoutId: ReturnType + + const server = createServer((request, response) => { + clearTimeout(timeoutId) + + const requestUrl = new URL(request.url ?? "/", `http://localhost:${port}`) + const code = requestUrl.searchParams.get("code") + const state = requestUrl.searchParams.get("state") + const error = requestUrl.searchParams.get("error") + + if (error) { + const errorDescription = requestUrl.searchParams.get("error_description") ?? error + response.writeHead(400, { "content-type": "text/html" }) + response.end("

Authorization failed

") + server.close() + reject(new Error(`OAuth authorization error: ${errorDescription}`)) + return + } + + if (!code || !state) { + response.writeHead(400, { "content-type": "text/html" }) + response.end("

Missing code or state

") + server.close() + reject(new Error("OAuth callback missing code or state parameter")) + return + } + + response.writeHead(200, { "content-type": "text/html" }) + response.end("

Authorization successful. You can close this tab.

") + server.close() + resolve({ code, state }) + }) + + timeoutId = setTimeout(() => { + server.close() + reject(new Error("OAuth callback timed out after 5 minutes")) + }, CALLBACK_TIMEOUT_MS) + + server.listen(port, "127.0.0.1") + server.on("error", (err) => { + clearTimeout(timeoutId) + reject(err) + }) + }) +} + +function openBrowser(url: string): void { + const platform = process.platform + let command: string + let args: string[] + + if (platform === "darwin") { + command = "open" + args = [url] + } else if (platform === "win32") { + command = "explorer" + args = [url] + } else { + command = "xdg-open" + args = [url] + } + + try { + const child = spawn(command, args, { stdio: "ignore", detached: true }) + child.on("error", () => {}) + child.unref() + } catch { + // Browser open failed — user must navigate manually + } +} + +export async function runAuthorizationCodeRedirect(options: { + authorizationEndpoint: string + callbackPort: number + clientId: string + redirectUri: string + scopes?: string[] + resource?: string +}): Promise<{ code: string; verifier: string }> { + const verifier = generateCodeVerifier() + const challenge = generateCodeChallenge(verifier) + const state = randomBytes(16).toString("hex") + + const authorizationUrl = buildAuthorizationUrl(options.authorizationEndpoint, { + clientId: options.clientId, + redirectUri: options.redirectUri, + codeChallenge: challenge, + state, + scopes: options.scopes, + resource: options.resource, + }) + + const callbackPromise = startCallbackServer(options.callbackPort) + openBrowser(authorizationUrl) + + const result = await callbackPromise + if (result.state !== state) { + throw new Error("OAuth state mismatch") + } + + return { code: result.code, verifier } +} diff --git a/src/features/mcp-oauth/provider.ts b/src/features/mcp-oauth/provider.ts index 6b4a69b34..bf098fdd4 100644 --- a/src/features/mcp-oauth/provider.ts +++ b/src/features/mcp-oauth/provider.ts @@ -1,6 +1,3 @@ -import { createHash, randomBytes } from "node:crypto" -import { createServer } from "node:http" -import { spawn } from "node:child_process" import type { OAuthTokenData } from "./storage" import { loadToken, saveToken } from "./storage" import { discoverOAuthServerMetadata } from "./discovery" @@ -8,6 +5,13 @@ import type { OAuthServerMetadata } from "./discovery" import { getOrRegisterClient } from "./dcr" import type { ClientCredentials, ClientRegistrationStorage } from "./dcr" import { findAvailablePort } from "./callback-server" +import { + buildAuthorizationUrl, + generateCodeChallenge, + generateCodeVerifier, + runAuthorizationCodeRedirect, + startCallbackServer, +} from "./oauth-authorization-flow" export type McpOAuthProviderOptions = { serverUrl: string @@ -15,121 +19,6 @@ export type McpOAuthProviderOptions = { scopes?: string[] } -type CallbackResult = { - code: string - state: string -} - -function generateCodeVerifier(): string { - return randomBytes(32).toString("base64url") -} - -function generateCodeChallenge(verifier: string): string { - return createHash("sha256").update(verifier).digest("base64url") -} - -function buildAuthorizationUrl( - authorizationEndpoint: string, - options: { - clientId: string - redirectUri: string - codeChallenge: string - state: string - scopes?: string[] - resource?: string - } -): string { - const url = new URL(authorizationEndpoint) - url.searchParams.set("response_type", "code") - url.searchParams.set("client_id", options.clientId) - url.searchParams.set("redirect_uri", options.redirectUri) - url.searchParams.set("code_challenge", options.codeChallenge) - url.searchParams.set("code_challenge_method", "S256") - url.searchParams.set("state", options.state) - if (options.scopes && options.scopes.length > 0) { - url.searchParams.set("scope", options.scopes.join(" ")) - } - if (options.resource) { - url.searchParams.set("resource", options.resource) - } - return url.toString() -} - -const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 - -function startCallbackServer(port: number): Promise { - return new Promise((resolve, reject) => { - let timeoutId: ReturnType - - const server = createServer((request, response) => { - clearTimeout(timeoutId) - - const requestUrl = new URL(request.url ?? "/", `http://localhost:${port}`) - const code = requestUrl.searchParams.get("code") - const state = requestUrl.searchParams.get("state") - const error = requestUrl.searchParams.get("error") - - if (error) { - const errorDescription = requestUrl.searchParams.get("error_description") ?? error - response.writeHead(400, { "content-type": "text/html" }) - response.end("

Authorization failed

") - server.close() - reject(new Error(`OAuth authorization error: ${errorDescription}`)) - return - } - - if (!code || !state) { - response.writeHead(400, { "content-type": "text/html" }) - response.end("

Missing code or state

") - server.close() - reject(new Error("OAuth callback missing code or state parameter")) - return - } - - response.writeHead(200, { "content-type": "text/html" }) - response.end("

Authorization successful. You can close this tab.

") - server.close() - resolve({ code, state }) - }) - - timeoutId = setTimeout(() => { - server.close() - reject(new Error("OAuth callback timed out after 5 minutes")) - }, CALLBACK_TIMEOUT_MS) - - server.listen(port, "127.0.0.1") - server.on("error", (err) => { - clearTimeout(timeoutId) - reject(err) - }) - }) -} - -function openBrowser(url: string): void { - const platform = process.platform - let cmd: string - let args: string[] - - if (platform === "darwin") { - cmd = "open" - args = [url] - } else if (platform === "win32") { - cmd = "explorer" - args = [url] - } else { - cmd = "xdg-open" - args = [url] - } - - try { - const child = spawn(cmd, args, { stdio: "ignore", detached: true }) - child.on("error", () => {}) - child.unref() - } catch { - // Browser open failed — user must navigate manually - } -} - export class McpOAuthProvider { private readonly serverUrl: string private readonly configClientId: string | undefined @@ -174,12 +63,7 @@ export class McpOAuthProvider { return this.storedCodeVerifier } - async redirectToAuthorization(metadata: OAuthServerMetadata): Promise { - const verifier = generateCodeVerifier() - this.saveCodeVerifier(verifier) - const challenge = generateCodeChallenge(verifier) - const state = randomBytes(16).toString("hex") - + async redirectToAuthorization(metadata: OAuthServerMetadata): Promise<{ code: string }> { const clientInfo = this.clientInformation() if (!clientInfo) { throw new Error("No client information available. Run login() or register a client first.") @@ -189,24 +73,17 @@ export class McpOAuthProvider { this.callbackPort = await findAvailablePort() } - const authUrl = buildAuthorizationUrl(metadata.authorizationEndpoint, { + const result = await runAuthorizationCodeRedirect({ + authorizationEndpoint: metadata.authorizationEndpoint, + callbackPort: this.callbackPort, clientId: clientInfo.clientId, redirectUri: this.redirectUrl(), - codeChallenge: challenge, - state, scopes: this.scopes, resource: metadata.resource, }) - const callbackPromise = startCallbackServer(this.callbackPort) - openBrowser(authUrl) - - const result = await callbackPromise - if (result.state !== state) { - throw new Error("OAuth state mismatch") - } - - return result + this.saveCodeVerifier(result.verifier) + return { code: result.code } } async login(): Promise { From 8dff42830cfa7b708041bd75e22aebefaf854cb0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:21:50 +0900 Subject: [PATCH 10/51] refactor(builtin-skills): extract git-master metadata to separate module Split prompt-heavy git-master.ts: - git-master-skill-metadata.ts: skill metadata constants (name, desc, agent) --- .../builtin-skills/skills/git-master-skill-metadata.ts | 4 ++++ src/features/builtin-skills/skills/git-master.ts | 10 +++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 src/features/builtin-skills/skills/git-master-skill-metadata.ts diff --git a/src/features/builtin-skills/skills/git-master-skill-metadata.ts b/src/features/builtin-skills/skills/git-master-skill-metadata.ts new file mode 100644 index 000000000..11acace01 --- /dev/null +++ b/src/features/builtin-skills/skills/git-master-skill-metadata.ts @@ -0,0 +1,4 @@ +export const GIT_MASTER_SKILL_NAME = "git-master" + +export const GIT_MASTER_SKILL_DESCRIPTION = + "MUST USE for ANY git operations. Atomic commits, rebase/squash, history search (blame, bisect, log -S). STRONGLY RECOMMENDED: Use with task(category='quick', load_skills=['git-master'], ...) to save context. Triggers: 'commit', 'rebase', 'squash', 'who wrote', 'when was X added', 'find the commit that'." diff --git a/src/features/builtin-skills/skills/git-master.ts b/src/features/builtin-skills/skills/git-master.ts index d93f94d17..e0c8b16e7 100644 --- a/src/features/builtin-skills/skills/git-master.ts +++ b/src/features/builtin-skills/skills/git-master.ts @@ -1,9 +1,13 @@ import type { BuiltinSkill } from "../types" +import { + GIT_MASTER_SKILL_DESCRIPTION, + GIT_MASTER_SKILL_NAME, +} from "./git-master-skill-metadata" + export const gitMasterSkill: BuiltinSkill = { - name: "git-master", - description: - "MUST USE for ANY git operations. Atomic commits, rebase/squash, history search (blame, bisect, log -S). STRONGLY RECOMMENDED: Use with task(category='quick', load_skills=['git-master'], ...) to save context. Triggers: 'commit', 'rebase', 'squash', 'who wrote', 'when was X added', 'find the commit that'.", + name: GIT_MASTER_SKILL_NAME, + description: GIT_MASTER_SKILL_DESCRIPTION, template: `# Git Master Agent You are a Git expert combining three specializations: From 161d6e4159eac7c263c73e7e42e95453b170a2e2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:22:01 +0900 Subject: [PATCH 11/51] refactor(context-window-recovery): split executor and storage into focused modules Extract recovery strategies and storage management: - recovery-strategy.ts, aggressive-truncation-strategy.ts - summarize-retry-strategy.ts, target-token-truncation.ts - empty-content-recovery.ts, message-builder.ts - tool-result-storage.ts, storage-paths.ts, state.ts - client.ts, tool-part-types.ts --- .../aggressive-truncation-strategy.ts | 81 ++++ .../client.ts | 33 ++ .../empty-content-recovery.ts | 85 ++++ .../executor.ts | 458 ++---------------- .../index.ts | 3 + .../message-builder.ts | 73 +++ .../message-storage-directory.ts | 36 ++ .../recovery-strategy.ts | 2 + .../state.ts | 53 ++ .../storage-paths.ts | 10 + .../storage.ts | 257 +--------- .../summarize-retry-strategy.ts | 120 +++++ .../target-token-truncation.ts | 85 ++++ .../tool-part-types.ts | 38 ++ .../tool-result-storage.ts | 107 ++++ 15 files changed, 762 insertions(+), 679 deletions(-) create mode 100644 src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts create mode 100644 src/hooks/anthropic-context-window-limit-recovery/client.ts create mode 100644 src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts create mode 100644 src/hooks/anthropic-context-window-limit-recovery/message-builder.ts create mode 100644 src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts create mode 100644 src/hooks/anthropic-context-window-limit-recovery/recovery-strategy.ts create mode 100644 src/hooks/anthropic-context-window-limit-recovery/state.ts create mode 100644 src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts create mode 100644 src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts create mode 100644 src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts create mode 100644 src/hooks/anthropic-context-window-limit-recovery/tool-part-types.ts create mode 100644 src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts diff --git a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts new file mode 100644 index 000000000..709cb0db3 --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts @@ -0,0 +1,81 @@ +import type { AutoCompactState } from "./types" +import { TRUNCATE_CONFIG } from "./types" +import { truncateUntilTargetTokens } from "./storage" +import type { Client } from "./client" +import { clearSessionState } from "./state" +import { formatBytes } from "./message-builder" +import { log } from "../../shared/logger" + +export async function runAggressiveTruncationStrategy(params: { + sessionID: string + autoCompactState: AutoCompactState + client: Client + directory: string + truncateAttempt: number + currentTokens: number + maxTokens: number +}): Promise<{ handled: boolean; nextTruncateAttempt: number }> { + if (params.truncateAttempt >= TRUNCATE_CONFIG.maxTruncateAttempts) { + return { handled: false, nextTruncateAttempt: params.truncateAttempt } + } + + log("[auto-compact] PHASE 2: aggressive truncation triggered", { + currentTokens: params.currentTokens, + maxTokens: params.maxTokens, + targetRatio: TRUNCATE_CONFIG.targetTokenRatio, + }) + + const aggressiveResult = truncateUntilTargetTokens( + params.sessionID, + params.currentTokens, + params.maxTokens, + TRUNCATE_CONFIG.targetTokenRatio, + TRUNCATE_CONFIG.charsPerToken, + ) + + if (aggressiveResult.truncatedCount <= 0) { + return { handled: false, nextTruncateAttempt: params.truncateAttempt } + } + + const nextTruncateAttempt = params.truncateAttempt + aggressiveResult.truncatedCount + const toolNames = aggressiveResult.truncatedTools.map((t) => t.toolName).join(", ") + const statusMsg = aggressiveResult.sufficient + ? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})` + : `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) - continuing to summarize...` + + await params.client.tui + .showToast({ + body: { + title: aggressiveResult.sufficient ? "Truncation Complete" : "Partial Truncation", + message: `${statusMsg}: ${toolNames}`, + variant: aggressiveResult.sufficient ? "success" : "warning", + duration: 4000, + }, + }) + .catch(() => {}) + + log("[auto-compact] aggressive truncation completed", aggressiveResult) + + if (aggressiveResult.sufficient) { + clearSessionState(params.autoCompactState, params.sessionID) + setTimeout(async () => { + try { + await params.client.session.prompt_async({ + path: { id: params.sessionID }, + body: { auto: true } as never, + query: { directory: params.directory }, + }) + } catch {} + }, 500) + + return { handled: true, nextTruncateAttempt } + } + + log("[auto-compact] truncation insufficient, falling through to summarize", { + sessionID: params.sessionID, + truncatedCount: aggressiveResult.truncatedCount, + sufficient: aggressiveResult.sufficient, + }) + + return { handled: false, nextTruncateAttempt } +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/client.ts b/src/hooks/anthropic-context-window-limit-recovery/client.ts new file mode 100644 index 000000000..13bef9aee --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/client.ts @@ -0,0 +1,33 @@ +export type Client = { + session: { + messages: (opts: { + path: { id: string } + query?: { directory?: string } + }) => Promise + summarize: (opts: { + path: { id: string } + body: { providerID: string; modelID: string } + query: { directory: string } + }) => Promise + revert: (opts: { + path: { id: string } + body: { messageID: string; partID?: string } + query: { directory: string } + }) => Promise + prompt_async: (opts: { + path: { id: string } + body: { parts: Array<{ type: string; text: string }> } + query: { directory: string } + }) => Promise + } + tui: { + showToast: (opts: { + body: { + title: string + message: string + variant: string + duration: number + } + }) => Promise + } +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts new file mode 100644 index 000000000..140d98aac --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts @@ -0,0 +1,85 @@ +import { + findEmptyMessages, + findEmptyMessageByIndex, + injectTextPart, + replaceEmptyTextParts, +} from "../session-recovery/storage" +import type { AutoCompactState } from "./types" +import type { Client } from "./client" +import { PLACEHOLDER_TEXT } from "./message-builder" +import { incrementEmptyContentAttempt } from "./state" + +export async function fixEmptyMessages(params: { + sessionID: string + autoCompactState: AutoCompactState + client: Client + messageIndex?: number +}): Promise { + incrementEmptyContentAttempt(params.autoCompactState, params.sessionID) + + let fixed = false + const fixedMessageIds: string[] = [] + + if (params.messageIndex !== undefined) { + const targetMessageId = findEmptyMessageByIndex(params.sessionID, params.messageIndex) + if (targetMessageId) { + const replaced = replaceEmptyTextParts(targetMessageId, PLACEHOLDER_TEXT) + if (replaced) { + fixed = true + fixedMessageIds.push(targetMessageId) + } else { + const injected = injectTextPart(params.sessionID, targetMessageId, PLACEHOLDER_TEXT) + if (injected) { + fixed = true + fixedMessageIds.push(targetMessageId) + } + } + } + } + + if (!fixed) { + const emptyMessageIds = findEmptyMessages(params.sessionID) + if (emptyMessageIds.length === 0) { + await params.client.tui + .showToast({ + body: { + title: "Empty Content Error", + message: "No empty messages found in storage. Cannot auto-recover.", + variant: "error", + duration: 5000, + }, + }) + .catch(() => {}) + return false + } + + for (const messageID of emptyMessageIds) { + const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT) + if (replaced) { + fixed = true + fixedMessageIds.push(messageID) + } else { + const injected = injectTextPart(params.sessionID, messageID, PLACEHOLDER_TEXT) + if (injected) { + fixed = true + fixedMessageIds.push(messageID) + } + } + } + } + + if (fixed) { + await params.client.tui + .showToast({ + body: { + title: "Session Recovery", + message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`, + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}) + } + + return fixed +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/executor.ts b/src/hooks/anthropic-context-window-limit-recovery/executor.ts index 1e9f0ea5f..16876cb55 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/executor.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/executor.ts @@ -1,259 +1,15 @@ -import type { - AutoCompactState, - RetryState, - TruncateState, -} from "./types"; +import type { AutoCompactState } from "./types"; import type { ExperimentalConfig } from "../../config"; -import { RETRY_CONFIG, TRUNCATE_CONFIG } from "./types"; +import { TRUNCATE_CONFIG } from "./types"; +import type { Client } from "./client"; +import { getOrCreateTruncateState } from "./state"; import { - findLargestToolResult, - truncateToolResult, - truncateUntilTargetTokens, -} from "./storage"; -import { - findEmptyMessages, - findEmptyMessageByIndex, - injectTextPart, - replaceEmptyTextParts, -} from "../session-recovery/storage"; -import { log } from "../../shared/logger"; + runAggressiveTruncationStrategy, + runSummarizeRetryStrategy, +} from "./recovery-strategy"; -const PLACEHOLDER_TEXT = "[user interrupted]"; - -type Client = { - session: { - messages: (opts: { - path: { id: string }; - query?: { directory?: string }; - }) => Promise; - summarize: (opts: { - path: { id: string }; - body: { providerID: string; modelID: string }; - query: { directory: string }; - }) => Promise; - revert: (opts: { - path: { id: string }; - body: { messageID: string; partID?: string }; - query: { directory: string }; - }) => Promise; - prompt_async: (opts: { - path: { id: string }; - body: { parts: Array<{ type: string; text: string }> }; - query: { directory: string }; - }) => Promise; - }; - tui: { - showToast: (opts: { - body: { - title: string; - message: string; - variant: string; - duration: number; - }; - }) => Promise; - }; -}; - -function getOrCreateRetryState( - autoCompactState: AutoCompactState, - sessionID: string, -): RetryState { - let state = autoCompactState.retryStateBySession.get(sessionID); - if (!state) { - state = { attempt: 0, lastAttemptTime: 0 }; - autoCompactState.retryStateBySession.set(sessionID, state); - } - return state; -} - - - -function getOrCreateTruncateState( - autoCompactState: AutoCompactState, - sessionID: string, -): TruncateState { - let state = autoCompactState.truncateStateBySession.get(sessionID); - if (!state) { - state = { truncateAttempt: 0 }; - autoCompactState.truncateStateBySession.set(sessionID, state); - } - return state; -} - - - -function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number { - const emptyMessageIds = findEmptyMessages(sessionID); - if (emptyMessageIds.length === 0) { - return 0; - } - - let fixedCount = 0; - for (const messageID of emptyMessageIds) { - const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT); - if (replaced) { - fixedCount++; - } else { - const injected = injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT); - if (injected) { - fixedCount++; - } - } - } - - if (fixedCount > 0) { - log("[auto-compact] pre-summarize sanitization fixed empty messages", { - sessionID, - fixedCount, - totalEmpty: emptyMessageIds.length, - }); - } - - return fixedCount; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes}B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; -} - -export async function getLastAssistant( - sessionID: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - client: any, - directory: string, -): Promise | null> { - try { - const resp = await (client as Client).session.messages({ - path: { id: sessionID }, - query: { directory }, - }); - - const data = (resp as { data?: unknown[] }).data; - if (!Array.isArray(data)) return null; - - const reversed = [...data].reverse(); - const last = reversed.find((m) => { - const msg = m as Record; - const info = msg.info as Record | undefined; - return info?.role === "assistant"; - }); - if (!last) return null; - return (last as { info?: Record }).info ?? null; - } catch { - return null; - } -} - - - -function clearSessionState( - autoCompactState: AutoCompactState, - sessionID: string, -): void { - autoCompactState.pendingCompact.delete(sessionID); - autoCompactState.errorDataBySession.delete(sessionID); - autoCompactState.retryStateBySession.delete(sessionID); - autoCompactState.truncateStateBySession.delete(sessionID); - autoCompactState.emptyContentAttemptBySession.delete(sessionID); - autoCompactState.compactionInProgress.delete(sessionID); -} - -function getOrCreateEmptyContentAttempt( - autoCompactState: AutoCompactState, - sessionID: string, -): number { - return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0; -} - -async function fixEmptyMessages( - sessionID: string, - autoCompactState: AutoCompactState, - client: Client, - messageIndex?: number, -): Promise { - const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID); - autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1); - - let fixed = false; - const fixedMessageIds: string[] = []; - - if (messageIndex !== undefined) { - const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex); - if (targetMessageId) { - const replaced = replaceEmptyTextParts( - targetMessageId, - "[user interrupted]", - ); - if (replaced) { - fixed = true; - fixedMessageIds.push(targetMessageId); - } else { - const injected = injectTextPart( - sessionID, - targetMessageId, - "[user interrupted]", - ); - if (injected) { - fixed = true; - fixedMessageIds.push(targetMessageId); - } - } - } - } - - if (!fixed) { - const emptyMessageIds = findEmptyMessages(sessionID); - if (emptyMessageIds.length === 0) { - await client.tui - .showToast({ - body: { - title: "Empty Content Error", - message: "No empty messages found in storage. Cannot auto-recover.", - variant: "error", - duration: 5000, - }, - }) - .catch(() => {}); - return false; - } - - for (const messageID of emptyMessageIds) { - const replaced = replaceEmptyTextParts(messageID, "[user interrupted]"); - if (replaced) { - fixed = true; - fixedMessageIds.push(messageID); - } else { - const injected = injectTextPart( - sessionID, - messageID, - "[user interrupted]", - ); - if (injected) { - fixed = true; - fixedMessageIds.push(messageID); - } - } - } - } - - if (fixed) { - await client.tui - .showToast({ - body: { - title: "Session Recovery", - message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`, - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}); - } - - return fixed; -} +export { getLastAssistant } from "./message-builder"; export async function executeCompact( sessionID: string, @@ -264,6 +20,8 @@ export async function executeCompact( directory: string, experimental?: ExperimentalConfig, ): Promise { + void experimental + if (autoCompactState.compactionInProgress.has(sessionID)) { await (client as Client).tui .showToast({ @@ -294,191 +52,29 @@ export async function executeCompact( isOverLimit && truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts ) { - log("[auto-compact] PHASE 2: aggressive truncation triggered", { + const result = await runAggressiveTruncationStrategy({ + sessionID, + autoCompactState, + client: client as Client, + directory, + truncateAttempt: truncateState.truncateAttempt, currentTokens: errorData.currentTokens, maxTokens: errorData.maxTokens, - targetRatio: TRUNCATE_CONFIG.targetTokenRatio, }); - const aggressiveResult = truncateUntilTargetTokens( - sessionID, - errorData.currentTokens, - errorData.maxTokens, - TRUNCATE_CONFIG.targetTokenRatio, - TRUNCATE_CONFIG.charsPerToken, - ); - - if (aggressiveResult.truncatedCount > 0) { - truncateState.truncateAttempt += aggressiveResult.truncatedCount; - - const toolNames = aggressiveResult.truncatedTools - .map((t) => t.toolName) - .join(", "); - const statusMsg = aggressiveResult.sufficient - ? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})` - : `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) - continuing to summarize...`; - - await (client as Client).tui - .showToast({ - body: { - title: aggressiveResult.sufficient - ? "Truncation Complete" - : "Partial Truncation", - message: `${statusMsg}: ${toolNames}`, - variant: aggressiveResult.sufficient ? "success" : "warning", - duration: 4000, - }, - }) - .catch(() => {}); - - log("[auto-compact] aggressive truncation completed", aggressiveResult); - - // Only return early if truncation was sufficient to get under token limit - // Otherwise fall through to PHASE 3 (Summarize) - if (aggressiveResult.sufficient) { - clearSessionState(autoCompactState, sessionID); - setTimeout(async () => { - try { - await (client as Client).session.prompt_async({ - path: { id: sessionID }, - body: { auto: true } as never, - query: { directory }, - }); - } catch {} - }, 500); - return; - } - // Truncation was insufficient - fall through to Summarize - log("[auto-compact] truncation insufficient, falling through to summarize", { - sessionID, - truncatedCount: aggressiveResult.truncatedCount, - sufficient: aggressiveResult.sufficient, - }); - } + truncateState.truncateAttempt = result.nextTruncateAttempt; + if (result.handled) return; } - // PHASE 3: Summarize - fallback when truncation insufficient or no tool outputs - const retryState = getOrCreateRetryState(autoCompactState, sessionID); - - if (errorData?.errorType?.includes("non-empty content")) { - const attempt = getOrCreateEmptyContentAttempt( - autoCompactState, - sessionID, - ); - if (attempt < 3) { - const fixed = await fixEmptyMessages( - sessionID, - autoCompactState, - client as Client, - errorData.messageIndex, - ); - if (fixed) { - setTimeout(() => { - executeCompact( - sessionID, - msg, - autoCompactState, - client, - directory, - experimental, - ); - }, 500); - return; - } - } else { - await (client as Client).tui - .showToast({ - body: { - title: "Recovery Failed", - message: - "Max recovery attempts (3) reached for empty content error. Please start a new session.", - variant: "error", - duration: 10000, - }, - }) - .catch(() => {}); - return; - } - } - - if (Date.now() - retryState.lastAttemptTime > 300000) { - retryState.attempt = 0; - autoCompactState.truncateStateBySession.delete(sessionID); - } - - if (retryState.attempt < RETRY_CONFIG.maxAttempts) { - retryState.attempt++; - retryState.lastAttemptTime = Date.now(); - - const providerID = msg.providerID as string | undefined; - const modelID = msg.modelID as string | undefined; - - if (providerID && modelID) { - try { - sanitizeEmptyMessagesBeforeSummarize(sessionID); - - await (client as Client).tui - .showToast({ - body: { - title: "Auto Compact", - message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`, - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}); - - const summarizeBody = { providerID, modelID, auto: true } - await (client as Client).session.summarize({ - path: { id: sessionID }, - body: summarizeBody as never, - query: { directory }, - }); - return; - } catch { - const delay = - RETRY_CONFIG.initialDelayMs * - Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1); - const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs); - - setTimeout(() => { - executeCompact( - sessionID, - msg, - autoCompactState, - client, - directory, - experimental, - ); - }, cappedDelay); - return; - } - } else { - await (client as Client).tui - .showToast({ - body: { - title: "Summarize Skipped", - message: "Missing providerID or modelID.", - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}); - } - } - - clearSessionState(autoCompactState, sessionID); - - await (client as Client).tui - .showToast({ - body: { - title: "Auto Compact Failed", - message: "All recovery attempts failed. Please start a new session.", - variant: "error", - duration: 5000, - }, - }) - .catch(() => {}); + await runSummarizeRetryStrategy({ + sessionID, + msg, + autoCompactState, + client: client as Client, + directory, + errorType: errorData?.errorType, + messageIndex: errorData?.messageIndex, + }) } finally { autoCompactState.compactionInProgress.delete(sessionID); } diff --git a/src/hooks/anthropic-context-window-limit-recovery/index.ts b/src/hooks/anthropic-context-window-limit-recovery/index.ts index 205170a9c..2cbe1f935 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/index.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/index.ts @@ -3,3 +3,6 @@ export type { AnthropicContextWindowLimitRecoveryOptions } from "./recovery-hook export type { AutoCompactState, ParsedTokenLimitError, TruncateState } from "./types" export { parseAnthropicTokenLimitError } from "./parser" export { executeCompact, getLastAssistant } from "./executor" +export * from "./state" +export * from "./message-builder" +export * from "./recovery-strategy" diff --git a/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts b/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts new file mode 100644 index 000000000..cb600ca20 --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts @@ -0,0 +1,73 @@ +import { log } from "../../shared/logger" +import { + findEmptyMessages, + injectTextPart, + replaceEmptyTextParts, +} from "../session-recovery/storage" +import type { Client } from "./client" + +export const PLACEHOLDER_TEXT = "[user interrupted]" + +export function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number { + const emptyMessageIds = findEmptyMessages(sessionID) + if (emptyMessageIds.length === 0) { + return 0 + } + + let fixedCount = 0 + for (const messageID of emptyMessageIds) { + const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT) + if (replaced) { + fixedCount++ + } else { + const injected = injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT) + if (injected) { + fixedCount++ + } + } + } + + if (fixedCount > 0) { + log("[auto-compact] pre-summarize sanitization fixed empty messages", { + sessionID, + fixedCount, + totalEmpty: emptyMessageIds.length, + }) + } + + return fixedCount +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +} + +export async function getLastAssistant( + sessionID: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + directory: string, +): Promise | null> { + try { + const resp = await (client as Client).session.messages({ + path: { id: sessionID }, + query: { directory }, + }) + + const data = (resp as { data?: unknown[] }).data + if (!Array.isArray(data)) return null + + const reversed = [...data].reverse() + const last = reversed.find((m) => { + const msg = m as Record + const info = msg.info as Record | undefined + return info?.role === "assistant" + }) + if (!last) return null + return (last as { info?: Record }).info ?? null + } catch { + return null + } +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts b/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts new file mode 100644 index 000000000..249e4644d --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts @@ -0,0 +1,36 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" + +import { MESSAGE_STORAGE_DIR } from "./storage-paths" + +export function getMessageDir(sessionID: string): string { + if (!existsSync(MESSAGE_STORAGE_DIR)) return "" + + const directPath = join(MESSAGE_STORAGE_DIR, sessionID) + if (existsSync(directPath)) { + return directPath + } + + for (const directory of readdirSync(MESSAGE_STORAGE_DIR)) { + const sessionPath = join(MESSAGE_STORAGE_DIR, directory, sessionID) + if (existsSync(sessionPath)) { + return sessionPath + } + } + + return "" +} + +export function getMessageIds(sessionID: string): string[] { + const messageDir = getMessageDir(sessionID) + if (!messageDir || !existsSync(messageDir)) return [] + + const messageIds: string[] = [] + for (const file of readdirSync(messageDir)) { + if (!file.endsWith(".json")) continue + const messageId = file.replace(".json", "") + messageIds.push(messageId) + } + + return messageIds +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-strategy.ts new file mode 100644 index 000000000..d1bf1f8a8 --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-strategy.ts @@ -0,0 +1,2 @@ +export { runAggressiveTruncationStrategy } from "./aggressive-truncation-strategy" +export { runSummarizeRetryStrategy } from "./summarize-retry-strategy" diff --git a/src/hooks/anthropic-context-window-limit-recovery/state.ts b/src/hooks/anthropic-context-window-limit-recovery/state.ts new file mode 100644 index 000000000..1ee1001fc --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/state.ts @@ -0,0 +1,53 @@ +import type { AutoCompactState, RetryState, TruncateState } from "./types" + +export function getOrCreateRetryState( + autoCompactState: AutoCompactState, + sessionID: string, +): RetryState { + let state = autoCompactState.retryStateBySession.get(sessionID) + if (!state) { + state = { attempt: 0, lastAttemptTime: 0 } + autoCompactState.retryStateBySession.set(sessionID, state) + } + return state +} + +export function getOrCreateTruncateState( + autoCompactState: AutoCompactState, + sessionID: string, +): TruncateState { + let state = autoCompactState.truncateStateBySession.get(sessionID) + if (!state) { + state = { truncateAttempt: 0 } + autoCompactState.truncateStateBySession.set(sessionID, state) + } + return state +} + +export function clearSessionState( + autoCompactState: AutoCompactState, + sessionID: string, +): void { + autoCompactState.pendingCompact.delete(sessionID) + autoCompactState.errorDataBySession.delete(sessionID) + autoCompactState.retryStateBySession.delete(sessionID) + autoCompactState.truncateStateBySession.delete(sessionID) + autoCompactState.emptyContentAttemptBySession.delete(sessionID) + autoCompactState.compactionInProgress.delete(sessionID) +} + +export function getEmptyContentAttempt( + autoCompactState: AutoCompactState, + sessionID: string, +): number { + return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0 +} + +export function incrementEmptyContentAttempt( + autoCompactState: AutoCompactState, + sessionID: string, +): number { + const attempt = getEmptyContentAttempt(autoCompactState, sessionID) + autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1) + return attempt +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts b/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts new file mode 100644 index 000000000..95825a0a4 --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts @@ -0,0 +1,10 @@ +import { join } from "node:path" +import { getOpenCodeStorageDir } from "../../shared/data-path" + +const OPENCODE_STORAGE_DIR = getOpenCodeStorageDir() + +export const MESSAGE_STORAGE_DIR = join(OPENCODE_STORAGE_DIR, "message") +export const PART_STORAGE_DIR = join(OPENCODE_STORAGE_DIR, "part") + +export const TRUNCATION_MESSAGE = + "[TOOL RESULT TRUNCATED - Context limit exceeded. Original output was too large and has been truncated to recover the session. Please re-run this tool if you need the full output.]" diff --git a/src/hooks/anthropic-context-window-limit-recovery/storage.ts b/src/hooks/anthropic-context-window-limit-recovery/storage.ts index e1a771aca..3cd302c89 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/storage.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/storage.ts @@ -1,250 +1,11 @@ -import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs" -import { join } from "node:path" -import { getOpenCodeStorageDir } from "../../shared/data-path" +export type { AggressiveTruncateResult, ToolResultInfo } from "./tool-part-types" -const OPENCODE_STORAGE = getOpenCodeStorageDir() -const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") -const PART_STORAGE = join(OPENCODE_STORAGE, "part") +export { + countTruncatedResults, + findLargestToolResult, + findToolResultsBySize, + getTotalToolOutputSize, + truncateToolResult, +} from "./tool-result-storage" -const TRUNCATION_MESSAGE = - "[TOOL RESULT TRUNCATED - Context limit exceeded. Original output was too large and has been truncated to recover the session. Please re-run this tool if you need the full output.]" - -interface StoredToolPart { - id: string - sessionID: string - messageID: string - type: "tool" - callID: string - tool: string - state: { - status: "pending" | "running" | "completed" | "error" - input: Record - output?: string - error?: string - time?: { - start: number - end?: number - compacted?: number - } - } - truncated?: boolean - originalSize?: number -} - -export interface ToolResultInfo { - partPath: string - partId: string - messageID: string - toolName: string - outputSize: number -} - -function getMessageDir(sessionID: string): string { - if (!existsSync(MESSAGE_STORAGE)) return "" - - 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 "" -} - -function getMessageIds(sessionID: string): string[] { - const messageDir = getMessageDir(sessionID) - if (!messageDir || !existsSync(messageDir)) return [] - - const messageIds: string[] = [] - for (const file of readdirSync(messageDir)) { - if (!file.endsWith(".json")) continue - const messageId = file.replace(".json", "") - messageIds.push(messageId) - } - - return messageIds -} - -export function findToolResultsBySize(sessionID: string): ToolResultInfo[] { - const messageIds = getMessageIds(sessionID) - const results: ToolResultInfo[] = [] - - for (const messageID of messageIds) { - const partDir = join(PART_STORAGE, messageID) - if (!existsSync(partDir)) continue - - for (const file of readdirSync(partDir)) { - if (!file.endsWith(".json")) continue - try { - const partPath = join(partDir, file) - const content = readFileSync(partPath, "utf-8") - const part = JSON.parse(content) as StoredToolPart - - if (part.type === "tool" && part.state?.output && !part.truncated) { - results.push({ - partPath, - partId: part.id, - messageID, - toolName: part.tool, - outputSize: part.state.output.length, - }) - } - } catch { - continue - } - } - } - - return results.sort((a, b) => b.outputSize - a.outputSize) -} - -export function findLargestToolResult(sessionID: string): ToolResultInfo | null { - const results = findToolResultsBySize(sessionID) - return results.length > 0 ? results[0] : null -} - -export function truncateToolResult(partPath: string): { - success: boolean - toolName?: string - originalSize?: number -} { - try { - const content = readFileSync(partPath, "utf-8") - const part = JSON.parse(content) as StoredToolPart - - if (!part.state?.output) { - return { success: false } - } - - const originalSize = part.state.output.length - const toolName = part.tool - - part.truncated = true - part.originalSize = originalSize - part.state.output = TRUNCATION_MESSAGE - - if (!part.state.time) { - part.state.time = { start: Date.now() } - } - part.state.time.compacted = Date.now() - - writeFileSync(partPath, JSON.stringify(part, null, 2)) - - return { success: true, toolName, originalSize } - } catch { - return { success: false } - } -} - -export function getTotalToolOutputSize(sessionID: string): number { - const results = findToolResultsBySize(sessionID) - return results.reduce((sum, r) => sum + r.outputSize, 0) -} - -export function countTruncatedResults(sessionID: string): number { - const messageIds = getMessageIds(sessionID) - let count = 0 - - for (const messageID of messageIds) { - const partDir = join(PART_STORAGE, messageID) - if (!existsSync(partDir)) continue - - for (const file of readdirSync(partDir)) { - if (!file.endsWith(".json")) continue - try { - const content = readFileSync(join(partDir, file), "utf-8") - const part = JSON.parse(content) - if (part.truncated === true) { - count++ - } - } catch { - continue - } - } - } - - return count -} - -export interface AggressiveTruncateResult { - success: boolean - sufficient: boolean - truncatedCount: number - totalBytesRemoved: number - targetBytesToRemove: number - truncatedTools: Array<{ toolName: string; originalSize: number }> -} - -export function truncateUntilTargetTokens( - sessionID: string, - currentTokens: number, - maxTokens: number, - targetRatio: number = 0.8, - charsPerToken: number = 4 -): AggressiveTruncateResult { - const targetTokens = Math.floor(maxTokens * targetRatio) - const tokensToReduce = currentTokens - targetTokens - const charsToReduce = tokensToReduce * charsPerToken - - if (tokensToReduce <= 0) { - return { - success: true, - sufficient: true, - truncatedCount: 0, - totalBytesRemoved: 0, - targetBytesToRemove: 0, - truncatedTools: [], - } - } - - const results = findToolResultsBySize(sessionID) - - if (results.length === 0) { - return { - success: false, - sufficient: false, - truncatedCount: 0, - totalBytesRemoved: 0, - targetBytesToRemove: charsToReduce, - truncatedTools: [], - } - } - - let totalRemoved = 0 - let truncatedCount = 0 - const truncatedTools: Array<{ toolName: string; originalSize: number }> = [] - - for (const result of results) { - const truncateResult = truncateToolResult(result.partPath) - if (truncateResult.success) { - truncatedCount++ - const removedSize = truncateResult.originalSize ?? result.outputSize - totalRemoved += removedSize - truncatedTools.push({ - toolName: truncateResult.toolName ?? result.toolName, - originalSize: removedSize, - }) - - if (totalRemoved >= charsToReduce) { - break - } - } - } - - const sufficient = totalRemoved >= charsToReduce - - return { - success: truncatedCount > 0, - sufficient, - truncatedCount, - totalBytesRemoved: totalRemoved, - targetBytesToRemove: charsToReduce, - truncatedTools, - } -} +export { truncateUntilTargetTokens } from "./target-token-truncation" diff --git a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts new file mode 100644 index 000000000..41db33d09 --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts @@ -0,0 +1,120 @@ +import type { AutoCompactState } from "./types" +import { RETRY_CONFIG } from "./types" +import type { Client } from "./client" +import { clearSessionState, getEmptyContentAttempt, getOrCreateRetryState } from "./state" +import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder" +import { fixEmptyMessages } from "./empty-content-recovery" + +export async function runSummarizeRetryStrategy(params: { + sessionID: string + msg: Record + autoCompactState: AutoCompactState + client: Client + directory: string + errorType?: string + messageIndex?: number +}): Promise { + const retryState = getOrCreateRetryState(params.autoCompactState, params.sessionID) + + if (params.errorType?.includes("non-empty content")) { + const attempt = getEmptyContentAttempt(params.autoCompactState, params.sessionID) + if (attempt < 3) { + const fixed = await fixEmptyMessages({ + sessionID: params.sessionID, + autoCompactState: params.autoCompactState, + client: params.client, + messageIndex: params.messageIndex, + }) + if (fixed) { + setTimeout(() => { + void runSummarizeRetryStrategy(params) + }, 500) + return + } + } else { + await params.client.tui + .showToast({ + body: { + title: "Recovery Failed", + message: + "Max recovery attempts (3) reached for empty content error. Please start a new session.", + variant: "error", + duration: 10000, + }, + }) + .catch(() => {}) + return + } + } + + if (Date.now() - retryState.lastAttemptTime > 300000) { + retryState.attempt = 0 + params.autoCompactState.truncateStateBySession.delete(params.sessionID) + } + + if (retryState.attempt < RETRY_CONFIG.maxAttempts) { + retryState.attempt++ + retryState.lastAttemptTime = Date.now() + + const providerID = params.msg.providerID as string | undefined + const modelID = params.msg.modelID as string | undefined + + if (providerID && modelID) { + try { + sanitizeEmptyMessagesBeforeSummarize(params.sessionID) + + await params.client.tui + .showToast({ + body: { + title: "Auto Compact", + message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`, + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}) + + const summarizeBody = { providerID, modelID, auto: true } + await params.client.session.summarize({ + path: { id: params.sessionID }, + body: summarizeBody as never, + query: { directory: params.directory }, + }) + return + } catch { + const delay = + RETRY_CONFIG.initialDelayMs * + Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1) + const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs) + + setTimeout(() => { + void runSummarizeRetryStrategy(params) + }, cappedDelay) + return + } + } else { + await params.client.tui + .showToast({ + body: { + title: "Summarize Skipped", + message: "Missing providerID or modelID.", + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}) + } + } + + clearSessionState(params.autoCompactState, params.sessionID) + await params.client.tui + .showToast({ + body: { + title: "Auto Compact Failed", + message: "All recovery attempts failed. Please start a new session.", + variant: "error", + duration: 5000, + }, + }) + .catch(() => {}) +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts b/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts new file mode 100644 index 000000000..6e5ea6c27 --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts @@ -0,0 +1,85 @@ +import type { AggressiveTruncateResult } from "./tool-part-types" +import { findToolResultsBySize, truncateToolResult } from "./tool-result-storage" + +function calculateTargetBytesToRemove( + currentTokens: number, + maxTokens: number, + targetRatio: number, + charsPerToken: number +): { tokensToReduce: number; targetBytesToRemove: number } { + const targetTokens = Math.floor(maxTokens * targetRatio) + const tokensToReduce = currentTokens - targetTokens + const targetBytesToRemove = tokensToReduce * charsPerToken + return { tokensToReduce, targetBytesToRemove } +} + +export function truncateUntilTargetTokens( + sessionID: string, + currentTokens: number, + maxTokens: number, + targetRatio: number = 0.8, + charsPerToken: number = 4 +): AggressiveTruncateResult { + const { tokensToReduce, targetBytesToRemove } = calculateTargetBytesToRemove( + currentTokens, + maxTokens, + targetRatio, + charsPerToken + ) + + if (tokensToReduce <= 0) { + return { + success: true, + sufficient: true, + truncatedCount: 0, + totalBytesRemoved: 0, + targetBytesToRemove: 0, + truncatedTools: [], + } + } + + const results = findToolResultsBySize(sessionID) + + if (results.length === 0) { + return { + success: false, + sufficient: false, + truncatedCount: 0, + totalBytesRemoved: 0, + targetBytesToRemove, + truncatedTools: [], + } + } + + let totalRemoved = 0 + let truncatedCount = 0 + const truncatedTools: Array<{ toolName: string; originalSize: number }> = [] + + for (const result of results) { + const truncateResult = truncateToolResult(result.partPath) + if (truncateResult.success) { + truncatedCount++ + const removedSize = truncateResult.originalSize ?? result.outputSize + totalRemoved += removedSize + truncatedTools.push({ + toolName: truncateResult.toolName ?? result.toolName, + originalSize: removedSize, + }) + + if (totalRemoved >= targetBytesToRemove) { + break + } + } + } + + const sufficient = totalRemoved >= targetBytesToRemove + + return { + success: truncatedCount > 0, + sufficient, + truncatedCount, + totalBytesRemoved: totalRemoved, + targetBytesToRemove, + truncatedTools, + } +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/tool-part-types.ts b/src/hooks/anthropic-context-window-limit-recovery/tool-part-types.ts new file mode 100644 index 000000000..748b79789 --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/tool-part-types.ts @@ -0,0 +1,38 @@ +export interface StoredToolPart { + id: string + sessionID: string + messageID: string + type: "tool" + callID: string + tool: string + state: { + status: "pending" | "running" | "completed" | "error" + input: Record + output?: string + error?: string + time?: { + start: number + end?: number + compacted?: number + } + } + truncated?: boolean + originalSize?: number +} + +export interface ToolResultInfo { + partPath: string + partId: string + messageID: string + toolName: string + outputSize: number +} + +export interface AggressiveTruncateResult { + success: boolean + sufficient: boolean + truncatedCount: number + totalBytesRemoved: number + targetBytesToRemove: number + truncatedTools: Array<{ toolName: string; originalSize: number }> +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts b/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts new file mode 100644 index 000000000..70d9ffa5d --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts @@ -0,0 +1,107 @@ +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +import { getMessageIds } from "./message-storage-directory" +import { PART_STORAGE_DIR, TRUNCATION_MESSAGE } from "./storage-paths" +import type { StoredToolPart, ToolResultInfo } from "./tool-part-types" + +export function findToolResultsBySize(sessionID: string): ToolResultInfo[] { + const messageIds = getMessageIds(sessionID) + const results: ToolResultInfo[] = [] + + for (const messageID of messageIds) { + const partDir = join(PART_STORAGE_DIR, messageID) + if (!existsSync(partDir)) continue + + for (const file of readdirSync(partDir)) { + if (!file.endsWith(".json")) continue + try { + const partPath = join(partDir, file) + const content = readFileSync(partPath, "utf-8") + const part = JSON.parse(content) as StoredToolPart + + if (part.type === "tool" && part.state?.output && !part.truncated) { + results.push({ + partPath, + partId: part.id, + messageID, + toolName: part.tool, + outputSize: part.state.output.length, + }) + } + } catch { + continue + } + } + } + + return results.sort((a, b) => b.outputSize - a.outputSize) +} + +export function findLargestToolResult(sessionID: string): ToolResultInfo | null { + const results = findToolResultsBySize(sessionID) + return results.length > 0 ? results[0] : null +} + +export function truncateToolResult(partPath: string): { + success: boolean + toolName?: string + originalSize?: number +} { + try { + const content = readFileSync(partPath, "utf-8") + const part = JSON.parse(content) as StoredToolPart + + if (!part.state?.output) { + return { success: false } + } + + const originalSize = part.state.output.length + const toolName = part.tool + + part.truncated = true + part.originalSize = originalSize + part.state.output = TRUNCATION_MESSAGE + + if (!part.state.time) { + part.state.time = { start: Date.now() } + } + part.state.time.compacted = Date.now() + + writeFileSync(partPath, JSON.stringify(part, null, 2)) + + return { success: true, toolName, originalSize } + } catch { + return { success: false } + } +} + +export function getTotalToolOutputSize(sessionID: string): number { + const results = findToolResultsBySize(sessionID) + return results.reduce((sum, result) => sum + result.outputSize, 0) +} + +export function countTruncatedResults(sessionID: string): number { + const messageIds = getMessageIds(sessionID) + let count = 0 + + for (const messageID of messageIds) { + const partDir = join(PART_STORAGE_DIR, messageID) + if (!existsSync(partDir)) continue + + for (const file of readdirSync(partDir)) { + if (!file.endsWith(".json")) continue + try { + const content = readFileSync(join(partDir, file), "utf-8") + const part = JSON.parse(content) + if (part.truncated === true) { + count++ + } + } catch { + continue + } + } + } + + return count +} From 0f145b2e403382a5a36b76288dc4a0995898b3bc Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:22:10 +0900 Subject: [PATCH 12/51] refactor(ralph-loop): split hook into state controller and event handler modules Extract Ralph loop lifecycle management: - loop-state-controller.ts: start/stop/recovery state machine - ralph-loop-event-handler.ts: event handling logic - continuation-prompt-builder.ts, continuation-prompt-injector.ts - completion-promise-detector.ts, loop-session-recovery.ts - message-storage-directory.ts --- .../ralph-loop/completion-promise-detector.ts | 90 ++++ .../ralph-loop/continuation-prompt-builder.ts | 27 ++ .../continuation-prompt-injector.ts | 61 +++ src/hooks/ralph-loop/loop-session-recovery.ts | 33 ++ src/hooks/ralph-loop/loop-state-controller.ts | 81 ++++ .../ralph-loop/message-storage-directory.ts | 16 + .../ralph-loop/ralph-loop-event-handler.ts | 178 ++++++++ src/hooks/ralph-loop/ralph-loop-hook.ts | 423 +----------------- 8 files changed, 510 insertions(+), 399 deletions(-) create mode 100644 src/hooks/ralph-loop/completion-promise-detector.ts create mode 100644 src/hooks/ralph-loop/continuation-prompt-builder.ts create mode 100644 src/hooks/ralph-loop/continuation-prompt-injector.ts create mode 100644 src/hooks/ralph-loop/loop-session-recovery.ts create mode 100644 src/hooks/ralph-loop/loop-state-controller.ts create mode 100644 src/hooks/ralph-loop/message-storage-directory.ts create mode 100644 src/hooks/ralph-loop/ralph-loop-event-handler.ts diff --git a/src/hooks/ralph-loop/completion-promise-detector.ts b/src/hooks/ralph-loop/completion-promise-detector.ts new file mode 100644 index 000000000..8fa1f9145 --- /dev/null +++ b/src/hooks/ralph-loop/completion-promise-detector.ts @@ -0,0 +1,90 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { existsSync, readFileSync } from "node:fs" +import { log } from "../../shared/logger" +import { HOOK_NAME } from "./constants" + +interface OpenCodeSessionMessage { + info?: { role?: string } + parts?: Array<{ type: string; text?: string }> +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function buildPromisePattern(promise: string): RegExp { + return new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") +} + +export function detectCompletionInTranscript( + transcriptPath: string | undefined, + promise: string, +): boolean { + if (!transcriptPath) return false + + try { + if (!existsSync(transcriptPath)) return false + + const content = readFileSync(transcriptPath, "utf-8") + const pattern = buildPromisePattern(promise) + const lines = content.split("\n").filter((line) => line.trim()) + + for (const line of lines) { + try { + const entry = JSON.parse(line) as { type?: string } + if (entry.type === "user") continue + if (pattern.test(line)) return true + } catch { + continue + } + } + return false + } catch { + return false + } +} + +export async function detectCompletionInSessionMessages( + ctx: PluginInput, + options: { + sessionID: string + promise: string + apiTimeoutMs: number + directory: string + }, +): Promise { + try { + const response = await Promise.race([ + ctx.client.session.messages({ + path: { id: options.sessionID }, + query: { directory: options.directory }, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("API timeout")), options.apiTimeoutMs), + ), + ]) + + const messages = (response as { data?: unknown[] }).data ?? [] + if (!Array.isArray(messages)) return false + + const assistantMessages = (messages as OpenCodeSessionMessage[]).filter( + (msg) => msg.info?.role === "assistant", + ) + const lastAssistant = assistantMessages[assistantMessages.length - 1] + if (!lastAssistant?.parts) return false + + const pattern = buildPromisePattern(options.promise) + const responseText = lastAssistant.parts + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join("\n") + + return pattern.test(responseText) + } catch (err) { + log(`[${HOOK_NAME}] Session messages check failed`, { + sessionID: options.sessionID, + error: String(err), + }) + return false + } +} diff --git a/src/hooks/ralph-loop/continuation-prompt-builder.ts b/src/hooks/ralph-loop/continuation-prompt-builder.ts new file mode 100644 index 000000000..b2727b8fd --- /dev/null +++ b/src/hooks/ralph-loop/continuation-prompt-builder.ts @@ -0,0 +1,27 @@ +import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" +import type { RalphLoopState } from "./types" + +const CONTINUATION_PROMPT = `${SYSTEM_DIRECTIVE_PREFIX} - RALPH LOOP {{ITERATION}}/{{MAX}}] + +Your previous attempt did not output the completion promise. Continue working on the task. + +IMPORTANT: +- Review your progress so far +- Continue from where you left off +- When FULLY complete, output: {{PROMISE}} +- Do not stop until the task is truly done + +Original task: +{{PROMPT}}` + +export function buildContinuationPrompt(state: RalphLoopState): string { + const continuationPrompt = CONTINUATION_PROMPT.replace( + "{{ITERATION}}", + String(state.iteration), + ) + .replace("{{MAX}}", String(state.max_iterations)) + .replace("{{PROMISE}}", state.completion_promise) + .replace("{{PROMPT}}", state.prompt) + + return state.ultrawork ? `ultrawork ${continuationPrompt}` : continuationPrompt +} diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.ts b/src/hooks/ralph-loop/continuation-prompt-injector.ts new file mode 100644 index 000000000..45e6dba52 --- /dev/null +++ b/src/hooks/ralph-loop/continuation-prompt-injector.ts @@ -0,0 +1,61 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared/logger" +import { findNearestMessageWithFields } from "../../features/hook-message-injector" +import { getMessageDir } from "./message-storage-directory" + +type MessageInfo = { + agent?: string + model?: { providerID: string; modelID: string } + modelID?: string + providerID?: string +} + +export async function injectContinuationPrompt( + ctx: PluginInput, + options: { sessionID: string; prompt: string; directory: string }, +): Promise { + let agent: string | undefined + let model: { providerID: string; modelID: string } | undefined + + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: options.sessionID }, + }) + const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }> + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i]?.info + if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { + agent = info.agent + model = + info.model ?? + (info.providerID && info.modelID + ? { providerID: info.providerID, modelID: info.modelID } + : undefined) + break + } + } + } catch { + const messageDir = getMessageDir(options.sessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agent = currentMessage?.agent + model = + currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { + providerID: currentMessage.model.providerID, + modelID: currentMessage.model.modelID, + } + : undefined + } + + await ctx.client.session.promptAsync({ + path: { id: options.sessionID }, + body: { + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), + parts: [{ type: "text", text: options.prompt }], + }, + query: { directory: options.directory }, + }) + + log("[ralph-loop] continuation injected", { sessionID: options.sessionID }) +} diff --git a/src/hooks/ralph-loop/loop-session-recovery.ts b/src/hooks/ralph-loop/loop-session-recovery.ts new file mode 100644 index 000000000..517200e5f --- /dev/null +++ b/src/hooks/ralph-loop/loop-session-recovery.ts @@ -0,0 +1,33 @@ +type SessionState = { + isRecovering?: boolean +} + +export function createLoopSessionRecovery(options?: { recoveryWindowMs?: number }) { + const recoveryWindowMs = options?.recoveryWindowMs ?? 5000 + const sessions = new Map() + + function getSessionState(sessionID: string): SessionState { + let state = sessions.get(sessionID) + if (!state) { + state = {} + sessions.set(sessionID, state) + } + return state + } + + return { + isRecovering(sessionID: string): boolean { + return getSessionState(sessionID).isRecovering === true + }, + markRecovering(sessionID: string): void { + const state = getSessionState(sessionID) + state.isRecovering = true + setTimeout(() => { + state.isRecovering = false + }, recoveryWindowMs) + }, + clear(sessionID: string): void { + sessions.delete(sessionID) + }, + } +} diff --git a/src/hooks/ralph-loop/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts new file mode 100644 index 000000000..402f92978 --- /dev/null +++ b/src/hooks/ralph-loop/loop-state-controller.ts @@ -0,0 +1,81 @@ +import type { RalphLoopOptions, RalphLoopState } from "./types" +import { + DEFAULT_COMPLETION_PROMISE, + DEFAULT_MAX_ITERATIONS, + HOOK_NAME, +} from "./constants" +import { clearState, incrementIteration, readState, writeState } from "./storage" +import { log } from "../../shared/logger" + +export function createLoopStateController(options: { + directory: string + stateDir: string | undefined + config: RalphLoopOptions["config"] | undefined +}) { + const directory = options.directory + const stateDir = options.stateDir + const config = options.config + + return { + startLoop( + sessionID: string, + prompt: string, + loopOptions?: { + maxIterations?: number + completionPromise?: string + ultrawork?: boolean + }, + ): boolean { + const state: RalphLoopState = { + active: true, + iteration: 1, + max_iterations: + loopOptions?.maxIterations ?? + config?.default_max_iterations ?? + DEFAULT_MAX_ITERATIONS, + completion_promise: + loopOptions?.completionPromise ?? + DEFAULT_COMPLETION_PROMISE, + ultrawork: loopOptions?.ultrawork, + started_at: new Date().toISOString(), + prompt, + session_id: sessionID, + } + + const success = writeState(directory, state, stateDir) + if (success) { + log(`[${HOOK_NAME}] Loop started`, { + sessionID, + maxIterations: state.max_iterations, + completionPromise: state.completion_promise, + }) + } + return success + }, + + cancelLoop(sessionID: string): boolean { + const state = readState(directory, stateDir) + if (!state || state.session_id !== sessionID) { + return false + } + + const success = clearState(directory, stateDir) + if (success) { + log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration }) + } + return success + }, + + getState(): RalphLoopState | null { + return readState(directory, stateDir) + }, + + clear(): boolean { + return clearState(directory, stateDir) + }, + + incrementIteration(): RalphLoopState | null { + return incrementIteration(directory, stateDir) + }, + } +} diff --git a/src/hooks/ralph-loop/message-storage-directory.ts b/src/hooks/ralph-loop/message-storage-directory.ts new file mode 100644 index 000000000..7d4caca1b --- /dev/null +++ b/src/hooks/ralph-loop/message-storage-directory.ts @@ -0,0 +1,16 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "../../features/hook-message-injector" + +export 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 +} diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts new file mode 100644 index 000000000..5ba52b87a --- /dev/null +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -0,0 +1,178 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared/logger" +import type { RalphLoopOptions, RalphLoopState } from "./types" +import { HOOK_NAME } from "./constants" +import { + detectCompletionInSessionMessages, + detectCompletionInTranscript, +} from "./completion-promise-detector" +import { buildContinuationPrompt } from "./continuation-prompt-builder" +import { injectContinuationPrompt } from "./continuation-prompt-injector" + +type SessionRecovery = { + isRecovering: (sessionID: string) => boolean + markRecovering: (sessionID: string) => void + clear: (sessionID: string) => void +} +type LoopStateController = { getState: () => RalphLoopState | null; clear: () => boolean; incrementIteration: () => RalphLoopState | null } +type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; sessionRecovery: SessionRecovery; loopState: LoopStateController } + +export function createRalphLoopEventHandler( + ctx: PluginInput, + options: RalphLoopEventHandlerOptions, +) { + return async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { + const props = event.properties as Record | undefined + + if (event.type === "session.idle") { + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + if (options.sessionRecovery.isRecovering(sessionID)) { + log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) + return + } + + const state = options.loopState.getState() + if (!state || !state.active) { + return + } + + if (state.session_id && state.session_id !== sessionID) { + if (options.checkSessionExists) { + try { + const exists = await options.checkSessionExists(state.session_id) + if (!exists) { + options.loopState.clear() + log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, { + orphanedSessionId: state.session_id, + currentSessionId: sessionID, + }) + return + } + } catch (err) { + log(`[${HOOK_NAME}] Failed to check session existence`, { + sessionId: state.session_id, + error: String(err), + }) + } + } + return + } + + const transcriptPath = options.getTranscriptPath(sessionID) + const completionViaTranscript = detectCompletionInTranscript(transcriptPath, state.completion_promise) + const completionViaApi = completionViaTranscript + ? false + : await detectCompletionInSessionMessages(ctx, { + sessionID, + promise: state.completion_promise, + apiTimeoutMs: options.apiTimeoutMs, + directory: options.directory, + }) + + if (completionViaTranscript || completionViaApi) { + log(`[${HOOK_NAME}] Completion detected!`, { + sessionID, + iteration: state.iteration, + promise: state.completion_promise, + detectedVia: completionViaTranscript + ? "transcript_file" + : "session_messages_api", + }) + options.loopState.clear() + + const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!" + const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)` + await ctx.client.tui.showToast({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {}) + return + } + + if (state.iteration >= state.max_iterations) { + log(`[${HOOK_NAME}] Max iterations reached`, { + sessionID, + iteration: state.iteration, + max: state.max_iterations, + }) + options.loopState.clear() + + await ctx.client.tui + .showToast({ + body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 }, + }) + .catch(() => {}) + return + } + + const newState = options.loopState.incrementIteration() + if (!newState) { + log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID }) + return + } + + log(`[${HOOK_NAME}] Continuing loop`, { + sessionID, + iteration: newState.iteration, + max: newState.max_iterations, + }) + + await ctx.client.tui + .showToast({ + body: { + title: "Ralph Loop", + message: `Iteration ${newState.iteration}/${newState.max_iterations}`, + variant: "info", + duration: 2000, + }, + }) + .catch(() => {}) + + try { + await injectContinuationPrompt(ctx, { + sessionID, + prompt: buildContinuationPrompt(newState), + directory: options.directory, + }) + } catch (err) { + log(`[${HOOK_NAME}] Failed to inject continuation`, { + sessionID, + error: String(err), + }) + } + return + } + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (!sessionInfo?.id) return + const state = options.loopState.getState() + if (state?.session_id === sessionInfo.id) { + options.loopState.clear() + log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id }) + } + options.sessionRecovery.clear(sessionInfo.id) + return + } + + if (event.type === "session.error") { + const sessionID = props?.sessionID as string | undefined + const error = props?.error as { name?: string } | undefined + + if (error?.name === "MessageAbortedError") { + if (sessionID) { + const state = options.loopState.getState() + if (state?.session_id === sessionID) { + options.loopState.clear() + log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID }) + } + options.sessionRecovery.clear(sessionID) + } + return + } + + if (sessionID) { + options.sessionRecovery.markRecovering(sessionID) + } + } + } +} diff --git a/src/hooks/ralph-loop/ralph-loop-hook.ts b/src/hooks/ralph-loop/ralph-loop-hook.ts index 6be3a5e8e..d55a1882d 100644 --- a/src/hooks/ralph-loop/ralph-loop-hook.ts +++ b/src/hooks/ralph-loop/ralph-loop-hook.ts @@ -1,60 +1,9 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { existsSync, readFileSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { log } from "../../shared/logger" -import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" -import { readState, writeState, clearState, incrementIteration } from "./storage" -import { - HOOK_NAME, - DEFAULT_MAX_ITERATIONS, - DEFAULT_COMPLETION_PROMISE, -} from "./constants" -import type { RalphLoopState, RalphLoopOptions } from "./types" +import type { RalphLoopOptions, RalphLoopState } from "./types" import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" - -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 -} - -export * from "./types" -export * from "./constants" -export { readState, writeState, clearState, incrementIteration } from "./storage" - -interface SessionState { - isRecovering?: boolean -} - -interface OpenCodeSessionMessage { - info?: { - role?: string - } - parts?: Array<{ - type: string - text?: string - [key: string]: unknown - }> -} - -const CONTINUATION_PROMPT = `${SYSTEM_DIRECTIVE_PREFIX} - RALPH LOOP {{ITERATION}}/{{MAX}}] - -Your previous attempt did not output the completion promise. Continue working on the task. - -IMPORTANT: -- Review your progress so far -- Continue from where you left off -- When FULLY complete, output: {{PROMISE}} -- Do not stop until the task is truly done - -Original task: -{{PROMPT}}` +import { createLoopSessionRecovery } from "./loop-session-recovery" +import { createLoopStateController } from "./loop-state-controller" +import { createRalphLoopEventHandler } from "./ralph-loop-event-handler" export interface RalphLoopHook { event: (input: { event: { type: string; properties?: unknown } }) => Promise @@ -73,356 +22,32 @@ export function createRalphLoopHook( ctx: PluginInput, options?: RalphLoopOptions ): RalphLoopHook { - const sessions = new Map() const config = options?.config const stateDir = config?.state_dir const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT const checkSessionExists = options?.checkSessionExists - function getSessionState(sessionID: string): SessionState { - let state = sessions.get(sessionID) - if (!state) { - state = {} - sessions.set(sessionID, state) - } - return state - } + const loopState = createLoopStateController({ + directory: ctx.directory, + stateDir, + config, + }) + const sessionRecovery = createLoopSessionRecovery() - function detectCompletionPromise( - transcriptPath: string | undefined, - promise: string - ): boolean { - if (!transcriptPath) return false + const event = createRalphLoopEventHandler(ctx, { + directory: ctx.directory, + apiTimeoutMs: apiTimeout, + getTranscriptPath, + checkSessionExists, + sessionRecovery, + loopState, + }) - try { - if (!existsSync(transcriptPath)) return false - - const content = readFileSync(transcriptPath, "utf-8") - const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") - const lines = content.split("\n").filter(l => l.trim()) - - for (const line of lines) { - try { - const entry = JSON.parse(line) - if (entry.type === "user") continue - if (pattern.test(line)) return true - } catch { - continue - } - } - return false - } catch { - return false - } - } - - function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - } - - async function detectCompletionInSessionMessages( - sessionID: string, - promise: string - ): Promise { - try { - const response = await Promise.race([ - ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("API timeout")), apiTimeout) - ), - ]) - - const messages = (response as { data?: unknown[] }).data ?? [] - if (!Array.isArray(messages)) return false - - const assistantMessages = (messages as OpenCodeSessionMessage[]).filter( - (msg) => msg.info?.role === "assistant" - ) - const lastAssistant = assistantMessages[assistantMessages.length - 1] - if (!lastAssistant?.parts) return false - - const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") - const responseText = lastAssistant.parts - .filter((p) => p.type === "text") - .map((p) => p.text ?? "") - .join("\n") - - return pattern.test(responseText) - } catch (err) { - log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) }) - return false - } - } - - const startLoop = ( - sessionID: string, - prompt: string, - loopOptions?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean } - ): boolean => { - const state: RalphLoopState = { - active: true, - iteration: 1, - max_iterations: - loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS, - completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE, - ultrawork: loopOptions?.ultrawork, - started_at: new Date().toISOString(), - prompt, - session_id: sessionID, - } - - const success = writeState(ctx.directory, state, stateDir) - if (success) { - log(`[${HOOK_NAME}] Loop started`, { - sessionID, - maxIterations: state.max_iterations, - completionPromise: state.completion_promise, - }) - } - return success - } - - const cancelLoop = (sessionID: string): boolean => { - const state = readState(ctx.directory, stateDir) - if (!state || state.session_id !== sessionID) { - return false - } - - const success = clearState(ctx.directory, stateDir) - if (success) { - log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration }) - } - return success - } - - const getState = (): RalphLoopState | null => { - return readState(ctx.directory, stateDir) - } - - const event = async ({ - event, - }: { - event: { type: string; properties?: unknown } - }): Promise => { - const props = event.properties as Record | undefined - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const sessionState = getSessionState(sessionID) - if (sessionState.isRecovering) { - log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) - return - } - - const state = readState(ctx.directory, stateDir) - if (!state || !state.active) { - return - } - - if (state.session_id && state.session_id !== sessionID) { - if (checkSessionExists) { - try { - const originalSessionExists = await checkSessionExists(state.session_id) - if (!originalSessionExists) { - clearState(ctx.directory, stateDir) - log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, { - orphanedSessionId: state.session_id, - currentSessionId: sessionID, - }) - return - } - } catch (err) { - log(`[${HOOK_NAME}] Failed to check session existence`, { - sessionId: state.session_id, - error: String(err), - }) - } - } - return - } - - const transcriptPath = getTranscriptPath(sessionID) - const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise) - - const completionDetectedViaApi = completionDetectedViaTranscript - ? false - : await detectCompletionInSessionMessages(sessionID, state.completion_promise) - - if (completionDetectedViaTranscript || completionDetectedViaApi) { - log(`[${HOOK_NAME}] Completion detected!`, { - sessionID, - iteration: state.iteration, - promise: state.completion_promise, - detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api", - }) - clearState(ctx.directory, stateDir) - - const title = state.ultrawork - ? "ULTRAWORK LOOP COMPLETE!" - : "Ralph Loop Complete!" - const message = state.ultrawork - ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` - : `Task completed after ${state.iteration} iteration(s)` - - await ctx.client.tui - .showToast({ - body: { - title, - message, - variant: "success", - duration: 5000, - }, - }) - .catch(() => {}) - - return - } - - if (state.iteration >= state.max_iterations) { - log(`[${HOOK_NAME}] Max iterations reached`, { - sessionID, - iteration: state.iteration, - max: state.max_iterations, - }) - clearState(ctx.directory, stateDir) - - await ctx.client.tui - .showToast({ - body: { - title: "Ralph Loop Stopped", - message: `Max iterations (${state.max_iterations}) reached without completion`, - variant: "warning", - duration: 5000, - }, - }) - .catch(() => {}) - - return - } - - const newState = incrementIteration(ctx.directory, stateDir) - if (!newState) { - log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID }) - return - } - - log(`[${HOOK_NAME}] Continuing loop`, { - sessionID, - iteration: newState.iteration, - max: newState.max_iterations, - }) - - const continuationPrompt = CONTINUATION_PROMPT.replace("{{ITERATION}}", String(newState.iteration)) - .replace("{{MAX}}", String(newState.max_iterations)) - .replace("{{PROMISE}}", newState.completion_promise) - .replace("{{PROMPT}}", newState.prompt) - - const finalPrompt = newState.ultrawork - ? `ultrawork ${continuationPrompt}` - : continuationPrompt - - await ctx.client.tui - .showToast({ - body: { - title: "Ralph Loop", - message: `Iteration ${newState.iteration}/${newState.max_iterations}`, - variant: "info", - duration: 2000, - }, - }) - .catch(() => {}) - - try { - let agent: string | undefined - let model: { providerID: string; modelID: string } | undefined - - try { - const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - agent = info.agent - model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) - break - } - } - } catch { - const messageDir = getMessageDir(sessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agent = currentMessage?.agent - model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - } - - await ctx.client.session.promptAsync({ - path: { id: sessionID }, - body: { - ...(agent !== undefined ? { agent } : {}), - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: finalPrompt }], - }, - query: { directory: ctx.directory }, - }) - } catch (err) { - log(`[${HOOK_NAME}] Failed to inject continuation`, { - sessionID, - error: String(err), - }) - } - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - const state = readState(ctx.directory, stateDir) - if (state?.session_id === sessionInfo.id) { - clearState(ctx.directory, stateDir) - log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id }) - } - sessions.delete(sessionInfo.id) - } - } - - if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined - const error = props?.error as { name?: string } | undefined - - if (error?.name === "MessageAbortedError") { - if (sessionID) { - const state = readState(ctx.directory, stateDir) - if (state?.session_id === sessionID) { - clearState(ctx.directory, stateDir) - log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID }) - } - sessions.delete(sessionID) - } - return - } - - if (sessionID) { - const sessionState = getSessionState(sessionID) - sessionState.isRecovering = true - setTimeout(() => { - sessionState.isRecovering = false - }, 5000) - } - } - } - - return { - event, - startLoop, - cancelLoop, - getState, - } + return { + event, + startLoop: loopState.startLoop, + cancelLoop: loopState.cancelLoop, + getState: loopState.getState as () => RalphLoopState | null, + } } From d3a3f0c3a64a2f88a063bffca6132883764e1b43 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:22:17 +0900 Subject: [PATCH 13/51] refactor(claude-code-hooks): extract handlers and session state Split hook into per-concern modules: - handlers/ directory for individual hook handlers - session-hook-state.ts: session-level hook state management --- .../claude-code-hooks-hook.ts | 419 +----------------- .../handlers/chat-message-handler.ts | 140 ++++++ .../handlers/pre-compact-handler.ts | 41 ++ .../handlers/session-event-handler.ts | 111 +++++ .../handlers/tool-execute-after-handler.ts | 105 +++++ .../handlers/tool-execute-before-handler.ts | 92 ++++ .../claude-code-hooks/session-hook-state.ts | 11 + 7 files changed, 510 insertions(+), 409 deletions(-) create mode 100644 src/hooks/claude-code-hooks/handlers/chat-message-handler.ts create mode 100644 src/hooks/claude-code-hooks/handlers/pre-compact-handler.ts create mode 100644 src/hooks/claude-code-hooks/handlers/session-event-handler.ts create mode 100644 src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts create mode 100644 src/hooks/claude-code-hooks/handlers/tool-execute-before-handler.ts create mode 100644 src/hooks/claude-code-hooks/session-hook-state.ts diff --git a/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts b/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts index de8bc9c47..b4c2a3124 100644 --- a/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts +++ b/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts @@ -1,37 +1,11 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { loadClaudeHooksConfig } from "./config" -import { loadPluginExtendedConfig } from "./config-loader" -import { - executePreToolUseHooks, - type PreToolUseContext, -} from "./pre-tool-use" -import { - executePostToolUseHooks, - type PostToolUseContext, - type PostToolUseClient, -} from "./post-tool-use" -import { - executeUserPromptSubmitHooks, - type UserPromptSubmitContext, - type MessagePart, -} from "./user-prompt-submit" -import { - executeStopHooks, - type StopContext, -} from "./stop" -import { - executePreCompactHooks, - type PreCompactContext, -} from "./pre-compact" -import { cacheToolInput, getToolInput } from "./tool-input-cache" -import { appendTranscriptEntry, getTranscriptPath } from "./transcript" import type { PluginConfig } from "./types" -import { log, isHookDisabled } from "../../shared" import type { ContextCollector } from "../../features/context-injector" - -const sessionFirstMessageProcessed = new Set() -const sessionErrorState = new Map() -const sessionInterruptState = new Map() +import { createChatMessageHandler } from "./handlers/chat-message-handler" +import { createPreCompactHandler } from "./handlers/pre-compact-handler" +import { createSessionEventHandler } from "./handlers/session-event-handler" +import { createToolExecuteAfterHandler } from "./handlers/tool-execute-after-handler" +import { createToolExecuteBeforeHandler } from "./handlers/tool-execute-before-handler" export function createClaudeCodeHooksHook( ctx: PluginInput, @@ -39,383 +13,10 @@ export function createClaudeCodeHooksHook( contextCollector?: ContextCollector ) { return { - "experimental.session.compacting": async ( - input: { sessionID: string }, - output: { context: string[] } - ): Promise => { - if (isHookDisabled(config, "PreCompact")) { - return - } - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const preCompactCtx: PreCompactContext = { - sessionId: input.sessionID, - cwd: ctx.directory, - } - - const result = await executePreCompactHooks(preCompactCtx, claudeConfig, extendedConfig) - - if (result.context.length > 0) { - log("PreCompact hooks injecting context", { - sessionID: input.sessionID, - contextCount: result.context.length, - hookName: result.hookName, - elapsedMs: result.elapsedMs, - }) - output.context.push(...result.context) - } - }, - - "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 interruptState = sessionInterruptState.get(input.sessionID) - if (interruptState?.interrupted) { - log("chat.message hook skipped - session interrupted", { sessionID: input.sessionID }) - return - } - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const textParts = output.parts.filter((p) => p.type === "text" && p.text) - const prompt = textParts.map((p) => p.text ?? "").join("\n") - - appendTranscriptEntry(input.sessionID, { - type: "user", - timestamp: new Date().toISOString(), - content: prompt, - }) - - const messageParts: MessagePart[] = textParts.map((p) => ({ - type: p.type as "text", - text: p.text, - })) - - const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID) - if (interruptStateBeforeHooks?.interrupted) { - log("chat.message hooks skipped - interrupted during preparation", { sessionID: input.sessionID }) - return - } - - let parentSessionId: string | undefined - try { - const sessionInfo = await ctx.client.session.get({ - path: { id: input.sessionID }, - }) - parentSessionId = sessionInfo.data?.parentID - } catch {} - - const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID) - sessionFirstMessageProcessed.add(input.sessionID) - - if (!isHookDisabled(config, "UserPromptSubmit")) { - const userPromptCtx: UserPromptSubmitContext = { - sessionId: input.sessionID, - parentSessionId, - prompt, - parts: messageParts, - cwd: ctx.directory, - } - - const result = await executeUserPromptSubmitHooks( - userPromptCtx, - claudeConfig, - extendedConfig - ) - - if (result.block) { - throw new Error(result.reason ?? "Hook blocked the prompt") - } - - const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID) - if (interruptStateAfterHooks?.interrupted) { - log("chat.message injection skipped - interrupted during hooks", { sessionID: input.sessionID }) - return - } - - if (result.messages.length > 0) { - const hookContent = result.messages.join("\n\n") - log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage }) - - if (contextCollector) { - log("[DEBUG] Registering hook content to contextCollector", { - sessionID: input.sessionID, - contentLength: hookContent.length, - contentPreview: hookContent.slice(0, 100), - }) - contextCollector.register(input.sessionID, { - id: "hook-context", - source: "custom", - content: hookContent, - priority: "high", - }) - - log("Hook content registered for synthetic message injection", { - sessionID: input.sessionID, - contentLength: hookContent.length, - }) - } - } - } - }, - - "tool.execute.before": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: Record } - ): Promise => { - if (input.tool.trim() === "todowrite" && typeof output.args.todos === "string") { - let parsed: unknown - try { - parsed = JSON.parse(output.args.todos) - } catch (e) { - throw new Error( - `[todowrite ERROR] Failed to parse todos string as JSON. ` + - `Received: ${output.args.todos.length > 100 ? output.args.todos.slice(0, 100) + '...' : output.args.todos} ` + - `Expected: Valid JSON array. Pass todos as an array, not a string.` - ) - } - - if (!Array.isArray(parsed)) { - throw new Error( - `[todowrite ERROR] Parsed JSON is not an array. ` + - `Received type: ${typeof parsed}. ` + - `Expected: Array of todo objects. Pass todos as [{id, content, status, priority}, ...].` - ) - } - - output.args.todos = parsed - log("todowrite: parsed todos string to array", { sessionID: input.sessionID }) - } - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - appendTranscriptEntry(input.sessionID, { - type: "tool_use", - timestamp: new Date().toISOString(), - tool_name: input.tool, - tool_input: output.args as Record, - }) - - cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record) - - if (!isHookDisabled(config, "PreToolUse")) { - const preCtx: PreToolUseContext = { - sessionId: input.sessionID, - toolName: input.tool, - toolInput: output.args as Record, - cwd: ctx.directory, - toolUseId: input.callID, - } - - const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig) - - if (result.decision === "deny") { - ctx.client.tui - .showToast({ - body: { - title: "PreToolUse Hook Executed", - message: `[BLOCKED] ${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`, - variant: "error" as const, - duration: 4000, - }, - }) - .catch(() => {}) - throw new Error(result.reason ?? "Hook blocked the operation") - } - - if (result.modifiedInput) { - Object.assign(output.args as Record, result.modifiedInput) - } - } - }, - - "tool.execute.after": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { title: string; output: string; metadata: unknown } - ): Promise => { - // Guard against undefined output (e.g., from /review command - see issue #1035) - if (!output) { - return - } - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {} - - // Use metadata if available and non-empty, otherwise wrap output.output in a structured object - // This ensures plugin tools (call_omo_agent, task) that return strings - // get their results properly recorded in transcripts instead of empty {} - const metadata = output.metadata as Record | undefined - const hasMetadata = metadata && typeof metadata === "object" && Object.keys(metadata).length > 0 - const toolOutput = hasMetadata ? metadata : { output: output.output } - appendTranscriptEntry(input.sessionID, { - type: "tool_result", - timestamp: new Date().toISOString(), - tool_name: input.tool, - tool_input: cachedInput, - tool_output: toolOutput, - }) - - if (!isHookDisabled(config, "PostToolUse")) { - const postClient: PostToolUseClient = { - session: { - messages: (opts) => ctx.client.session.messages(opts), - }, - } - - const postCtx: PostToolUseContext = { - sessionId: input.sessionID, - toolName: input.tool, - toolInput: cachedInput, - toolOutput: { - title: input.tool, - output: output.output, - metadata: output.metadata as Record, - }, - cwd: ctx.directory, - transcriptPath: getTranscriptPath(input.sessionID), - toolUseId: input.callID, - client: postClient, - permissionMode: "bypassPermissions", - } - - const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig) - - if (result.block) { - ctx.client.tui - .showToast({ - body: { - title: "PostToolUse Hook Warning", - message: result.reason ?? "Hook returned warning", - variant: "warning", - duration: 4000, - }, - }) - .catch(() => {}) - } - - if (result.warnings && result.warnings.length > 0) { - output.output = `${output.output}\n\n${result.warnings.join("\n")}` - } - - if (result.message) { - output.output = `${output.output}\n\n${result.message}` - } - - if (result.hookName) { - ctx.client.tui - .showToast({ - body: { - title: "PostToolUse Hook Executed", - message: `▶ ${result.toolName ?? input.tool} ${result.hookName}: ${result.elapsedMs ?? 0}ms`, - variant: "success", - duration: 2000, - }, - }) - .catch(() => {}) - } - } - }, - - event: async (input: { event: { type: string; properties?: unknown } }) => { - const { event } = input - - if (event.type === "session.error") { - const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined - if (sessionID) { - sessionErrorState.set(sessionID, { - hasError: true, - errorMessage: String(props?.error ?? "Unknown error"), - }) - } - return - } - - if (event.type === "session.deleted") { - const props = event.properties as Record | undefined - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - sessionErrorState.delete(sessionInfo.id) - sessionInterruptState.delete(sessionInfo.id) - sessionFirstMessageProcessed.delete(sessionInfo.id) - } - return - } - - if (event.type === "session.idle") { - const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined - - if (!sessionID) return - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const errorStateBefore = sessionErrorState.get(sessionID) - const endedWithErrorBefore = errorStateBefore?.hasError === true - const interruptStateBefore = sessionInterruptState.get(sessionID) - const interruptedBefore = interruptStateBefore?.interrupted === true - - let parentSessionId: string | undefined - try { - const sessionInfo = await ctx.client.session.get({ - path: { id: sessionID }, - }) - parentSessionId = sessionInfo.data?.parentID - } catch {} - - if (!isHookDisabled(config, "Stop")) { - const stopCtx: StopContext = { - sessionId: sessionID, - parentSessionId, - cwd: ctx.directory, - } - - const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig) - - const errorStateAfter = sessionErrorState.get(sessionID) - const endedWithErrorAfter = errorStateAfter?.hasError === true - const interruptStateAfter = sessionInterruptState.get(sessionID) - const interruptedAfter = interruptStateAfter?.interrupted === true - - const shouldBypass = endedWithErrorBefore || endedWithErrorAfter || interruptedBefore || interruptedAfter - - if (shouldBypass && stopResult.block) { - const interrupted = interruptedBefore || interruptedAfter - const endedWithError = endedWithErrorBefore || endedWithErrorAfter - log("Stop hook block ignored", { sessionID, block: stopResult.block, interrupted, endedWithError }) - } else if (stopResult.block && stopResult.injectPrompt) { - log("Stop hook returned block with inject_prompt", { sessionID }) - ctx.client.session - .prompt({ - path: { id: sessionID }, - body: { parts: [{ type: "text", text: stopResult.injectPrompt }] }, - query: { directory: ctx.directory }, - }) - .catch((err: unknown) => log("Failed to inject prompt from Stop hook", err)) - } else if (stopResult.block) { - log("Stop hook returned block", { sessionID, reason: stopResult.reason }) - } - } - - sessionErrorState.delete(sessionID) - sessionInterruptState.delete(sessionID) - } - }, + "experimental.session.compacting": createPreCompactHandler(ctx, config), + "chat.message": createChatMessageHandler(ctx, config, contextCollector), + "tool.execute.before": createToolExecuteBeforeHandler(ctx, config), + "tool.execute.after": createToolExecuteAfterHandler(ctx, config), + event: createSessionEventHandler(ctx, config), } } diff --git a/src/hooks/claude-code-hooks/handlers/chat-message-handler.ts b/src/hooks/claude-code-hooks/handlers/chat-message-handler.ts new file mode 100644 index 000000000..927e6b96d --- /dev/null +++ b/src/hooks/claude-code-hooks/handlers/chat-message-handler.ts @@ -0,0 +1,140 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { loadClaudeHooksConfig } from "../config" +import { loadPluginExtendedConfig } from "../config-loader" +import { + executeUserPromptSubmitHooks, + type MessagePart, + type UserPromptSubmitContext, +} from "../user-prompt-submit" +import type { PluginConfig } from "../types" +import type { ContextCollector } from "../../../features/context-injector" +import { isHookDisabled, log } from "../../../shared" +import { appendTranscriptEntry } from "../transcript" +import { sessionFirstMessageProcessed, sessionInterruptState } from "../session-hook-state" + +export function createChatMessageHandler( + ctx: PluginInput, + config: PluginConfig, + contextCollector?: ContextCollector, +) { + return 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 interruptState = sessionInterruptState.get(input.sessionID) + if (interruptState?.interrupted) { + log("chat.message hook skipped - session interrupted", { + sessionID: input.sessionID, + }) + return + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const textParts = output.parts.filter((p) => p.type === "text" && p.text) + const prompt = textParts.map((p) => p.text ?? "").join("\n") + + appendTranscriptEntry(input.sessionID, { + type: "user", + timestamp: new Date().toISOString(), + content: prompt, + }) + + const messageParts: MessagePart[] = textParts.map((p) => ({ + type: "text", + text: p.text, + })) + + const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID) + if (interruptStateBeforeHooks?.interrupted) { + log("chat.message hooks skipped - interrupted during preparation", { + sessionID: input.sessionID, + }) + return + } + + let parentSessionId: string | undefined + try { + const sessionInfo = await ctx.client.session.get({ + path: { id: input.sessionID }, + }) + parentSessionId = sessionInfo.data?.parentID + } catch { + parentSessionId = undefined + } + + const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID) + sessionFirstMessageProcessed.add(input.sessionID) + + if (isHookDisabled(config, "UserPromptSubmit")) { + return + } + + const userPromptCtx: UserPromptSubmitContext = { + sessionId: input.sessionID, + parentSessionId, + prompt, + parts: messageParts, + cwd: ctx.directory, + } + + const result = await executeUserPromptSubmitHooks( + userPromptCtx, + claudeConfig, + extendedConfig, + ) + + if (result.block) { + throw new Error(result.reason ?? "Hook blocked the prompt") + } + + const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID) + if (interruptStateAfterHooks?.interrupted) { + log("chat.message injection skipped - interrupted during hooks", { + sessionID: input.sessionID, + }) + return + } + + if (result.messages.length === 0) { + return + } + + const hookContent = result.messages.join("\n\n") + log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { + sessionID: input.sessionID, + contentLength: hookContent.length, + isFirstMessage, + }) + + if (!contextCollector) { + return + } + + log("[DEBUG] Registering hook content to contextCollector", { + sessionID: input.sessionID, + contentLength: hookContent.length, + contentPreview: hookContent.slice(0, 100), + }) + contextCollector.register(input.sessionID, { + id: "hook-context", + source: "custom", + content: hookContent, + priority: "high", + }) + + log("Hook content registered for synthetic message injection", { + sessionID: input.sessionID, + contentLength: hookContent.length, + }) + } +} diff --git a/src/hooks/claude-code-hooks/handlers/pre-compact-handler.ts b/src/hooks/claude-code-hooks/handlers/pre-compact-handler.ts new file mode 100644 index 000000000..832d053dd --- /dev/null +++ b/src/hooks/claude-code-hooks/handlers/pre-compact-handler.ts @@ -0,0 +1,41 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { loadClaudeHooksConfig } from "../config" +import { loadPluginExtendedConfig } from "../config-loader" +import { executePreCompactHooks, type PreCompactContext } from "../pre-compact" +import type { PluginConfig } from "../types" +import { isHookDisabled, log } from "../../../shared" + +export function createPreCompactHandler(ctx: PluginInput, config: PluginConfig) { + return async ( + input: { sessionID: string }, + output: { context: string[] }, + ): Promise => { + if (isHookDisabled(config, "PreCompact")) { + return + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const preCompactCtx: PreCompactContext = { + sessionId: input.sessionID, + cwd: ctx.directory, + } + + const result = await executePreCompactHooks( + preCompactCtx, + claudeConfig, + extendedConfig, + ) + + if (result.context.length > 0) { + log("PreCompact hooks injecting context", { + sessionID: input.sessionID, + contextCount: result.context.length, + hookName: result.hookName, + elapsedMs: result.elapsedMs, + }) + output.context.push(...result.context) + } + } +} diff --git a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts new file mode 100644 index 000000000..6c6d3a584 --- /dev/null +++ b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts @@ -0,0 +1,111 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { loadClaudeHooksConfig } from "../config" +import { loadPluginExtendedConfig } from "../config-loader" +import { executeStopHooks, type StopContext } from "../stop" +import type { PluginConfig } from "../types" +import { isHookDisabled, log } from "../../../shared" +import { + clearSessionHookState, + sessionErrorState, + sessionInterruptState, +} from "../session-hook-state" + +export function createSessionEventHandler(ctx: PluginInput, config: PluginConfig) { + return async (input: { event: { type: string; properties?: unknown } }) => { + const { event } = input + + if (event.type === "session.error") { + const props = event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + if (sessionID) { + sessionErrorState.set(sessionID, { + hasError: true, + errorMessage: String(props?.error ?? "Unknown error"), + }) + } + return + } + + if (event.type === "session.deleted") { + const props = event.properties as Record | undefined + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + clearSessionHookState(sessionInfo.id) + } + return + } + + if (event.type !== "session.idle") { + return + } + + const props = event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const errorStateBefore = sessionErrorState.get(sessionID) + const endedWithErrorBefore = errorStateBefore?.hasError === true + const interruptStateBefore = sessionInterruptState.get(sessionID) + const interruptedBefore = interruptStateBefore?.interrupted === true + + let parentSessionId: string | undefined + try { + const sessionInfo = await ctx.client.session.get({ + path: { id: sessionID }, + }) + parentSessionId = sessionInfo.data?.parentID + } catch { + parentSessionId = undefined + } + + if (!isHookDisabled(config, "Stop")) { + const stopCtx: StopContext = { + sessionId: sessionID, + parentSessionId, + cwd: ctx.directory, + } + + const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig) + + const errorStateAfter = sessionErrorState.get(sessionID) + const endedWithErrorAfter = errorStateAfter?.hasError === true + const interruptStateAfter = sessionInterruptState.get(sessionID) + const interruptedAfter = interruptStateAfter?.interrupted === true + + const shouldBypass = + endedWithErrorBefore || + endedWithErrorAfter || + interruptedBefore || + interruptedAfter + + if (shouldBypass && stopResult.block) { + log("Stop hook block ignored", { + sessionID, + block: stopResult.block, + interrupted: interruptedBefore || interruptedAfter, + endedWithError: endedWithErrorBefore || endedWithErrorAfter, + }) + } else if (stopResult.block && stopResult.injectPrompt) { + log("Stop hook returned block with inject_prompt", { sessionID }) + ctx.client.session + .prompt({ + path: { id: sessionID }, + body: { + parts: [{ type: "text", text: stopResult.injectPrompt }], + }, + query: { directory: ctx.directory }, + }) + .catch((err: unknown) => + log("Failed to inject prompt from Stop hook", { error: String(err) }), + ) + } else if (stopResult.block) { + log("Stop hook returned block", { sessionID, reason: stopResult.reason }) + } + } + + clearSessionHookState(sessionID) + } +} diff --git a/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts b/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts new file mode 100644 index 000000000..0dc934ea2 --- /dev/null +++ b/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts @@ -0,0 +1,105 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { loadClaudeHooksConfig } from "../config" +import { loadPluginExtendedConfig } from "../config-loader" +import { + executePostToolUseHooks, + type PostToolUseClient, + type PostToolUseContext, +} from "../post-tool-use" +import { getToolInput } from "../tool-input-cache" +import { appendTranscriptEntry, getTranscriptPath } from "../transcript" +import type { PluginConfig } from "../types" +import { isHookDisabled, log } from "../../../shared" + +export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginConfig) { + return async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown } | undefined, + ): Promise => { + if (!output) { + return + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {} + + const metadata = output.metadata as Record | undefined + const hasMetadata = + metadata && typeof metadata === "object" && Object.keys(metadata).length > 0 + const toolOutput = hasMetadata ? metadata : { output: output.output } + + appendTranscriptEntry(input.sessionID, { + type: "tool_result", + timestamp: new Date().toISOString(), + tool_name: input.tool, + tool_input: cachedInput, + tool_output: toolOutput, + }) + + if (isHookDisabled(config, "PostToolUse")) { + return + } + + const postClient: PostToolUseClient = { + session: { + messages: (opts) => ctx.client.session.messages(opts), + }, + } + + const postCtx: PostToolUseContext = { + sessionId: input.sessionID, + toolName: input.tool, + toolInput: cachedInput, + toolOutput: { + title: input.tool, + output: output.output, + metadata: output.metadata as Record, + }, + cwd: ctx.directory, + transcriptPath: getTranscriptPath(input.sessionID), + toolUseId: input.callID, + client: postClient, + permissionMode: "bypassPermissions", + } + + const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig) + + if (result.block) { + ctx.client.tui + .showToast({ + body: { + title: "PostToolUse Hook Warning", + message: result.reason ?? "Hook returned warning", + variant: "warning", + duration: 4000, + }, + }) + .catch(() => {}) + } + + if (result.warnings && result.warnings.length > 0) { + output.output = `${output.output}\n\n${result.warnings.join("\n")}` + } + + if (result.message) { + output.output = `${output.output}\n\n${result.message}` + } + + if (result.hookName) { + ctx.client.tui + .showToast({ + body: { + title: "PostToolUse Hook Executed", + message: `▶ ${result.toolName ?? input.tool} ${result.hookName}: ${ + result.elapsedMs ?? 0 + }ms`, + variant: "success", + duration: 2000, + }, + }) + .catch(() => {}) + } + } +} diff --git a/src/hooks/claude-code-hooks/handlers/tool-execute-before-handler.ts b/src/hooks/claude-code-hooks/handlers/tool-execute-before-handler.ts new file mode 100644 index 000000000..164c52682 --- /dev/null +++ b/src/hooks/claude-code-hooks/handlers/tool-execute-before-handler.ts @@ -0,0 +1,92 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { loadClaudeHooksConfig } from "../config" +import { loadPluginExtendedConfig } from "../config-loader" +import { + executePreToolUseHooks, + type PreToolUseContext, +} from "../pre-tool-use" +import { appendTranscriptEntry } from "../transcript" +import { cacheToolInput } from "../tool-input-cache" +import type { PluginConfig } from "../types" +import { isHookDisabled, log } from "../../../shared" + +export function createToolExecuteBeforeHandler(ctx: PluginInput, config: PluginConfig) { + return async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record }, + ): Promise => { + if (input.tool.trim() === "todowrite" && typeof output.args.todos === "string") { + let parsed: unknown + try { + parsed = JSON.parse(output.args.todos) + } catch { + throw new Error( + `[todowrite ERROR] Failed to parse todos string as JSON. ` + + `Received: ${ + output.args.todos.length > 100 + ? output.args.todos.slice(0, 100) + "..." + : output.args.todos + } ` + + `Expected: Valid JSON array. Pass todos as an array, not a string.`, + ) + } + + if (!Array.isArray(parsed)) { + throw new Error( + `[todowrite ERROR] Parsed JSON is not an array. ` + + `Received type: ${typeof parsed}. ` + + `Expected: Array of todo objects. Pass todos as [{id, content, status, priority}, ...].`, + ) + } + + output.args.todos = parsed + log("todowrite: parsed todos string to array", { sessionID: input.sessionID }) + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + appendTranscriptEntry(input.sessionID, { + type: "tool_use", + timestamp: new Date().toISOString(), + tool_name: input.tool, + tool_input: output.args, + }) + + cacheToolInput(input.sessionID, input.tool, input.callID, output.args) + + if (isHookDisabled(config, "PreToolUse")) { + return + } + + const preCtx: PreToolUseContext = { + sessionId: input.sessionID, + toolName: input.tool, + toolInput: output.args, + cwd: ctx.directory, + toolUseId: input.callID, + } + + const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig) + + if (result.decision === "deny") { + ctx.client.tui + .showToast({ + body: { + title: "PreToolUse Hook Executed", + message: `[BLOCKED] ${result.toolName ?? input.tool} ${ + result.hookName ?? "hook" + }: ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`, + variant: "error" as const, + duration: 4000, + }, + }) + .catch(() => {}) + throw new Error(result.reason ?? "Hook blocked the operation") + } + + if (result.modifiedInput) { + Object.assign(output.args, result.modifiedInput) + } + } +} diff --git a/src/hooks/claude-code-hooks/session-hook-state.ts b/src/hooks/claude-code-hooks/session-hook-state.ts new file mode 100644 index 000000000..50a2887cb --- /dev/null +++ b/src/hooks/claude-code-hooks/session-hook-state.ts @@ -0,0 +1,11 @@ +export const sessionFirstMessageProcessed = new Set() + +export const sessionErrorState = new Map() + +export const sessionInterruptState = new Map() + +export function clearSessionHookState(sessionID: string): void { + sessionErrorState.delete(sessionID) + sessionInterruptState.delete(sessionID) + sessionFirstMessageProcessed.delete(sessionID) +} From c2efdb43340fafde83dbddc29de0d5ad026a119b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:22:25 +0900 Subject: [PATCH 14/51] refactor(interactive-bash-session): extract tracker and command parser Split hook into focused modules: - interactive-bash-session-tracker.ts: session tracking logic - tmux-command-parser.ts: tmux command parsing utilities --- src/hooks/interactive-bash-session/index.ts | 2 + .../interactive-bash-session-hook.ts | 216 ++---------------- .../interactive-bash-session-tracker.ts | 118 ++++++++++ .../tmux-command-parser.ts | 125 ++++++++++ 4 files changed, 260 insertions(+), 201 deletions(-) create mode 100644 src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts create mode 100644 src/hooks/interactive-bash-session/tmux-command-parser.ts diff --git a/src/hooks/interactive-bash-session/index.ts b/src/hooks/interactive-bash-session/index.ts index b9be8e12b..22b925ff4 100644 --- a/src/hooks/interactive-bash-session/index.ts +++ b/src/hooks/interactive-bash-session/index.ts @@ -1 +1,3 @@ export { createInteractiveBashSessionHook } from "./interactive-bash-session-hook" +export { createInteractiveBashSessionTracker } from "./interactive-bash-session-tracker" +export { parseTmuxCommand } from "./tmux-command-parser" diff --git a/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts b/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts index dd0a87002..b3ba0976b 100644 --- a/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts +++ b/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts @@ -1,12 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin"; -import { - loadInteractiveBashSessionState, - saveInteractiveBashSessionState, - clearInteractiveBashSessionState, -} from "./storage"; -import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants"; -import type { InteractiveBashSessionState } from "./types"; -import { subagentSessions } from "../../features/claude-code-session-state"; +import { createInteractiveBashSessionTracker } from "./interactive-bash-session-tracker"; +import { parseTmuxCommand } from "./tmux-command-parser"; interface ToolExecuteInput { tool: string; @@ -28,162 +22,10 @@ interface EventInput { }; } -/** - * Quote-aware command tokenizer with escape handling - * Handles single/double quotes and backslash escapes - */ -function tokenizeCommand(cmd: string): string[] { - const tokens: string[] = [] - let current = "" - let inQuote = false - let quoteChar = "" - let escaped = false - - for (let i = 0; i < cmd.length; i++) { - const char = cmd[i] - - if (escaped) { - current += char - escaped = false - continue - } - - if (char === "\\") { - escaped = true - continue - } - - if ((char === "'" || char === '"') && !inQuote) { - inQuote = true - quoteChar = char - } else if (char === quoteChar && inQuote) { - inQuote = false - quoteChar = "" - } else if (char === " " && !inQuote) { - if (current) { - tokens.push(current) - current = "" - } - } else { - current += char - } - } - - if (current) tokens.push(current) - return tokens -} - -/** - * Normalize session name by stripping :window and .pane suffixes - * e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x" - */ -function normalizeSessionName(name: string): string { - return name.split(":")[0].split(".")[0] -} - -function findFlagValue(tokens: string[], flag: string): string | null { - for (let i = 0; i < tokens.length - 1; i++) { - if (tokens[i] === flag) return tokens[i + 1] - } - return null -} - -/** - * Extract session name from tokens, considering the subCommand - * For new-session: prioritize -s over -t - * For other commands: use -t - */ -function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null { - if (subCommand === "new-session") { - const sFlag = findFlagValue(tokens, "-s") - if (sFlag) return normalizeSessionName(sFlag) - const tFlag = findFlagValue(tokens, "-t") - if (tFlag) return normalizeSessionName(tFlag) - } else { - const tFlag = findFlagValue(tokens, "-t") - if (tFlag) return normalizeSessionName(tFlag) - } - return null -} - -/** - * Find the tmux subcommand from tokens, skipping global options. - * tmux allows global options before the subcommand: - * e.g., `tmux -L socket-name new-session -s omo-x` - * Global options with args: -L, -S, -f, -c, -T - * Standalone flags: -C, -v, -V, etc. - * Special: -- (end of options marker) - */ -function findSubcommand(tokens: string[]): string { - // Options that require an argument: -L, -S, -f, -c, -T - const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"]) - - let i = 0 - while (i < tokens.length) { - const token = tokens[i] - - // Handle end of options marker - if (token === "--") { - // Next token is the subcommand - return tokens[i + 1] ?? "" - } - - if (globalOptionsWithArgs.has(token)) { - // Skip the option and its argument - i += 2 - continue - } - - if (token.startsWith("-")) { - // Skip standalone flags like -C, -v, -V - i++ - continue - } - - // Found the subcommand - return token - } - - return "" -} - export function createInteractiveBashSessionHook(ctx: PluginInput) { - const sessionStates = new Map(); - - function getOrCreateState(sessionID: string): InteractiveBashSessionState { - if (!sessionStates.has(sessionID)) { - const persisted = loadInteractiveBashSessionState(sessionID); - const state: InteractiveBashSessionState = persisted ?? { - sessionID, - tmuxSessions: new Set(), - updatedAt: Date.now(), - }; - sessionStates.set(sessionID, state); - } - return sessionStates.get(sessionID)!; - } - - function isOmoSession(sessionName: string | null): boolean { - return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX); - } - - async function killAllTrackedSessions( - state: InteractiveBashSessionState, - ): Promise { - for (const sessionName of state.tmuxSessions) { - try { - const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { - stdout: "ignore", - stderr: "ignore", - }); - await proc.exited; - } catch {} - } - - for (const sessionId of subagentSessions) { - ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {}) - } - } + const tracker = createInteractiveBashSessionTracker({ + abortSession: (args) => ctx.client.session.abort(args), + }) const toolExecuteAfter = async ( input: ToolExecuteInput, @@ -201,46 +43,21 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) { } const tmuxCommand = args.tmux_command; - const tokens = tokenizeCommand(tmuxCommand); - const subCommand = findSubcommand(tokens); - const state = getOrCreateState(sessionID); - let stateChanged = false; + const { subCommand, sessionName } = parseTmuxCommand(tmuxCommand) const toolOutput = output?.output ?? "" if (toolOutput.startsWith("Error:")) { return } - const isNewSession = subCommand === "new-session"; - const isKillSession = subCommand === "kill-session"; - const isKillServer = subCommand === "kill-server"; - - const sessionName = extractSessionNameFromTokens(tokens, subCommand); - - if (isNewSession && isOmoSession(sessionName)) { - state.tmuxSessions.add(sessionName!); - stateChanged = true; - } else if (isKillSession && isOmoSession(sessionName)) { - state.tmuxSessions.delete(sessionName!); - stateChanged = true; - } else if (isKillServer) { - state.tmuxSessions.clear(); - stateChanged = true; - } - - if (stateChanged) { - state.updatedAt = Date.now(); - saveInteractiveBashSessionState(state); - } - - const isSessionOperation = isNewSession || isKillSession || isKillServer; - if (isSessionOperation) { - const reminder = buildSessionReminderMessage( - Array.from(state.tmuxSessions), - ); - if (reminder) { - output.output += reminder; - } + const { reminderToAppend } = tracker.handleTmuxCommand({ + sessionID, + subCommand, + sessionName, + toolOutput, + }) + if (reminderToAppend) { + output.output += reminderToAppend } }; @@ -252,10 +69,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) { const sessionID = sessionInfo?.id; if (sessionID) { - const state = getOrCreateState(sessionID); - await killAllTrackedSessions(state); - sessionStates.delete(sessionID); - clearInteractiveBashSessionState(sessionID); + await tracker.handleSessionDeleted(sessionID) } } }; diff --git a/src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts b/src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts new file mode 100644 index 000000000..428d6bbaa --- /dev/null +++ b/src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts @@ -0,0 +1,118 @@ +import { + loadInteractiveBashSessionState, + saveInteractiveBashSessionState, + clearInteractiveBashSessionState, +} from "./storage"; +import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants"; +import type { InteractiveBashSessionState } from "./types"; +import { subagentSessions } from "../../features/claude-code-session-state"; + +type AbortSession = (args: { path: { id: string } }) => Promise + +function isOmoSession(sessionName: string | null): sessionName is string { + return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX) +} + +async function killAllTrackedSessions( + abortSession: AbortSession, + state: InteractiveBashSessionState, +): Promise { + for (const sessionName of state.tmuxSessions) { + try { + const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { + stdout: "ignore", + stderr: "ignore", + }) + await proc.exited + } catch { + // best-effort cleanup + } + } + + for (const sessionId of subagentSessions) { + abortSession({ path: { id: sessionId } }).catch(() => {}) + } +} + +export function createInteractiveBashSessionTracker(options: { + abortSession: AbortSession +}): { + getOrCreateState: (sessionID: string) => InteractiveBashSessionState + handleSessionDeleted: (sessionID: string) => Promise + handleTmuxCommand: (input: { + sessionID: string + subCommand: string + sessionName: string | null + toolOutput: string + }) => { reminderToAppend: string | null } +} { + const { abortSession } = options + const sessionStates = new Map() + + function getOrCreateState(sessionID: string): InteractiveBashSessionState { + const existing = sessionStates.get(sessionID) + if (existing) return existing + + const persisted = loadInteractiveBashSessionState(sessionID) + const state: InteractiveBashSessionState = persisted ?? { + sessionID, + tmuxSessions: new Set(), + updatedAt: Date.now(), + } + sessionStates.set(sessionID, state) + return state + } + + async function handleSessionDeleted(sessionID: string): Promise { + const state = getOrCreateState(sessionID) + await killAllTrackedSessions(abortSession, state) + sessionStates.delete(sessionID) + clearInteractiveBashSessionState(sessionID) + } + + function handleTmuxCommand(input: { + sessionID: string + subCommand: string + sessionName: string | null + toolOutput: string + }): { reminderToAppend: string | null } { + const { sessionID, subCommand, sessionName, toolOutput } = input + + const state = getOrCreateState(sessionID) + let stateChanged = false + + if (toolOutput.startsWith("Error:")) { + return { reminderToAppend: null } + } + + const isNewSession = subCommand === "new-session" + const isKillSession = subCommand === "kill-session" + const isKillServer = subCommand === "kill-server" + + if (isNewSession && isOmoSession(sessionName)) { + state.tmuxSessions.add(sessionName) + stateChanged = true + } else if (isKillSession && isOmoSession(sessionName)) { + state.tmuxSessions.delete(sessionName) + stateChanged = true + } else if (isKillServer) { + state.tmuxSessions.clear() + stateChanged = true + } + + if (stateChanged) { + state.updatedAt = Date.now() + saveInteractiveBashSessionState(state) + } + + const isSessionOperation = isNewSession || isKillSession || isKillServer + if (!isSessionOperation) { + return { reminderToAppend: null } + } + + const reminder = buildSessionReminderMessage(Array.from(state.tmuxSessions)) + return { reminderToAppend: reminder || null } + } + + return { getOrCreateState, handleSessionDeleted, handleTmuxCommand } +} diff --git a/src/hooks/interactive-bash-session/tmux-command-parser.ts b/src/hooks/interactive-bash-session/tmux-command-parser.ts new file mode 100644 index 000000000..7587c9ac7 --- /dev/null +++ b/src/hooks/interactive-bash-session/tmux-command-parser.ts @@ -0,0 +1,125 @@ +/** + * Quote-aware command tokenizer with escape handling. + * Handles single/double quotes and backslash escapes. + */ +function tokenizeCommand(cmd: string): string[] { + const tokens: string[] = [] + let current = "" + let inQuote = false + let quoteChar = "" + let escaped = false + + for (let i = 0; i < cmd.length; i++) { + const char = cmd[i] + + if (escaped) { + current += char + escaped = false + continue + } + + if (char === "\\") { + escaped = true + continue + } + + if ((char === "'" || char === '"') && !inQuote) { + inQuote = true + quoteChar = char + } else if (char === quoteChar && inQuote) { + inQuote = false + quoteChar = "" + } else if (char === " " && !inQuote) { + if (current) { + tokens.push(current) + current = "" + } + } else { + current += char + } + } + + if (current) tokens.push(current) + return tokens +} + +/** + * Normalize session name by stripping :window and .pane suffixes. + * e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x" + */ +function normalizeSessionName(name: string): string { + return name.split(":")[0].split(".")[0] +} + +function findFlagValue(tokens: string[], flag: string): string | null { + for (let i = 0; i < tokens.length - 1; i++) { + if (tokens[i] === flag) return tokens[i + 1] + } + return null +} + +/** + * Extract session name from tokens, considering the subcommand. + * For new-session: prioritize -s over -t + * For other commands: use -t + */ +function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null { + if (subCommand === "new-session") { + const sFlag = findFlagValue(tokens, "-s") + if (sFlag) return normalizeSessionName(sFlag) + const tFlag = findFlagValue(tokens, "-t") + if (tFlag) return normalizeSessionName(tFlag) + } else { + const tFlag = findFlagValue(tokens, "-t") + if (tFlag) return normalizeSessionName(tFlag) + } + return null +} + +/** + * Find the tmux subcommand from tokens, skipping global options. + * tmux allows global options before the subcommand: + * e.g., `tmux -L socket-name new-session -s omo-x` + */ +function findSubcommand(tokens: string[]): string { + // Options that require an argument: -L, -S, -f, -c, -T + const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"]) + + let i = 0 + while (i < tokens.length) { + const token = tokens[i] + + // Handle end of options marker + if (token === "--") { + // Next token is the subcommand + return tokens[i + 1] ?? "" + } + + if (globalOptionsWithArgs.has(token)) { + // Skip the option and its argument + i += 2 + continue + } + + if (token.startsWith("-")) { + // Skip standalone flags like -C, -v, -V + i++ + continue + } + + // Found the subcommand + return token + } + + return "" +} + +export function parseTmuxCommand(tmuxCommand: string): { + subCommand: string + sessionName: string | null +} { + const tokens = tokenizeCommand(tmuxCommand) + const subCommand = findSubcommand(tokens) + const sessionName = extractSessionNameFromTokens(tokens, subCommand) + return { subCommand, sessionName } +} From 2d22a54b550ec99ec53b7c5a95863d848b738bd3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:22:33 +0900 Subject: [PATCH 15/51] refactor(rules-injector): split finder.ts into rule discovery modules Extract rule finding logic: - project-root-finder.ts: project root detection - rule-file-finder.ts: rule file discovery - rule-file-scanner.ts: filesystem scanning for rules - rule-distance.ts: rule-to-file distance calculation --- src/hooks/rules-injector/finder.ts | 266 +----------------- src/hooks/rules-injector/index.ts | 1 + .../rules-injector/project-root-finder.ts | 36 +++ src/hooks/rules-injector/rule-distance.ts | 53 ++++ src/hooks/rules-injector/rule-file-finder.ts | 119 ++++++++ src/hooks/rules-injector/rule-file-scanner.ts | 55 ++++ 6 files changed, 267 insertions(+), 263 deletions(-) create mode 100644 src/hooks/rules-injector/project-root-finder.ts create mode 100644 src/hooks/rules-injector/rule-distance.ts create mode 100644 src/hooks/rules-injector/rule-file-finder.ts create mode 100644 src/hooks/rules-injector/rule-file-scanner.ts diff --git a/src/hooks/rules-injector/finder.ts b/src/hooks/rules-injector/finder.ts index 3bf293946..4cd19fb35 100644 --- a/src/hooks/rules-injector/finder.ts +++ b/src/hooks/rules-injector/finder.ts @@ -1,263 +1,3 @@ -import { - existsSync, - readdirSync, - realpathSync, - statSync, -} from "node:fs"; -import { dirname, join, relative } from "node:path"; -import { - GITHUB_INSTRUCTIONS_PATTERN, - PROJECT_MARKERS, - PROJECT_RULE_FILES, - PROJECT_RULE_SUBDIRS, - RULE_EXTENSIONS, - USER_RULE_DIR, -} from "./constants"; -import type { RuleFileCandidate } from "./types"; - -function isGitHubInstructionsDir(dir: string): boolean { - return dir.includes(".github/instructions") || dir.endsWith(".github/instructions"); -} - -function isValidRuleFile(fileName: string, dir: string): boolean { - if (isGitHubInstructionsDir(dir)) { - return GITHUB_INSTRUCTIONS_PATTERN.test(fileName); - } - return RULE_EXTENSIONS.some((ext) => fileName.endsWith(ext)); -} - -/** - * Find project root by walking up from startPath. - * Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.) - * - * @param startPath - Starting path to search from (file or directory) - * @returns Project root path or null if not found - */ -export function findProjectRoot(startPath: string): string | null { - let current: string; - - try { - const stat = statSync(startPath); - current = stat.isDirectory() ? startPath : dirname(startPath); - } catch { - current = dirname(startPath); - } - - while (true) { - for (const marker of PROJECT_MARKERS) { - const markerPath = join(current, marker); - if (existsSync(markerPath)) { - return current; - } - } - - const parent = dirname(current); - if (parent === current) { - return null; - } - current = parent; - } -} - -/** - * Recursively find all rule files (*.md, *.mdc) in a directory - * - * @param dir - Directory to search - * @param results - Array to accumulate results - */ -function findRuleFilesRecursive(dir: string, results: string[]): void { - if (!existsSync(dir)) return; - - try { - const entries = readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = join(dir, entry.name); - - if (entry.isDirectory()) { - findRuleFilesRecursive(fullPath, results); - } else if (entry.isFile()) { - if (isValidRuleFile(entry.name, dir)) { - results.push(fullPath); - } - } - } - } catch { - // Permission denied or other errors - silently skip - } -} - -/** - * Resolve symlinks safely with fallback to original path - * - * @param filePath - Path to resolve - * @returns Real path or original path if resolution fails - */ -function safeRealpathSync(filePath: string): string { - try { - return realpathSync(filePath); - } catch { - return filePath; - } -} - -/** - * Calculate directory distance between a rule file and current file. - * Distance is based on common ancestor within project root. - * - * @param rulePath - Path to the rule file - * @param currentFile - Path to the current file being edited - * @param projectRoot - Project root for relative path calculation - * @returns Distance (0 = same directory, higher = further) - */ -export function calculateDistance( - rulePath: string, - currentFile: string, - projectRoot: string | null, -): number { - if (!projectRoot) { - return 9999; - } - - try { - const ruleDir = dirname(rulePath); - const currentDir = dirname(currentFile); - - const ruleRel = relative(projectRoot, ruleDir); - const currentRel = relative(projectRoot, currentDir); - - // Handle paths outside project root - if (ruleRel.startsWith("..") || currentRel.startsWith("..")) { - return 9999; - } - - // Split by both forward and back slashes for cross-platform compatibility - // path.relative() returns OS-native separators (backslashes on Windows) - const ruleParts = ruleRel ? ruleRel.split(/[/\\]/) : []; - const currentParts = currentRel ? currentRel.split(/[/\\]/) : []; - - // Find common prefix length - let common = 0; - for (let i = 0; i < Math.min(ruleParts.length, currentParts.length); i++) { - if (ruleParts[i] === currentParts[i]) { - common++; - } else { - break; - } - } - - // Distance is how many directories up from current file to common ancestor - return currentParts.length - common; - } catch { - return 9999; - } -} - -/** - * Find all rule files for a given context. - * Searches from currentFile upward to projectRoot for rule directories, - * then user-level directory (~/.claude/rules). - * - * IMPORTANT: This searches EVERY directory from file to project root. - * Not just the project root itself. - * - * @param projectRoot - Project root path (or null if outside any project) - * @param homeDir - User home directory - * @param currentFile - Current file being edited (for distance calculation) - * @returns Array of rule file candidates sorted by distance - */ -export function findRuleFiles( - projectRoot: string | null, - homeDir: string, - currentFile: string, -): RuleFileCandidate[] { - const candidates: RuleFileCandidate[] = []; - const seenRealPaths = new Set(); - - // Search from current file's directory up to project root - let currentDir = dirname(currentFile); - let distance = 0; - - while (true) { - // Search rule directories in current directory - for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) { - const ruleDir = join(currentDir, parent, subdir); - const files: string[] = []; - findRuleFilesRecursive(ruleDir, files); - - for (const filePath of files) { - const realPath = safeRealpathSync(filePath); - if (seenRealPaths.has(realPath)) continue; - seenRealPaths.add(realPath); - - candidates.push({ - path: filePath, - realPath, - isGlobal: false, - distance, - }); - } - } - - // Stop at project root or filesystem root - if (projectRoot && currentDir === projectRoot) break; - const parentDir = dirname(currentDir); - if (parentDir === currentDir) break; - currentDir = parentDir; - distance++; - } - - // Check for single-file rules at project root (e.g., .github/copilot-instructions.md) - if (projectRoot) { - for (const ruleFile of PROJECT_RULE_FILES) { - const filePath = join(projectRoot, ruleFile); - if (existsSync(filePath)) { - try { - const stat = statSync(filePath); - if (stat.isFile()) { - const realPath = safeRealpathSync(filePath); - if (!seenRealPaths.has(realPath)) { - seenRealPaths.add(realPath); - candidates.push({ - path: filePath, - realPath, - isGlobal: false, - distance: 0, - isSingleFile: true, - }); - } - } - } catch { - // Skip if file can't be read - } - } - } - } - - // Search user-level rule directory (~/.claude/rules) - const userRuleDir = join(homeDir, USER_RULE_DIR); - const userFiles: string[] = []; - findRuleFilesRecursive(userRuleDir, userFiles); - - for (const filePath of userFiles) { - const realPath = safeRealpathSync(filePath); - if (seenRealPaths.has(realPath)) continue; - seenRealPaths.add(realPath); - - candidates.push({ - path: filePath, - realPath, - isGlobal: true, - distance: 9999, // Global rules always have max distance - }); - } - - // Sort by distance (closest first, then global rules last) - candidates.sort((a, b) => { - if (a.isGlobal !== b.isGlobal) { - return a.isGlobal ? 1 : -1; - } - return a.distance - b.distance; - }); - - return candidates; -} +export { findProjectRoot } from "./project-root-finder"; +export { calculateDistance } from "./rule-distance"; +export { findRuleFiles } from "./rule-file-finder"; diff --git a/src/hooks/rules-injector/index.ts b/src/hooks/rules-injector/index.ts index 8bcd0bb0f..181729cad 100644 --- a/src/hooks/rules-injector/index.ts +++ b/src/hooks/rules-injector/index.ts @@ -1 +1,2 @@ export { createRulesInjectorHook } from "./hook"; +export { calculateDistance, findProjectRoot, findRuleFiles } from "./finder"; diff --git a/src/hooks/rules-injector/project-root-finder.ts b/src/hooks/rules-injector/project-root-finder.ts new file mode 100644 index 000000000..da697f0d9 --- /dev/null +++ b/src/hooks/rules-injector/project-root-finder.ts @@ -0,0 +1,36 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { PROJECT_MARKERS } from "./constants"; + +/** + * Find project root by walking up from startPath. + * Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.) + * + * @param startPath - Starting path to search from (file or directory) + * @returns Project root path or null if not found + */ +export function findProjectRoot(startPath: string): string | null { + let current: string; + + try { + const stat = statSync(startPath); + current = stat.isDirectory() ? startPath : dirname(startPath); + } catch { + current = dirname(startPath); + } + + while (true) { + for (const marker of PROJECT_MARKERS) { + const markerPath = join(current, marker); + if (existsSync(markerPath)) { + return current; + } + } + + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} diff --git a/src/hooks/rules-injector/rule-distance.ts b/src/hooks/rules-injector/rule-distance.ts new file mode 100644 index 000000000..4cee64be3 --- /dev/null +++ b/src/hooks/rules-injector/rule-distance.ts @@ -0,0 +1,53 @@ +import { dirname, relative } from "node:path"; + +/** + * Calculate directory distance between a rule file and current file. + * Distance is based on common ancestor within project root. + * + * @param rulePath - Path to the rule file + * @param currentFile - Path to the current file being edited + * @param projectRoot - Project root for relative path calculation + * @returns Distance (0 = same directory, higher = further) + */ +export function calculateDistance( + rulePath: string, + currentFile: string, + projectRoot: string | null, +): number { + if (!projectRoot) { + return 9999; + } + + try { + const ruleDir = dirname(rulePath); + const currentDir = dirname(currentFile); + + const ruleRel = relative(projectRoot, ruleDir); + const currentRel = relative(projectRoot, currentDir); + + // Handle paths outside project root + if (ruleRel.startsWith("..") || currentRel.startsWith("..")) { + return 9999; + } + + // Split by both forward and back slashes for cross-platform compatibility + // path.relative() returns OS-native separators (backslashes on Windows) + const ruleParts = ruleRel ? ruleRel.split(/[/\\]/) : []; + const currentParts = currentRel ? currentRel.split(/[/\\]/) : []; + + // Find common prefix length + let common = 0; + for (let i = 0; i < Math.min(ruleParts.length, currentParts.length); i++) { + if (ruleParts[i] === currentParts[i]) { + common++; + } else { + break; + } + } + + // Distance is how many directories up from current file to common ancestor + return currentParts.length - common; + } catch { + return 9999; + } +} diff --git a/src/hooks/rules-injector/rule-file-finder.ts b/src/hooks/rules-injector/rule-file-finder.ts new file mode 100644 index 000000000..a08b12a10 --- /dev/null +++ b/src/hooks/rules-injector/rule-file-finder.ts @@ -0,0 +1,119 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + PROJECT_RULE_FILES, + PROJECT_RULE_SUBDIRS, + USER_RULE_DIR, +} from "./constants"; +import type { RuleFileCandidate } from "./types"; +import { findRuleFilesRecursive, safeRealpathSync } from "./rule-file-scanner"; + +/** + * Find all rule files for a given context. + * Searches from currentFile upward to projectRoot for rule directories, + * then user-level directory (~/.claude/rules). + * + * IMPORTANT: This searches EVERY directory from file to project root. + * Not just the project root itself. + * + * @param projectRoot - Project root path (or null if outside any project) + * @param homeDir - User home directory + * @param currentFile - Current file being edited (for distance calculation) + * @returns Array of rule file candidates sorted by distance + */ +export function findRuleFiles( + projectRoot: string | null, + homeDir: string, + currentFile: string, +): RuleFileCandidate[] { + const candidates: RuleFileCandidate[] = []; + const seenRealPaths = new Set(); + + // Search from current file's directory up to project root + let currentDir = dirname(currentFile); + let distance = 0; + + while (true) { + // Search rule directories in current directory + for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) { + const ruleDir = join(currentDir, parent, subdir); + const files: string[] = []; + findRuleFilesRecursive(ruleDir, files); + + for (const filePath of files) { + const realPath = safeRealpathSync(filePath); + if (seenRealPaths.has(realPath)) continue; + seenRealPaths.add(realPath); + + candidates.push({ + path: filePath, + realPath, + isGlobal: false, + distance, + }); + } + } + + // Stop at project root or filesystem root + if (projectRoot && currentDir === projectRoot) break; + const parentDir = dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + distance++; + } + + // Check for single-file rules at project root (e.g., .github/copilot-instructions.md) + if (projectRoot) { + for (const ruleFile of PROJECT_RULE_FILES) { + const filePath = join(projectRoot, ruleFile); + if (existsSync(filePath)) { + try { + const stat = statSync(filePath); + if (stat.isFile()) { + const realPath = safeRealpathSync(filePath); + if (!seenRealPaths.has(realPath)) { + seenRealPaths.add(realPath); + candidates.push({ + path: filePath, + realPath, + isGlobal: false, + distance: 0, + isSingleFile: true, + }); + } + } + } catch { + // Skip if file can't be read + } + } + } + } + + // Search user-level rule directory (~/.claude/rules) + const userRuleDir = join(homeDir, USER_RULE_DIR); + const userFiles: string[] = []; + findRuleFilesRecursive(userRuleDir, userFiles); + + for (const filePath of userFiles) { + const realPath = safeRealpathSync(filePath); + if (seenRealPaths.has(realPath)) continue; + seenRealPaths.add(realPath); + + candidates.push({ + path: filePath, + realPath, + isGlobal: true, + distance: 9999, // Global rules always have max distance + }); + } + + // Sort by distance (closest first, then global rules last) + candidates.sort((a, b) => { + if (a.isGlobal !== b.isGlobal) { + return a.isGlobal ? 1 : -1; + } + return a.distance - b.distance; + }); + + return candidates; +} diff --git a/src/hooks/rules-injector/rule-file-scanner.ts b/src/hooks/rules-injector/rule-file-scanner.ts new file mode 100644 index 000000000..ffd87d8a9 --- /dev/null +++ b/src/hooks/rules-injector/rule-file-scanner.ts @@ -0,0 +1,55 @@ +import { existsSync, readdirSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { GITHUB_INSTRUCTIONS_PATTERN, RULE_EXTENSIONS } from "./constants"; + +function isGitHubInstructionsDir(dir: string): boolean { + return dir.includes(".github/instructions") || dir.endsWith(".github/instructions"); +} + +function isValidRuleFile(fileName: string, dir: string): boolean { + if (isGitHubInstructionsDir(dir)) { + return GITHUB_INSTRUCTIONS_PATTERN.test(fileName); + } + return RULE_EXTENSIONS.some((ext) => fileName.endsWith(ext)); +} + +/** + * Recursively find all rule files (*.md, *.mdc) in a directory + * + * @param dir - Directory to search + * @param results - Array to accumulate results + */ +export function findRuleFilesRecursive(dir: string, results: string[]): void { + if (!existsSync(dir)) return; + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + findRuleFilesRecursive(fullPath, results); + } else if (entry.isFile()) { + if (isValidRuleFile(entry.name, dir)) { + results.push(fullPath); + } + } + } + } catch { + // Permission denied or other errors - silently skip + } +} + +/** + * Resolve symlinks safely with fallback to original path + * + * @param filePath - Path to resolve + * @returns Real path or original path if resolution fails + */ +export function safeRealpathSync(filePath: string): string { + try { + return realpathSync(filePath); + } catch { + return filePath; + } +} From e4583668c00f85f339aad2ba33dbc1301dd2872b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:23:56 +0900 Subject: [PATCH 16/51] refactor(hooks): split session-notification and unstable-agent-babysitter Extract notification and babysitter logic: - session-notification-formatting.ts, session-notification-scheduler.ts - session-notification-sender.ts, session-todo-status.ts - task-message-analyzer.ts: message analysis for babysitter hook --- src/hooks/index.ts | 4 + src/hooks/session-notification-formatting.ts | 25 ++ src/hooks/session-notification-scheduler.ts | 154 ++++++++++ src/hooks/session-notification-sender.ts | 102 +++++++ src/hooks/session-notification.ts | 271 ++---------------- src/hooks/session-todo-status.ts | 19 ++ src/hooks/unstable-agent-babysitter/index.ts | 8 + .../task-message-analyzer.ts | 91 ++++++ .../unstable-agent-babysitter-hook.ts | 98 +------ 9 files changed, 433 insertions(+), 339 deletions(-) create mode 100644 src/hooks/session-notification-formatting.ts create mode 100644 src/hooks/session-notification-scheduler.ts create mode 100644 src/hooks/session-notification-sender.ts create mode 100644 src/hooks/session-todo-status.ts create mode 100644 src/hooks/unstable-agent-babysitter/task-message-analyzer.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a964780c7..954475277 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,6 +1,10 @@ export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer"; export { createContextWindowMonitorHook } from "./context-window-monitor"; export { createSessionNotification } from "./session-notification"; +export { sendSessionNotification, playSessionNotificationSound, detectPlatform, getDefaultSoundPath } from "./session-notification-sender"; +export { buildWindowsToastScript, escapeAppleScriptText, escapePowerShellSingleQuotedText } from "./session-notification-formatting"; +export { hasIncompleteTodos } from "./session-todo-status"; +export { createIdleNotificationScheduler } from "./session-notification-scheduler"; export { createSessionRecoveryHook, type SessionRecoveryHook, type SessionRecoveryOptions } from "./session-recovery"; export { createCommentCheckerHooks } from "./comment-checker"; export { createToolOutputTruncatorHook } from "./tool-output-truncator"; diff --git a/src/hooks/session-notification-formatting.ts b/src/hooks/session-notification-formatting.ts new file mode 100644 index 000000000..c39cb30d8 --- /dev/null +++ b/src/hooks/session-notification-formatting.ts @@ -0,0 +1,25 @@ +export function escapeAppleScriptText(input: string): string { + return input.replace(/\\/g, "\\\\").replace(/"/g, '\\"') +} + +export function escapePowerShellSingleQuotedText(input: string): string { + return input.replace(/'/g, "''") +} + +export function buildWindowsToastScript(title: string, message: string): string { + const psTitle = escapePowerShellSingleQuotedText(title) + const psMessage = escapePowerShellSingleQuotedText(message) + + return ` +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null +$Template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02) +$RawXml = [xml] $Template.GetXml() +($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '1'}).AppendChild($RawXml.CreateTextNode('${psTitle}')) | Out-Null +($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '2'}).AppendChild($RawXml.CreateTextNode('${psMessage}')) | Out-Null +$SerializedXml = New-Object Windows.Data.Xml.Dom.XmlDocument +$SerializedXml.LoadXml($RawXml.OuterXml) +$Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml) +$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode') +$Notifier.Show($Toast) +`.trim().replace(/\n/g, "; ") +} diff --git a/src/hooks/session-notification-scheduler.ts b/src/hooks/session-notification-scheduler.ts new file mode 100644 index 000000000..d28abd112 --- /dev/null +++ b/src/hooks/session-notification-scheduler.ts @@ -0,0 +1,154 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { Platform } from "./session-notification-sender" + +type SessionNotificationConfig = { + title: string + message: string + playSound: boolean + soundPath: string + idleConfirmationDelay: number + skipIfIncompleteTodos: boolean + maxTrackedSessions: number +} + +export function createIdleNotificationScheduler(options: { + ctx: PluginInput + platform: Platform + config: SessionNotificationConfig + hasIncompleteTodos: (ctx: PluginInput, sessionID: string) => Promise + send: (ctx: PluginInput, platform: Platform, title: string, message: string) => Promise + playSound: (ctx: PluginInput, platform: Platform, soundPath: string) => Promise +}) { + const notifiedSessions = new Set() + const pendingTimers = new Map>() + const sessionActivitySinceIdle = new Set() + const notificationVersions = new Map() + const executingNotifications = new Set() + + function cleanupOldSessions(): void { + const maxSessions = options.config.maxTrackedSessions + if (notifiedSessions.size > maxSessions) { + const sessionsToRemove = Array.from(notifiedSessions).slice(0, notifiedSessions.size - maxSessions) + sessionsToRemove.forEach((id) => notifiedSessions.delete(id)) + } + if (sessionActivitySinceIdle.size > maxSessions) { + const sessionsToRemove = Array.from(sessionActivitySinceIdle).slice(0, sessionActivitySinceIdle.size - maxSessions) + sessionsToRemove.forEach((id) => sessionActivitySinceIdle.delete(id)) + } + if (notificationVersions.size > maxSessions) { + const sessionsToRemove = Array.from(notificationVersions.keys()).slice(0, notificationVersions.size - maxSessions) + sessionsToRemove.forEach((id) => notificationVersions.delete(id)) + } + if (executingNotifications.size > maxSessions) { + const sessionsToRemove = Array.from(executingNotifications).slice(0, executingNotifications.size - maxSessions) + sessionsToRemove.forEach((id) => executingNotifications.delete(id)) + } + } + + function cancelPendingNotification(sessionID: string): void { + const timer = pendingTimers.get(sessionID) + if (timer) { + clearTimeout(timer) + pendingTimers.delete(sessionID) + } + sessionActivitySinceIdle.add(sessionID) + notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1) + } + + function markSessionActivity(sessionID: string): void { + cancelPendingNotification(sessionID) + if (!executingNotifications.has(sessionID)) { + notifiedSessions.delete(sessionID) + } + } + + async function executeNotification(sessionID: string, version: number): Promise { + if (executingNotifications.has(sessionID)) { + pendingTimers.delete(sessionID) + return + } + + if (notificationVersions.get(sessionID) !== version) { + pendingTimers.delete(sessionID) + return + } + + if (sessionActivitySinceIdle.has(sessionID)) { + sessionActivitySinceIdle.delete(sessionID) + pendingTimers.delete(sessionID) + return + } + + if (notifiedSessions.has(sessionID)) { + pendingTimers.delete(sessionID) + return + } + + executingNotifications.add(sessionID) + try { + if (options.config.skipIfIncompleteTodos) { + const hasPendingWork = await options.hasIncompleteTodos(options.ctx, sessionID) + if (notificationVersions.get(sessionID) !== version) { + return + } + if (hasPendingWork) return + } + + if (notificationVersions.get(sessionID) !== version) { + return + } + + if (sessionActivitySinceIdle.has(sessionID)) { + sessionActivitySinceIdle.delete(sessionID) + return + } + + notifiedSessions.add(sessionID) + + await options.send(options.ctx, options.platform, options.config.title, options.config.message) + + if (options.config.playSound && options.config.soundPath) { + await options.playSound(options.ctx, options.platform, options.config.soundPath) + } + } finally { + executingNotifications.delete(sessionID) + pendingTimers.delete(sessionID) + if (sessionActivitySinceIdle.has(sessionID)) { + notifiedSessions.delete(sessionID) + sessionActivitySinceIdle.delete(sessionID) + } + } + } + + function scheduleIdleNotification(sessionID: string): void { + if (notifiedSessions.has(sessionID)) return + if (pendingTimers.has(sessionID)) return + if (executingNotifications.has(sessionID)) return + + sessionActivitySinceIdle.delete(sessionID) + + const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1 + notificationVersions.set(sessionID, currentVersion) + + const timer = setTimeout(() => { + executeNotification(sessionID, currentVersion) + }, options.config.idleConfirmationDelay) + + pendingTimers.set(sessionID, timer) + cleanupOldSessions() + } + + function deleteSession(sessionID: string): void { + cancelPendingNotification(sessionID) + notifiedSessions.delete(sessionID) + sessionActivitySinceIdle.delete(sessionID) + notificationVersions.delete(sessionID) + executingNotifications.delete(sessionID) + } + + return { + markSessionActivity, + scheduleIdleNotification, + deleteSession, + } +} diff --git a/src/hooks/session-notification-sender.ts b/src/hooks/session-notification-sender.ts new file mode 100644 index 000000000..8c5cf1df7 --- /dev/null +++ b/src/hooks/session-notification-sender.ts @@ -0,0 +1,102 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { platform } from "os" +import { + getOsascriptPath, + getNotifySendPath, + getPowershellPath, + getAfplayPath, + getPaplayPath, + getAplayPath, +} from "./session-notification-utils" +import { buildWindowsToastScript, escapeAppleScriptText, escapePowerShellSingleQuotedText } from "./session-notification-formatting" + +export type Platform = "darwin" | "linux" | "win32" | "unsupported" + +export function detectPlatform(): Platform { + const detected = platform() + if (detected === "darwin" || detected === "linux" || detected === "win32") return detected + return "unsupported" +} + +export function getDefaultSoundPath(platform: Platform): string { + switch (platform) { + case "darwin": + return "/System/Library/Sounds/Glass.aiff" + case "linux": + return "/usr/share/sounds/freedesktop/stereo/complete.oga" + case "win32": + return "C:\\Windows\\Media\\notify.wav" + default: + return "" + } +} + +export async function sendSessionNotification( + ctx: PluginInput, + platform: Platform, + title: string, + message: string +): Promise { + switch (platform) { + case "darwin": { + const osascriptPath = await getOsascriptPath() + if (!osascriptPath) return + + const escapedTitle = escapeAppleScriptText(title) + const escapedMessage = escapeAppleScriptText(message) + await ctx.$`${osascriptPath} -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`.catch( + () => {} + ) + break + } + case "linux": { + const notifySendPath = await getNotifySendPath() + if (!notifySendPath) return + + await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {}) + break + } + case "win32": { + const powershellPath = await getPowershellPath() + if (!powershellPath) return + + const toastScript = buildWindowsToastScript(title, message) + await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {}) + break + } + } +} + +export async function playSessionNotificationSound( + ctx: PluginInput, + platform: Platform, + soundPath: string +): Promise { + switch (platform) { + case "darwin": { + const afplayPath = await getAfplayPath() + if (!afplayPath) return + ctx.$`${afplayPath} ${soundPath}`.catch(() => {}) + break + } + case "linux": { + const paplayPath = await getPaplayPath() + if (paplayPath) { + ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) + } else { + const aplayPath = await getAplayPath() + if (aplayPath) { + ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) + } + } + break + } + case "win32": { + const powershellPath = await getPowershellPath() + if (!powershellPath) return + const escaped = escapePowerShellSingleQuotedText(soundPath) + ctx.$`${powershellPath} -Command ${("(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()")}`.catch(() => {}) + break + } + } +} diff --git a/src/hooks/session-notification.ts b/src/hooks/session-notification.ts index 76b97dc9a..a6380c5a5 100644 --- a/src/hooks/session-notification.ts +++ b/src/hooks/session-notification.ts @@ -1,22 +1,16 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { platform } from "os" import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state" import { - getOsascriptPath, - getNotifySendPath, - getPowershellPath, - getAfplayPath, - getPaplayPath, - getAplayPath, startBackgroundCheck, } from "./session-notification-utils" - -interface Todo { - content: string - status: string - priority: string - id: string -} +import { + detectPlatform, + getDefaultSoundPath, + playSessionNotificationSound, + sendSessionNotification, +} from "./session-notification-sender" +import { hasIncompleteTodos } from "./session-todo-status" +import { createIdleNotificationScheduler } from "./session-notification-scheduler" interface SessionNotificationConfig { title?: string @@ -30,115 +24,6 @@ interface SessionNotificationConfig { /** Maximum number of sessions to track before cleanup (default: 100) */ maxTrackedSessions?: number } - -type Platform = "darwin" | "linux" | "win32" | "unsupported" - -function detectPlatform(): Platform { - const p = platform() - if (p === "darwin" || p === "linux" || p === "win32") return p - return "unsupported" -} - -function getDefaultSoundPath(p: Platform): string { - switch (p) { - case "darwin": - return "/System/Library/Sounds/Glass.aiff" - case "linux": - return "/usr/share/sounds/freedesktop/stereo/complete.oga" - case "win32": - return "C:\\Windows\\Media\\notify.wav" - default: - return "" - } -} - -async function sendNotification( - ctx: PluginInput, - p: Platform, - title: string, - message: string -): Promise { - switch (p) { - case "darwin": { - const osascriptPath = await getOsascriptPath() - if (!osascriptPath) return - - const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"') - const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"') - await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {}) - break - } - case "linux": { - const notifySendPath = await getNotifySendPath() - if (!notifySendPath) return - - await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {}) - break - } - case "win32": { - const powershellPath = await getPowershellPath() - if (!powershellPath) return - - const psTitle = title.replace(/'/g, "''") - const psMessage = message.replace(/'/g, "''") - const toastScript = ` -[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null -$Template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02) -$RawXml = [xml] $Template.GetXml() -($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '1'}).AppendChild($RawXml.CreateTextNode('${psTitle}')) | Out-Null -($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '2'}).AppendChild($RawXml.CreateTextNode('${psMessage}')) | Out-Null -$SerializedXml = New-Object Windows.Data.Xml.Dom.XmlDocument -$SerializedXml.LoadXml($RawXml.OuterXml) -$Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml) -$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode') -$Notifier.Show($Toast) -`.trim().replace(/\n/g, "; ") - await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {}) - break - } - } -} - -async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Promise { - switch (p) { - case "darwin": { - const afplayPath = await getAfplayPath() - if (!afplayPath) return - ctx.$`${afplayPath} ${soundPath}`.catch(() => {}) - break - } - case "linux": { - const paplayPath = await getPaplayPath() - if (paplayPath) { - ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) - } else { - const aplayPath = await getAplayPath() - if (aplayPath) { - ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) - } - } - break - } - case "win32": { - const powershellPath = await getPowershellPath() - if (!powershellPath) return - ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath.replace(/'/g, "''") + "').PlaySync()"}`.catch(() => {}) - break - } - } -} - -async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise { - try { - const response = await ctx.client.session.todo({ path: { id: sessionID } }) - const todos = (response.data ?? response) as Todo[] - if (!todos || todos.length === 0) return false - return todos.some((t) => t.status !== "completed" && t.status !== "cancelled") - } catch { - return false - } -} - export function createSessionNotification( ctx: PluginInput, config: SessionNotificationConfig = {} @@ -159,110 +44,14 @@ export function createSessionNotification( ...config, } - const notifiedSessions = new Set() - const pendingTimers = new Map>() - const sessionActivitySinceIdle = new Set() - // Track notification execution version to handle race conditions - const notificationVersions = new Map() - // Track sessions currently executing notification (prevents duplicate execution) - const executingNotifications = new Set() - - function cleanupOldSessions() { - const maxSessions = mergedConfig.maxTrackedSessions - if (notifiedSessions.size > maxSessions) { - const sessionsToRemove = Array.from(notifiedSessions).slice(0, notifiedSessions.size - maxSessions) - sessionsToRemove.forEach(id => notifiedSessions.delete(id)) - } - if (sessionActivitySinceIdle.size > maxSessions) { - const sessionsToRemove = Array.from(sessionActivitySinceIdle).slice(0, sessionActivitySinceIdle.size - maxSessions) - sessionsToRemove.forEach(id => sessionActivitySinceIdle.delete(id)) - } - if (notificationVersions.size > maxSessions) { - const sessionsToRemove = Array.from(notificationVersions.keys()).slice(0, notificationVersions.size - maxSessions) - sessionsToRemove.forEach(id => notificationVersions.delete(id)) - } - if (executingNotifications.size > maxSessions) { - const sessionsToRemove = Array.from(executingNotifications).slice(0, executingNotifications.size - maxSessions) - sessionsToRemove.forEach(id => executingNotifications.delete(id)) - } - } - - function cancelPendingNotification(sessionID: string) { - const timer = pendingTimers.get(sessionID) - if (timer) { - clearTimeout(timer) - pendingTimers.delete(sessionID) - } - sessionActivitySinceIdle.add(sessionID) - // Increment version to invalidate any in-flight notifications - notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1) - } - - function markSessionActivity(sessionID: string) { - cancelPendingNotification(sessionID) - if (!executingNotifications.has(sessionID)) { - notifiedSessions.delete(sessionID) - } - } - - async function executeNotification(sessionID: string, version: number) { - if (executingNotifications.has(sessionID)) { - pendingTimers.delete(sessionID) - return - } - - if (notificationVersions.get(sessionID) !== version) { - pendingTimers.delete(sessionID) - return - } - - if (sessionActivitySinceIdle.has(sessionID)) { - sessionActivitySinceIdle.delete(sessionID) - pendingTimers.delete(sessionID) - return - } - - if (notifiedSessions.has(sessionID)) { - pendingTimers.delete(sessionID) - return - } - - executingNotifications.add(sessionID) - try { - if (mergedConfig.skipIfIncompleteTodos) { - const hasPendingWork = await hasIncompleteTodos(ctx, sessionID) - if (notificationVersions.get(sessionID) !== version) { - return - } - if (hasPendingWork) return - } - - if (notificationVersions.get(sessionID) !== version) { - return - } - - if (sessionActivitySinceIdle.has(sessionID)) { - sessionActivitySinceIdle.delete(sessionID) - return - } - - notifiedSessions.add(sessionID) - - await sendNotification(ctx, currentPlatform, mergedConfig.title, mergedConfig.message) - - if (mergedConfig.playSound && mergedConfig.soundPath) { - await playSound(ctx, currentPlatform, mergedConfig.soundPath) - } - } finally { - executingNotifications.delete(sessionID) - pendingTimers.delete(sessionID) - // Clear notified state if there was activity during notification - if (sessionActivitySinceIdle.has(sessionID)) { - notifiedSessions.delete(sessionID) - sessionActivitySinceIdle.delete(sessionID) - } - } - } + const scheduler = createIdleNotificationScheduler({ + ctx, + platform: currentPlatform, + config: mergedConfig, + hasIncompleteTodos, + send: sendSessionNotification, + playSound: playSessionNotificationSound, + }) return async ({ event }: { event: { type: string; properties?: unknown } }) => { if (currentPlatform === "unsupported") return @@ -273,7 +62,7 @@ export function createSessionNotification( const info = props?.info as Record | undefined const sessionID = info?.id as string | undefined if (sessionID) { - markSessionActivity(sessionID) + scheduler.markSessionActivity(sessionID) } return } @@ -288,21 +77,7 @@ export function createSessionNotification( const mainSessionID = getMainSessionID() if (mainSessionID && sessionID !== mainSessionID) return - if (notifiedSessions.has(sessionID)) return - if (pendingTimers.has(sessionID)) return - if (executingNotifications.has(sessionID)) return - - sessionActivitySinceIdle.delete(sessionID) - - const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1 - notificationVersions.set(sessionID, currentVersion) - - const timer = setTimeout(() => { - executeNotification(sessionID, currentVersion) - }, mergedConfig.idleConfirmationDelay) - - pendingTimers.set(sessionID, timer) - cleanupOldSessions() + scheduler.scheduleIdleNotification(sessionID) return } @@ -310,7 +85,7 @@ export function createSessionNotification( const info = props?.info as Record | undefined const sessionID = info?.sessionID as string | undefined if (sessionID) { - markSessionActivity(sessionID) + scheduler.markSessionActivity(sessionID) } return } @@ -318,7 +93,7 @@ export function createSessionNotification( if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { const sessionID = props?.sessionID as string | undefined if (sessionID) { - markSessionActivity(sessionID) + scheduler.markSessionActivity(sessionID) } return } @@ -326,11 +101,7 @@ export function createSessionNotification( if (event.type === "session.deleted") { const sessionInfo = props?.info as { id?: string } | undefined if (sessionInfo?.id) { - cancelPendingNotification(sessionInfo.id) - notifiedSessions.delete(sessionInfo.id) - sessionActivitySinceIdle.delete(sessionInfo.id) - notificationVersions.delete(sessionInfo.id) - executingNotifications.delete(sessionInfo.id) + scheduler.deleteSession(sessionInfo.id) } } } diff --git a/src/hooks/session-todo-status.ts b/src/hooks/session-todo-status.ts new file mode 100644 index 000000000..cb2a28f23 --- /dev/null +++ b/src/hooks/session-todo-status.ts @@ -0,0 +1,19 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +interface Todo { + content: string + status: string + priority: string + id: string +} + +export async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise { + try { + const response = await ctx.client.session.todo({ path: { id: sessionID } }) + const todos = (response.data ?? response) as Todo[] + if (!todos || todos.length === 0) return false + return todos.some((todo) => todo.status !== "completed" && todo.status !== "cancelled") + } catch { + return false + } +} diff --git a/src/hooks/unstable-agent-babysitter/index.ts b/src/hooks/unstable-agent-babysitter/index.ts index 1850f1869..4a0945f44 100644 --- a/src/hooks/unstable-agent-babysitter/index.ts +++ b/src/hooks/unstable-agent-babysitter/index.ts @@ -1 +1,9 @@ export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter-hook" +export { + buildReminder, + extractMessages, + getMessageInfo, + getMessageParts, + isUnstableTask, + THINKING_SUMMARY_MAX_CHARS, +} from "./task-message-analyzer" diff --git a/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts b/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts new file mode 100644 index 000000000..be536630a --- /dev/null +++ b/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts @@ -0,0 +1,91 @@ +import type { BackgroundTask } from "../../features/background-agent" + +export const THINKING_SUMMARY_MAX_CHARS = 500 as const + +type MessageInfo = { + role?: string + agent?: string + model?: { providerID: string; modelID: string } + providerID?: string + modelID?: string +} + +type MessagePart = { + type?: string + text?: string + thinking?: string +} + +function hasData(value: unknown): value is { data?: unknown } { + return typeof value === "object" && value !== null && "data" in value +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +export function getMessageInfo(value: unknown): MessageInfo | undefined { + if (!isRecord(value)) return undefined + if (!isRecord(value.info)) return undefined + const info = value.info + const modelValue = isRecord(info.model) + ? info.model + : undefined + const model = modelValue && typeof modelValue.providerID === "string" && typeof modelValue.modelID === "string" + ? { providerID: modelValue.providerID, modelID: modelValue.modelID } + : undefined + return { + role: typeof info.role === "string" ? info.role : undefined, + agent: typeof info.agent === "string" ? info.agent : undefined, + model, + providerID: typeof info.providerID === "string" ? info.providerID : undefined, + modelID: typeof info.modelID === "string" ? info.modelID : undefined, + } +} + +export function getMessageParts(value: unknown): MessagePart[] { + if (!isRecord(value)) return [] + if (!Array.isArray(value.parts)) return [] + return value.parts.filter(isRecord).map((part) => ({ + type: typeof part.type === "string" ? part.type : undefined, + text: typeof part.text === "string" ? part.text : undefined, + thinking: typeof part.thinking === "string" ? part.thinking : undefined, + })) +} + +export function extractMessages(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value + } + if (hasData(value) && Array.isArray(value.data)) { + return value.data + } + return [] +} + +export function isUnstableTask(task: BackgroundTask): boolean { + if (task.isUnstableAgent === true) return true + const modelId = task.model?.modelID?.toLowerCase() + return modelId ? modelId.includes("gemini") || modelId.includes("minimax") : false +} + +export function buildReminder(task: BackgroundTask, summary: string | null, idleMs: number): string { + const idleSeconds = Math.round(idleMs / 1000) + const summaryText = summary ?? "(No thinking trace available)" + return `Unstable background agent appears idle for ${idleSeconds}s. + +Task ID: ${task.id} +Description: ${task.description} +Agent: ${task.agent} +Status: ${task.status} +Session ID: ${task.sessionID ?? "N/A"} + +Thinking summary (first ${THINKING_SUMMARY_MAX_CHARS} chars): +${summaryText} + +Suggested actions: +- background_output task_id="${task.id}" full_session=true include_thinking=true include_tool_results=true message_limit=50 +- background_cancel taskId="${task.id}" + +This is a reminder only. No automatic action was taken.` +} diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts index a7b2b551f..52a6ac86d 100644 --- a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -1,11 +1,18 @@ -import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" +import type { BackgroundManager } from "../../features/background-agent" import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" +import { + buildReminder, + extractMessages, + getMessageInfo, + getMessageParts, + isUnstableTask, + THINKING_SUMMARY_MAX_CHARS, +} from "./task-message-analyzer" const HOOK_NAME = "unstable-agent-babysitter" const DEFAULT_TIMEOUT_MS = 120000 const COOLDOWN_MS = 5 * 60 * 1000 -const THINKING_SUMMARY_MAX_CHARS = 500 as const type BabysittingConfig = { timeout_ms?: number @@ -43,72 +50,6 @@ type BabysitterOptions = { config?: BabysittingConfig } -type MessageInfo = { - role?: string - agent?: string - model?: { providerID: string; modelID: string } - providerID?: string - modelID?: string -} - -type MessagePart = { - type?: string - text?: string - thinking?: string -} - -function hasData(value: unknown): value is { data?: unknown } { - return typeof value === "object" && value !== null && "data" in value -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} - -function getMessageInfo(value: unknown): MessageInfo | undefined { - if (!isRecord(value)) return undefined - if (!isRecord(value.info)) return undefined - const info = value.info - const modelValue = isRecord(info.model) - ? info.model - : undefined - const model = modelValue && typeof modelValue.providerID === "string" && typeof modelValue.modelID === "string" - ? { providerID: modelValue.providerID, modelID: modelValue.modelID } - : undefined - return { - role: typeof info.role === "string" ? info.role : undefined, - agent: typeof info.agent === "string" ? info.agent : undefined, - model, - providerID: typeof info.providerID === "string" ? info.providerID : undefined, - modelID: typeof info.modelID === "string" ? info.modelID : undefined, - } -} - -function getMessageParts(value: unknown): MessagePart[] { - if (!isRecord(value)) return [] - if (!Array.isArray(value.parts)) return [] - return value.parts.filter(isRecord).map((part) => ({ - type: typeof part.type === "string" ? part.type : undefined, - text: typeof part.text === "string" ? part.text : undefined, - thinking: typeof part.thinking === "string" ? part.thinking : undefined, - })) -} - -function extractMessages(value: unknown): unknown[] { - if (Array.isArray(value)) { - return value - } - if (hasData(value) && Array.isArray(value.data)) { - return value.data - } - return [] -} - -function isUnstableTask(task: BackgroundTask): boolean { - if (task.isUnstableAgent === true) return true - const modelId = task.model?.modelID?.toLowerCase() - return modelId ? modelId.includes("gemini") || modelId.includes("minimax") : false -} async function resolveMainSessionTarget( ctx: BabysitterContext, @@ -169,27 +110,6 @@ async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Pr } } -function buildReminder(task: BackgroundTask, summary: string | null, idleMs: number): string { - const idleSeconds = Math.round(idleMs / 1000) - const summaryText = summary ?? "(No thinking trace available)" - return `Unstable background agent appears idle for ${idleSeconds}s. - -Task ID: ${task.id} -Description: ${task.description} -Agent: ${task.agent} -Status: ${task.status} -Session ID: ${task.sessionID ?? "N/A"} - -Thinking summary (first ${THINKING_SUMMARY_MAX_CHARS} chars): -${summaryText} - -Suggested actions: -- background_output task_id="${task.id}" full_session=true include_thinking=true include_tool_results=true message_limit=50 -- background_cancel taskId="${task.id}" - -This is a reminder only. No automatic action was taken.` -} - export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) { const reminderCooldowns = new Map() From 76fad73550dee8fae85d2ca0f7ca3f7fa8c9d7ac Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:24:03 +0900 Subject: [PATCH 17/51] refactor(ast-grep): split cli.ts and constants.ts into focused modules Extract AST-grep tooling into single-responsibility files: - cli-binary-path-resolution.ts, sg-cli-path.ts - environment-check.ts, language-support.ts - process-output-timeout.ts, sg-compact-json-output.ts --- .../ast-grep/cli-binary-path-resolution.ts | 60 +++++ src/tools/ast-grep/cli.ts | 220 +++++---------- src/tools/ast-grep/constants.ts | 254 +----------------- src/tools/ast-grep/environment-check.ts | 89 ++++++ src/tools/ast-grep/language-support.ts | 63 +++++ src/tools/ast-grep/process-output-timeout.ts | 28 ++ src/tools/ast-grep/sg-cli-path.ts | 102 +++++++ src/tools/ast-grep/sg-compact-json-output.ts | 54 ++++ 8 files changed, 464 insertions(+), 406 deletions(-) create mode 100644 src/tools/ast-grep/cli-binary-path-resolution.ts create mode 100644 src/tools/ast-grep/environment-check.ts create mode 100644 src/tools/ast-grep/language-support.ts create mode 100644 src/tools/ast-grep/process-output-timeout.ts create mode 100644 src/tools/ast-grep/sg-cli-path.ts create mode 100644 src/tools/ast-grep/sg-compact-json-output.ts diff --git a/src/tools/ast-grep/cli-binary-path-resolution.ts b/src/tools/ast-grep/cli-binary-path-resolution.ts new file mode 100644 index 000000000..7fbd760ef --- /dev/null +++ b/src/tools/ast-grep/cli-binary-path-resolution.ts @@ -0,0 +1,60 @@ +import { existsSync } from "fs" + +import { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./constants" +import { ensureAstGrepBinary } from "./downloader" + +let resolvedCliPath: string | null = null +let initPromise: Promise | null = null + +export async function getAstGrepPath(): Promise { + if (resolvedCliPath !== null && existsSync(resolvedCliPath)) { + return resolvedCliPath + } + + if (initPromise) { + return initPromise + } + + initPromise = (async () => { + const syncPath = findSgCliPathSync() + if (syncPath && existsSync(syncPath)) { + resolvedCliPath = syncPath + setSgCliPath(syncPath) + return syncPath + } + + const downloadedPath = await ensureAstGrepBinary() + if (downloadedPath) { + resolvedCliPath = downloadedPath + setSgCliPath(downloadedPath) + return downloadedPath + } + + return null + })() + + return initPromise +} + +export function startBackgroundInit(): void { + if (!initPromise) { + initPromise = getAstGrepPath() + initPromise.catch(() => {}) + } +} + +export function isCliAvailable(): boolean { + const path = findSgCliPathSync() + return path !== null && existsSync(path) +} + +export async function ensureCliAvailable(): Promise { + const path = await getAstGrepPath() + return path !== null && existsSync(path) +} + +export function getResolvedSgCliPath(): string | null { + const path = getSgCliPath() + if (path && existsSync(path)) return path + return null +} diff --git a/src/tools/ast-grep/cli.ts b/src/tools/ast-grep/cli.ts index 19d12443a..868a1c544 100644 --- a/src/tools/ast-grep/cli.ts +++ b/src/tools/ast-grep/cli.ts @@ -1,64 +1,31 @@ import { spawn } from "bun" import { existsSync } from "fs" import { - getSgCliPath, - setSgCliPath, - findSgCliPathSync, - DEFAULT_TIMEOUT_MS, - DEFAULT_MAX_OUTPUT_BYTES, - DEFAULT_MAX_MATCHES, + getSgCliPath, + DEFAULT_TIMEOUT_MS, } from "./constants" import { ensureAstGrepBinary } from "./downloader" -import type { CliMatch, CliLanguage, SgResult } from "./types" +import type { CliLanguage, SgResult } from "./types" + +import { getAstGrepPath } from "./cli-binary-path-resolution" +import { collectProcessOutputWithTimeout } from "./process-output-timeout" +import { createSgResultFromStdout } from "./sg-compact-json-output" + +export { + ensureCliAvailable, + getAstGrepPath, + isCliAvailable, + startBackgroundInit, +} from "./cli-binary-path-resolution" export interface RunOptions { - pattern: string - lang: CliLanguage - paths?: string[] - globs?: string[] - rewrite?: string - context?: number - updateAll?: boolean -} - -let resolvedCliPath: string | null = null -let initPromise: Promise | null = null - -export async function getAstGrepPath(): Promise { - if (resolvedCliPath !== null && existsSync(resolvedCliPath)) { - return resolvedCliPath - } - - if (initPromise) { - return initPromise - } - - initPromise = (async () => { - const syncPath = findSgCliPathSync() - if (syncPath && existsSync(syncPath)) { - resolvedCliPath = syncPath - setSgCliPath(syncPath) - return syncPath - } - - const downloadedPath = await ensureAstGrepBinary() - if (downloadedPath) { - resolvedCliPath = downloadedPath - setSgCliPath(downloadedPath) - return downloadedPath - } - - return null - })() - - return initPromise -} - -export function startBackgroundInit(): void { - if (!initPromise) { - initPromise = getAstGrepPath() - initPromise.catch(() => {}) - } + pattern: string + lang: CliLanguage + paths?: string[] + globs?: string[] + rewrite?: string + context?: number + updateAll?: boolean } export async function runSg(options: RunOptions): Promise { @@ -107,51 +74,44 @@ export async function runSg(options: RunOptions): Promise { const timeout = DEFAULT_TIMEOUT_MS - const proc = spawn([cliPath, ...args], { - stdout: "pipe", - stderr: "pipe", - }) + const proc = spawn([cliPath, ...args], { + stdout: "pipe", + stderr: "pipe", + }) - const timeoutPromise = new Promise((_, reject) => { - const id = setTimeout(() => { - proc.kill() - reject(new Error(`Search timeout after ${timeout}ms`)) - }, timeout) - proc.exited.then(() => clearTimeout(id)) - }) + let stdout: string + let stderr: string + let exitCode: number - let stdout: string - let stderr: string - let exitCode: number + try { + const output = await collectProcessOutputWithTimeout(proc, timeout) + stdout = output.stdout + stderr = output.stderr + exitCode = output.exitCode + } catch (error) { + if (error instanceof Error && error.message.includes("timeout")) { + return { + matches: [], + totalMatches: 0, + truncated: true, + truncatedReason: "timeout", + error: error.message, + } + } - try { - stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise]) - stderr = await new Response(proc.stderr).text() - exitCode = await proc.exited - } catch (e) { - const error = e as Error - if (error.message?.includes("timeout")) { - return { - matches: [], - totalMatches: 0, - truncated: true, - truncatedReason: "timeout", - error: error.message, - } - } + const errorMessage = error instanceof Error ? error.message : String(error) + const errorCode = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: unknown }).code + : undefined + const isNoEntry = + errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found") - const nodeError = e as NodeJS.ErrnoException - if ( - nodeError.code === "ENOENT" || - nodeError.message?.includes("ENOENT") || - nodeError.message?.includes("not found") - ) { - const downloadedPath = await ensureAstGrepBinary() - if (downloadedPath) { - resolvedCliPath = downloadedPath - setSgCliPath(downloadedPath) - return runSg(options) - } else { + if (isNoEntry) { + const downloadedPath = await ensureAstGrepBinary() + if (downloadedPath) { + return runSg(options) + } else { return { matches: [], totalMatches: 0, @@ -166,13 +126,13 @@ export async function runSg(options: RunOptions): Promise { } } - return { - matches: [], - totalMatches: 0, - truncated: false, - error: `Failed to spawn ast-grep: ${error.message}`, - } - } + return { + matches: [], + totalMatches: 0, + truncated: false, + error: `Failed to spawn ast-grep: ${errorMessage}`, + } + } if (exitCode !== 0 && stdout.trim() === "") { if (stderr.includes("No files found")) { @@ -184,59 +144,5 @@ export async function runSg(options: RunOptions): Promise { return { matches: [], totalMatches: 0, truncated: false } } - if (!stdout.trim()) { - return { matches: [], totalMatches: 0, truncated: false } - } - - const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES - const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout - - let matches: CliMatch[] = [] - try { - matches = JSON.parse(outputToProcess) as CliMatch[] - } catch { - if (outputTruncated) { - try { - const lastValidIndex = outputToProcess.lastIndexOf("}") - if (lastValidIndex > 0) { - const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex) - if (bracketIndex > 0) { - const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]" - matches = JSON.parse(truncatedJson) as CliMatch[] - } - } - } catch { - return { - matches: [], - totalMatches: 0, - truncated: true, - truncatedReason: "max_output_bytes", - error: "Output too large and could not be parsed", - } - } - } else { - return { matches: [], totalMatches: 0, truncated: false } - } - } - - const totalMatches = matches.length - const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES - const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches - - return { - matches: finalMatches, - totalMatches, - truncated: outputTruncated || matchesTruncated, - truncatedReason: outputTruncated ? "max_output_bytes" : matchesTruncated ? "max_matches" : undefined, - } -} - -export function isCliAvailable(): boolean { - const path = findSgCliPathSync() - return path !== null && existsSync(path) -} - -export async function ensureCliAvailable(): Promise { - const path = await getAstGrepPath() - return path !== null && existsSync(path) + return createSgResultFromStdout(stdout) } diff --git a/src/tools/ast-grep/constants.ts b/src/tools/ast-grep/constants.ts index d1d1609bd..8ceba1733 100644 --- a/src/tools/ast-grep/constants.ts +++ b/src/tools/ast-grep/constants.ts @@ -1,249 +1,5 @@ -import { createRequire } from "module" -import { dirname, join } from "path" -import { existsSync, statSync } from "fs" -import { getCachedBinaryPath } from "./downloader" - -type Platform = "darwin" | "linux" | "win32" | "unsupported" - -function isValidBinary(filePath: string): boolean { - try { - return statSync(filePath).size > 10000 - } catch { - return false - } -} - -function getPlatformPackageName(): string | null { - const platform = process.platform as Platform - const arch = process.arch - - const platformMap: Record = { - "darwin-arm64": "@ast-grep/cli-darwin-arm64", - "darwin-x64": "@ast-grep/cli-darwin-x64", - "linux-arm64": "@ast-grep/cli-linux-arm64-gnu", - "linux-x64": "@ast-grep/cli-linux-x64-gnu", - "win32-x64": "@ast-grep/cli-win32-x64-msvc", - "win32-arm64": "@ast-grep/cli-win32-arm64-msvc", - "win32-ia32": "@ast-grep/cli-win32-ia32-msvc", - } - - return platformMap[`${platform}-${arch}`] ?? null -} - -export function findSgCliPathSync(): string | null { - const binaryName = process.platform === "win32" ? "sg.exe" : "sg" - - const cachedPath = getCachedBinaryPath() - if (cachedPath && isValidBinary(cachedPath)) { - return cachedPath - } - - try { - const require = createRequire(import.meta.url) - const cliPkgPath = require.resolve("@ast-grep/cli/package.json") - const cliDir = dirname(cliPkgPath) - const sgPath = join(cliDir, binaryName) - - if (existsSync(sgPath) && isValidBinary(sgPath)) { - return sgPath - } - } catch { - // @ast-grep/cli not installed - } - - const platformPkg = getPlatformPackageName() - if (platformPkg) { - try { - const require = createRequire(import.meta.url) - const pkgPath = require.resolve(`${platformPkg}/package.json`) - const pkgDir = dirname(pkgPath) - const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep" - const binaryPath = join(pkgDir, astGrepName) - - if (existsSync(binaryPath) && isValidBinary(binaryPath)) { - return binaryPath - } - } catch { - // Platform-specific package not installed - } - } - - if (process.platform === "darwin") { - const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"] - for (const path of homebrewPaths) { - if (existsSync(path) && isValidBinary(path)) { - return path - } - } - } - - return null -} - -let resolvedCliPath: string | null = null - -export function getSgCliPath(): string | null { - if (resolvedCliPath !== null) { - return resolvedCliPath - } - - const syncPath = findSgCliPathSync() - if (syncPath) { - resolvedCliPath = syncPath - return syncPath - } - - return null -} - -export function setSgCliPath(path: string): void { - resolvedCliPath = path -} - -// CLI supported languages (25 total) -export const CLI_LANGUAGES = [ - "bash", - "c", - "cpp", - "csharp", - "css", - "elixir", - "go", - "haskell", - "html", - "java", - "javascript", - "json", - "kotlin", - "lua", - "nix", - "php", - "python", - "ruby", - "rust", - "scala", - "solidity", - "swift", - "typescript", - "tsx", - "yaml", -] as const - -// NAPI supported languages (5 total - native bindings) -export const NAPI_LANGUAGES = ["html", "javascript", "tsx", "css", "typescript"] as const - -// Language to file extensions mapping -export const DEFAULT_TIMEOUT_MS = 300_000 -export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024 -export const DEFAULT_MAX_MATCHES = 500 - -export const LANG_EXTENSIONS: Record = { - bash: [".bash", ".sh", ".zsh", ".bats"], - c: [".c", ".h"], - cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".h"], - csharp: [".cs"], - css: [".css"], - elixir: [".ex", ".exs"], - go: [".go"], - haskell: [".hs", ".lhs"], - html: [".html", ".htm"], - java: [".java"], - javascript: [".js", ".jsx", ".mjs", ".cjs"], - json: [".json"], - kotlin: [".kt", ".kts"], - lua: [".lua"], - nix: [".nix"], - php: [".php"], - python: [".py", ".pyi"], - ruby: [".rb", ".rake"], - rust: [".rs"], - scala: [".scala", ".sc"], - solidity: [".sol"], - swift: [".swift"], - typescript: [".ts", ".cts", ".mts"], - tsx: [".tsx"], - yaml: [".yml", ".yaml"], -} - -export interface EnvironmentCheckResult { - cli: { - available: boolean - path: string - error?: string - } - napi: { - available: boolean - error?: string - } -} - -/** - * Check if ast-grep CLI and NAPI are available. - * Call this at startup to provide early feedback about missing dependencies. - */ -export function checkEnvironment(): EnvironmentCheckResult { - const cliPath = getSgCliPath() - const result: EnvironmentCheckResult = { - cli: { - available: false, - path: cliPath ?? "not found", - }, - napi: { - available: false, - }, - } - - if (cliPath && existsSync(cliPath)) { - result.cli.available = true - } else if (!cliPath) { - result.cli.error = "ast-grep binary not found. Install with: bun add -D @ast-grep/cli" - } else { - result.cli.error = `Binary not found: ${cliPath}` - } - - // Check NAPI availability - try { - require("@ast-grep/napi") - result.napi.available = true - } catch (e) { - result.napi.available = false - result.napi.error = `@ast-grep/napi not installed: ${e instanceof Error ? e.message : String(e)}` - } - - return result -} - -/** - * Format environment check result as user-friendly message. - */ -export function formatEnvironmentCheck(result: EnvironmentCheckResult): string { - const lines: string[] = ["ast-grep Environment Status:", ""] - - // CLI status - if (result.cli.available) { - lines.push(`[OK] CLI: Available (${result.cli.path})`) - } else { - lines.push(`[X] CLI: Not available`) - if (result.cli.error) { - lines.push(` Error: ${result.cli.error}`) - } - lines.push(` Install: bun add -D @ast-grep/cli`) - } - - // NAPI status - if (result.napi.available) { - lines.push(`[OK] NAPI: Available`) - } else { - lines.push(`[X] NAPI: Not available`) - if (result.napi.error) { - lines.push(` Error: ${result.napi.error}`) - } - lines.push(` Install: bun add -D @ast-grep/napi`) - } - - lines.push("") - lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`) - lines.push(`NAPI supports ${NAPI_LANGUAGES.length} languages: ${NAPI_LANGUAGES.join(", ")}`) - - return lines.join("\n") -} +export type { EnvironmentCheckResult } from "./environment-check" +export { checkEnvironment, formatEnvironmentCheck } from "./environment-check" +export { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./language-support" +export { DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_MATCHES } from "./language-support" +export { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./sg-cli-path" diff --git a/src/tools/ast-grep/environment-check.ts b/src/tools/ast-grep/environment-check.ts new file mode 100644 index 000000000..d78b7b09d --- /dev/null +++ b/src/tools/ast-grep/environment-check.ts @@ -0,0 +1,89 @@ +import { existsSync } from "fs" + +import { CLI_LANGUAGES, NAPI_LANGUAGES } from "./language-support" +import { getSgCliPath } from "./sg-cli-path" + +export interface EnvironmentCheckResult { + cli: { + available: boolean + path: string + error?: string + } + napi: { + available: boolean + error?: string + } +} + +/** + * Check if ast-grep CLI and NAPI are available. + * Call this at startup to provide early feedback about missing dependencies. + */ +export function checkEnvironment(): EnvironmentCheckResult { + const cliPath = getSgCliPath() + const result: EnvironmentCheckResult = { + cli: { + available: false, + path: cliPath ?? "not found", + }, + napi: { + available: false, + }, + } + + if (cliPath && existsSync(cliPath)) { + result.cli.available = true + } else if (!cliPath) { + result.cli.error = "ast-grep binary not found. Install with: bun add -D @ast-grep/cli" + } else { + result.cli.error = `Binary not found: ${cliPath}` + } + + // Check NAPI availability + try { + require("@ast-grep/napi") + result.napi.available = true + } catch (error) { + result.napi.available = false + result.napi.error = `@ast-grep/napi not installed: ${ + error instanceof Error ? error.message : String(error) + }` + } + + return result +} + +/** + * Format environment check result as user-friendly message. + */ +export function formatEnvironmentCheck(result: EnvironmentCheckResult): string { + const lines: string[] = ["ast-grep Environment Status:", ""] + + // CLI status + if (result.cli.available) { + lines.push(`[OK] CLI: Available (${result.cli.path})`) + } else { + lines.push("[X] CLI: Not available") + if (result.cli.error) { + lines.push(` Error: ${result.cli.error}`) + } + lines.push(" Install: bun add -D @ast-grep/cli") + } + + // NAPI status + if (result.napi.available) { + lines.push("[OK] NAPI: Available") + } else { + lines.push("[X] NAPI: Not available") + if (result.napi.error) { + lines.push(` Error: ${result.napi.error}`) + } + lines.push(" Install: bun add -D @ast-grep/napi") + } + + lines.push("") + lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`) + lines.push(`NAPI supports ${NAPI_LANGUAGES.length} languages: ${NAPI_LANGUAGES.join(", ")}`) + + return lines.join("\n") +} diff --git a/src/tools/ast-grep/language-support.ts b/src/tools/ast-grep/language-support.ts new file mode 100644 index 000000000..b8abc9e9a --- /dev/null +++ b/src/tools/ast-grep/language-support.ts @@ -0,0 +1,63 @@ +// CLI supported languages (25 total) +export const CLI_LANGUAGES = [ + "bash", + "c", + "cpp", + "csharp", + "css", + "elixir", + "go", + "haskell", + "html", + "java", + "javascript", + "json", + "kotlin", + "lua", + "nix", + "php", + "python", + "ruby", + "rust", + "scala", + "solidity", + "swift", + "typescript", + "tsx", + "yaml", +] as const + +// NAPI supported languages (5 total - native bindings) +export const NAPI_LANGUAGES = ["html", "javascript", "tsx", "css", "typescript"] as const + +export const DEFAULT_TIMEOUT_MS = 300_000 +export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024 +export const DEFAULT_MAX_MATCHES = 500 + +export const LANG_EXTENSIONS: Record = { + bash: [".bash", ".sh", ".zsh", ".bats"], + c: [".c", ".h"], + cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".h"], + csharp: [".cs"], + css: [".css"], + elixir: [".ex", ".exs"], + go: [".go"], + haskell: [".hs", ".lhs"], + html: [".html", ".htm"], + java: [".java"], + javascript: [".js", ".jsx", ".mjs", ".cjs"], + json: [".json"], + kotlin: [".kt", ".kts"], + lua: [".lua"], + nix: [".nix"], + php: [".php"], + python: [".py", ".pyi"], + ruby: [".rb", ".rake"], + rust: [".rs"], + scala: [".scala", ".sc"], + solidity: [".sol"], + swift: [".swift"], + typescript: [".ts", ".cts", ".mts"], + tsx: [".tsx"], + yaml: [".yml", ".yaml"], +} diff --git a/src/tools/ast-grep/process-output-timeout.ts b/src/tools/ast-grep/process-output-timeout.ts new file mode 100644 index 000000000..2292b37c3 --- /dev/null +++ b/src/tools/ast-grep/process-output-timeout.ts @@ -0,0 +1,28 @@ +type SpawnedProcess = { + stdout: ReadableStream | null + stderr: ReadableStream | null + exited: Promise + kill: () => void +} + +export async function collectProcessOutputWithTimeout( + process: SpawnedProcess, + timeoutMs: number +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const timeoutPromise = new Promise((_, reject) => { + const timeoutId = setTimeout(() => { + process.kill() + reject(new Error(`Search timeout after ${timeoutMs}ms`)) + }, timeoutMs) + process.exited.then(() => clearTimeout(timeoutId)) + }) + + const stdoutPromise = process.stdout ? new Response(process.stdout).text() : Promise.resolve("") + const stderrPromise = process.stderr ? new Response(process.stderr).text() : Promise.resolve("") + + const stdout = await Promise.race([stdoutPromise, timeoutPromise]) + const stderr = await stderrPromise + const exitCode = await process.exited + + return { stdout, stderr, exitCode } +} diff --git a/src/tools/ast-grep/sg-cli-path.ts b/src/tools/ast-grep/sg-cli-path.ts new file mode 100644 index 000000000..5d7103f57 --- /dev/null +++ b/src/tools/ast-grep/sg-cli-path.ts @@ -0,0 +1,102 @@ +import { createRequire } from "module" +import { dirname, join } from "path" +import { existsSync, statSync } from "fs" + +import { getCachedBinaryPath } from "./downloader" + +type Platform = "darwin" | "linux" | "win32" | "unsupported" + +function isValidBinary(filePath: string): boolean { + try { + return statSync(filePath).size > 10000 + } catch { + return false + } +} + +function getPlatformPackageName(): string | null { + const platform = process.platform as Platform + const arch = process.arch + + const platformMap: Record = { + "darwin-arm64": "@ast-grep/cli-darwin-arm64", + "darwin-x64": "@ast-grep/cli-darwin-x64", + "linux-arm64": "@ast-grep/cli-linux-arm64-gnu", + "linux-x64": "@ast-grep/cli-linux-x64-gnu", + "win32-x64": "@ast-grep/cli-win32-x64-msvc", + "win32-arm64": "@ast-grep/cli-win32-arm64-msvc", + "win32-ia32": "@ast-grep/cli-win32-ia32-msvc", + } + + return platformMap[`${platform}-${arch}`] ?? null +} + +export function findSgCliPathSync(): string | null { + const binaryName = process.platform === "win32" ? "sg.exe" : "sg" + + const cachedPath = getCachedBinaryPath() + if (cachedPath && isValidBinary(cachedPath)) { + return cachedPath + } + + try { + const require = createRequire(import.meta.url) + const cliPackageJsonPath = require.resolve("@ast-grep/cli/package.json") + const cliDirectory = dirname(cliPackageJsonPath) + const sgPath = join(cliDirectory, binaryName) + + if (existsSync(sgPath) && isValidBinary(sgPath)) { + return sgPath + } + } catch { + // @ast-grep/cli not installed + } + + const platformPackage = getPlatformPackageName() + if (platformPackage) { + try { + const require = createRequire(import.meta.url) + const packageJsonPath = require.resolve(`${platformPackage}/package.json`) + const packageDirectory = dirname(packageJsonPath) + const astGrepBinaryName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep" + const binaryPath = join(packageDirectory, astGrepBinaryName) + + if (existsSync(binaryPath) && isValidBinary(binaryPath)) { + return binaryPath + } + } catch { + // Platform-specific package not installed + } + } + + if (process.platform === "darwin") { + const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"] + for (const path of homebrewPaths) { + if (existsSync(path) && isValidBinary(path)) { + return path + } + } + } + + return null +} + +let resolvedCliPath: string | null = null + +export function getSgCliPath(): string | null { + if (resolvedCliPath !== null) { + return resolvedCliPath + } + + const syncPath = findSgCliPathSync() + if (syncPath) { + resolvedCliPath = syncPath + return syncPath + } + + return null +} + +export function setSgCliPath(path: string): void { + resolvedCliPath = path +} diff --git a/src/tools/ast-grep/sg-compact-json-output.ts b/src/tools/ast-grep/sg-compact-json-output.ts new file mode 100644 index 000000000..218253165 --- /dev/null +++ b/src/tools/ast-grep/sg-compact-json-output.ts @@ -0,0 +1,54 @@ +import { DEFAULT_MAX_MATCHES, DEFAULT_MAX_OUTPUT_BYTES } from "./constants" +import type { CliMatch, SgResult } from "./types" + +export function createSgResultFromStdout(stdout: string): SgResult { + if (!stdout.trim()) { + return { matches: [], totalMatches: 0, truncated: false } + } + + const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES + const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout + + let matches: CliMatch[] = [] + try { + matches = JSON.parse(outputToProcess) as CliMatch[] + } catch { + if (outputTruncated) { + try { + const lastValidIndex = outputToProcess.lastIndexOf("}") + if (lastValidIndex > 0) { + const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex) + if (bracketIndex > 0) { + const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]" + matches = JSON.parse(truncatedJson) as CliMatch[] + } + } + } catch { + return { + matches: [], + totalMatches: 0, + truncated: true, + truncatedReason: "max_output_bytes", + error: "Output too large and could not be parsed", + } + } + } else { + return { matches: [], totalMatches: 0, truncated: false } + } + } + + const totalMatches = matches.length + const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES + const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches + + return { + matches: finalMatches, + totalMatches, + truncated: outputTruncated || matchesTruncated, + truncatedReason: outputTruncated + ? "max_output_bytes" + : matchesTruncated + ? "max_matches" + : undefined, + } +} From 6e0f6d53a76e305a12fcca92a41c4251444bab23 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:24:13 +0900 Subject: [PATCH 18/51] refactor(call-omo-agent): split tools.ts into agent execution modules Extract agent call pipeline: - agent-type-normalizer.ts, tool-context-with-metadata.ts - subagent-session-creator.ts, subagent-session-prompter.ts - sync-agent-executor.ts, background-agent-executor.ts - session-completion-poller.ts, session-message-output-extractor.ts - message-storage-directory.ts --- .../call-omo-agent/agent-type-normalizer.ts | 12 + .../background-agent-executor.ts | 81 +++++ .../message-storage-directory.ts | 18 + .../session-completion-poller.ts | 76 ++++ .../session-message-output-extractor.ts | 93 +++++ .../subagent-session-creator.ts | 67 ++++ .../subagent-session-prompter.ts | 26 ++ .../call-omo-agent/sync-agent-executor.ts | 89 +++++ .../tool-context-with-metadata.ts | 10 + src/tools/call-omo-agent/tools.ts | 340 +----------------- 10 files changed, 482 insertions(+), 330 deletions(-) create mode 100644 src/tools/call-omo-agent/agent-type-normalizer.ts create mode 100644 src/tools/call-omo-agent/background-agent-executor.ts create mode 100644 src/tools/call-omo-agent/message-storage-directory.ts create mode 100644 src/tools/call-omo-agent/session-completion-poller.ts create mode 100644 src/tools/call-omo-agent/session-message-output-extractor.ts create mode 100644 src/tools/call-omo-agent/subagent-session-creator.ts create mode 100644 src/tools/call-omo-agent/subagent-session-prompter.ts create mode 100644 src/tools/call-omo-agent/sync-agent-executor.ts create mode 100644 src/tools/call-omo-agent/tool-context-with-metadata.ts diff --git a/src/tools/call-omo-agent/agent-type-normalizer.ts b/src/tools/call-omo-agent/agent-type-normalizer.ts new file mode 100644 index 000000000..ac40d5363 --- /dev/null +++ b/src/tools/call-omo-agent/agent-type-normalizer.ts @@ -0,0 +1,12 @@ +import { ALLOWED_AGENTS } from "./constants" +import type { AllowedAgentType } from "./types" + +export function normalizeAgentType(input: string): AllowedAgentType | null { + const lowered = input.toLowerCase() + for (const allowed of ALLOWED_AGENTS) { + if (allowed.toLowerCase() === lowered) { + return allowed + } + } + return null +} diff --git a/src/tools/call-omo-agent/background-agent-executor.ts b/src/tools/call-omo-agent/background-agent-executor.ts new file mode 100644 index 000000000..0993c7fe5 --- /dev/null +++ b/src/tools/call-omo-agent/background-agent-executor.ts @@ -0,0 +1,81 @@ +import type { BackgroundManager } from "../../features/background-agent" +import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector" +import { getSessionAgent } from "../../features/claude-code-session-state" +import { log } from "../../shared" +import type { CallOmoAgentArgs } from "./types" +import type { ToolContextWithMetadata } from "./tool-context-with-metadata" +import { getMessageDir } from "./message-storage-directory" + +export async function executeBackgroundAgent( + args: CallOmoAgentArgs, + toolContext: ToolContextWithMetadata, + manager: BackgroundManager, +): Promise { + try { + const messageDir = getMessageDir(toolContext.sessionID) + const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null + const sessionAgent = getSessionAgent(toolContext.sessionID) + const parentAgent = + toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent + + log("[call_omo_agent] parentAgent resolution", { + sessionID: toolContext.sessionID, + messageDir, + ctxAgent: toolContext.agent, + sessionAgent, + firstMessageAgent, + prevMessageAgent: prevMessage?.agent, + resolvedParentAgent: parentAgent, + }) + + const task = await manager.launch({ + description: args.description, + prompt: args.prompt, + agent: args.subagent_type, + parentSessionID: toolContext.sessionID, + parentMessageID: toolContext.messageID, + parentAgent, + }) + + const waitStart = Date.now() + const waitTimeoutMs = 30_000 + const waitIntervalMs = 50 + + let sessionId = task.sessionID + while (!sessionId && Date.now() - waitStart < waitTimeoutMs) { + if (toolContext.abort?.aborted) { + return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}` + } + const updated = manager.getTask(task.id) + if (updated?.status === "error" || updated?.status === "cancelled") { + return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}` + } + await new Promise((resolve) => { + setTimeout(resolve, waitIntervalMs) + }) + sessionId = manager.getTask(task.id)?.sessionID + } + + await toolContext.metadata?.({ + title: args.description, + metadata: { sessionId: sessionId ?? "pending" }, + }) + + return `Background agent task launched successfully. + +Task ID: ${task.id} +Session ID: ${sessionId ?? "pending"} +Description: ${task.description} +Agent: ${task.agent} (subagent) +Status: ${task.status} + +The system will notify you when the task completes. +Use \`background_output\` tool with task_id="${task.id}" to check progress: +- block=false (default): Check status immediately - returns full status info +- block=true: Wait for completion (rarely needed since system notifies)` + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return `Failed to launch background agent task: ${message}` + } +} diff --git a/src/tools/call-omo-agent/message-storage-directory.ts b/src/tools/call-omo-agent/message-storage-directory.ts new file mode 100644 index 000000000..30fecd6e9 --- /dev/null +++ b/src/tools/call-omo-agent/message-storage-directory.ts @@ -0,0 +1,18 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "../../features/hook-message-injector" + +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 +} diff --git a/src/tools/call-omo-agent/session-completion-poller.ts b/src/tools/call-omo-agent/session-completion-poller.ts new file mode 100644 index 000000000..7b40c2652 --- /dev/null +++ b/src/tools/call-omo-agent/session-completion-poller.ts @@ -0,0 +1,76 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared" + +function getSessionStatusType(statusResult: unknown, sessionID: string): string | null { + if (typeof statusResult !== "object" || statusResult === null) return null + if (!("data" in statusResult)) return null + const data = (statusResult as { data?: unknown }).data + if (typeof data !== "object" || data === null) return null + const record = data as Record + const entry = record[sessionID] + if (typeof entry !== "object" || entry === null) return null + const typeValue = (entry as Record)["type"] + return typeof typeValue === "string" ? typeValue : null +} + +function getMessagesArray(result: unknown): unknown[] { + if (Array.isArray(result)) return result + if (typeof result !== "object" || result === null) return [] + if (!("data" in result)) return [] + const data = (result as { data?: unknown }).data + return Array.isArray(data) ? data : [] +} + +export async function waitForSessionCompletion( + ctx: PluginInput, + options: { + sessionID: string + abortSignal?: AbortSignal + maxPollTimeMs: number + pollIntervalMs: number + stabilityRequired: number + }, +): Promise<{ ok: true } | { ok: false; reason: "aborted" | "timeout" }> { + const pollStart = Date.now() + let lastMsgCount = 0 + let stablePolls = 0 + + while (Date.now() - pollStart < options.maxPollTimeMs) { + if (options.abortSignal?.aborted) { + log("[call_omo_agent] Aborted by user") + return { ok: false, reason: "aborted" } + } + + await new Promise((resolve) => { + setTimeout(resolve, options.pollIntervalMs) + }) + + const statusResult = await ctx.client.session.status() + const sessionStatusType = getSessionStatusType(statusResult, options.sessionID) + + if (sessionStatusType && sessionStatusType !== "idle") { + stablePolls = 0 + lastMsgCount = 0 + continue + } + + const messagesCheck = await ctx.client.session.messages({ + path: { id: options.sessionID }, + }) + const currentMsgCount = getMessagesArray(messagesCheck).length + + if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) { + stablePolls++ + if (stablePolls >= options.stabilityRequired) { + log("[call_omo_agent] Session complete", { messageCount: currentMsgCount }) + return { ok: true } + } + } else { + stablePolls = 0 + lastMsgCount = currentMsgCount + } + } + + log("[call_omo_agent] Timeout reached") + return { ok: false, reason: "timeout" } +} diff --git a/src/tools/call-omo-agent/session-message-output-extractor.ts b/src/tools/call-omo-agent/session-message-output-extractor.ts new file mode 100644 index 000000000..3cc2347a2 --- /dev/null +++ b/src/tools/call-omo-agent/session-message-output-extractor.ts @@ -0,0 +1,93 @@ +import { consumeNewMessages, type CursorMessage } from "../../shared/session-cursor" + +type SessionMessagePart = { + type: string + text?: string + content?: unknown +} + +export type SessionMessage = CursorMessage & { + info?: CursorMessage["info"] & { role?: string } + parts?: SessionMessagePart[] +} + +function getRole(message: SessionMessage): string | null { + const role = message.info?.role + return typeof role === "string" ? role : null +} + +function getCreatedTime(message: SessionMessage): number { + const time = message.info?.time + if (typeof time === "number") return time + if (typeof time === "string") return Number(time) || 0 + const created = time?.created + if (typeof created === "number") return created + if (typeof created === "string") return Number(created) || 0 + return 0 +} + +function isRelevantRole(role: string | null): boolean { + return role === "assistant" || role === "tool" +} + +function extractTextFromParts(parts: SessionMessagePart[] | undefined): string[] { + if (!parts) return [] + const extracted: string[] = [] + + for (const part of parts) { + if ((part.type === "text" || part.type === "reasoning") && part.text) { + extracted.push(part.text) + continue + } + if (part.type !== "tool_result") continue + const content = part.content + if (typeof content === "string" && content) { + extracted.push(content) + continue + } + if (!Array.isArray(content)) continue + for (const block of content) { + if (typeof block !== "object" || block === null) continue + const record = block as Record + const typeValue = record["type"] + const textValue = record["text"] + if ( + (typeValue === "text" || typeValue === "reasoning") && + typeof textValue === "string" && + textValue + ) { + extracted.push(textValue) + } + } + } + + return extracted +} + +export function extractNewSessionOutput( + sessionID: string, + messages: SessionMessage[], +): { output: string; hasNewOutput: boolean } { + const relevantMessages = messages.filter((message) => + isRelevantRole(getRole(message)), + ) + if (relevantMessages.length === 0) { + return { output: "", hasNewOutput: false } + } + + const sortedMessages = [...relevantMessages].sort( + (a, b) => getCreatedTime(a) - getCreatedTime(b), + ) + const newMessages = consumeNewMessages(sessionID, sortedMessages) + if (newMessages.length === 0) { + return { output: "", hasNewOutput: false } + } + + const chunks: string[] = [] + for (const message of newMessages) { + chunks.push(...extractTextFromParts(message.parts)) + } + + const output = chunks.filter((text) => text.length > 0).join("\n\n") + return { output, hasNewOutput: output.length > 0 } +} diff --git a/src/tools/call-omo-agent/subagent-session-creator.ts b/src/tools/call-omo-agent/subagent-session-creator.ts new file mode 100644 index 000000000..908060da8 --- /dev/null +++ b/src/tools/call-omo-agent/subagent-session-creator.ts @@ -0,0 +1,67 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared" +import type { CallOmoAgentArgs } from "./types" +import type { ToolContextWithMetadata } from "./tool-context-with-metadata" + +export async function resolveOrCreateSessionId( + ctx: PluginInput, + args: CallOmoAgentArgs, + toolContext: ToolContextWithMetadata, +): Promise<{ ok: true; sessionID: string } | { ok: false; error: string }> { + if (args.session_id) { + log(`[call_omo_agent] Using existing session: ${args.session_id}`) + const sessionResult = await ctx.client.session.get({ + path: { id: args.session_id }, + }) + if (sessionResult.error) { + log("[call_omo_agent] Session get error", { error: sessionResult.error }) + return { + ok: false, + error: `Error: Failed to get existing session: ${sessionResult.error}`, + } + } + return { ok: true, sessionID: args.session_id } + } + + log(`[call_omo_agent] Creating new session with parent: ${toolContext.sessionID}`) + const parentSession = await ctx.client.session + .get({ path: { id: toolContext.sessionID } }) + .catch((err) => { + log("[call_omo_agent] Failed to get parent session", { error: String(err) }) + return null + }) + const parentDirectory = parentSession?.data?.directory ?? ctx.directory + + const body = { + parentID: toolContext.sessionID, + title: `${args.description} (@${args.subagent_type} subagent)`, + } + + const createResult = await ctx.client.session.create({ + body, + query: { directory: parentDirectory }, + }) + + if (createResult.error) { + log("[call_omo_agent] Session create error", { error: createResult.error }) + const errorStr = String(createResult.error) + if (errorStr.toLowerCase().includes("unauthorized")) { + return { + ok: false, + error: `Error: Failed to create session (Unauthorized). This may be due to: +1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only) +2. Provider authentication issues +3. Session permission inheritance problems + +Try using a different provider or API key authentication. + +Original error: ${createResult.error}`, + } + } + return { ok: false, error: `Error: Failed to create session: ${createResult.error}` } + } + + const sessionID = createResult.data.id + log(`[call_omo_agent] Created session: ${sessionID}`) + return { ok: true, sessionID } +} diff --git a/src/tools/call-omo-agent/subagent-session-prompter.ts b/src/tools/call-omo-agent/subagent-session-prompter.ts new file mode 100644 index 000000000..ce8e3796b --- /dev/null +++ b/src/tools/call-omo-agent/subagent-session-prompter.ts @@ -0,0 +1,26 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log, getAgentToolRestrictions } from "../../shared" + +export async function promptSubagentSession( + ctx: PluginInput, + options: { sessionID: string; agent: string; prompt: string }, +): Promise<{ ok: true } | { ok: false; error: string }> { + try { + await ctx.client.session.promptAsync({ + path: { id: options.sessionID }, + body: { + agent: options.agent, + tools: { + ...getAgentToolRestrictions(options.agent), + task: false, + }, + parts: [{ type: "text", text: options.prompt }], + }, + }) + return { ok: true } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + log("[call_omo_agent] Prompt error", { error: errorMessage }) + return { ok: false, error: errorMessage } + } +} diff --git a/src/tools/call-omo-agent/sync-agent-executor.ts b/src/tools/call-omo-agent/sync-agent-executor.ts new file mode 100644 index 000000000..9b1a1bd67 --- /dev/null +++ b/src/tools/call-omo-agent/sync-agent-executor.ts @@ -0,0 +1,89 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared" +import { extractNewSessionOutput, type SessionMessage } from "./session-message-output-extractor" +import { waitForSessionCompletion } from "./session-completion-poller" +import { resolveOrCreateSessionId } from "./subagent-session-creator" +import { promptSubagentSession } from "./subagent-session-prompter" +import type { CallOmoAgentArgs } from "./types" +import type { ToolContextWithMetadata } from "./tool-context-with-metadata" + +function buildTaskMetadata(sessionID: string): string { + return ["", `session_id: ${sessionID}`, ""].join( + "\n", + ) +} + +function getMessagesArray(result: unknown): SessionMessage[] { + if (Array.isArray(result)) return result as SessionMessage[] + if (typeof result !== "object" || result === null) return [] + if (!("data" in result)) return [] + const data = (result as { data?: unknown }).data + return Array.isArray(data) ? (data as SessionMessage[]) : [] +} + +export async function executeSyncAgent( + args: CallOmoAgentArgs, + toolContext: ToolContextWithMetadata, + ctx: PluginInput, +): Promise { + const sessionResult = await resolveOrCreateSessionId(ctx, args, toolContext) + if (!sessionResult.ok) { + return sessionResult.error + } + const sessionID = sessionResult.sessionID + + await toolContext.metadata?.({ + title: args.description, + metadata: { sessionId: sessionID }, + }) + + log(`[call_omo_agent] Sending prompt to session ${sessionID}`) + log("[call_omo_agent] Prompt preview", { preview: args.prompt.substring(0, 100) }) + + const promptResult = await promptSubagentSession(ctx, { + sessionID, + agent: args.subagent_type, + prompt: args.prompt, + }) + if (!promptResult.ok) { + const errorMessage = promptResult.error + if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { + return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n${buildTaskMetadata(sessionID)}` + } + return `Error: Failed to send prompt: ${errorMessage}\n\n${buildTaskMetadata(sessionID)}` + } + + log("[call_omo_agent] Prompt sent, polling for completion...") + const completion = await waitForSessionCompletion(ctx, { + sessionID, + abortSignal: toolContext.abort, + maxPollTimeMs: 5 * 60 * 1000, + pollIntervalMs: 500, + stabilityRequired: 3, + }) + if (!completion.ok) { + if (completion.reason === "aborted") { + return `Task aborted.\n\n${buildTaskMetadata(sessionID)}` + } + return `Error: Agent task timed out after 5 minutes.\n\n${buildTaskMetadata(sessionID)}` + } + + const messagesResult = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + if (messagesResult.error) { + log("[call_omo_agent] Messages error", { error: messagesResult.error }) + return `Error: Failed to get messages: ${messagesResult.error}` + } + + const messages = getMessagesArray(messagesResult) + log("[call_omo_agent] Got messages", { count: messages.length }) + + const extracted = extractNewSessionOutput(sessionID, messages) + if (!extracted.hasNewOutput) { + return `No new output since last check.\n\n${buildTaskMetadata(sessionID)}` + } + + log("[call_omo_agent] Got response", { length: extracted.output.length }) + return `${extracted.output}\n\n${buildTaskMetadata(sessionID)}` +} diff --git a/src/tools/call-omo-agent/tool-context-with-metadata.ts b/src/tools/call-omo-agent/tool-context-with-metadata.ts new file mode 100644 index 000000000..e3f771b23 --- /dev/null +++ b/src/tools/call-omo-agent/tool-context-with-metadata.ts @@ -0,0 +1,10 @@ +export type ToolContextWithMetadata = { + sessionID: string + messageID: string + agent: string + abort: AbortSignal + metadata?: (input: { + title?: string + metadata?: Record + }) => void +} diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index 64fa74a40..242b4d5c4 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -1,36 +1,12 @@ import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants" import type { CallOmoAgentArgs } from "./types" import type { BackgroundManager } from "../../features/background-agent" -import { log, getAgentToolRestrictions } from "../../shared" -import { consumeNewMessages } from "../../shared/session-cursor" -import { findFirstMessageWithAgent, findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" -import { getSessionAgent } from "../../features/claude-code-session-state" - -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 -} - -type ToolContextWithMetadata = { - sessionID: string - messageID: string - agent: string - abort: AbortSignal - metadata?: (input: { title?: string; metadata?: Record }) => void -} +import { log } from "../../shared" +import { normalizeAgentType } from "./agent-type-normalizer" +import { executeBackgroundAgent } from "./background-agent-executor" +import { executeSyncAgent } from "./sync-agent-executor" +import type { ToolContextWithMetadata } from "./tool-context-with-metadata" export function createCallOmoAgent( ctx: PluginInput, @@ -58,317 +34,21 @@ export function createCallOmoAgent( const toolCtx = toolContext as ToolContextWithMetadata log(`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`) - // Case-insensitive agent validation - allows "Explore", "EXPLORE", "explore" etc. - if (![...ALLOWED_AGENTS].some( - (name) => name.toLowerCase() === args.subagent_type.toLowerCase() - )) { + const normalizedAgent = normalizeAgentType(args.subagent_type) + if (!normalizedAgent) { return `Error: Invalid agent type "${args.subagent_type}". Only ${ALLOWED_AGENTS.join(", ")} are allowed.` } - - const normalizedAgent = args.subagent_type.toLowerCase() as typeof ALLOWED_AGENTS[number] + args = { ...args, subagent_type: normalizedAgent } if (args.run_in_background) { if (args.session_id) { return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.` } - return await executeBackground(args, toolCtx, backgroundManager) + return await executeBackgroundAgent(args, toolCtx, backgroundManager) } - return await executeSync(args, toolCtx, ctx) + return await executeSyncAgent(args, toolCtx, ctx) }, }) } - -async function executeBackground( - args: CallOmoAgentArgs, - toolContext: ToolContextWithMetadata, - manager: BackgroundManager -): Promise { - try { - const messageDir = getMessageDir(toolContext.sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null - const sessionAgent = getSessionAgent(toolContext.sessionID) - const parentAgent = toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent - - log("[call_omo_agent] parentAgent resolution", { - sessionID: toolContext.sessionID, - messageDir, - ctxAgent: toolContext.agent, - sessionAgent, - firstMessageAgent, - prevMessageAgent: prevMessage?.agent, - resolvedParentAgent: parentAgent, - }) - - const task = await manager.launch({ - description: args.description, - prompt: args.prompt, - agent: args.subagent_type, - parentSessionID: toolContext.sessionID, - parentMessageID: toolContext.messageID, - parentAgent, - }) - - const WAIT_FOR_SESSION_INTERVAL_MS = 50 - const WAIT_FOR_SESSION_TIMEOUT_MS = 30000 - const waitStart = Date.now() - let sessionId = task.sessionID - while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) { - if (toolContext.abort?.aborted) { - return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}` - } - const updated = manager.getTask(task.id) - if (updated?.status === "error" || updated?.status === "cancelled") { - return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}` - } - await new Promise(resolve => setTimeout(resolve, WAIT_FOR_SESSION_INTERVAL_MS)) - sessionId = manager.getTask(task.id)?.sessionID - } - - await toolContext.metadata?.({ - title: args.description, - metadata: { sessionId: sessionId ?? "pending" }, - }) - - return `Background agent task launched successfully. - -Task ID: ${task.id} -Session ID: ${sessionId ?? "pending"} -Description: ${task.description} -Agent: ${task.agent} (subagent) -Status: ${task.status} - -The system will notify you when the task completes. -Use \`background_output\` tool with task_id="${task.id}" to check progress: -- block=false (default): Check status immediately - returns full status info -- block=true: Wait for completion (rarely needed since system notifies)` - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return `Failed to launch background agent task: ${message}` - } -} - -async function executeSync( - args: CallOmoAgentArgs, - toolContext: ToolContextWithMetadata, - ctx: PluginInput -): Promise { - let sessionID: string - - if (args.session_id) { - log(`[call_omo_agent] Using existing session: ${args.session_id}`) - const sessionResult = await ctx.client.session.get({ - path: { id: args.session_id }, - }) - if (sessionResult.error) { - log(`[call_omo_agent] Session get error:`, sessionResult.error) - return `Error: Failed to get existing session: ${sessionResult.error}` - } - sessionID = args.session_id - } else { - log(`[call_omo_agent] Creating new session with parent: ${toolContext.sessionID}`) - const parentSession = await ctx.client.session.get({ - path: { id: toolContext.sessionID }, - }).catch((err) => { - log(`[call_omo_agent] Failed to get parent session:`, err) - return null - }) - log(`[call_omo_agent] Parent session dir: ${parentSession?.data?.directory}, fallback: ${ctx.directory}`) - const parentDirectory = parentSession?.data?.directory ?? ctx.directory - - const createResult = await ctx.client.session.create({ - body: { - parentID: toolContext.sessionID, - title: `${args.description} (@${args.subagent_type} subagent)`, - permission: [ - { permission: "question", action: "deny" as const, pattern: "*" }, - ], - } as any, - query: { - directory: parentDirectory, - }, - }) - - if (createResult.error) { - log(`[call_omo_agent] Session create error:`, createResult.error) - const errorStr = String(createResult.error) - if (errorStr.toLowerCase().includes("unauthorized")) { - return `Error: Failed to create session (Unauthorized). This may be due to: -1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only) -2. Provider authentication issues -3. Session permission inheritance problems - -Try using a different provider or API key authentication. - -Original error: ${createResult.error}` - } - return `Error: Failed to create session: ${createResult.error}` - } - - sessionID = createResult.data.id - log(`[call_omo_agent] Created session: ${sessionID}`) - } - - await toolContext.metadata?.({ - title: args.description, - metadata: { sessionId: sessionID }, - }) - - log(`[call_omo_agent] Sending prompt to session ${sessionID}`) - log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100)) - - try { - await (ctx.client.session as any).promptAsync({ - path: { id: sessionID }, - body: { - agent: args.subagent_type, - tools: { - ...getAgentToolRestrictions(args.subagent_type), - task: false, - }, - parts: [{ type: "text", text: args.prompt }], - }, - }) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - log(`[call_omo_agent] Prompt error:`, errorMessage) - if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { - return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n\nsession_id: ${sessionID}\n` - } - return `Error: Failed to send prompt: ${errorMessage}\n\n\nsession_id: ${sessionID}\n` - } - - log(`[call_omo_agent] Prompt sent, polling for completion...`) - - // Poll for session completion - const POLL_INTERVAL_MS = 500 - const MAX_POLL_TIME_MS = 5 * 60 * 1000 // 5 minutes max - const pollStart = Date.now() - let lastMsgCount = 0 - let stablePolls = 0 - const STABILITY_REQUIRED = 3 - - while (Date.now() - pollStart < MAX_POLL_TIME_MS) { - // Check if aborted - if (toolContext.abort?.aborted) { - log(`[call_omo_agent] Aborted by user`) - return `Task aborted.\n\n\nsession_id: ${sessionID}\n` - } - - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) - - // Check session status - const statusResult = await ctx.client.session.status() - const allStatuses = (statusResult.data ?? {}) as Record - const sessionStatus = allStatuses[sessionID] - - // If session is actively running, reset stability counter - if (sessionStatus && sessionStatus.type !== "idle") { - stablePolls = 0 - lastMsgCount = 0 - continue - } - - // Session is idle - check message stability - const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } }) - const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array - const currentMsgCount = msgs.length - - if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) { - stablePolls++ - if (stablePolls >= STABILITY_REQUIRED) { - log(`[call_omo_agent] Session complete, ${currentMsgCount} messages`) - break - } - } else { - stablePolls = 0 - lastMsgCount = currentMsgCount - } - } - - if (Date.now() - pollStart >= MAX_POLL_TIME_MS) { - log(`[call_omo_agent] Timeout reached`) - return `Error: Agent task timed out after 5 minutes.\n\n\nsession_id: ${sessionID}\n` - } - - const messagesResult = await ctx.client.session.messages({ - path: { id: sessionID }, - }) - - if (messagesResult.error) { - log(`[call_omo_agent] Messages error:`, messagesResult.error) - return `Error: Failed to get messages: ${messagesResult.error}` - } - - const messages = messagesResult.data - log(`[call_omo_agent] Got ${messages.length} messages`) - - // Include both assistant messages AND tool messages - // Tool results (grep, glob, bash output) come from role "tool" - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const relevantMessages = messages.filter( - (m: any) => m.info?.role === "assistant" || m.info?.role === "tool" - ) - - if (relevantMessages.length === 0) { - log(`[call_omo_agent] No assistant or tool messages found`) - log(`[call_omo_agent] All messages:`, JSON.stringify(messages, null, 2)) - return `Error: No assistant or tool response found\n\n\nsession_id: ${sessionID}\n` - } - - log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`) - - // Sort by time ascending (oldest first) to process messages in order - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sortedMessages = [...relevantMessages].sort((a: any, b: any) => { - const timeA = a.info?.time?.created ?? 0 - const timeB = b.info?.time?.created ?? 0 - return timeA - timeB - }) - - const newMessages = consumeNewMessages(sessionID, sortedMessages) - - if (newMessages.length === 0) { - return `No new output since last check.\n\n\nsession_id: ${sessionID}\n` - } - - // Extract content from ALL messages, not just the last one - // Tool results may be in earlier messages while the final message is empty - const extractedContent: string[] = [] - - for (const message of newMessages) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - for (const part of (message as any).parts ?? []) { - // Handle both "text" and "reasoning" parts (thinking models use "reasoning") - if ((part.type === "text" || part.type === "reasoning") && part.text) { - extractedContent.push(part.text) - } else if (part.type === "tool_result") { - // Tool results contain the actual output from tool calls - const toolResult = part as { content?: string | Array<{ type: string; text?: string }> } - if (typeof toolResult.content === "string" && toolResult.content) { - extractedContent.push(toolResult.content) - } else if (Array.isArray(toolResult.content)) { - // Handle array of content blocks - for (const block of toolResult.content) { - if ((block.type === "text" || block.type === "reasoning") && block.text) { - extractedContent.push(block.text) - } - } - } - } - } - } - - const responseText = extractedContent - .filter((text) => text.length > 0) - .join("\n\n") - - log(`[call_omo_agent] Got response, length: ${responseText.length}`) - - const output = - responseText + "\n\n" + ["", `session_id: ${sessionID}`, ""].join("\n") - - return output -} From 480dcff42032ae9837d54ec8ad543328ec695bde Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:24:21 +0900 Subject: [PATCH 19/51] refactor(look-at): split tools.ts into argument parsing and extraction modules Extract multimodal look-at tool internals: - look-at-arguments.ts: argument validation and parsing - assistant-message-extractor.ts: response extraction - mime-type-inference.ts: file type detection - multimodal-agent-metadata.ts: agent metadata constants --- .../look-at/assistant-message-extractor.ts | 67 +++++++ src/tools/look-at/look-at-arguments.ts | 31 +++ src/tools/look-at/mime-type-inference.ts | 71 +++++++ .../look-at/multimodal-agent-metadata.ts | 56 ++++++ src/tools/look-at/tools.ts | 187 +++--------------- 5 files changed, 254 insertions(+), 158 deletions(-) create mode 100644 src/tools/look-at/assistant-message-extractor.ts create mode 100644 src/tools/look-at/look-at-arguments.ts create mode 100644 src/tools/look-at/mime-type-inference.ts create mode 100644 src/tools/look-at/multimodal-agent-metadata.ts diff --git a/src/tools/look-at/assistant-message-extractor.ts b/src/tools/look-at/assistant-message-extractor.ts new file mode 100644 index 000000000..f7db57b76 --- /dev/null +++ b/src/tools/look-at/assistant-message-extractor.ts @@ -0,0 +1,67 @@ +type MessageTime = { created?: number } + +type MessageInfo = { + role?: string + time?: MessageTime +} + +type MessagePart = { + type?: string + text?: string +} + +type SessionMessage = { + info?: MessageInfo + parts?: unknown +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function asSessionMessage(value: unknown): SessionMessage | null { + if (!isObject(value)) return null + const info = value["info"] + const parts = value["parts"] + return { + info: isObject(info) + ? { + role: typeof info["role"] === "string" ? info["role"] : undefined, + time: isObject(info["time"]) ? { created: typeof info["time"]["created"] === "number" ? info["time"]["created"] : undefined } : undefined, + } + : undefined, + parts, + } +} + +function getCreatedTime(message: SessionMessage): number { + return message.info?.time?.created ?? 0 +} + +function getTextParts(message: SessionMessage): MessagePart[] { + if (!Array.isArray(message.parts)) return [] + return message.parts + .filter((part): part is Record => isObject(part)) + .map((part) => ({ + type: typeof part["type"] === "string" ? part["type"] : undefined, + text: typeof part["text"] === "string" ? part["text"] : undefined, + })) + .filter((part) => part.type === "text" && Boolean(part.text)) +} + +export function extractLatestAssistantText(messages: unknown): string | null { + if (!Array.isArray(messages) || messages.length === 0) return null + + const assistantMessages = messages + .map(asSessionMessage) + .filter((message): message is SessionMessage => message !== null) + .filter((message) => message.info?.role === "assistant") + .sort((a, b) => getCreatedTime(b) - getCreatedTime(a)) + + const lastAssistantMessage = assistantMessages[0] + if (!lastAssistantMessage) return null + + const textParts = getTextParts(lastAssistantMessage) + const responseText = textParts.map((part) => part.text).join("\n") + return responseText +} diff --git a/src/tools/look-at/look-at-arguments.ts b/src/tools/look-at/look-at-arguments.ts new file mode 100644 index 000000000..dc98f6925 --- /dev/null +++ b/src/tools/look-at/look-at-arguments.ts @@ -0,0 +1,31 @@ +import type { LookAtArgs } from "./types" + +export interface LookAtArgsWithAlias extends LookAtArgs { + path?: string +} + +export function normalizeArgs(args: LookAtArgsWithAlias): LookAtArgs { + return { + file_path: args.file_path ?? args.path, + image_data: args.image_data, + goal: args.goal ?? "", + } +} + +export function validateArgs(args: LookAtArgs): string | null { + const hasFilePath = Boolean(args.file_path && args.file_path.length > 0) + const hasImageData = Boolean(args.image_data && args.image_data.length > 0) + + if (!hasFilePath && !hasImageData) { + return `Error: Must provide either 'file_path' or 'image_data'. Usage: +- look_at(file_path="/path/to/file", goal="what to extract") +- look_at(image_data="base64_encoded_data", goal="what to extract")` + } + if (hasFilePath && hasImageData) { + return "Error: Provide only one of 'file_path' or 'image_data', not both." + } + if (!args.goal) { + return "Error: Missing required parameter 'goal'. Usage: look_at(file_path=\"/path/to/file\", goal=\"what to extract\")" + } + return null +} diff --git a/src/tools/look-at/mime-type-inference.ts b/src/tools/look-at/mime-type-inference.ts new file mode 100644 index 000000000..18954c46c --- /dev/null +++ b/src/tools/look-at/mime-type-inference.ts @@ -0,0 +1,71 @@ +import { extname } from "node:path" + +export function inferMimeTypeFromBase64(base64Data: string): string { + if (base64Data.startsWith("data:")) { + const match = base64Data.match(/^data:([^;]+);/) + if (match) return match[1] + } + + try { + const cleanData = base64Data.replace(/^data:[^;]+;base64,/, "") + const header = atob(cleanData.slice(0, 16)) + + if (header.startsWith("\x89PNG")) return "image/png" + if (header.startsWith("\xFF\xD8\xFF")) return "image/jpeg" + if (header.startsWith("GIF8")) return "image/gif" + if (header.startsWith("RIFF") && header.includes("WEBP")) return "image/webp" + if (header.startsWith("%PDF")) return "application/pdf" + } catch { + // invalid base64 - fall through + } + + return "image/png" +} + +export function inferMimeTypeFromFilePath(filePath: string): string { + const ext = extname(filePath).toLowerCase() + const mimeTypes: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".heic": "image/heic", + ".heif": "image/heif", + ".mp4": "video/mp4", + ".mpeg": "video/mpeg", + ".mpg": "video/mpeg", + ".mov": "video/mov", + ".avi": "video/avi", + ".flv": "video/x-flv", + ".webm": "video/webm", + ".wmv": "video/wmv", + ".3gpp": "video/3gpp", + ".3gp": "video/3gpp", + ".wav": "audio/wav", + ".mp3": "audio/mp3", + ".aiff": "audio/aiff", + ".aac": "audio/aac", + ".ogg": "audio/ogg", + ".flac": "audio/flac", + ".pdf": "application/pdf", + ".txt": "text/plain", + ".csv": "text/csv", + ".md": "text/md", + ".html": "text/html", + ".json": "application/json", + ".xml": "application/xml", + ".js": "text/javascript", + ".py": "text/x-python", + } + return mimeTypes[ext] || "application/octet-stream" +} + +export function extractBase64Data(imageData: string): string { + if (imageData.startsWith("data:")) { + const commaIndex = imageData.indexOf(",") + if (commaIndex !== -1) { + return imageData.slice(commaIndex + 1) + } + } + return imageData +} diff --git a/src/tools/look-at/multimodal-agent-metadata.ts b/src/tools/look-at/multimodal-agent-metadata.ts new file mode 100644 index 000000000..e24c8b6fb --- /dev/null +++ b/src/tools/look-at/multimodal-agent-metadata.ts @@ -0,0 +1,56 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { MULTIMODAL_LOOKER_AGENT } from "./constants" +import { log } from "../../shared" + +type AgentModel = { providerID: string; modelID: string } + +type ResolvedAgentMetadata = { + agentModel?: AgentModel + agentVariant?: string +} + +type AgentInfo = { + name?: string + model?: AgentModel + variant?: string +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function toAgentInfo(value: unknown): AgentInfo | null { + if (!isObject(value)) return null + const name = typeof value["name"] === "string" ? value["name"] : undefined + const variant = typeof value["variant"] === "string" ? value["variant"] : undefined + const modelValue = value["model"] + const model = + isObject(modelValue) && + typeof modelValue["providerID"] === "string" && + typeof modelValue["modelID"] === "string" + ? { providerID: modelValue["providerID"], modelID: modelValue["modelID"] } + : undefined + return { name, model, variant } +} + +export async function resolveMultimodalLookerAgentMetadata( + ctx: PluginInput +): Promise { + try { + const agentsResult = await ctx.client.app?.agents?.() + const agentsRaw = isObject(agentsResult) ? agentsResult["data"] : undefined + const agents = Array.isArray(agentsRaw) ? agentsRaw.map(toAgentInfo).filter(Boolean) : [] + + const matched = agents.find( + (agent) => agent?.name?.toLowerCase() === MULTIMODAL_LOOKER_AGENT.toLowerCase() + ) + + return { + agentModel: matched?.model, + agentVariant: matched?.variant, + } + } catch (error) { + log("[look_at] Failed to resolve multimodal-looker model info", error) + return {} + } +} diff --git a/src/tools/look-at/tools.ts b/src/tools/look-at/tools.ts index 28e6edf80..c9ae3448d 100644 --- a/src/tools/look-at/tools.ts +++ b/src/tools/look-at/tools.ts @@ -1,109 +1,20 @@ -import { extname, basename } from "node:path" +import { basename } from "node:path" import { pathToFileURL } from "node:url" import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants" import type { LookAtArgs } from "./types" import { log, promptSyncWithModelSuggestionRetry } from "../../shared" +import { extractLatestAssistantText } from "./assistant-message-extractor" +import type { LookAtArgsWithAlias } from "./look-at-arguments" +import { normalizeArgs, validateArgs } from "./look-at-arguments" +import { + extractBase64Data, + inferMimeTypeFromBase64, + inferMimeTypeFromFilePath, +} from "./mime-type-inference" +import { resolveMultimodalLookerAgentMetadata } from "./multimodal-agent-metadata" -interface LookAtArgsWithAlias extends LookAtArgs { - path?: string -} - -export function normalizeArgs(args: LookAtArgsWithAlias): LookAtArgs { - return { - file_path: args.file_path ?? args.path, - image_data: args.image_data, - goal: args.goal ?? "", - } -} - -export function validateArgs(args: LookAtArgs): string | null { - const hasFilePath = args.file_path && args.file_path.length > 0 - const hasImageData = args.image_data && args.image_data.length > 0 - - if (!hasFilePath && !hasImageData) { - return `Error: Must provide either 'file_path' or 'image_data'. Usage: -- look_at(file_path="/path/to/file", goal="what to extract") -- look_at(image_data="base64_encoded_data", goal="what to extract")` - } - if (hasFilePath && hasImageData) { - return `Error: Provide only one of 'file_path' or 'image_data', not both.` - } - if (!args.goal) { - return `Error: Missing required parameter 'goal'. Usage: look_at(file_path="/path/to/file", goal="what to extract")` - } - return null -} - -function inferMimeTypeFromBase64(base64Data: string): string { - if (base64Data.startsWith("data:")) { - const match = base64Data.match(/^data:([^;]+);/) - if (match) return match[1] - } - - try { - const cleanData = base64Data.replace(/^data:[^;]+;base64,/, "") - const header = atob(cleanData.slice(0, 16)) - - if (header.startsWith("\x89PNG")) return "image/png" - if (header.startsWith("\xFF\xD8\xFF")) return "image/jpeg" - if (header.startsWith("GIF8")) return "image/gif" - if (header.startsWith("RIFF") && header.includes("WEBP")) return "image/webp" - if (header.startsWith("%PDF")) return "application/pdf" - } catch { - // Invalid base64 - fall through to default - } - - return "image/png" -} - -function inferMimeType(filePath: string): string { - const ext = extname(filePath).toLowerCase() - const mimeTypes: Record = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".webp": "image/webp", - ".heic": "image/heic", - ".heif": "image/heif", - ".mp4": "video/mp4", - ".mpeg": "video/mpeg", - ".mpg": "video/mpeg", - ".mov": "video/mov", - ".avi": "video/avi", - ".flv": "video/x-flv", - ".webm": "video/webm", - ".wmv": "video/wmv", - ".3gpp": "video/3gpp", - ".3gp": "video/3gpp", - ".wav": "audio/wav", - ".mp3": "audio/mp3", - ".aiff": "audio/aiff", - ".aac": "audio/aac", - ".ogg": "audio/ogg", - ".flac": "audio/flac", - ".pdf": "application/pdf", - ".txt": "text/plain", - ".csv": "text/csv", - ".md": "text/md", - ".html": "text/html", - ".json": "application/json", - ".xml": "application/xml", - ".js": "text/javascript", - ".py": "text/x-python", - } - return mimeTypes[ext] || "application/octet-stream" -} - -function extractBase64Data(imageData: string): string { - if (imageData.startsWith("data:")) { - const commaIndex = imageData.indexOf(",") - if (commaIndex !== -1) { - return imageData.slice(commaIndex + 1) - } - } - return imageData -} +export { normalizeArgs, validateArgs } from "./look-at-arguments" export function createLookAt(ctx: PluginInput): ToolDefinition { return tool({ @@ -125,27 +36,30 @@ export function createLookAt(ctx: PluginInput): ToolDefinition { const sourceDescription = isBase64Input ? "clipboard/pasted image" : args.file_path log(`[look_at] Analyzing ${sourceDescription}, goal: ${args.goal}`) + const imageData = args.image_data + const filePath = args.file_path + let mimeType: string let filePart: { type: "file"; mime: string; url: string; filename: string } - if (isBase64Input) { - mimeType = inferMimeTypeFromBase64(args.image_data!) - const base64Content = extractBase64Data(args.image_data!) - const dataUrl = `data:${mimeType};base64,${base64Content}` + if (imageData) { + mimeType = inferMimeTypeFromBase64(imageData) filePart = { type: "file", mime: mimeType, - url: dataUrl, + url: `data:${mimeType};base64,${extractBase64Data(imageData)}`, filename: `clipboard-image.${mimeType.split("/")[1] || "png"}`, } - } else { - mimeType = inferMimeType(args.file_path!) + } else if (filePath) { + mimeType = inferMimeTypeFromFilePath(filePath) filePart = { type: "file", mime: mimeType, - url: pathToFileURL(args.file_path!).href, - filename: basename(args.file_path!), + url: pathToFileURL(filePath).href, + filename: basename(filePath), } + } else { + return "Error: Must provide either 'file_path' or 'image_data'." } const prompt = `Analyze this ${isBase64Input ? "image" : "file"} and extract the requested information. @@ -166,13 +80,8 @@ If the requested information is not found, clearly state what is missing.` body: { parentID: toolContext.sessionID, title: `look_at: ${args.goal.substring(0, 50)}`, - permission: [ - { permission: "question", action: "deny" as const, pattern: "*" }, - ], - } as any, - query: { - directory: parentDirectory, }, + query: { directory: parentDirectory }, }) if (createResult.error) { @@ -194,32 +103,7 @@ Original error: ${createResult.error}` const sessionID = createResult.data.id log(`[look_at] Created session: ${sessionID}`) - let agentModel: { providerID: string; modelID: string } | undefined - let agentVariant: string | undefined - - try { - const agentsResult = await ctx.client.app?.agents?.() - type AgentInfo = { - name: string - mode?: "subagent" | "primary" | "all" - model?: { providerID: string; modelID: string } - variant?: string - } - const agents = ((agentsResult as { data?: AgentInfo[] })?.data ?? agentsResult) as AgentInfo[] | undefined - if (agents?.length) { - const matchedAgent = agents.find( - (agent) => agent.name.toLowerCase() === MULTIMODAL_LOOKER_AGENT.toLowerCase() - ) - if (matchedAgent?.model) { - agentModel = matchedAgent.model - } - if (matchedAgent?.variant) { - agentVariant = matchedAgent.variant - } - } - } catch (error) { - log("[look_at] Failed to resolve multimodal-looker model info", error) - } + const { agentModel, agentVariant } = await resolveMultimodalLookerAgentMetadata(ctx) log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`) try { @@ -242,7 +126,6 @@ Original error: ${createResult.error}` }, }) } catch (promptError) { - const errorMessage = promptError instanceof Error ? promptError.message : String(promptError) log(`[look_at] Prompt error:`, promptError) throw promptError @@ -262,25 +145,13 @@ Original error: ${createResult.error}` const messages = messagesResult.data log(`[look_at] Got ${messages.length} messages`) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const lastAssistantMessage = messages - .filter((m: any) => m.info.role === "assistant") - .sort((a: any, b: any) => (b.info.time?.created || 0) - (a.info.time?.created || 0))[0] - - if (!lastAssistantMessage) { - log(`[look_at] No assistant message found`) - return `Error: No response from multimodal-looker agent` + const responseText = extractLatestAssistantText(messages) + if (!responseText) { + log("[look_at] No assistant message found") + return "Error: No response from multimodal-looker agent" } - log(`[look_at] Found assistant message with ${lastAssistantMessage.parts.length} parts`) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const textParts = lastAssistantMessage.parts.filter((p: any) => p.type === "text") - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const responseText = textParts.map((p: any) => p.text).join("\n") - log(`[look_at] Got response, length: ${responseText.length}`) - return responseText }, }) From 4400e18a529ee53057a2ba85aaa94a140e9b6c46 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:24:34 +0900 Subject: [PATCH 20/51] refactor(slashcommand): split tools.ts into discovery and formatting modules Extract slash command tool internals: - command-discovery.ts: command finding and listing - command-output-formatter.ts: output formatting - skill-command-converter.ts: skill-to-command conversion - slashcommand-description.ts: tool description generation - slashcommand-tool.ts: core tool definition --- src/tools/slashcommand/command-discovery.ts | 85 +++++ .../slashcommand/command-output-formatter.ts | 73 +++++ .../slashcommand/skill-command-converter.ts | 20 ++ .../slashcommand/slashcommand-description.ts | 26 ++ src/tools/slashcommand/slashcommand-tool.ts | 96 ++++++ src/tools/slashcommand/tools.ts | 296 +----------------- 6 files changed, 302 insertions(+), 294 deletions(-) create mode 100644 src/tools/slashcommand/command-discovery.ts create mode 100644 src/tools/slashcommand/command-output-formatter.ts create mode 100644 src/tools/slashcommand/skill-command-converter.ts create mode 100644 src/tools/slashcommand/slashcommand-description.ts create mode 100644 src/tools/slashcommand/slashcommand-tool.ts diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts new file mode 100644 index 000000000..b44581af7 --- /dev/null +++ b/src/tools/slashcommand/command-discovery.ts @@ -0,0 +1,85 @@ +import { existsSync, readdirSync, readFileSync } from "fs" +import { basename, join } from "path" +import { parseFrontmatter, sanitizeModelField, getOpenCodeConfigDir } from "../../shared" +import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types" +import { isMarkdownFile } from "../../shared/file-utils" +import { getClaudeConfigDir } from "../../shared" +import { loadBuiltinCommands } from "../../features/builtin-commands" +import type { CommandInfo, CommandMetadata, CommandScope } from "./types" + +function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] { + if (!existsSync(commandsDir)) return [] + + const entries = readdirSync(commandsDir, { withFileTypes: true }) + const commands: CommandInfo[] = [] + + for (const entry of entries) { + if (!isMarkdownFile(entry)) continue + + const commandPath = join(commandsDir, entry.name) + const commandName = basename(entry.name, ".md") + + try { + const content = readFileSync(commandPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const isOpencodeSource = scope === "opencode" || scope === "opencode-project" + const metadata: CommandMetadata = { + name: commandName, + description: data.description || "", + argumentHint: data["argument-hint"], + model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), + agent: data.agent, + subtask: Boolean(data.subtask), + } + + commands.push({ + name: commandName, + path: commandPath, + metadata, + content: body, + scope, + }) + } catch { + continue + } + } + + return commands +} + +export function discoverCommandsSync(): CommandInfo[] { + const configDir = getOpenCodeConfigDir({ binary: "opencode" }) + const userCommandsDir = join(getClaudeConfigDir(), "commands") + const projectCommandsDir = join(process.cwd(), ".claude", "commands") + const opencodeGlobalDir = join(configDir, "command") + const opencodeProjectDir = join(process.cwd(), ".opencode", "command") + + const userCommands = discoverCommandsFromDir(userCommandsDir, "user") + const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode") + const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project") + const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project") + + const builtinCommandsMap = loadBuiltinCommands() + const builtinCommands: CommandInfo[] = Object.values(builtinCommandsMap).map((command) => ({ + name: command.name, + metadata: { + name: command.name, + description: command.description || "", + argumentHint: command.argumentHint, + model: command.model, + agent: command.agent, + subtask: command.subtask, + }, + content: command.template, + scope: "builtin", + })) + + return [ + ...builtinCommands, + ...opencodeProjectCommands, + ...projectCommands, + ...opencodeGlobalCommands, + ...userCommands, + ] +} diff --git a/src/tools/slashcommand/command-output-formatter.ts b/src/tools/slashcommand/command-output-formatter.ts new file mode 100644 index 000000000..36c714c68 --- /dev/null +++ b/src/tools/slashcommand/command-output-formatter.ts @@ -0,0 +1,73 @@ +import { dirname } from "path" +import { resolveCommandsInText, resolveFileReferencesInText } from "../../shared" +import type { CommandInfo } from "./types" + +export async function formatLoadedCommand( + command: CommandInfo, + userMessage?: string +): Promise { + const sections: string[] = [] + + sections.push(`# /${command.name} Command\n`) + + if (command.metadata.description) { + sections.push(`**Description**: ${command.metadata.description}\n`) + } + + if (command.metadata.argumentHint) { + sections.push(`**Usage**: /${command.name} ${command.metadata.argumentHint}\n`) + } + + if (userMessage) { + sections.push(`**Arguments**: ${userMessage}\n`) + } + + if (command.metadata.model) { + sections.push(`**Model**: ${command.metadata.model}\n`) + } + + if (command.metadata.agent) { + sections.push(`**Agent**: ${command.metadata.agent}\n`) + } + + if (command.metadata.subtask) { + sections.push("**Subtask**: true\n") + } + + sections.push(`**Scope**: ${command.scope}\n`) + sections.push("---\n") + sections.push("## Command Instructions\n") + + let content = command.content || "" + if (!content && command.lazyContentLoader) { + content = await command.lazyContentLoader.load() + } + + const commandDir = command.path ? dirname(command.path) : process.cwd() + const withFileReferences = await resolveFileReferencesInText(content, commandDir) + const resolvedContent = await resolveCommandsInText(withFileReferences) + + let finalContent = resolvedContent.trim() + if (userMessage) { + finalContent = finalContent.replace(/\$\{user_message\}/g, userMessage) + } + + sections.push(finalContent) + return sections.join("\n") +} + +export function formatCommandList(items: CommandInfo[]): string { + if (items.length === 0) return "No commands or skills found." + + const lines = ["# Available Commands & Skills\n"] + + for (const command of items) { + const hint = command.metadata.argumentHint ? ` ${command.metadata.argumentHint}` : "" + lines.push( + `- **/${command.name}${hint}**: ${command.metadata.description || "(no description)"} (${command.scope})` + ) + } + + lines.push(`\n**Total**: ${items.length} items`) + return lines.join("\n") +} diff --git a/src/tools/slashcommand/skill-command-converter.ts b/src/tools/slashcommand/skill-command-converter.ts new file mode 100644 index 000000000..166b8ad99 --- /dev/null +++ b/src/tools/slashcommand/skill-command-converter.ts @@ -0,0 +1,20 @@ +import type { LoadedSkill } from "../../features/opencode-skill-loader" +import type { CommandInfo } from "./types" + +export function skillToCommandInfo(skill: LoadedSkill): CommandInfo { + return { + name: skill.name, + path: skill.path, + metadata: { + name: skill.name, + description: skill.definition.description || "", + argumentHint: skill.definition.argumentHint, + model: skill.definition.model, + agent: skill.definition.agent, + subtask: skill.definition.subtask, + }, + content: skill.definition.template, + scope: skill.scope, + lazyContentLoader: skill.lazyContent, + } +} diff --git a/src/tools/slashcommand/slashcommand-description.ts b/src/tools/slashcommand/slashcommand-description.ts new file mode 100644 index 000000000..00717e913 --- /dev/null +++ b/src/tools/slashcommand/slashcommand-description.ts @@ -0,0 +1,26 @@ +import type { CommandInfo } from "./types" + +export const TOOL_DESCRIPTION_PREFIX = `Load a skill or execute a command to get detailed instructions for a specific task. + +Skills and commands provide specialized knowledge and step-by-step guidance. +Use this when a task matches an available skill's or command's description. + +**How to use:** +- Call with command name only: command='publish' +- Call with command and arguments: command='publish' user_message='patch' +- The tool will return detailed instructions for the command with your arguments substituted. +` + +export function buildDescriptionFromItems(items: CommandInfo[]): string { + const commandListForDescription = items + .map((command) => { + const hint = command.metadata.argumentHint ? ` ${command.metadata.argumentHint}` : "" + return `- /${command.name}${hint}: ${command.metadata.description} (${command.scope})` + }) + .join("\n") + + return `${TOOL_DESCRIPTION_PREFIX} + +${commandListForDescription} +` +} diff --git a/src/tools/slashcommand/slashcommand-tool.ts b/src/tools/slashcommand/slashcommand-tool.ts new file mode 100644 index 000000000..fb1227f3c --- /dev/null +++ b/src/tools/slashcommand/slashcommand-tool.ts @@ -0,0 +1,96 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader" +import type { CommandInfo, SlashcommandToolOptions } from "./types" +import { discoverCommandsSync } from "./command-discovery" +import { buildDescriptionFromItems, TOOL_DESCRIPTION_PREFIX } from "./slashcommand-description" +import { formatCommandList, formatLoadedCommand } from "./command-output-formatter" +import { skillToCommandInfo } from "./skill-command-converter" + +export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition { + let cachedCommands: CommandInfo[] | null = options.commands ?? null + let cachedSkills: LoadedSkill[] | null = options.skills ?? null + let cachedDescription: string | null = null + + const getCommands = (): CommandInfo[] => { + if (cachedCommands) return cachedCommands + cachedCommands = discoverCommandsSync() + return cachedCommands + } + + const getSkills = async (): Promise => { + if (cachedSkills) return cachedSkills + cachedSkills = await discoverAllSkills() + return cachedSkills + } + + const getAllItems = async (): Promise => { + const commands = getCommands() + const skills = await getSkills() + return [...commands, ...skills.map(skillToCommandInfo)] + } + + const buildDescription = async (): Promise => { + if (cachedDescription) return cachedDescription + const allItems = await getAllItems() + cachedDescription = buildDescriptionFromItems(allItems) + return cachedDescription + } + + if (options.commands !== undefined && options.skills !== undefined) { + const allItems = [...options.commands, ...options.skills.map(skillToCommandInfo)] + cachedDescription = buildDescriptionFromItems(allItems) + } else { + void buildDescription() + } + + return tool({ + get description() { + return cachedDescription ?? TOOL_DESCRIPTION_PREFIX + }, + + args: { + command: tool.schema + .string() + .describe( + "The slash command name (without leading slash). E.g., 'publish', 'commit', 'plan'" + ), + user_message: tool.schema + .string() + .optional() + .describe( + "Optional arguments or context to pass to the command. E.g., for '/publish patch', command='publish' user_message='patch'" + ), + }, + + async execute(args) { + const allItems = await getAllItems() + + if (!args.command) { + return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute." + } + + const commandName = args.command.replace(/^\//, "") + + const exactMatch = allItems.find( + (command) => command.name.toLowerCase() === commandName.toLowerCase() + ) + + if (exactMatch) { + return await formatLoadedCommand(exactMatch, args.user_message) + } + + const partialMatches = allItems.filter((command) => + command.name.toLowerCase().includes(commandName.toLowerCase()) + ) + + if (partialMatches.length > 0) { + const matchList = partialMatches.map((command) => `/${command.name}`).join(", ") + return `No exact match for "/${commandName}". Did you mean: ${matchList}?\n\n${formatCommandList(allItems)}` + } + + return `Command or skill "/${commandName}" not found.\n\n${formatCommandList(allItems)}\n\nTry a different name.` + }, + }) +} + +export const slashcommand: ToolDefinition = createSlashcommandTool() diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts index 8cf3ff3b4..d4bbe4cbc 100644 --- a/src/tools/slashcommand/tools.ts +++ b/src/tools/slashcommand/tools.ts @@ -1,294 +1,2 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin" -import { existsSync, readdirSync, readFileSync } from "fs" -import { join, basename, dirname } from "path" -import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField, getOpenCodeConfigDir } from "../../shared" -import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types" -import { isMarkdownFile } from "../../shared/file-utils" -import { getClaudeConfigDir } from "../../shared" -import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader" -import { loadBuiltinCommands } from "../../features/builtin-commands" -import type { CommandScope, CommandMetadata, CommandInfo, SlashcommandToolOptions } from "./types" - -function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] { - if (!existsSync(commandsDir)) { - return [] - } - - const entries = readdirSync(commandsDir, { withFileTypes: true }) - const commands: CommandInfo[] = [] - - for (const entry of entries) { - if (!isMarkdownFile(entry)) continue - - const commandPath = join(commandsDir, entry.name) - const commandName = basename(entry.name, ".md") - - try { - const content = readFileSync(commandPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const isOpencodeSource = scope === "opencode" || scope === "opencode-project" - const metadata: CommandMetadata = { - name: commandName, - description: data.description || "", - argumentHint: data["argument-hint"], - model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), - agent: data.agent, - subtask: Boolean(data.subtask), - } - - commands.push({ - name: commandName, - path: commandPath, - metadata, - content: body, - scope, - }) - } catch { - continue - } - } - - return commands -} - -export function discoverCommandsSync(): CommandInfo[] { - const configDir = getOpenCodeConfigDir({ binary: "opencode" }) - const userCommandsDir = join(getClaudeConfigDir(), "commands") - const projectCommandsDir = join(process.cwd(), ".claude", "commands") - const opencodeGlobalDir = join(configDir, "command") - const opencodeProjectDir = join(process.cwd(), ".opencode", "command") - - const userCommands = discoverCommandsFromDir(userCommandsDir, "user") - const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode") - const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project") - const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project") - - const builtinCommandsMap = loadBuiltinCommands() - const builtinCommands: CommandInfo[] = Object.values(builtinCommandsMap).map(cmd => ({ - name: cmd.name, - metadata: { - name: cmd.name, - description: cmd.description || "", - argumentHint: cmd.argumentHint, - model: cmd.model, - agent: cmd.agent, - subtask: cmd.subtask - }, - content: cmd.template, - scope: "builtin" - })) - - return [...builtinCommands, ...opencodeProjectCommands, ...projectCommands, ...opencodeGlobalCommands, ...userCommands] -} - -function skillToCommandInfo(skill: LoadedSkill): CommandInfo { - return { - name: skill.name, - path: skill.path, - metadata: { - name: skill.name, - description: skill.definition.description || "", - argumentHint: skill.definition.argumentHint, - model: skill.definition.model, - agent: skill.definition.agent, - subtask: skill.definition.subtask, - }, - content: skill.definition.template, - scope: skill.scope, - lazyContentLoader: skill.lazyContent, - } -} - -async function formatLoadedCommand(cmd: CommandInfo, userMessage?: string): Promise { - const sections: string[] = [] - - sections.push(`# /${cmd.name} Command\n`) - - if (cmd.metadata.description) { - sections.push(`**Description**: ${cmd.metadata.description}\n`) - } - - if (cmd.metadata.argumentHint) { - sections.push(`**Usage**: /${cmd.name} ${cmd.metadata.argumentHint}\n`) - } - - if (userMessage) { - sections.push(`**Arguments**: ${userMessage}\n`) - } - - if (cmd.metadata.model) { - sections.push(`**Model**: ${cmd.metadata.model}\n`) - } - - if (cmd.metadata.agent) { - sections.push(`**Agent**: ${cmd.metadata.agent}\n`) - } - - if (cmd.metadata.subtask) { - sections.push(`**Subtask**: true\n`) - } - - sections.push(`**Scope**: ${cmd.scope}\n`) - sections.push("---\n") - sections.push("## Command Instructions\n") - - let content = cmd.content || "" - if (!content && cmd.lazyContentLoader) { - content = await cmd.lazyContentLoader.load() - } - - const commandDir = cmd.path ? dirname(cmd.path) : process.cwd() - const withFileRefs = await resolveFileReferencesInText(content, commandDir) - const resolvedContent = await resolveCommandsInText(withFileRefs) - - // Substitute user_message into content if provided - let finalContent = resolvedContent.trim() - if (userMessage) { - finalContent = finalContent.replace(/\$\{user_message\}/g, userMessage) - } - - sections.push(finalContent) - - return sections.join("\n") -} - -function formatCommandList(items: CommandInfo[]): string { - if (items.length === 0) { - return "No commands or skills found." - } - - const lines = ["# Available Commands & Skills\n"] - - for (const cmd of items) { - const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : "" - lines.push( - `- **/${cmd.name}${hint}**: ${cmd.metadata.description || "(no description)"} (${cmd.scope})` - ) - } - - lines.push(`\n**Total**: ${items.length} items`) - return lines.join("\n") -} - -const TOOL_DESCRIPTION_PREFIX = `Load a skill or execute a command to get detailed instructions for a specific task. - -Skills and commands provide specialized knowledge and step-by-step guidance. -Use this when a task matches an available skill's or command's description. - -**How to use:** -- Call with command name only: command='publish' -- Call with command and arguments: command='publish' user_message='patch' -- The tool will return detailed instructions for the command with your arguments substituted. -` - -function buildDescriptionFromItems(items: CommandInfo[]): string { - const commandListForDescription = items - .map((cmd) => { - const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : "" - return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})` - }) - .join("\n") - - return `${TOOL_DESCRIPTION_PREFIX} - -${commandListForDescription} -` -} - -export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition { - let cachedCommands: CommandInfo[] | null = options.commands ?? null - let cachedSkills: LoadedSkill[] | null = options.skills ?? null - let cachedDescription: string | null = null - - const getCommands = (): CommandInfo[] => { - if (cachedCommands) return cachedCommands - cachedCommands = discoverCommandsSync() - return cachedCommands - } - - const getSkills = async (): Promise => { - if (cachedSkills) return cachedSkills - cachedSkills = await discoverAllSkills() - return cachedSkills - } - - const getAllItems = async (): Promise => { - const commands = getCommands() - const skills = await getSkills() - return [...commands, ...skills.map(skillToCommandInfo)] - } - - const buildDescription = async (): Promise => { - if (cachedDescription) return cachedDescription - const allItems = await getAllItems() - cachedDescription = buildDescriptionFromItems(allItems) - return cachedDescription - } - - if (options.commands !== undefined && options.skills !== undefined) { - const allItems = [...options.commands, ...options.skills.map(skillToCommandInfo)] - cachedDescription = buildDescriptionFromItems(allItems) - } else { - buildDescription() - } - - return tool({ - get description() { - return cachedDescription ?? TOOL_DESCRIPTION_PREFIX - }, - - args: { - command: tool.schema - .string() - .describe( - "The slash command name (without leading slash). E.g., 'publish', 'commit', 'plan'" - ), - user_message: tool.schema - .string() - .optional() - .describe( - "Optional arguments or context to pass to the command. E.g., for '/publish patch', command='publish' user_message='patch'" - ), - }, - - async execute(args) { - const allItems = await getAllItems() - - if (!args.command) { - return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute." - } - - const cmdName = args.command.replace(/^\//, "") - - const exactMatch = allItems.find( - (cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase() - ) - - if (exactMatch) { - return await formatLoadedCommand(exactMatch, args.user_message) - } - - const partialMatches = allItems.filter((cmd) => - cmd.name.toLowerCase().includes(cmdName.toLowerCase()) - ) - - if (partialMatches.length > 0) { - const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ") - return ( - `No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` + - formatCommandList(allItems) - ) - } - - return ( - `Command or skill "/${cmdName}" not found.\n\n` + - formatCommandList(allItems) + - "\n\nTry a different name." - ) - }, - }) -} - -// Default instance for backward compatibility (lazy loading) -export const slashcommand: ToolDefinition = createSlashcommandTool() +export { discoverCommandsSync } from "./command-discovery" +export { createSlashcommandTool, slashcommand } from "./slashcommand-tool" From 052beb364ffafd7682557901561269a1fd83abb2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:24:43 +0900 Subject: [PATCH 21/51] refactor(task-tool): split task.ts into per-action modules Extract CRUD actions into dedicated modules: - task-action-create.ts, task-action-get.ts - task-action-list.ts, task-action-update.ts, task-action-delete.ts - task-id-validator.ts: ID validation logic --- src/tools/task/task-action-create.ts | 46 +++++ src/tools/task/task-action-delete.ts | 36 ++++ src/tools/task/task-action-get.ts | 21 +++ src/tools/task/task-action-list.ts | 60 +++++++ src/tools/task/task-action-update.ts | 57 +++++++ src/tools/task/task-id-validator.ts | 6 + src/tools/task/task.ts | 240 +-------------------------- 7 files changed, 232 insertions(+), 234 deletions(-) create mode 100644 src/tools/task/task-action-create.ts create mode 100644 src/tools/task/task-action-delete.ts create mode 100644 src/tools/task/task-action-get.ts create mode 100644 src/tools/task/task-action-list.ts create mode 100644 src/tools/task/task-action-update.ts create mode 100644 src/tools/task/task-id-validator.ts diff --git a/src/tools/task/task-action-create.ts b/src/tools/task/task-action-create.ts new file mode 100644 index 000000000..be60b1864 --- /dev/null +++ b/src/tools/task/task-action-create.ts @@ -0,0 +1,46 @@ +import { join } from "path" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import type { TaskObject } from "./types" +import { TaskCreateInputSchema, TaskObjectSchema } from "./types" +import { + acquireLock, + generateTaskId, + getTaskDir, + writeJsonAtomic, +} from "../../features/claude-tasks/storage" + +export async function handleCreate( + args: Record, + config: Partial, + context: { sessionID: string } +): Promise { + const validatedArgs = TaskCreateInputSchema.parse(args) + const taskDir = getTaskDir(config) + const lock = acquireLock(taskDir) + + if (!lock.acquired) { + return JSON.stringify({ error: "task_lock_unavailable" }) + } + + try { + const taskId = generateTaskId() + const task: TaskObject = { + id: taskId, + subject: validatedArgs.subject, + description: validatedArgs.description ?? "", + status: "pending", + blocks: validatedArgs.blocks ?? [], + blockedBy: validatedArgs.blockedBy ?? [], + repoURL: validatedArgs.repoURL, + parentID: validatedArgs.parentID, + threadID: context.sessionID, + } + + const validatedTask = TaskObjectSchema.parse(task) + writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask) + + return JSON.stringify({ task: validatedTask }) + } finally { + lock.release() + } +} diff --git a/src/tools/task/task-action-delete.ts b/src/tools/task/task-action-delete.ts new file mode 100644 index 000000000..a66992c97 --- /dev/null +++ b/src/tools/task/task-action-delete.ts @@ -0,0 +1,36 @@ +import { existsSync, unlinkSync } from "fs" +import { join } from "path" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import { TaskDeleteInputSchema } from "./types" +import { acquireLock, getTaskDir } from "../../features/claude-tasks/storage" +import { parseTaskId } from "./task-id-validator" + +export async function handleDelete( + args: Record, + config: Partial +): Promise { + const validatedArgs = TaskDeleteInputSchema.parse(args) + const taskId = parseTaskId(validatedArgs.id) + if (!taskId) { + return JSON.stringify({ error: "invalid_task_id" }) + } + const taskDir = getTaskDir(config) + const lock = acquireLock(taskDir) + + if (!lock.acquired) { + return JSON.stringify({ error: "task_lock_unavailable" }) + } + + try { + const taskPath = join(taskDir, `${taskId}.json`) + + if (!existsSync(taskPath)) { + return JSON.stringify({ error: "task_not_found" }) + } + + unlinkSync(taskPath) + return JSON.stringify({ success: true }) + } finally { + lock.release() + } +} diff --git a/src/tools/task/task-action-get.ts b/src/tools/task/task-action-get.ts new file mode 100644 index 000000000..65a59a983 --- /dev/null +++ b/src/tools/task/task-action-get.ts @@ -0,0 +1,21 @@ +import { join } from "path" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import { TaskGetInputSchema, TaskObjectSchema } from "./types" +import { getTaskDir, readJsonSafe } from "../../features/claude-tasks/storage" +import { parseTaskId } from "./task-id-validator" + +export async function handleGet( + args: Record, + config: Partial +): Promise { + const validatedArgs = TaskGetInputSchema.parse(args) + const taskId = parseTaskId(validatedArgs.id) + if (!taskId) { + return JSON.stringify({ error: "invalid_task_id" }) + } + const taskDir = getTaskDir(config) + const taskPath = join(taskDir, `${taskId}.json`) + + const task = readJsonSafe(taskPath, TaskObjectSchema) + return JSON.stringify({ task: task ?? null }) +} diff --git a/src/tools/task/task-action-list.ts b/src/tools/task/task-action-list.ts new file mode 100644 index 000000000..ec57830b6 --- /dev/null +++ b/src/tools/task/task-action-list.ts @@ -0,0 +1,60 @@ +import { existsSync } from "fs" +import { join } from "path" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import type { TaskObject } from "./types" +import { TaskListInputSchema, TaskObjectSchema } from "./types" +import { getTaskDir, listTaskFiles, readJsonSafe } from "../../features/claude-tasks/storage" + +export async function handleList( + args: Record, + config: Partial +): Promise { + const validatedArgs = TaskListInputSchema.parse(args) + const taskDir = getTaskDir(config) + + if (!existsSync(taskDir)) { + return JSON.stringify({ tasks: [] }) + } + + const files = listTaskFiles(config) + if (files.length === 0) { + return JSON.stringify({ tasks: [] }) + } + + const allTasks: TaskObject[] = [] + for (const fileId of files) { + const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema) + if (task) { + allTasks.push(task) + } + } + + let tasks = allTasks.filter((task) => task.status !== "completed") + + if (validatedArgs.status) { + tasks = tasks.filter((task) => task.status === validatedArgs.status) + } + + if (validatedArgs.parentID) { + tasks = tasks.filter((task) => task.parentID === validatedArgs.parentID) + } + + const ready = args["ready"] === true + if (ready) { + tasks = tasks.filter((task) => { + if (task.blockedBy.length === 0) return true + return task.blockedBy.every((depId) => { + const depTask = allTasks.find((t) => t.id === depId) + return depTask?.status === "completed" + }) + }) + } + + const limitRaw = args["limit"] + const limit = typeof limitRaw === "number" ? limitRaw : undefined + if (limit !== undefined && limit > 0) { + tasks = tasks.slice(0, limit) + } + + return JSON.stringify({ tasks }) +} diff --git a/src/tools/task/task-action-update.ts b/src/tools/task/task-action-update.ts new file mode 100644 index 000000000..ebdecdf70 --- /dev/null +++ b/src/tools/task/task-action-update.ts @@ -0,0 +1,57 @@ +import { join } from "path" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import { TaskUpdateInputSchema, TaskObjectSchema } from "./types" +import { acquireLock, getTaskDir, readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage" +import { parseTaskId } from "./task-id-validator" + +export async function handleUpdate( + args: Record, + config: Partial +): Promise { + const validatedArgs = TaskUpdateInputSchema.parse(args) + const taskId = parseTaskId(validatedArgs.id) + if (!taskId) { + return JSON.stringify({ error: "invalid_task_id" }) + } + const taskDir = getTaskDir(config) + const lock = acquireLock(taskDir) + + if (!lock.acquired) { + return JSON.stringify({ error: "task_lock_unavailable" }) + } + + try { + const taskPath = join(taskDir, `${taskId}.json`) + const task = readJsonSafe(taskPath, TaskObjectSchema) + + if (!task) { + return JSON.stringify({ error: "task_not_found" }) + } + + if (validatedArgs.subject !== undefined) { + task.subject = validatedArgs.subject + } + if (validatedArgs.description !== undefined) { + task.description = validatedArgs.description + } + if (validatedArgs.status !== undefined) { + task.status = validatedArgs.status + } + if (validatedArgs.addBlockedBy !== undefined) { + task.blockedBy = [...task.blockedBy, ...validatedArgs.addBlockedBy] + } + if (validatedArgs.repoURL !== undefined) { + task.repoURL = validatedArgs.repoURL + } + if (validatedArgs.parentID !== undefined) { + task.parentID = validatedArgs.parentID + } + + const validatedTask = TaskObjectSchema.parse(task) + writeJsonAtomic(taskPath, validatedTask) + + return JSON.stringify({ task: validatedTask }) + } finally { + lock.release() + } +} diff --git a/src/tools/task/task-id-validator.ts b/src/tools/task/task-id-validator.ts new file mode 100644 index 000000000..acb83be0a --- /dev/null +++ b/src/tools/task/task-id-validator.ts @@ -0,0 +1,6 @@ +const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/ + +export function parseTaskId(id: string): string | null { + if (!TASK_ID_PATTERN.test(id)) return null + return id +} diff --git a/src/tools/task/task.ts b/src/tools/task/task.ts index dc6602810..df6b6aa01 100644 --- a/src/tools/task/task.ts +++ b/src/tools/task/task.ts @@ -1,38 +1,10 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" -import { existsSync, readdirSync, unlinkSync } from "fs" -import { join } from "path" import type { OhMyOpenCodeConfig } from "../../config/schema" -import type { - TaskObject, - TaskCreateInput, - TaskListInput, - TaskGetInput, - TaskUpdateInput, - TaskDeleteInput, -} from "./types" -import { - TaskObjectSchema, - TaskCreateInputSchema, - TaskListInputSchema, - TaskGetInputSchema, - TaskUpdateInputSchema, - TaskDeleteInputSchema, -} from "./types" -import { - getTaskDir, - readJsonSafe, - writeJsonAtomic, - acquireLock, - generateTaskId, - listTaskFiles, -} from "../../features/claude-tasks/storage" - -const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/ - -function parseTaskId(id: string): string | null { - if (!TASK_ID_PATTERN.test(id)) return null - return id -} +import { handleCreate } from "./task-action-create" +import { handleDelete } from "./task-action-delete" +import { handleGet } from "./task-action-get" +import { handleList } from "./task-action-list" +import { handleUpdate } from "./task-action-update" export function createTask(config: Partial): ToolDefinition { return tool({ @@ -66,9 +38,7 @@ All actions return JSON strings.`, limit: tool.schema.number().optional().describe("Maximum number of tasks to return"), }, execute: async (args, context) => { - const action = args.action as "create" | "list" | "get" | "update" | "delete" - - switch (action) { + switch (args.action) { case "create": return handleCreate(args, config, context) case "list": @@ -85,201 +55,3 @@ All actions return JSON strings.`, }, }) } - -async function handleCreate( - args: Record, - config: Partial, - context: { sessionID: string } -): Promise { - const validatedArgs = TaskCreateInputSchema.parse(args) - const taskDir = getTaskDir(config) - const lock = acquireLock(taskDir) - - if (!lock.acquired) { - return JSON.stringify({ error: "task_lock_unavailable" }) - } - - try { - const taskId = generateTaskId() - const task: TaskObject = { - id: taskId, - subject: validatedArgs.subject, - description: validatedArgs.description ?? "", - status: "pending", - blocks: validatedArgs.blocks ?? [], - blockedBy: validatedArgs.blockedBy ?? [], - repoURL: validatedArgs.repoURL, - parentID: validatedArgs.parentID, - threadID: context.sessionID, - } - - const validatedTask = TaskObjectSchema.parse(task) - writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask) - - return JSON.stringify({ task: validatedTask }) - } finally { - lock.release() - } -} - -async function handleList( - args: Record, - config: Partial -): Promise { - const validatedArgs = TaskListInputSchema.parse(args) - const taskDir = getTaskDir(config) - - if (!existsSync(taskDir)) { - return JSON.stringify({ tasks: [] }) - } - - const files = listTaskFiles(config) - if (files.length === 0) { - return JSON.stringify({ tasks: [] }) - } - - const allTasks: TaskObject[] = [] - for (const fileId of files) { - const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema) - if (task) { - allTasks.push(task) - } - } - - // Filter out completed tasks by default - let tasks = allTasks.filter((task) => task.status !== "completed") - - // Apply status filter if provided - if (validatedArgs.status) { - tasks = tasks.filter((task) => task.status === validatedArgs.status) - } - - // Apply parentID filter if provided - if (validatedArgs.parentID) { - tasks = tasks.filter((task) => task.parentID === validatedArgs.parentID) - } - - // Apply ready filter if requested - if (args.ready) { - tasks = tasks.filter((task) => { - if (task.blockedBy.length === 0) { - return true - } - - // All blocking tasks must be completed - return task.blockedBy.every((depId: string) => { - const depTask = allTasks.find((t) => t.id === depId) - return depTask?.status === "completed" - }) - }) - } - - // Apply limit if provided - const limit = args.limit as number | undefined - if (limit !== undefined && limit > 0) { - tasks = tasks.slice(0, limit) - } - - return JSON.stringify({ tasks }) -} - -async function handleGet( - args: Record, - config: Partial -): Promise { - const validatedArgs = TaskGetInputSchema.parse(args) - const taskId = parseTaskId(validatedArgs.id) - if (!taskId) { - return JSON.stringify({ error: "invalid_task_id" }) - } - const taskDir = getTaskDir(config) - const taskPath = join(taskDir, `${taskId}.json`) - - const task = readJsonSafe(taskPath, TaskObjectSchema) - - return JSON.stringify({ task: task ?? null }) -} - -async function handleUpdate( - args: Record, - config: Partial -): Promise { - const validatedArgs = TaskUpdateInputSchema.parse(args) - const taskId = parseTaskId(validatedArgs.id) - if (!taskId) { - return JSON.stringify({ error: "invalid_task_id" }) - } - const taskDir = getTaskDir(config) - const lock = acquireLock(taskDir) - - if (!lock.acquired) { - return JSON.stringify({ error: "task_lock_unavailable" }) - } - - try { - const taskPath = join(taskDir, `${taskId}.json`) - const task = readJsonSafe(taskPath, TaskObjectSchema) - - if (!task) { - return JSON.stringify({ error: "task_not_found" }) - } - - // Update fields if provided - if (validatedArgs.subject !== undefined) { - task.subject = validatedArgs.subject - } - if (validatedArgs.description !== undefined) { - task.description = validatedArgs.description - } - if (validatedArgs.status !== undefined) { - task.status = validatedArgs.status - } - if (validatedArgs.addBlockedBy !== undefined) { - task.blockedBy = [...task.blockedBy, ...validatedArgs.addBlockedBy] - } - if (validatedArgs.repoURL !== undefined) { - task.repoURL = validatedArgs.repoURL - } - if (validatedArgs.parentID !== undefined) { - task.parentID = validatedArgs.parentID - } - - const validatedTask = TaskObjectSchema.parse(task) - writeJsonAtomic(taskPath, validatedTask) - - return JSON.stringify({ task: validatedTask }) - } finally { - lock.release() - } -} - -async function handleDelete( - args: Record, - config: Partial -): Promise { - const validatedArgs = TaskDeleteInputSchema.parse(args) - const taskId = parseTaskId(validatedArgs.id) - if (!taskId) { - return JSON.stringify({ error: "invalid_task_id" }) - } - const taskDir = getTaskDir(config) - const lock = acquireLock(taskDir) - - if (!lock.acquired) { - return JSON.stringify({ error: "task_lock_unavailable" }) - } - - try { - const taskPath = join(taskDir, `${taskId}.json`) - - if (!existsSync(taskPath)) { - return JSON.stringify({ error: "task_not_found" }) - } - - unlinkSync(taskPath) - - return JSON.stringify({ success: true }) - } finally { - lock.release() - } -} From 4e5792ce4d2a1a702b10254204c17ba36172c76f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:24:52 +0900 Subject: [PATCH 22/51] refactor(shared): split model-availability.ts into model resolution modules Extract model availability checking pipeline: - available-models-fetcher.ts: top-level model fetching orchestration - model-cache-availability.ts, models-json-cache-reader.ts - provider-models-cache-model-reader.ts: provider cache reading with null guard - fallback-model-availability.ts, model-name-matcher.ts - open-code-client-accessors.ts, open-code-client-shapes.ts - record-type-guard.ts --- src/shared/available-models-fetcher.ts | 114 ++++++ src/shared/fallback-model-availability.ts | 67 ++++ src/shared/model-availability.ts | 361 +----------------- src/shared/model-cache-availability.ts | 14 + src/shared/model-name-matcher.ts | 91 +++++ src/shared/models-json-cache-reader.ts | 52 +++ src/shared/open-code-client-accessors.ts | 20 + src/shared/open-code-client-shapes.ts | 7 + .../provider-models-cache-model-reader.ts | 39 ++ src/shared/record-type-guard.ts | 3 + 10 files changed, 411 insertions(+), 357 deletions(-) create mode 100644 src/shared/available-models-fetcher.ts create mode 100644 src/shared/fallback-model-availability.ts create mode 100644 src/shared/model-cache-availability.ts create mode 100644 src/shared/model-name-matcher.ts create mode 100644 src/shared/models-json-cache-reader.ts create mode 100644 src/shared/open-code-client-accessors.ts create mode 100644 src/shared/open-code-client-shapes.ts create mode 100644 src/shared/provider-models-cache-model-reader.ts create mode 100644 src/shared/record-type-guard.ts diff --git a/src/shared/available-models-fetcher.ts b/src/shared/available-models-fetcher.ts new file mode 100644 index 000000000..b19defce1 --- /dev/null +++ b/src/shared/available-models-fetcher.ts @@ -0,0 +1,114 @@ +import { addModelsFromModelsJsonCache } from "./models-json-cache-reader" +import { getModelListFunction, getProviderListFunction } from "./open-code-client-accessors" +import { addModelsFromProviderModelsCache } from "./provider-models-cache-model-reader" +import { log } from "./logger" + +export async function getConnectedProviders(client: unknown): Promise { + const providerList = getProviderListFunction(client) + if (!providerList) { + log("[getConnectedProviders] client.provider.list not available") + return [] + } + + try { + const result = await providerList() + const connected = result.data?.connected ?? [] + log("[getConnectedProviders] connected providers", { + count: connected.length, + providers: connected, + }) + return connected + } catch (err) { + log("[getConnectedProviders] SDK error", { error: String(err) }) + return [] + } +} + +export async function fetchAvailableModels( + client?: unknown, + options?: { connectedProviders?: string[] | null }, +): Promise> { + let connectedProviders = options?.connectedProviders ?? null + let connectedProvidersUnknown = connectedProviders === null + + log("[fetchAvailableModels] CALLED", { + connectedProvidersUnknown, + connectedProviders: options?.connectedProviders, + }) + + if (connectedProvidersUnknown && client !== undefined) { + const liveConnected = await getConnectedProviders(client) + if (liveConnected.length > 0) { + connectedProviders = liveConnected + connectedProvidersUnknown = false + log("[fetchAvailableModels] connected providers fetched from client", { + count: liveConnected.length, + }) + } + } + + if (connectedProvidersUnknown) { + const modelList = client === undefined ? null : getModelListFunction(client) + if (modelList) { + const modelSet = new Set() + try { + const modelsResult = await modelList() + const models = modelsResult.data ?? [] + for (const model of models) { + if (model.provider && model.id) { + modelSet.add(`${model.provider}/${model.id}`) + } + } + log( + "[fetchAvailableModels] fetched models from client without provider filter", + { count: modelSet.size }, + ) + return modelSet + } catch (err) { + log("[fetchAvailableModels] client.model.list error", { + error: String(err), + }) + } + } + log( + "[fetchAvailableModels] connected providers unknown, returning empty set for fallback resolution", + ) + return new Set() + } + + const connectedProvidersList = connectedProviders ?? [] + const connectedSet = new Set(connectedProvidersList) + const modelSet = new Set() + + if (addModelsFromProviderModelsCache(connectedSet, modelSet)) { + return modelSet + } + log("[fetchAvailableModels] provider-models cache not found, falling back to models.json") + if (addModelsFromModelsJsonCache(connectedSet, modelSet)) { + return modelSet + } + + const modelList = client === undefined ? null : getModelListFunction(client) + if (modelList) { + try { + const modelsResult = await modelList() + const models = modelsResult.data ?? [] + + for (const model of models) { + if (!model.provider || !model.id) continue + if (connectedSet.has(model.provider)) { + modelSet.add(`${model.provider}/${model.id}`) + } + } + + log("[fetchAvailableModels] fetched models from client (filtered)", { + count: modelSet.size, + connectedProviders: connectedProvidersList.slice(0, 5), + }) + } catch (err) { + log("[fetchAvailableModels] client.model.list error", { error: String(err) }) + } + } + + return modelSet +} diff --git a/src/shared/fallback-model-availability.ts b/src/shared/fallback-model-availability.ts new file mode 100644 index 000000000..f6fc30cc1 --- /dev/null +++ b/src/shared/fallback-model-availability.ts @@ -0,0 +1,67 @@ +import { readConnectedProvidersCache } from "./connected-providers-cache" +import { log } from "./logger" +import { fuzzyMatchModel } from "./model-name-matcher" + +export function isAnyFallbackModelAvailable( + fallbackChain: Array<{ providers: string[]; model: string }>, + availableModels: Set, +): boolean { + if (availableModels.size > 0) { + for (const entry of fallbackChain) { + const hasAvailableProvider = entry.providers.some((provider) => { + return fuzzyMatchModel(entry.model, availableModels, [provider]) !== null + }) + if (hasAvailableProvider) { + return true + } + } + } + + const connectedProviders = readConnectedProvidersCache() + if (connectedProviders) { + const connectedSet = new Set(connectedProviders) + for (const entry of fallbackChain) { + if (entry.providers.some((p) => connectedSet.has(p))) { + log( + "[isAnyFallbackModelAvailable] model not in available set, but provider is connected", + { model: entry.model, availableCount: availableModels.size }, + ) + return true + } + } + } + + return false +} + +export function isAnyProviderConnected( + providers: string[], + availableModels: Set, +): boolean { + if (availableModels.size > 0) { + const providerSet = new Set(providers) + for (const model of availableModels) { + const [provider] = model.split("/") + if (providerSet.has(provider)) { + log("[isAnyProviderConnected] found model from required provider", { + provider, + model, + }) + return true + } + } + } + + const connectedProviders = readConnectedProvidersCache() + if (connectedProviders) { + const connectedSet = new Set(connectedProviders) + for (const provider of providers) { + if (connectedSet.has(provider)) { + log("[isAnyProviderConnected] provider connected via cache", { provider }) + return true + } + } + } + + return false +} diff --git a/src/shared/model-availability.ts b/src/shared/model-availability.ts index 6fa2fb173..ad5df3b48 100644 --- a/src/shared/model-availability.ts +++ b/src/shared/model-availability.ts @@ -1,357 +1,4 @@ -import { existsSync, readFileSync } from "fs" -import { join } from "path" -import { log } from "./logger" -import { getOpenCodeCacheDir } from "./data-path" -import { readProviderModelsCache, hasProviderModelsCache, readConnectedProvidersCache } from "./connected-providers-cache" - -/** - * Fuzzy match a target model name against available models - * - * @param target - The model name or substring to search for (e.g., "gpt-5.2", "claude-opus") - * @param available - Set of available model names in format "provider/model-name" - * @param providers - Optional array of provider names to filter by (e.g., ["openai", "anthropic"]) - * @returns The matched model name or null if no match found - * - * Matching priority: - * 1. Exact match (if exists) - * 2. Shorter model name (more specific) - * - * Matching is case-insensitive substring match. - * If providers array is given, only models starting with "provider/" are considered. - * - * @example - * const available = new Set(["openai/gpt-5.2", "openai/gpt-5.3-codex", "anthropic/claude-opus-4-6"]) - * fuzzyMatchModel("gpt-5.2", available) // → "openai/gpt-5.2" - * fuzzyMatchModel("claude", available, ["openai"]) // → null (provider filter excludes anthropic) - */ -function normalizeModelName(name: string): string { - return name - .toLowerCase() - .replace(/claude-(opus|sonnet|haiku)-4-5/g, "claude-$1-4.5") - .replace(/claude-(opus|sonnet|haiku)-4\.5/g, "claude-$1-4.5") -} - -export function fuzzyMatchModel( - target: string, - available: Set, - providers?: string[], -): string | null { - log("[fuzzyMatchModel] called", { target, availableCount: available.size, providers }) - - if (available.size === 0) { - log("[fuzzyMatchModel] empty available set") - return null - } - - const targetNormalized = normalizeModelName(target) - - // Filter by providers if specified - let candidates = Array.from(available) - if (providers && providers.length > 0) { - const providerSet = new Set(providers) - candidates = candidates.filter((model) => { - const [provider] = model.split("/") - return providerSet.has(provider) - }) - log("[fuzzyMatchModel] filtered by providers", { candidateCount: candidates.length, candidates: candidates.slice(0, 10) }) - } - - if (candidates.length === 0) { - log("[fuzzyMatchModel] no candidates after filter") - return null - } - - // Find all matches (case-insensitive substring match with normalization) - const matches = candidates.filter((model) => - normalizeModelName(model).includes(targetNormalized), - ) - - log("[fuzzyMatchModel] substring matches", { targetNormalized, matchCount: matches.length, matches }) - - if (matches.length === 0) { - return null - } - - // Priority 1: Exact match (normalized full model string) - const exactMatch = matches.find((model) => normalizeModelName(model) === targetNormalized) - if (exactMatch) { - log("[fuzzyMatchModel] exact match found", { exactMatch }) - return exactMatch - } - - // Priority 2: Exact model ID match (part after provider/) - // This ensures "glm-4.7-free" matches "zai-coding-plan/glm-4.7-free" over "zai-coding-plan/glm-4.7" - // Use filter + shortest to handle multi-provider cases (e.g., openai/gpt-5.2 + opencode/gpt-5.2) - const exactModelIdMatches = matches.filter((model) => { - const modelId = model.split("/").slice(1).join("/") - return normalizeModelName(modelId) === targetNormalized - }) - if (exactModelIdMatches.length > 0) { - const result = exactModelIdMatches.reduce((shortest, current) => - current.length < shortest.length ? current : shortest, - ) - log("[fuzzyMatchModel] exact model ID match found", { result, candidateCount: exactModelIdMatches.length }) - return result - } - - // Priority 3: Shorter model name (more specific, fallback for partial matches) - const result = matches.reduce((shortest, current) => - current.length < shortest.length ? current : shortest, - ) - log("[fuzzyMatchModel] shortest match", { result }) - return result -} - -/** - * Check if a target model is available (fuzzy match by model name, no provider filtering) - * - * @param targetModel - Model name to check (e.g., "gpt-5.3-codex") - * @param availableModels - Set of available models in "provider/model" format - * @returns true if model is available, false otherwise - */ -export function isModelAvailable( - targetModel: string, - availableModels: Set, -): boolean { - return fuzzyMatchModel(targetModel, availableModels) !== null -} - -export async function getConnectedProviders(client: any): Promise { - if (!client?.provider?.list) { - log("[getConnectedProviders] client.provider.list not available") - return [] - } - - try { - const result = await client.provider.list() - const connected = result.data?.connected ?? [] - log("[getConnectedProviders] connected providers", { count: connected.length, providers: connected }) - return connected - } catch (err) { - log("[getConnectedProviders] SDK error", { error: String(err) }) - return [] - } -} - -export async function fetchAvailableModels( - client?: any, - options?: { connectedProviders?: string[] | null } -): Promise> { - let connectedProviders = options?.connectedProviders ?? null - let connectedProvidersUnknown = connectedProviders === null - - log("[fetchAvailableModels] CALLED", { - connectedProvidersUnknown, - connectedProviders: options?.connectedProviders - }) - - if (connectedProvidersUnknown && client) { - const liveConnected = await getConnectedProviders(client) - if (liveConnected.length > 0) { - connectedProviders = liveConnected - connectedProvidersUnknown = false - log("[fetchAvailableModels] connected providers fetched from client", { count: liveConnected.length }) - } - } - - if (connectedProvidersUnknown) { - if (client?.model?.list) { - const modelSet = new Set() - try { - const modelsResult = await client.model.list() - const models = modelsResult.data ?? [] - for (const model of models) { - if (model?.provider && model?.id) { - modelSet.add(`${model.provider}/${model.id}`) - } - } - log("[fetchAvailableModels] fetched models from client without provider filter", { - count: modelSet.size, - }) - return modelSet - } catch (err) { - log("[fetchAvailableModels] client.model.list error", { error: String(err) }) - } - } - log("[fetchAvailableModels] connected providers unknown, returning empty set for fallback resolution") - return new Set() - } - - const connectedProvidersList = connectedProviders ?? [] - const connectedSet = new Set(connectedProvidersList) - const modelSet = new Set() - - const providerModelsCache = readProviderModelsCache() - if (providerModelsCache) { - const providerCount = Object.keys(providerModelsCache.models).length - if (providerCount === 0) { - log("[fetchAvailableModels] provider-models cache empty, falling back to models.json") - } else { - log("[fetchAvailableModels] using provider-models cache (whitelist-filtered)") - - for (const [providerId, modelIds] of Object.entries(providerModelsCache.models)) { - if (!connectedSet.has(providerId)) { - continue - } - for (const modelItem of modelIds) { - // Handle both string[] (legacy) and object[] (with metadata) formats - const modelId = typeof modelItem === 'string' - ? modelItem - : (modelItem as any)?.id - - if (modelId) { - modelSet.add(`${providerId}/${modelId}`) - } - } - } - - log("[fetchAvailableModels] parsed from provider-models cache", { - count: modelSet.size, - connectedProviders: connectedProvidersList.slice(0, 5) - }) - - if (modelSet.size > 0) { - return modelSet - } - log("[fetchAvailableModels] provider-models cache produced no models for connected providers, falling back to models.json") - } - } - - log("[fetchAvailableModels] provider-models cache not found, falling back to models.json") - const cacheFile = join(getOpenCodeCacheDir(), "models.json") - - if (!existsSync(cacheFile)) { - log("[fetchAvailableModels] models.json cache file not found, falling back to client") - } else { - try { - const content = readFileSync(cacheFile, "utf-8") - const data = JSON.parse(content) as Record }> - - const providerIds = Object.keys(data) - log("[fetchAvailableModels] providers found in models.json", { count: providerIds.length, providers: providerIds.slice(0, 10) }) - - for (const providerId of providerIds) { - if (!connectedSet.has(providerId)) { - continue - } - - const provider = data[providerId] - const models = provider?.models - if (!models || typeof models !== "object") continue - - for (const modelKey of Object.keys(models)) { - modelSet.add(`${providerId}/${modelKey}`) - } - } - - log("[fetchAvailableModels] parsed models from models.json (NO whitelist filtering)", { - count: modelSet.size, - connectedProviders: connectedProvidersList.slice(0, 5) - }) - - if (modelSet.size > 0) { - return modelSet - } - } catch (err) { - log("[fetchAvailableModels] error", { error: String(err) }) - } - } - - if (client?.model?.list) { - try { - const modelsResult = await client.model.list() - const models = modelsResult.data ?? [] - - for (const model of models) { - if (!model?.provider || !model?.id) continue - if (connectedSet.has(model.provider)) { - modelSet.add(`${model.provider}/${model.id}`) - } - } - - log("[fetchAvailableModels] fetched models from client (filtered)", { - count: modelSet.size, - connectedProviders: connectedProvidersList.slice(0, 5), - }) - } catch (err) { - log("[fetchAvailableModels] client.model.list error", { error: String(err) }) - } - } - - return modelSet -} - -export function isAnyFallbackModelAvailable( - fallbackChain: Array<{ providers: string[]; model: string }>, - availableModels: Set, -): boolean { - // If we have models, check them first - if (availableModels.size > 0) { - for (const entry of fallbackChain) { - const hasAvailableProvider = entry.providers.some((provider) => { - return fuzzyMatchModel(entry.model, availableModels, [provider]) !== null - }) - if (hasAvailableProvider) { - return true - } - } - } - - // Fallback: check if any provider in the chain is connected - // This handles race conditions where availableModels is empty or incomplete - // but we know the provider is connected. - const connectedProviders = readConnectedProvidersCache() - if (connectedProviders) { - const connectedSet = new Set(connectedProviders) - for (const entry of fallbackChain) { - if (entry.providers.some((p) => connectedSet.has(p))) { - log("[isAnyFallbackModelAvailable] model not in available set, but provider is connected", { - model: entry.model, - availableCount: availableModels.size, - }) - return true - } - } - } - - return false -} - -export function isAnyProviderConnected( - providers: string[], - availableModels: Set, -): boolean { - if (availableModels.size > 0) { - const providerSet = new Set(providers) - for (const model of availableModels) { - const [provider] = model.split("/") - if (providerSet.has(provider)) { - log("[isAnyProviderConnected] found model from required provider", { provider, model }) - return true - } - } - } - - const connectedProviders = readConnectedProvidersCache() - if (connectedProviders) { - const connectedSet = new Set(connectedProviders) - for (const provider of providers) { - if (connectedSet.has(provider)) { - log("[isAnyProviderConnected] provider connected via cache", { provider }) - return true - } - } - } - - return false -} - -export function __resetModelCache(): void {} - -export function isModelCacheAvailable(): boolean { - if (hasProviderModelsCache()) { - return true - } - const cacheFile = join(getOpenCodeCacheDir(), "models.json") - return existsSync(cacheFile) -} +export { fetchAvailableModels, getConnectedProviders } from "./available-models-fetcher" +export { isAnyFallbackModelAvailable, isAnyProviderConnected } from "./fallback-model-availability" +export { __resetModelCache, isModelCacheAvailable } from "./model-cache-availability" +export { fuzzyMatchModel, isModelAvailable } from "./model-name-matcher" diff --git a/src/shared/model-cache-availability.ts b/src/shared/model-cache-availability.ts new file mode 100644 index 000000000..d0a2807c3 --- /dev/null +++ b/src/shared/model-cache-availability.ts @@ -0,0 +1,14 @@ +import { existsSync } from "fs" +import { join } from "path" +import { getOpenCodeCacheDir } from "./data-path" +import { hasProviderModelsCache } from "./connected-providers-cache" + +export function __resetModelCache(): void {} + +export function isModelCacheAvailable(): boolean { + if (hasProviderModelsCache()) { + return true + } + const cacheFile = join(getOpenCodeCacheDir(), "models.json") + return existsSync(cacheFile) +} diff --git a/src/shared/model-name-matcher.ts b/src/shared/model-name-matcher.ts new file mode 100644 index 000000000..4cbc03810 --- /dev/null +++ b/src/shared/model-name-matcher.ts @@ -0,0 +1,91 @@ +import { log } from "./logger" + +function normalizeModelName(name: string): string { + return name + .toLowerCase() + .replace(/claude-(opus|sonnet|haiku)-4-5/g, "claude-$1-4.5") + .replace(/claude-(opus|sonnet|haiku)-4\.5/g, "claude-$1-4.5") +} + +export function fuzzyMatchModel( + target: string, + available: Set, + providers?: string[], +): string | null { + log("[fuzzyMatchModel] called", { target, availableCount: available.size, providers }) + + if (available.size === 0) { + log("[fuzzyMatchModel] empty available set") + return null + } + + const targetNormalized = normalizeModelName(target) + + let candidates = Array.from(available) + if (providers && providers.length > 0) { + const providerSet = new Set(providers) + candidates = candidates.filter((model) => { + const [provider] = model.split("/") + return providerSet.has(provider) + }) + log("[fuzzyMatchModel] filtered by providers", { + candidateCount: candidates.length, + candidates: candidates.slice(0, 10), + }) + } + + if (candidates.length === 0) { + log("[fuzzyMatchModel] no candidates after filter") + return null + } + + const matches = candidates.filter((model) => + normalizeModelName(model).includes(targetNormalized), + ) + + log("[fuzzyMatchModel] substring matches", { + targetNormalized, + matchCount: matches.length, + matches, + }) + + if (matches.length === 0) { + return null + } + + const exactMatch = matches.find( + (model) => normalizeModelName(model) === targetNormalized, + ) + if (exactMatch) { + log("[fuzzyMatchModel] exact match found", { exactMatch }) + return exactMatch + } + + const exactModelIdMatches = matches.filter((model) => { + const modelId = model.split("/").slice(1).join("/") + return normalizeModelName(modelId) === targetNormalized + }) + if (exactModelIdMatches.length > 0) { + const result = exactModelIdMatches.reduce((shortest, current) => + current.length < shortest.length ? current : shortest, + ) + log("[fuzzyMatchModel] exact model ID match found", { + result, + candidateCount: exactModelIdMatches.length, + }) + return result + } + + const result = matches.reduce((shortest, current) => + current.length < shortest.length ? current : shortest, + ) + log("[fuzzyMatchModel] shortest match", { result }) + return result +} + +export function isModelAvailable( + targetModel: string, + availableModels: Set, +): boolean { + return fuzzyMatchModel(targetModel, availableModels) !== null +} diff --git a/src/shared/models-json-cache-reader.ts b/src/shared/models-json-cache-reader.ts new file mode 100644 index 000000000..d2291f282 --- /dev/null +++ b/src/shared/models-json-cache-reader.ts @@ -0,0 +1,52 @@ +import { existsSync, readFileSync } from "fs" +import { join } from "path" +import { getOpenCodeCacheDir } from "./data-path" +import { log } from "./logger" +import { isRecord } from "./record-type-guard" + +export function addModelsFromModelsJsonCache( + connectedProviders: Set, + modelSet: Set, +): boolean { + const cacheFile = join(getOpenCodeCacheDir(), "models.json") + if (!existsSync(cacheFile)) { + log("[fetchAvailableModels] models.json cache file not found, falling back to client") + return false + } + + try { + const content = readFileSync(cacheFile, "utf-8") + const data: unknown = JSON.parse(content) + if (!isRecord(data)) { + return false + } + + const providerIds = Object.keys(data) + log("[fetchAvailableModels] providers found in models.json", { + count: providerIds.length, + providers: providerIds.slice(0, 10), + }) + + const previousSize = modelSet.size + for (const providerId of providerIds) { + if (!connectedProviders.has(providerId)) continue + const providerValue = data[providerId] + if (!isRecord(providerValue)) continue + const modelsValue = providerValue["models"] + if (!isRecord(modelsValue)) continue + for (const modelKey of Object.keys(modelsValue)) { + modelSet.add(`${providerId}/${modelKey}`) + } + } + + log("[fetchAvailableModels] parsed models from models.json (NO whitelist filtering)", { + count: modelSet.size, + connectedProviders: Array.from(connectedProviders).slice(0, 5), + }) + + return modelSet.size > previousSize + } catch (err) { + log("[fetchAvailableModels] error", { error: String(err) }) + return false + } +} diff --git a/src/shared/open-code-client-accessors.ts b/src/shared/open-code-client-accessors.ts new file mode 100644 index 000000000..d20f9290d --- /dev/null +++ b/src/shared/open-code-client-accessors.ts @@ -0,0 +1,20 @@ +import type { ModelListFunction, ProviderListFunction } from "./open-code-client-shapes" +import { isRecord } from "./record-type-guard" + +export function getProviderListFunction(client: unknown): ProviderListFunction | null { + if (!isRecord(client)) return null + const provider = client["provider"] + if (!isRecord(provider)) return null + const list = provider["list"] + if (typeof list !== "function") return null + return list as ProviderListFunction +} + +export function getModelListFunction(client: unknown): ModelListFunction | null { + if (!isRecord(client)) return null + const model = client["model"] + if (!isRecord(model)) return null + const list = model["list"] + if (typeof list !== "function") return null + return list as ModelListFunction +} diff --git a/src/shared/open-code-client-shapes.ts b/src/shared/open-code-client-shapes.ts new file mode 100644 index 000000000..701091a36 --- /dev/null +++ b/src/shared/open-code-client-shapes.ts @@ -0,0 +1,7 @@ +export type ProviderListResponse = { data?: { connected?: string[] } } +export type ModelListResponse = { + data?: Array<{ id?: string; provider?: string }> +} + +export type ProviderListFunction = () => Promise +export type ModelListFunction = () => Promise diff --git a/src/shared/provider-models-cache-model-reader.ts b/src/shared/provider-models-cache-model-reader.ts new file mode 100644 index 000000000..c012b94e0 --- /dev/null +++ b/src/shared/provider-models-cache-model-reader.ts @@ -0,0 +1,39 @@ +import { readProviderModelsCache } from "./connected-providers-cache" +import { log } from "./logger" + +export function addModelsFromProviderModelsCache( + connectedProviders: Set, + modelSet: Set, +): boolean { + const providerModelsCache = readProviderModelsCache() + if (!providerModelsCache) { + return false + } + + const providerCount = Object.keys(providerModelsCache.models).length + if (providerCount === 0) { + log("[fetchAvailableModels] provider-models cache empty, falling back to models.json") + return false + } + + log("[fetchAvailableModels] using provider-models cache (whitelist-filtered)") + const previousSize = modelSet.size + + for (const [providerId, modelIds] of Object.entries(providerModelsCache.models)) { + if (!connectedProviders.has(providerId)) continue + for (const modelItem of modelIds) { + if (!modelItem) continue + const modelId = typeof modelItem === "string" ? modelItem : modelItem.id + if (modelId) { + modelSet.add(`${providerId}/${modelId}`) + } + } + } + + log("[fetchAvailableModels] parsed from provider-models cache", { + count: modelSet.size, + connectedProviders: Array.from(connectedProviders).slice(0, 5), + }) + + return modelSet.size > previousSize +} diff --git a/src/shared/record-type-guard.ts b/src/shared/record-type-guard.ts new file mode 100644 index 000000000..a901f1a65 --- /dev/null +++ b/src/shared/record-type-guard.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} From 3c1e71f256f3a8fc6eb351638e81184228b6b946 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:25:01 +0900 Subject: [PATCH 23/51] refactor(cli): split doctor/model-resolution and run/events into focused modules Doctor checks: - model-resolution-cache.ts, model-resolution-config.ts - model-resolution-details.ts, model-resolution-effective-model.ts - model-resolution-types.ts, model-resolution-variant.ts Run events: - event-formatting.ts, event-handlers.ts - event-state.ts, event-stream-processor.ts --- src/cli/doctor/checks/index.ts | 6 + .../doctor/checks/model-resolution-cache.ts | 36 ++ .../doctor/checks/model-resolution-config.ts | 34 ++ .../doctor/checks/model-resolution-details.ts | 52 +++ .../model-resolution-effective-model.ts | 27 ++ .../doctor/checks/model-resolution-types.ts | 35 ++ .../doctor/checks/model-resolution-variant.ts | 55 +++ src/cli/doctor/checks/model-resolution.ts | 240 +------------ src/cli/run/event-formatting.ts | 130 +++++++ src/cli/run/event-handlers.ts | 120 +++++++ src/cli/run/event-state.ts | 25 ++ src/cli/run/event-stream-processor.ts | 43 +++ src/cli/run/events.ts | 333 +----------------- src/cli/run/index.ts | 2 + 14 files changed, 577 insertions(+), 561 deletions(-) create mode 100644 src/cli/doctor/checks/model-resolution-cache.ts create mode 100644 src/cli/doctor/checks/model-resolution-config.ts create mode 100644 src/cli/doctor/checks/model-resolution-details.ts create mode 100644 src/cli/doctor/checks/model-resolution-effective-model.ts create mode 100644 src/cli/doctor/checks/model-resolution-types.ts create mode 100644 src/cli/doctor/checks/model-resolution-variant.ts create mode 100644 src/cli/run/event-formatting.ts create mode 100644 src/cli/run/event-handlers.ts create mode 100644 src/cli/run/event-state.ts create mode 100644 src/cli/run/event-stream-processor.ts diff --git a/src/cli/doctor/checks/index.ts b/src/cli/doctor/checks/index.ts index 089271055..9135cd1bc 100644 --- a/src/cli/doctor/checks/index.ts +++ b/src/cli/doctor/checks/index.ts @@ -15,6 +15,12 @@ export * from "./opencode" export * from "./plugin" export * from "./config" export * from "./model-resolution" +export * from "./model-resolution-types" +export * from "./model-resolution-cache" +export * from "./model-resolution-config" +export * from "./model-resolution-effective-model" +export * from "./model-resolution-variant" +export * from "./model-resolution-details" export * from "./auth" export * from "./dependencies" export * from "./gh" diff --git a/src/cli/doctor/checks/model-resolution-cache.ts b/src/cli/doctor/checks/model-resolution-cache.ts new file mode 100644 index 000000000..9628db9e5 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-cache.ts @@ -0,0 +1,36 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import type { AvailableModelsInfo } from "./model-resolution-types" + +function getOpenCodeCacheDir(): string { + const xdgCache = process.env.XDG_CACHE_HOME + if (xdgCache) return join(xdgCache, "opencode") + return join(homedir(), ".cache", "opencode") +} + +export function loadAvailableModelsFromCache(): AvailableModelsInfo { + const cacheFile = join(getOpenCodeCacheDir(), "models.json") + + if (!existsSync(cacheFile)) { + return { providers: [], modelCount: 0, cacheExists: false } + } + + try { + const content = readFileSync(cacheFile, "utf-8") + const data = JSON.parse(content) as Record }> + + const providers = Object.keys(data) + let modelCount = 0 + for (const providerId of providers) { + const models = data[providerId]?.models + if (models && typeof models === "object") { + modelCount += Object.keys(models).length + } + } + + return { providers, modelCount, cacheExists: true } + } catch { + return { providers: [], modelCount: 0, cacheExists: false } + } +} diff --git a/src/cli/doctor/checks/model-resolution-config.ts b/src/cli/doctor/checks/model-resolution-config.ts new file mode 100644 index 000000000..e84853ee4 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-config.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { detectConfigFile, parseJsonc } from "../../../shared" +import type { OmoConfig } from "./model-resolution-types" + +const PACKAGE_NAME = "oh-my-opencode" +const USER_CONFIG_DIR = join(homedir(), ".config", "opencode") +const USER_CONFIG_BASE = join(USER_CONFIG_DIR, PACKAGE_NAME) +const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME) + +export function loadOmoConfig(): OmoConfig | null { + const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE) + if (projectDetected.format !== "none") { + try { + const content = readFileSync(projectDetected.path, "utf-8") + return parseJsonc(content) + } catch { + return null + } + } + + const userDetected = detectConfigFile(USER_CONFIG_BASE) + if (userDetected.format !== "none") { + try { + const content = readFileSync(userDetected.path, "utf-8") + return parseJsonc(content) + } catch { + return null + } + } + + return null +} diff --git a/src/cli/doctor/checks/model-resolution-details.ts b/src/cli/doctor/checks/model-resolution-details.ts new file mode 100644 index 000000000..7489a2c24 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-details.ts @@ -0,0 +1,52 @@ +import type { AvailableModelsInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types" +import { formatModelWithVariant, getCategoryEffectiveVariant, getEffectiveVariant } from "./model-resolution-variant" + +export function buildModelResolutionDetails(options: { + info: ModelResolutionInfo + available: AvailableModelsInfo + config: OmoConfig +}): string[] { + const details: string[] = [] + + details.push("═══ Available Models (from cache) ═══") + details.push("") + if (options.available.cacheExists) { + details.push(` Providers in cache: ${options.available.providers.length}`) + details.push( + ` Sample: ${options.available.providers.slice(0, 6).join(", ")}${options.available.providers.length > 6 ? "..." : ""}` + ) + details.push(` Total models: ${options.available.modelCount}`) + details.push(` Cache: ~/.cache/opencode/models.json`) + details.push(` ℹ Runtime: only connected providers used`) + details.push(` Refresh: opencode models --refresh`) + } else { + details.push(" ⚠ Cache not found. Run 'opencode' to populate.") + } + details.push("") + + details.push("═══ Configured Models ═══") + details.push("") + details.push("Agents:") + for (const agent of options.info.agents) { + const marker = agent.userOverride ? "●" : "○" + const display = formatModelWithVariant( + agent.effectiveModel, + getEffectiveVariant(agent.name, agent.requirement, options.config) + ) + details.push(` ${marker} ${agent.name}: ${display}`) + } + details.push("") + details.push("Categories:") + for (const category of options.info.categories) { + const marker = category.userOverride ? "●" : "○" + const display = formatModelWithVariant( + category.effectiveModel, + getCategoryEffectiveVariant(category.name, category.requirement, options.config) + ) + details.push(` ${marker} ${category.name}: ${display}`) + } + details.push("") + details.push("● = user override, ○ = provider fallback") + + return details +} diff --git a/src/cli/doctor/checks/model-resolution-effective-model.ts b/src/cli/doctor/checks/model-resolution-effective-model.ts new file mode 100644 index 000000000..44df0e8f6 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-effective-model.ts @@ -0,0 +1,27 @@ +import type { ModelRequirement } from "../../../shared/model-requirements" + +function formatProviderChain(providers: string[]): string { + return providers.join(" → ") +} + +export function getEffectiveModel(requirement: ModelRequirement, userOverride?: string): string { + if (userOverride) { + return userOverride + } + const firstEntry = requirement.fallbackChain[0] + if (!firstEntry) { + return "unknown" + } + return `${firstEntry.providers[0]}/${firstEntry.model}` +} + +export function buildEffectiveResolution(requirement: ModelRequirement, userOverride?: string): string { + if (userOverride) { + return `User override: ${userOverride}` + } + const firstEntry = requirement.fallbackChain[0] + if (!firstEntry) { + return "No fallback chain defined" + } + return `Provider fallback: ${formatProviderChain(firstEntry.providers)} → ${firstEntry.model}` +} diff --git a/src/cli/doctor/checks/model-resolution-types.ts b/src/cli/doctor/checks/model-resolution-types.ts new file mode 100644 index 000000000..c0396d958 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-types.ts @@ -0,0 +1,35 @@ +import type { ModelRequirement } from "../../../shared/model-requirements" + +export interface AgentResolutionInfo { + name: string + requirement: ModelRequirement + userOverride?: string + userVariant?: string + effectiveModel: string + effectiveResolution: string +} + +export interface CategoryResolutionInfo { + name: string + requirement: ModelRequirement + userOverride?: string + userVariant?: string + effectiveModel: string + effectiveResolution: string +} + +export interface ModelResolutionInfo { + agents: AgentResolutionInfo[] + categories: CategoryResolutionInfo[] +} + +export interface OmoConfig { + agents?: Record + categories?: Record +} + +export interface AvailableModelsInfo { + providers: string[] + modelCount: number + cacheExists: boolean +} diff --git a/src/cli/doctor/checks/model-resolution-variant.ts b/src/cli/doctor/checks/model-resolution-variant.ts new file mode 100644 index 000000000..a9bea3007 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-variant.ts @@ -0,0 +1,55 @@ +import type { ModelRequirement } from "../../../shared/model-requirements" +import type { OmoConfig } from "./model-resolution-types" + +export function formatModelWithVariant(model: string, variant?: string): string { + return variant ? `${model} (${variant})` : model +} + +function getAgentOverride( + agentName: string, + config: OmoConfig +): { variant?: string; category?: string } | undefined { + const agentOverrides = config.agents + if (!agentOverrides) return undefined + + return ( + agentOverrides[agentName] ?? + Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] + ) +} + +export function getEffectiveVariant( + agentName: string, + requirement: ModelRequirement, + config: OmoConfig +): string | undefined { + const agentOverride = getAgentOverride(agentName, config) + + if (agentOverride?.variant) { + return agentOverride.variant + } + + const categoryName = agentOverride?.category + if (categoryName) { + const categoryVariant = config.categories?.[categoryName]?.variant + if (categoryVariant) { + return categoryVariant + } + } + + const firstEntry = requirement.fallbackChain[0] + return firstEntry?.variant ?? requirement.variant +} + +export function getCategoryEffectiveVariant( + categoryName: string, + requirement: ModelRequirement, + config: OmoConfig +): string | undefined { + const categoryVariant = config.categories?.[categoryName]?.variant + if (categoryVariant) { + return categoryVariant + } + const firstEntry = requirement.fallbackChain[0] + return firstEntry?.variant ?? requirement.variant +} diff --git a/src/cli/doctor/checks/model-resolution.ts b/src/cli/doctor/checks/model-resolution.ts index 8599803ce..c2f8a77f9 100644 --- a/src/cli/doctor/checks/model-resolution.ts +++ b/src/cli/doctor/checks/model-resolution.ts @@ -1,132 +1,14 @@ -import { readFileSync, existsSync } from "node:fs" import type { CheckResult, CheckDefinition } from "../types" import { CHECK_IDS, CHECK_NAMES } from "../constants" -import { parseJsonc, detectConfigFile } from "../../../shared" import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS, - type ModelRequirement, } from "../../../shared/model-requirements" -import { homedir } from "node:os" -import { join } from "node:path" - -function getOpenCodeCacheDir(): string { - const xdgCache = process.env.XDG_CACHE_HOME - if (xdgCache) return join(xdgCache, "opencode") - return join(homedir(), ".cache", "opencode") -} - -function loadAvailableModels(): { providers: string[]; modelCount: number; cacheExists: boolean } { - const cacheFile = join(getOpenCodeCacheDir(), "models.json") - - if (!existsSync(cacheFile)) { - return { providers: [], modelCount: 0, cacheExists: false } - } - - try { - const content = readFileSync(cacheFile, "utf-8") - const data = JSON.parse(content) as Record }> - - const providers = Object.keys(data) - let modelCount = 0 - for (const providerId of providers) { - const models = data[providerId]?.models - if (models && typeof models === "object") { - modelCount += Object.keys(models).length - } - } - - return { providers, modelCount, cacheExists: true } - } catch { - return { providers: [], modelCount: 0, cacheExists: false } - } -} - -const PACKAGE_NAME = "oh-my-opencode" -const USER_CONFIG_DIR = join(homedir(), ".config", "opencode") -const USER_CONFIG_BASE = join(USER_CONFIG_DIR, PACKAGE_NAME) -const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME) - -export interface AgentResolutionInfo { - name: string - requirement: ModelRequirement - userOverride?: string - userVariant?: string - effectiveModel: string - effectiveResolution: string -} - -export interface CategoryResolutionInfo { - name: string - requirement: ModelRequirement - userOverride?: string - userVariant?: string - effectiveModel: string - effectiveResolution: string -} - -export interface ModelResolutionInfo { - agents: AgentResolutionInfo[] - categories: CategoryResolutionInfo[] -} - -interface OmoConfig { - agents?: Record - categories?: Record -} - -function loadConfig(): OmoConfig | null { - const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE) - if (projectDetected.format !== "none") { - try { - const content = readFileSync(projectDetected.path, "utf-8") - return parseJsonc(content) - } catch { - return null - } - } - - const userDetected = detectConfigFile(USER_CONFIG_BASE) - if (userDetected.format !== "none") { - try { - const content = readFileSync(userDetected.path, "utf-8") - return parseJsonc(content) - } catch { - return null - } - } - - return null -} - -function formatProviderChain(providers: string[]): string { - return providers.join(" → ") -} - -function getEffectiveModel(requirement: ModelRequirement, userOverride?: string): string { - if (userOverride) { - return userOverride - } - const firstEntry = requirement.fallbackChain[0] - if (!firstEntry) { - return "unknown" - } - return `${firstEntry.providers[0]}/${firstEntry.model}` -} - -function buildEffectiveResolution( - requirement: ModelRequirement, - userOverride?: string, -): string { - if (userOverride) { - return `User override: ${userOverride}` - } - const firstEntry = requirement.fallbackChain[0] - if (!firstEntry) { - return "No fallback chain defined" - } - return `Provider fallback: ${formatProviderChain(firstEntry.providers)} → ${firstEntry.model}` -} +import type { OmoConfig, ModelResolutionInfo, AgentResolutionInfo, CategoryResolutionInfo } from "./model-resolution-types" +import { loadAvailableModelsFromCache } from "./model-resolution-cache" +import { loadOmoConfig } from "./model-resolution-config" +import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model" +import { buildModelResolutionDetails } from "./model-resolution-details" export function getModelResolutionInfo(): ModelResolutionInfo { const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map( @@ -184,116 +66,10 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes return { agents, categories } } -function formatModelWithVariant(model: string, variant?: string): string { - return variant ? `${model} (${variant})` : model -} - -function getAgentOverride( - agentName: string, - config: OmoConfig, -): { variant?: string; category?: string } | undefined { - const agentOverrides = config.agents - if (!agentOverrides) return undefined - - // Direct lookup first, then case-insensitive lookup (matches agent-variant.ts) - return ( - agentOverrides[agentName] ?? - Object.entries(agentOverrides).find( - ([key]) => key.toLowerCase() === agentName.toLowerCase() - )?.[1] - ) -} - -function getEffectiveVariant( - name: string, - requirement: ModelRequirement, - config: OmoConfig, -): string | undefined { - const agentOverride = getAgentOverride(name, config) - - // Priority 1: Agent's direct variant override - if (agentOverride?.variant) { - return agentOverride.variant - } - - // Priority 2: Agent's category -> category's variant (matches agent-variant.ts) - const categoryName = agentOverride?.category - if (categoryName) { - const categoryVariant = config.categories?.[categoryName]?.variant - if (categoryVariant) { - return categoryVariant - } - } - - // Priority 3: Fall back to requirement's fallback chain - const firstEntry = requirement.fallbackChain[0] - return firstEntry?.variant ?? requirement.variant -} - -interface AvailableModelsInfo { - providers: string[] - modelCount: number - cacheExists: boolean -} - -function getCategoryEffectiveVariant( - categoryName: string, - requirement: ModelRequirement, - config: OmoConfig, -): string | undefined { - const categoryVariant = config.categories?.[categoryName]?.variant - if (categoryVariant) { - return categoryVariant - } - const firstEntry = requirement.fallbackChain[0] - return firstEntry?.variant ?? requirement.variant -} - -function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModelsInfo, config: OmoConfig): string[] { - const details: string[] = [] - - details.push("═══ Available Models (from cache) ═══") - details.push("") - if (available.cacheExists) { - details.push(` Providers in cache: ${available.providers.length}`) - details.push(` Sample: ${available.providers.slice(0, 6).join(", ")}${available.providers.length > 6 ? "..." : ""}`) - details.push(` Total models: ${available.modelCount}`) - details.push(` Cache: ~/.cache/opencode/models.json`) - details.push(` ℹ Runtime: only connected providers used`) - details.push(` Refresh: opencode models --refresh`) - } else { - details.push(" ⚠ Cache not found. Run 'opencode' to populate.") - } - details.push("") - - details.push("═══ Configured Models ═══") - details.push("") - details.push("Agents:") - for (const agent of info.agents) { - const marker = agent.userOverride ? "●" : "○" - const display = formatModelWithVariant(agent.effectiveModel, getEffectiveVariant(agent.name, agent.requirement, config)) - details.push(` ${marker} ${agent.name}: ${display}`) - } - details.push("") - details.push("Categories:") - for (const category of info.categories) { - const marker = category.userOverride ? "●" : "○" - const display = formatModelWithVariant( - category.effectiveModel, - getCategoryEffectiveVariant(category.name, category.requirement, config) - ) - details.push(` ${marker} ${category.name}: ${display}`) - } - details.push("") - details.push("● = user override, ○ = provider fallback") - - return details -} - export async function checkModelResolution(): Promise { - const config = loadConfig() ?? {} + const config = loadOmoConfig() ?? {} const info = getModelResolutionInfoWithOverrides(config) - const available = loadAvailableModels() + const available = loadAvailableModelsFromCache() const agentCount = info.agents.length const categoryCount = info.categories.length @@ -308,7 +84,7 @@ export async function checkModelResolution(): Promise { name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION], status: available.cacheExists ? "pass" : "warn", message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`, - details: buildDetailsArray(info, available, config), + details: buildModelResolutionDetails({ info, available, config }), } } diff --git a/src/cli/run/event-formatting.ts b/src/cli/run/event-formatting.ts new file mode 100644 index 000000000..f9056641e --- /dev/null +++ b/src/cli/run/event-formatting.ts @@ -0,0 +1,130 @@ +import pc from "picocolors" +import type { + RunContext, + EventPayload, + MessageUpdatedProps, + MessagePartUpdatedProps, + ToolExecuteProps, + ToolResultProps, + SessionErrorProps, +} from "./types" + +export function serializeError(error: unknown): string { + if (!error) return "Unknown error" + + if (error instanceof Error) { + const parts = [error.message] + if (error.cause) { + parts.push(`Cause: ${serializeError(error.cause)}`) + } + return parts.join(" | ") + } + + if (typeof error === "string") { + return error + } + + if (typeof error === "object") { + const obj = error as Record + + const messagePaths = [ + obj.message, + obj.error, + (obj.data as Record)?.message, + (obj.data as Record)?.error, + (obj.error as Record)?.message, + ] + + for (const msg of messagePaths) { + if (typeof msg === "string" && msg.length > 0) { + return msg + } + } + + try { + const json = JSON.stringify(error, null, 2) + if (json !== "{}") { + return json + } + } catch (_) { + void _ + } + } + + return String(error) +} + +function getSessionTag(ctx: RunContext, payload: EventPayload): string { + const props = payload.properties as Record | undefined + const info = props?.info as Record | undefined + const sessionID = props?.sessionID ?? info?.sessionID + const isMainSession = sessionID === ctx.sessionID + if (isMainSession) return pc.green("[MAIN]") + if (sessionID) return pc.yellow(`[${String(sessionID).slice(0, 8)}]`) + return pc.dim("[system]") +} + +export function logEventVerbose(ctx: RunContext, payload: EventPayload): void { + const sessionTag = getSessionTag(ctx, payload) + const props = payload.properties as Record | undefined + + switch (payload.type) { + case "session.idle": + case "session.status": { + const status = (props?.status as { type?: string })?.type ?? "idle" + console.error(pc.dim(`${sessionTag} ${payload.type}: ${status}`)) + break + } + + case "message.part.updated": { + const partProps = props as MessagePartUpdatedProps | undefined + const part = partProps?.part + if (part?.type === "tool-invocation") { + const toolPart = part as { toolName?: string; state?: string } + console.error(pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)) + } else if (part?.type === "text" && part.text) { + const preview = part.text.slice(0, 80).replace(/\n/g, "\\n") + console.error(pc.dim(`${sessionTag} message.part (text): "${preview}${part.text.length > 80 ? "..." : ""}"`)) + } + break + } + + case "message.updated": { + const msgProps = props as MessageUpdatedProps | undefined + const role = msgProps?.info?.role ?? "unknown" + const model = msgProps?.info?.modelID + const agent = msgProps?.info?.agent + const details = [role, agent, model].filter(Boolean).join(", ") + console.error(pc.dim(`${sessionTag} message.updated (${details})`)) + break + } + + case "tool.execute": { + const toolProps = props as ToolExecuteProps | undefined + const toolName = toolProps?.name ?? "unknown" + const input = toolProps?.input ?? {} + const inputStr = JSON.stringify(input).slice(0, 150) + console.error(pc.cyan(`${sessionTag} TOOL.EXECUTE: ${pc.bold(toolName)}`)) + console.error(pc.dim(` input: ${inputStr}${inputStr.length >= 150 ? "..." : ""}`)) + break + } + + case "tool.result": { + const resultProps = props as ToolResultProps | undefined + const output = resultProps?.output ?? "" + const preview = output.slice(0, 200).replace(/\n/g, "\\n") + console.error(pc.green(`${sessionTag} TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`)) + break + } + + case "session.error": { + const errorProps = props as SessionErrorProps | undefined + const errorMsg = serializeError(errorProps?.error) + console.error(pc.red(`${sessionTag} SESSION.ERROR: ${errorMsg}`)) + break + } + + default: + console.error(pc.dim(`${sessionTag} ${payload.type}`)) + } +} diff --git a/src/cli/run/event-handlers.ts b/src/cli/run/event-handlers.ts new file mode 100644 index 000000000..9f1dcabd4 --- /dev/null +++ b/src/cli/run/event-handlers.ts @@ -0,0 +1,120 @@ +import pc from "picocolors" +import type { + RunContext, + EventPayload, + SessionIdleProps, + SessionStatusProps, + SessionErrorProps, + MessageUpdatedProps, + MessagePartUpdatedProps, + ToolExecuteProps, + ToolResultProps, +} from "./types" +import type { EventState } from "./event-state" +import { serializeError } from "./event-formatting" + +export function handleSessionIdle(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "session.idle") return + + const props = payload.properties as SessionIdleProps | undefined + if (props?.sessionID === ctx.sessionID) { + state.mainSessionIdle = true + } +} + +export function handleSessionStatus(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "session.status") return + + const props = payload.properties as SessionStatusProps | undefined + if (props?.sessionID === ctx.sessionID && props?.status?.type === "busy") { + state.mainSessionIdle = false + } +} + +export function handleSessionError(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "session.error") return + + const props = payload.properties as SessionErrorProps | undefined + if (props?.sessionID === ctx.sessionID) { + state.mainSessionError = true + state.lastError = serializeError(props?.error) + console.error(pc.red(`\n[session.error] ${state.lastError}`)) + } +} + +export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "message.part.updated") return + + const props = payload.properties as MessagePartUpdatedProps | undefined + if (props?.info?.sessionID !== ctx.sessionID) return + if (props?.info?.role !== "assistant") return + + const part = props.part + if (!part) return + + if (part.type === "text" && part.text) { + const newText = part.text.slice(state.lastPartText.length) + if (newText) { + process.stdout.write(newText) + state.hasReceivedMeaningfulWork = true + } + state.lastPartText = part.text + } +} + +export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "message.updated") return + + const props = payload.properties as MessageUpdatedProps | undefined + if (props?.info?.sessionID !== ctx.sessionID) return + if (props?.info?.role !== "assistant") return + + state.hasReceivedMeaningfulWork = true + state.messageCount++ +} + +export function handleToolExecute(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "tool.execute") return + + const props = payload.properties as ToolExecuteProps | undefined + if (props?.sessionID !== ctx.sessionID) return + + const toolName = props?.name || "unknown" + state.currentTool = toolName + + let inputPreview = "" + if (props?.input) { + const input = props.input + if (input.command) { + inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}` + } else if (input.pattern) { + inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}` + } else if (input.filePath) { + inputPreview = ` ${pc.dim(String(input.filePath))}` + } else if (input.query) { + inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}` + } + } + + state.hasReceivedMeaningfulWork = true + process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`) +} + +export function handleToolResult(ctx: RunContext, payload: EventPayload, state: EventState): void { + if (payload.type !== "tool.result") return + + const props = payload.properties as ToolResultProps | undefined + if (props?.sessionID !== ctx.sessionID) return + + const output = props?.output || "" + const maxLen = 200 + const preview = output.length > maxLen ? output.slice(0, maxLen) + "..." : output + + if (preview.trim()) { + const lines = preview.split("\n").slice(0, 3) + process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`)) + } + + state.currentTool = null + state.lastPartText = "" +} diff --git a/src/cli/run/event-state.ts b/src/cli/run/event-state.ts new file mode 100644 index 000000000..db49f5653 --- /dev/null +++ b/src/cli/run/event-state.ts @@ -0,0 +1,25 @@ +export interface EventState { + mainSessionIdle: boolean + mainSessionError: boolean + lastError: string | null + lastOutput: string + lastPartText: string + currentTool: string | null + /** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */ + hasReceivedMeaningfulWork: boolean + /** Count of assistant messages for the main session */ + messageCount: number +} + +export function createEventState(): EventState { + return { + mainSessionIdle: false, + mainSessionError: false, + lastError: null, + lastOutput: "", + lastPartText: "", + currentTool: null, + hasReceivedMeaningfulWork: false, + messageCount: 0, + } +} diff --git a/src/cli/run/event-stream-processor.ts b/src/cli/run/event-stream-processor.ts new file mode 100644 index 000000000..7f629a041 --- /dev/null +++ b/src/cli/run/event-stream-processor.ts @@ -0,0 +1,43 @@ +import pc from "picocolors" +import type { RunContext, EventPayload } from "./types" +import type { EventState } from "./event-state" +import { logEventVerbose } from "./event-formatting" +import { + handleSessionError, + handleSessionIdle, + handleSessionStatus, + handleMessagePartUpdated, + handleMessageUpdated, + handleToolExecute, + handleToolResult, +} from "./event-handlers" + +export async function processEvents( + ctx: RunContext, + stream: AsyncIterable, + state: EventState +): Promise { + for await (const event of stream) { + if (ctx.abortController.signal.aborted) break + + try { + const payload = event as EventPayload + if (!payload?.type) { + console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`)) + continue + } + + logEventVerbose(ctx, payload) + + handleSessionError(ctx, payload, state) + handleSessionIdle(ctx, payload, state) + handleSessionStatus(ctx, payload, state) + handleMessagePartUpdated(ctx, payload, state) + handleMessageUpdated(ctx, payload, state) + handleToolExecute(ctx, payload, state) + handleToolResult(ctx, payload, state) + } catch (err) { + console.error(pc.red(`[event error] ${err}`)) + } + } +} diff --git a/src/cli/run/events.ts b/src/cli/run/events.ts index ff3af1f73..114b65ae1 100644 --- a/src/cli/run/events.ts +++ b/src/cli/run/events.ts @@ -1,329 +1,4 @@ -import pc from "picocolors" -import type { - RunContext, - EventPayload, - SessionIdleProps, - SessionStatusProps, - SessionErrorProps, - MessageUpdatedProps, - MessagePartUpdatedProps, - ToolExecuteProps, - ToolResultProps, -} from "./types" - -export function serializeError(error: unknown): string { - if (!error) return "Unknown error" - - if (error instanceof Error) { - const parts = [error.message] - if (error.cause) { - parts.push(`Cause: ${serializeError(error.cause)}`) - } - return parts.join(" | ") - } - - if (typeof error === "string") { - return error - } - - if (typeof error === "object") { - const obj = error as Record - - const messagePaths = [ - obj.message, - obj.error, - (obj.data as Record)?.message, - (obj.data as Record)?.error, - (obj.error as Record)?.message, - ] - - for (const msg of messagePaths) { - if (typeof msg === "string" && msg.length > 0) { - return msg - } - } - - try { - const json = JSON.stringify(error, null, 2) - if (json !== "{}") { - return json - } - } catch (_) { - void _ - } - } - - return String(error) -} - -export interface EventState { - mainSessionIdle: boolean - mainSessionError: boolean - lastError: string | null - lastOutput: string - lastPartText: string - currentTool: string | null - /** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */ - hasReceivedMeaningfulWork: boolean - /** Count of assistant messages for the main session */ - messageCount: number -} - -export function createEventState(): EventState { - return { - mainSessionIdle: false, - mainSessionError: false, - lastError: null, - lastOutput: "", - lastPartText: "", - currentTool: null, - hasReceivedMeaningfulWork: false, - messageCount: 0, - } -} - -export async function processEvents( - ctx: RunContext, - stream: AsyncIterable, - state: EventState -): Promise { - for await (const event of stream) { - if (ctx.abortController.signal.aborted) break - - try { - const payload = event as EventPayload - if (!payload?.type) { - console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`)) - continue - } - - logEventVerbose(ctx, payload) - - handleSessionError(ctx, payload, state) - handleSessionIdle(ctx, payload, state) - handleSessionStatus(ctx, payload, state) - handleMessagePartUpdated(ctx, payload, state) - handleMessageUpdated(ctx, payload, state) - handleToolExecute(ctx, payload, state) - handleToolResult(ctx, payload, state) - } catch (err) { - console.error(pc.red(`[event error] ${err}`)) - } - } -} - -function logEventVerbose(ctx: RunContext, payload: EventPayload): void { - const props = payload.properties as Record | undefined - const info = props?.info as Record | undefined - const sessionID = props?.sessionID ?? info?.sessionID - const isMainSession = sessionID === ctx.sessionID - const sessionTag = isMainSession - ? pc.green("[MAIN]") - : sessionID - ? pc.yellow(`[${String(sessionID).slice(0, 8)}]`) - : pc.dim("[system]") - - switch (payload.type) { - case "session.idle": - case "session.status": { - const status = (props?.status as { type?: string })?.type ?? "idle" - console.error(pc.dim(`${sessionTag} ${payload.type}: ${status}`)) - break - } - - case "message.part.updated": { - const partProps = props as MessagePartUpdatedProps | undefined - const part = partProps?.part - if (part?.type === "tool-invocation") { - const toolPart = part as { toolName?: string; state?: string } - console.error( - pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`) - ) - } else if (part?.type === "text" && part.text) { - const preview = part.text.slice(0, 80).replace(/\n/g, "\\n") - console.error( - pc.dim(`${sessionTag} message.part (text): "${preview}${part.text.length > 80 ? "..." : ""}"`) - ) - } - break - } - - case "message.updated": { - const msgProps = props as MessageUpdatedProps | undefined - const role = msgProps?.info?.role ?? "unknown" - const model = msgProps?.info?.modelID - const agent = msgProps?.info?.agent - const details = [role, agent, model].filter(Boolean).join(", ") - console.error(pc.dim(`${sessionTag} message.updated (${details})`)) - break - } - - case "tool.execute": { - const toolProps = props as ToolExecuteProps | undefined - const toolName = toolProps?.name ?? "unknown" - const input = toolProps?.input ?? {} - const inputStr = JSON.stringify(input).slice(0, 150) - console.error( - pc.cyan(`${sessionTag} TOOL.EXECUTE: ${pc.bold(toolName)}`) - ) - console.error(pc.dim(` input: ${inputStr}${inputStr.length >= 150 ? "..." : ""}`)) - break - } - - case "tool.result": { - const resultProps = props as ToolResultProps | undefined - const output = resultProps?.output ?? "" - const preview = output.slice(0, 200).replace(/\n/g, "\\n") - console.error( - pc.green(`${sessionTag} TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`) - ) - break - } - - case "session.error": { - const errorProps = props as SessionErrorProps | undefined - const errorMsg = serializeError(errorProps?.error) - console.error(pc.red(`${sessionTag} SESSION.ERROR: ${errorMsg}`)) - break - } - - default: - console.error(pc.dim(`${sessionTag} ${payload.type}`)) - } -} - -function handleSessionIdle( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "session.idle") return - - const props = payload.properties as SessionIdleProps | undefined - if (props?.sessionID === ctx.sessionID) { - state.mainSessionIdle = true - } -} - -function handleSessionStatus( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "session.status") return - - const props = payload.properties as SessionStatusProps | undefined - if (props?.sessionID === ctx.sessionID && props?.status?.type === "busy") { - state.mainSessionIdle = false - } -} - -function handleSessionError( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "session.error") return - - const props = payload.properties as SessionErrorProps | undefined - if (props?.sessionID === ctx.sessionID) { - state.mainSessionError = true - state.lastError = serializeError(props?.error) - console.error(pc.red(`\n[session.error] ${state.lastError}`)) - } -} - -function handleMessagePartUpdated( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "message.part.updated") return - - const props = payload.properties as MessagePartUpdatedProps | undefined - if (props?.info?.sessionID !== ctx.sessionID) return - if (props?.info?.role !== "assistant") return - - const part = props.part - if (!part) return - - if (part.type === "text" && part.text) { - const newText = part.text.slice(state.lastPartText.length) - if (newText) { - process.stdout.write(newText) - state.hasReceivedMeaningfulWork = true - } - state.lastPartText = part.text - } -} - -function handleMessageUpdated( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "message.updated") return - - const props = payload.properties as MessageUpdatedProps | undefined - if (props?.info?.sessionID !== ctx.sessionID) return - if (props?.info?.role !== "assistant") return - - state.hasReceivedMeaningfulWork = true - state.messageCount++ -} - -function handleToolExecute( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "tool.execute") return - - const props = payload.properties as ToolExecuteProps | undefined - if (props?.sessionID !== ctx.sessionID) return - - const toolName = props?.name || "unknown" - state.currentTool = toolName - - let inputPreview = "" - if (props?.input) { - const input = props.input - if (input.command) { - inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}` - } else if (input.pattern) { - inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}` - } else if (input.filePath) { - inputPreview = ` ${pc.dim(String(input.filePath))}` - } else if (input.query) { - inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}` - } - } - - state.hasReceivedMeaningfulWork = true - process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`) -} - -function handleToolResult( - ctx: RunContext, - payload: EventPayload, - state: EventState -): void { - if (payload.type !== "tool.result") return - - const props = payload.properties as ToolResultProps | undefined - if (props?.sessionID !== ctx.sessionID) return - - const output = props?.output || "" - const maxLen = 200 - const preview = output.length > maxLen - ? output.slice(0, maxLen) + "..." - : output - - if (preview.trim()) { - const lines = preview.split("\n").slice(0, 3) - process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`)) - } - - state.currentTool = null - state.lastPartText = "" -} +export type { EventState } from "./event-state" +export { createEventState } from "./event-state" +export { serializeError } from "./event-formatting" +export { processEvents } from "./event-stream-processor" diff --git a/src/cli/run/index.ts b/src/cli/run/index.ts index 33d9ff9be..8e4528e81 100644 --- a/src/cli/run/index.ts +++ b/src/cli/run/index.ts @@ -4,4 +4,6 @@ export { createServerConnection } from "./server-connection" export { resolveSession } from "./session-resolver" export { createJsonOutputManager } from "./json-output" export { executeOnCompleteHook } from "./on-complete-hook" +export { createEventState, processEvents, serializeError } from "./events" +export type { EventState } from "./events" export type { RunOptions, RunContext, RunResult, ServerConnection } from "./types" From d525958a9d097193ea58edad1c8a8b880c94f4df Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:25:12 +0900 Subject: [PATCH 24/51] refactor(cli): split install.ts and model-fallback.ts into focused modules Install pipeline: - cli-installer.ts, tui-installer.ts, tui-install-prompts.ts - install-validators.ts Model fallback: - model-fallback-types.ts, fallback-chain-resolution.ts - provider-availability.ts, provider-model-id-transform.ts --- src/cli/cli-installer.ts | 164 ++++++++ src/cli/fallback-chain-resolution.ts | 55 +++ src/cli/install-validators.ts | 189 +++++++++ src/cli/install.ts | 540 +------------------------ src/cli/model-fallback-types.ts | 29 ++ src/cli/model-fallback.ts | 130 +----- src/cli/provider-availability.ts | 30 ++ src/cli/provider-model-id-transform.ts | 12 + src/cli/tui-install-prompts.ts | 111 +++++ src/cli/tui-installer.ts | 135 +++++++ 10 files changed, 741 insertions(+), 654 deletions(-) create mode 100644 src/cli/cli-installer.ts create mode 100644 src/cli/fallback-chain-resolution.ts create mode 100644 src/cli/install-validators.ts create mode 100644 src/cli/model-fallback-types.ts create mode 100644 src/cli/provider-availability.ts create mode 100644 src/cli/provider-model-id-transform.ts create mode 100644 src/cli/tui-install-prompts.ts create mode 100644 src/cli/tui-installer.ts diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts new file mode 100644 index 000000000..a38b2c803 --- /dev/null +++ b/src/cli/cli-installer.ts @@ -0,0 +1,164 @@ +import color from "picocolors" +import type { InstallArgs } from "./types" +import { + addAuthPlugins, + addPluginToOpenCodeConfig, + addProviderConfig, + detectCurrentConfig, + getOpenCodeVersion, + isOpenCodeInstalled, + writeOmoConfig, +} from "./config-manager" +import { + SYMBOLS, + argsToConfig, + detectedToInitialValues, + formatConfigSummary, + printBox, + printError, + printHeader, + printInfo, + printStep, + printSuccess, + printWarning, + validateNonTuiArgs, +} from "./install-validators" + +export async function runCliInstaller(args: InstallArgs, version: string): Promise { + const validation = validateNonTuiArgs(args) + if (!validation.valid) { + printHeader(false) + printError("Validation failed:") + for (const err of validation.errors) { + console.log(` ${SYMBOLS.bullet} ${err}`) + } + console.log() + printInfo( + "Usage: bunx oh-my-opencode install --no-tui --claude= --gemini= --copilot=", + ) + console.log() + return 1 + } + + const detected = detectCurrentConfig() + const isUpdate = detected.isInstalled + + printHeader(isUpdate) + + const totalSteps = 6 + let step = 1 + + printStep(step++, totalSteps, "Checking OpenCode installation...") + const installed = await isOpenCodeInstalled() + const openCodeVersion = await getOpenCodeVersion() + if (!installed) { + printWarning( + "OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.", + ) + printInfo("Visit https://opencode.ai/docs for installation instructions") + } else { + printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`) + } + + if (isUpdate) { + const initial = detectedToInitialValues(detected) + printInfo(`Current config: Claude=${initial.claude}, Gemini=${initial.gemini}`) + } + + const config = argsToConfig(args) + + printStep(step++, totalSteps, "Adding oh-my-opencode plugin...") + const pluginResult = await addPluginToOpenCodeConfig(version) + if (!pluginResult.success) { + printError(`Failed: ${pluginResult.error}`) + return 1 + } + printSuccess( + `Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`, + ) + + if (config.hasGemini) { + printStep(step++, totalSteps, "Adding auth plugins...") + const authResult = await addAuthPlugins(config) + if (!authResult.success) { + printError(`Failed: ${authResult.error}`) + return 1 + } + printSuccess(`Auth plugins configured ${SYMBOLS.arrow} ${color.dim(authResult.configPath)}`) + + printStep(step++, totalSteps, "Adding provider configurations...") + const providerResult = addProviderConfig(config) + if (!providerResult.success) { + printError(`Failed: ${providerResult.error}`) + return 1 + } + printSuccess(`Providers configured ${SYMBOLS.arrow} ${color.dim(providerResult.configPath)}`) + } else { + step += 2 + } + + printStep(step++, totalSteps, "Writing oh-my-opencode configuration...") + const omoResult = writeOmoConfig(config) + if (!omoResult.success) { + printError(`Failed: ${omoResult.error}`) + return 1 + } + printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`) + + printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete") + + if (!config.hasClaude) { + console.log() + console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING ")))) + console.log() + console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5."))) + console.log(color.red(" Without Claude, you may experience significantly degraded performance:")) + console.log(color.dim(" • Reduced orchestration quality")) + console.log(color.dim(" • Weaker tool selection and delegation")) + console.log(color.dim(" • Less reliable task completion")) + console.log() + console.log(color.yellow(" Consider subscribing to Claude Pro/Max for the best experience.")) + console.log() + } + + if ( + !config.hasClaude && + !config.hasOpenAI && + !config.hasGemini && + !config.hasCopilot && + !config.hasOpencodeZen + ) { + printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.") + } + + console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`) + console.log(` Run ${color.cyan("opencode")} to start!`) + console.log() + + printBox( + `${color.bold("Pro Tip:")} Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` + + `All features work like magic—parallel agents, background tasks,\n` + + `deep exploration, and relentless execution until completion.`, + "The Magic Word", + ) + + console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`) + console.log( + ` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`, + ) + console.log() + console.log(color.dim("oMoMoMoMo... Enjoy!")) + console.log() + + if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) { + printBox( + `Run ${color.cyan("opencode auth login")} and select your provider:\n` + + (config.hasClaude ? ` ${SYMBOLS.bullet} Anthropic ${color.gray("→ Claude Pro/Max")}\n` : "") + + (config.hasGemini ? ` ${SYMBOLS.bullet} Google ${color.gray("→ OAuth with Antigravity")}\n` : "") + + (config.hasCopilot ? ` ${SYMBOLS.bullet} GitHub ${color.gray("→ Copilot")}` : ""), + "Authenticate Your Providers", + ) + } + + return 0 +} diff --git a/src/cli/fallback-chain-resolution.ts b/src/cli/fallback-chain-resolution.ts new file mode 100644 index 000000000..528aef0e6 --- /dev/null +++ b/src/cli/fallback-chain-resolution.ts @@ -0,0 +1,55 @@ +import { + AGENT_MODEL_REQUIREMENTS, + type FallbackEntry, +} from "../shared/model-requirements" +import type { ProviderAvailability } from "./model-fallback-types" +import { isProviderAvailable } from "./provider-availability" +import { transformModelForProvider } from "./provider-model-id-transform" + +export function resolveModelFromChain( + fallbackChain: FallbackEntry[], + availability: ProviderAvailability +): { model: string; variant?: string } | null { + for (const entry of fallbackChain) { + for (const provider of entry.providers) { + if (isProviderAvailable(provider, availability)) { + const transformedModel = transformModelForProvider(provider, entry.model) + return { + model: `${provider}/${transformedModel}`, + variant: entry.variant, + } + } + } + } + return null +} + +export function getSisyphusFallbackChain(): FallbackEntry[] { + return AGENT_MODEL_REQUIREMENTS.sisyphus.fallbackChain +} + +export function isAnyFallbackEntryAvailable( + fallbackChain: FallbackEntry[], + availability: ProviderAvailability +): boolean { + return fallbackChain.some((entry) => + entry.providers.some((provider) => isProviderAvailable(provider, availability)) + ) +} + +export function isRequiredModelAvailable( + requiresModel: string, + fallbackChain: FallbackEntry[], + availability: ProviderAvailability +): boolean { + const matchingEntry = fallbackChain.find((entry) => entry.model === requiresModel) + if (!matchingEntry) return false + return matchingEntry.providers.some((provider) => isProviderAvailable(provider, availability)) +} + +export function isRequiredProviderAvailable( + requiredProviders: string[], + availability: ProviderAvailability +): boolean { + return requiredProviders.some((provider) => isProviderAvailable(provider, availability)) +} diff --git a/src/cli/install-validators.ts b/src/cli/install-validators.ts new file mode 100644 index 000000000..be601d681 --- /dev/null +++ b/src/cli/install-validators.ts @@ -0,0 +1,189 @@ +import color from "picocolors" +import type { + BooleanArg, + ClaudeSubscription, + DetectedConfig, + InstallArgs, + InstallConfig, +} from "./types" + +export const SYMBOLS = { + check: color.green("[OK]"), + cross: color.red("[X]"), + arrow: color.cyan("->"), + bullet: color.dim("*"), + info: color.blue("[i]"), + warn: color.yellow("[!]"), + star: color.yellow("*"), +} + +function formatProvider(name: string, enabled: boolean, detail?: string): string { + const status = enabled ? SYMBOLS.check : color.dim("○") + const label = enabled ? color.white(name) : color.dim(name) + const suffix = detail ? color.dim(` (${detail})`) : "" + return ` ${status} ${label}${suffix}` +} + +export function formatConfigSummary(config: InstallConfig): string { + const lines: string[] = [] + + lines.push(color.bold(color.white("Configuration Summary"))) + lines.push("") + + const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined + lines.push(formatProvider("Claude", config.hasClaude, claudeDetail)) + lines.push(formatProvider("OpenAI/ChatGPT", config.hasOpenAI, "GPT-5.2 for Oracle")) + lines.push(formatProvider("Gemini", config.hasGemini)) + lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback")) + lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models")) + lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal")) + lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback")) + + lines.push("") + lines.push(color.dim("─".repeat(40))) + lines.push("") + + lines.push(color.bold(color.white("Model Assignment"))) + lines.push("") + lines.push(` ${SYMBOLS.info} Models auto-configured based on provider priority`) + lines.push(` ${SYMBOLS.bullet} Priority: Native > Copilot > OpenCode Zen > Z.ai`) + + return lines.join("\n") +} + +export function printHeader(isUpdate: boolean): void { + const mode = isUpdate ? "Update" : "Install" + console.log() + console.log(color.bgMagenta(color.white(` oMoMoMoMo... ${mode} `))) + console.log() +} + +export function printStep(step: number, total: number, message: string): void { + const progress = color.dim(`[${step}/${total}]`) + console.log(`${progress} ${message}`) +} + +export function printSuccess(message: string): void { + console.log(`${SYMBOLS.check} ${message}`) +} + +export function printError(message: string): void { + console.log(`${SYMBOLS.cross} ${color.red(message)}`) +} + +export function printInfo(message: string): void { + console.log(`${SYMBOLS.info} ${message}`) +} + +export function printWarning(message: string): void { + console.log(`${SYMBOLS.warn} ${color.yellow(message)}`) +} + +export function printBox(content: string, title?: string): void { + const lines = content.split("\n") + const maxWidth = + Math.max( + ...lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").length), + title?.length ?? 0, + ) + 4 + const border = color.dim("─".repeat(maxWidth)) + + console.log() + if (title) { + console.log( + color.dim("┌─") + + color.bold(` ${title} `) + + color.dim("─".repeat(maxWidth - title.length - 4)) + + color.dim("┐"), + ) + } else { + console.log(color.dim("┌") + border + color.dim("┐")) + } + + for (const line of lines) { + const stripped = line.replace(/\x1b\[[0-9;]*m/g, "") + const padding = maxWidth - stripped.length + console.log(color.dim("│") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("│")) + } + + console.log(color.dim("└") + border + color.dim("┘")) + console.log() +} + +export function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string[] } { + const errors: string[] = [] + + if (args.claude === undefined) { + errors.push("--claude is required (values: no, yes, max20)") + } else if (!["no", "yes", "max20"].includes(args.claude)) { + errors.push(`Invalid --claude value: ${args.claude} (expected: no, yes, max20)`) + } + + if (args.gemini === undefined) { + errors.push("--gemini is required (values: no, yes)") + } else if (!["no", "yes"].includes(args.gemini)) { + errors.push(`Invalid --gemini value: ${args.gemini} (expected: no, yes)`) + } + + if (args.copilot === undefined) { + errors.push("--copilot is required (values: no, yes)") + } else if (!["no", "yes"].includes(args.copilot)) { + errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`) + } + + if (args.openai !== undefined && !["no", "yes"].includes(args.openai)) { + errors.push(`Invalid --openai value: ${args.openai} (expected: no, yes)`) + } + + if (args.opencodeZen !== undefined && !["no", "yes"].includes(args.opencodeZen)) { + errors.push(`Invalid --opencode-zen value: ${args.opencodeZen} (expected: no, yes)`) + } + + if (args.zaiCodingPlan !== undefined && !["no", "yes"].includes(args.zaiCodingPlan)) { + errors.push(`Invalid --zai-coding-plan value: ${args.zaiCodingPlan} (expected: no, yes)`) + } + + if (args.kimiForCoding !== undefined && !["no", "yes"].includes(args.kimiForCoding)) { + errors.push(`Invalid --kimi-for-coding value: ${args.kimiForCoding} (expected: no, yes)`) + } + + return { valid: errors.length === 0, errors } +} + +export function argsToConfig(args: InstallArgs): InstallConfig { + return { + hasClaude: args.claude !== "no", + isMax20: args.claude === "max20", + hasOpenAI: args.openai === "yes", + hasGemini: args.gemini === "yes", + hasCopilot: args.copilot === "yes", + hasOpencodeZen: args.opencodeZen === "yes", + hasZaiCodingPlan: args.zaiCodingPlan === "yes", + hasKimiForCoding: args.kimiForCoding === "yes", + } +} + +export function detectedToInitialValues(detected: DetectedConfig): { + claude: ClaudeSubscription + openai: BooleanArg + gemini: BooleanArg + copilot: BooleanArg + opencodeZen: BooleanArg + zaiCodingPlan: BooleanArg + kimiForCoding: BooleanArg +} { + let claude: ClaudeSubscription = "no" + if (detected.hasClaude) { + claude = detected.isMax20 ? "max20" : "yes" + } + + return { + claude, + openai: detected.hasOpenAI ? "yes" : "no", + gemini: detected.hasGemini ? "yes" : "no", + copilot: detected.hasCopilot ? "yes" : "no", + opencodeZen: detected.hasOpencodeZen ? "yes" : "no", + zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no", + kimiForCoding: detected.hasKimiForCoding ? "yes" : "no", + } +} diff --git a/src/cli/install.ts b/src/cli/install.ts index a4143cd39..fe8722492 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -1,542 +1,10 @@ -import * as p from "@clack/prompts" -import color from "picocolors" -import type { InstallArgs, InstallConfig, ClaudeSubscription, BooleanArg, DetectedConfig } from "./types" -import { - addPluginToOpenCodeConfig, - writeOmoConfig, - isOpenCodeInstalled, - getOpenCodeVersion, - addAuthPlugins, - addProviderConfig, - detectCurrentConfig, -} from "./config-manager" -import { shouldShowChatGPTOnlyWarning } from "./model-fallback" import packageJson from "../../package.json" with { type: "json" } +import type { InstallArgs } from "./types" +import { runCliInstaller } from "./cli-installer" +import { runTuiInstaller } from "./tui-installer" const VERSION = packageJson.version -const SYMBOLS = { - check: color.green("[OK]"), - cross: color.red("[X]"), - arrow: color.cyan("->"), - bullet: color.dim("*"), - info: color.blue("[i]"), - warn: color.yellow("[!]"), - star: color.yellow("*"), -} - -function formatProvider(name: string, enabled: boolean, detail?: string): string { - const status = enabled ? SYMBOLS.check : color.dim("○") - const label = enabled ? color.white(name) : color.dim(name) - const suffix = detail ? color.dim(` (${detail})`) : "" - return ` ${status} ${label}${suffix}` -} - -function formatConfigSummary(config: InstallConfig): string { - const lines: string[] = [] - - lines.push(color.bold(color.white("Configuration Summary"))) - lines.push("") - - const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined - lines.push(formatProvider("Claude", config.hasClaude, claudeDetail)) - lines.push(formatProvider("OpenAI/ChatGPT", config.hasOpenAI, "GPT-5.2 for Oracle")) - lines.push(formatProvider("Gemini", config.hasGemini)) - lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback")) - lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models")) - lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal")) - lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback")) - - lines.push("") - lines.push(color.dim("─".repeat(40))) - lines.push("") - - lines.push(color.bold(color.white("Model Assignment"))) - lines.push("") - lines.push(` ${SYMBOLS.info} Models auto-configured based on provider priority`) - lines.push(` ${SYMBOLS.bullet} Priority: Native > Copilot > OpenCode Zen > Z.ai`) - - return lines.join("\n") -} - -function printHeader(isUpdate: boolean): void { - const mode = isUpdate ? "Update" : "Install" - console.log() - console.log(color.bgMagenta(color.white(` oMoMoMoMo... ${mode} `))) - console.log() -} - -function printStep(step: number, total: number, message: string): void { - const progress = color.dim(`[${step}/${total}]`) - console.log(`${progress} ${message}`) -} - -function printSuccess(message: string): void { - console.log(`${SYMBOLS.check} ${message}`) -} - -function printError(message: string): void { - console.log(`${SYMBOLS.cross} ${color.red(message)}`) -} - -function printInfo(message: string): void { - console.log(`${SYMBOLS.info} ${message}`) -} - -function printWarning(message: string): void { - console.log(`${SYMBOLS.warn} ${color.yellow(message)}`) -} - -function printBox(content: string, title?: string): void { - const lines = content.split("\n") - const maxWidth = Math.max(...lines.map(l => l.replace(/\x1b\[[0-9;]*m/g, "").length), title?.length ?? 0) + 4 - const border = color.dim("─".repeat(maxWidth)) - - console.log() - if (title) { - console.log(color.dim("┌─") + color.bold(` ${title} `) + color.dim("─".repeat(maxWidth - title.length - 4)) + color.dim("┐")) - } else { - console.log(color.dim("┌") + border + color.dim("┐")) - } - - for (const line of lines) { - const stripped = line.replace(/\x1b\[[0-9;]*m/g, "") - const padding = maxWidth - stripped.length - console.log(color.dim("│") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("│")) - } - - console.log(color.dim("└") + border + color.dim("┘")) - console.log() -} - -function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string[] } { - const errors: string[] = [] - - if (args.claude === undefined) { - errors.push("--claude is required (values: no, yes, max20)") - } else if (!["no", "yes", "max20"].includes(args.claude)) { - errors.push(`Invalid --claude value: ${args.claude} (expected: no, yes, max20)`) - } - - if (args.gemini === undefined) { - errors.push("--gemini is required (values: no, yes)") - } else if (!["no", "yes"].includes(args.gemini)) { - errors.push(`Invalid --gemini value: ${args.gemini} (expected: no, yes)`) - } - - if (args.copilot === undefined) { - errors.push("--copilot is required (values: no, yes)") - } else if (!["no", "yes"].includes(args.copilot)) { - errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`) - } - - if (args.openai !== undefined && !["no", "yes"].includes(args.openai)) { - errors.push(`Invalid --openai value: ${args.openai} (expected: no, yes)`) - } - - if (args.opencodeZen !== undefined && !["no", "yes"].includes(args.opencodeZen)) { - errors.push(`Invalid --opencode-zen value: ${args.opencodeZen} (expected: no, yes)`) - } - - if (args.zaiCodingPlan !== undefined && !["no", "yes"].includes(args.zaiCodingPlan)) { - errors.push(`Invalid --zai-coding-plan value: ${args.zaiCodingPlan} (expected: no, yes)`) - } - - if (args.kimiForCoding !== undefined && !["no", "yes"].includes(args.kimiForCoding)) { - errors.push(`Invalid --kimi-for-coding value: ${args.kimiForCoding} (expected: no, yes)`) - } - - return { valid: errors.length === 0, errors } -} - -function argsToConfig(args: InstallArgs): InstallConfig { - return { - hasClaude: args.claude !== "no", - isMax20: args.claude === "max20", - hasOpenAI: args.openai === "yes", - hasGemini: args.gemini === "yes", - hasCopilot: args.copilot === "yes", - hasOpencodeZen: args.opencodeZen === "yes", - hasZaiCodingPlan: args.zaiCodingPlan === "yes", - hasKimiForCoding: args.kimiForCoding === "yes", - } -} - -function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubscription; openai: BooleanArg; gemini: BooleanArg; copilot: BooleanArg; opencodeZen: BooleanArg; zaiCodingPlan: BooleanArg; kimiForCoding: BooleanArg } { - let claude: ClaudeSubscription = "no" - if (detected.hasClaude) { - claude = detected.isMax20 ? "max20" : "yes" - } - - return { - claude, - openai: detected.hasOpenAI ? "yes" : "no", - gemini: detected.hasGemini ? "yes" : "no", - copilot: detected.hasCopilot ? "yes" : "no", - opencodeZen: detected.hasOpencodeZen ? "yes" : "no", - zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no", - kimiForCoding: detected.hasKimiForCoding ? "yes" : "no", - } -} - -async function runTuiMode(detected: DetectedConfig): Promise { - const initial = detectedToInitialValues(detected) - - const claude = await p.select({ - message: "Do you have a Claude Pro/Max subscription?", - options: [ - { value: "no" as const, label: "No", hint: "Will use opencode/glm-4.7-free as fallback" }, - { value: "yes" as const, label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" }, - { value: "max20" as const, label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" }, - ], - initialValue: initial.claude, - }) - - if (p.isCancel(claude)) { - p.cancel("Installation cancelled.") - return null - } - - const openai = await p.select({ - message: "Do you have an OpenAI/ChatGPT Plus subscription?", - options: [ - { value: "no" as const, label: "No", hint: "Oracle will use fallback models" }, - { value: "yes" as const, label: "Yes", hint: "GPT-5.2 for Oracle (high-IQ debugging)" }, - ], - initialValue: initial.openai, - }) - - if (p.isCancel(openai)) { - p.cancel("Installation cancelled.") - return null - } - - const gemini = await p.select({ - message: "Will you integrate Google Gemini?", - options: [ - { value: "no" as const, label: "No", hint: "Frontend/docs agents will use fallback" }, - { value: "yes" as const, label: "Yes", hint: "Beautiful UI generation with Gemini 3 Pro" }, - ], - initialValue: initial.gemini, - }) - - if (p.isCancel(gemini)) { - p.cancel("Installation cancelled.") - return null - } - - const copilot = await p.select({ - message: "Do you have a GitHub Copilot subscription?", - options: [ - { value: "no" as const, label: "No", hint: "Only native providers will be used" }, - { value: "yes" as const, label: "Yes", hint: "Fallback option when native providers unavailable" }, - ], - initialValue: initial.copilot, - }) - - if (p.isCancel(copilot)) { - p.cancel("Installation cancelled.") - return null - } - - const opencodeZen = await p.select({ - message: "Do you have access to OpenCode Zen (opencode/ models)?", - options: [ - { value: "no" as const, label: "No", hint: "Will use other configured providers" }, - { value: "yes" as const, label: "Yes", hint: "opencode/claude-opus-4-6, opencode/gpt-5.2, etc." }, - ], - initialValue: initial.opencodeZen, - }) - - if (p.isCancel(opencodeZen)) { - p.cancel("Installation cancelled.") - return null - } - - const zaiCodingPlan = await p.select({ - message: "Do you have a Z.ai Coding Plan subscription?", - options: [ - { value: "no" as const, label: "No", hint: "Will use other configured providers" }, - { value: "yes" as const, label: "Yes", hint: "Fallback for Librarian and Multimodal Looker" }, - ], - initialValue: initial.zaiCodingPlan, - }) - - if (p.isCancel(zaiCodingPlan)) { - p.cancel("Installation cancelled.") - return null - } - - const kimiForCoding = await p.select({ - message: "Do you have a Kimi For Coding subscription?", - options: [ - { value: "no" as const, label: "No", hint: "Will use other configured providers" }, - { value: "yes" as const, label: "Yes", hint: "Kimi K2.5 for Sisyphus/Prometheus fallback" }, - ], - initialValue: initial.kimiForCoding, - }) - - if (p.isCancel(kimiForCoding)) { - p.cancel("Installation cancelled.") - return null - } - - return { - hasClaude: claude !== "no", - isMax20: claude === "max20", - hasOpenAI: openai === "yes", - hasGemini: gemini === "yes", - hasCopilot: copilot === "yes", - hasOpencodeZen: opencodeZen === "yes", - hasZaiCodingPlan: zaiCodingPlan === "yes", - hasKimiForCoding: kimiForCoding === "yes", - } -} - -async function runNonTuiInstall(args: InstallArgs): Promise { - const validation = validateNonTuiArgs(args) - if (!validation.valid) { - printHeader(false) - printError("Validation failed:") - for (const err of validation.errors) { - console.log(` ${SYMBOLS.bullet} ${err}`) - } - console.log() - printInfo("Usage: bunx oh-my-opencode install --no-tui --claude= --gemini= --copilot=") - console.log() - return 1 - } - - const detected = detectCurrentConfig() - const isUpdate = detected.isInstalled - - printHeader(isUpdate) - - const totalSteps = 6 - let step = 1 - - printStep(step++, totalSteps, "Checking OpenCode installation...") - const installed = await isOpenCodeInstalled() - const version = await getOpenCodeVersion() - if (!installed) { - printWarning("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.") - printInfo("Visit https://opencode.ai/docs for installation instructions") - } else { - printSuccess(`OpenCode ${version ?? ""} detected`) - } - - if (isUpdate) { - const initial = detectedToInitialValues(detected) - printInfo(`Current config: Claude=${initial.claude}, Gemini=${initial.gemini}`) - } - - const config = argsToConfig(args) - - printStep(step++, totalSteps, "Adding oh-my-opencode plugin...") - const pluginResult = await addPluginToOpenCodeConfig(VERSION) - if (!pluginResult.success) { - printError(`Failed: ${pluginResult.error}`) - return 1 - } - printSuccess(`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`) - - if (config.hasGemini) { - printStep(step++, totalSteps, "Adding auth plugins...") - const authResult = await addAuthPlugins(config) - if (!authResult.success) { - printError(`Failed: ${authResult.error}`) - return 1 - } - printSuccess(`Auth plugins configured ${SYMBOLS.arrow} ${color.dim(authResult.configPath)}`) - - printStep(step++, totalSteps, "Adding provider configurations...") - const providerResult = addProviderConfig(config) - if (!providerResult.success) { - printError(`Failed: ${providerResult.error}`) - return 1 - } - printSuccess(`Providers configured ${SYMBOLS.arrow} ${color.dim(providerResult.configPath)}`) - } else { - step += 2 - } - - printStep(step++, totalSteps, "Writing oh-my-opencode configuration...") - const omoResult = writeOmoConfig(config) - if (!omoResult.success) { - printError(`Failed: ${omoResult.error}`) - return 1 - } - printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`) - - printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete") - - if (!config.hasClaude) { - console.log() - console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING ")))) - console.log() - console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5."))) - console.log(color.red(" Without Claude, you may experience significantly degraded performance:")) - console.log(color.dim(" • Reduced orchestration quality")) - console.log(color.dim(" • Weaker tool selection and delegation")) - console.log(color.dim(" • Less reliable task completion")) - console.log() - console.log(color.yellow(" Consider subscribing to Claude Pro/Max for the best experience.")) - console.log() - } - - if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) { - printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.") - } - - console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`) - console.log(` Run ${color.cyan("opencode")} to start!`) - console.log() - - printBox( - `${color.bold("Pro Tip:")} Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` + - `All features work like magic—parallel agents, background tasks,\n` + - `deep exploration, and relentless execution until completion.`, - "The Magic Word" - ) - - console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`) - console.log(` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`) - console.log() - console.log(color.dim("oMoMoMoMo... Enjoy!")) - console.log() - - if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) { - printBox( - `Run ${color.cyan("opencode auth login")} and select your provider:\n` + - (config.hasClaude ? ` ${SYMBOLS.bullet} Anthropic ${color.gray("→ Claude Pro/Max")}\n` : "") + - (config.hasGemini ? ` ${SYMBOLS.bullet} Google ${color.gray("→ OAuth with Antigravity")}\n` : "") + - (config.hasCopilot ? ` ${SYMBOLS.bullet} GitHub ${color.gray("→ Copilot")}` : ""), - "Authenticate Your Providers" - ) - } - - return 0 -} - export async function install(args: InstallArgs): Promise { - if (!args.tui) { - return runNonTuiInstall(args) - } - - const detected = detectCurrentConfig() - const isUpdate = detected.isInstalled - - p.intro(color.bgMagenta(color.white(isUpdate ? " oMoMoMoMo... Update " : " oMoMoMoMo... "))) - - if (isUpdate) { - const initial = detectedToInitialValues(detected) - p.log.info(`Existing configuration detected: Claude=${initial.claude}, Gemini=${initial.gemini}`) - } - - const s = p.spinner() - s.start("Checking OpenCode installation") - - const installed = await isOpenCodeInstalled() - const version = await getOpenCodeVersion() - if (!installed) { - s.stop(`OpenCode binary not found ${color.yellow("[!]")}`) - p.log.warn("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.") - p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide") - } else { - s.stop(`OpenCode ${version ?? "installed"} ${color.green("[OK]")}`) - } - - const config = await runTuiMode(detected) - if (!config) return 1 - - s.start("Adding oh-my-opencode to OpenCode config") - const pluginResult = await addPluginToOpenCodeConfig(VERSION) - if (!pluginResult.success) { - s.stop(`Failed to add plugin: ${pluginResult.error}`) - p.outro(color.red("Installation failed.")) - return 1 - } - s.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`) - - if (config.hasGemini) { - s.start("Adding auth plugins (fetching latest versions)") - const authResult = await addAuthPlugins(config) - if (!authResult.success) { - s.stop(`Failed to add auth plugins: ${authResult.error}`) - p.outro(color.red("Installation failed.")) - return 1 - } - s.stop(`Auth plugins added to ${color.cyan(authResult.configPath)}`) - - s.start("Adding provider configurations") - const providerResult = addProviderConfig(config) - if (!providerResult.success) { - s.stop(`Failed to add provider config: ${providerResult.error}`) - p.outro(color.red("Installation failed.")) - return 1 - } - s.stop(`Provider config added to ${color.cyan(providerResult.configPath)}`) - } - - s.start("Writing oh-my-opencode configuration") - const omoResult = writeOmoConfig(config) - if (!omoResult.success) { - s.stop(`Failed to write config: ${omoResult.error}`) - p.outro(color.red("Installation failed.")) - return 1 - } - s.stop(`Config written to ${color.cyan(omoResult.configPath)}`) - - if (!config.hasClaude) { - console.log() - console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING ")))) - console.log() - console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5."))) - console.log(color.red(" Without Claude, you may experience significantly degraded performance:")) - console.log(color.dim(" • Reduced orchestration quality")) - console.log(color.dim(" • Weaker tool selection and delegation")) - console.log(color.dim(" • Less reliable task completion")) - console.log() - console.log(color.yellow(" Consider subscribing to Claude Pro/Max for the best experience.")) - console.log() - } - - if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) { - p.log.warn("No model providers configured. Using opencode/glm-4.7-free as fallback.") - } - - p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete") - - p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!")) - p.log.message(`Run ${color.cyan("opencode")} to start!`) - - p.note( - `Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` + - `All features work like magic—parallel agents, background tasks,\n` + - `deep exploration, and relentless execution until completion.`, - "The Magic Word" - ) - - p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`) - p.log.message(` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`) - - p.outro(color.green("oMoMoMoMo... Enjoy!")) - - if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) { - const providers: string[] = [] - if (config.hasClaude) providers.push(`Anthropic ${color.gray("→ Claude Pro/Max")}`) - if (config.hasGemini) providers.push(`Google ${color.gray("→ OAuth with Antigravity")}`) - if (config.hasCopilot) providers.push(`GitHub ${color.gray("→ Copilot")}`) - - console.log() - console.log(color.bold("Authenticate Your Providers")) - console.log() - console.log(` Run ${color.cyan("opencode auth login")} and select:`) - for (const provider of providers) { - console.log(` ${SYMBOLS.bullet} ${provider}`) - } - console.log() - } - - return 0 + return args.tui ? runTuiInstaller(args, VERSION) : runCliInstaller(args, VERSION) } diff --git a/src/cli/model-fallback-types.ts b/src/cli/model-fallback-types.ts new file mode 100644 index 000000000..98dcab86e --- /dev/null +++ b/src/cli/model-fallback-types.ts @@ -0,0 +1,29 @@ +export interface ProviderAvailability { + native: { + claude: boolean + openai: boolean + gemini: boolean + } + opencodeZen: boolean + copilot: boolean + zai: boolean + kimiForCoding: boolean + isMaxPlan: boolean +} + +export interface AgentConfig { + model: string + variant?: string +} + +export interface CategoryConfig { + model: string + variant?: string +} + +export interface GeneratedOmoConfig { + $schema: string + agents?: Record + categories?: Record + [key: string]: unknown +} diff --git a/src/cli/model-fallback.ts b/src/cli/model-fallback.ts index 531b8ee40..bbc8e02c1 100644 --- a/src/cli/model-fallback.ts +++ b/src/cli/model-fallback.ts @@ -1,133 +1,27 @@ import { - AGENT_MODEL_REQUIREMENTS, - CATEGORY_MODEL_REQUIREMENTS, - type FallbackEntry, + AGENT_MODEL_REQUIREMENTS, + CATEGORY_MODEL_REQUIREMENTS, } from "../shared/model-requirements" import type { InstallConfig } from "./types" -interface ProviderAvailability { - native: { - claude: boolean - openai: boolean - gemini: boolean - } - opencodeZen: boolean - copilot: boolean - zai: boolean - kimiForCoding: boolean - isMaxPlan: boolean -} +import type { AgentConfig, CategoryConfig, GeneratedOmoConfig } from "./model-fallback-types" +import { toProviderAvailability } from "./provider-availability" +import { + getSisyphusFallbackChain, + isAnyFallbackEntryAvailable, + isRequiredModelAvailable, + isRequiredProviderAvailable, + resolveModelFromChain, +} from "./fallback-chain-resolution" -interface AgentConfig { - model: string - variant?: string -} - -interface CategoryConfig { - model: string - variant?: string -} - -export interface GeneratedOmoConfig { - $schema: string - agents?: Record - categories?: Record - [key: string]: unknown -} +export type { GeneratedOmoConfig } from "./model-fallback-types" const ZAI_MODEL = "zai-coding-plan/glm-4.7" const ULTIMATE_FALLBACK = "opencode/glm-4.7-free" const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json" -function toProviderAvailability(config: InstallConfig): ProviderAvailability { - return { - native: { - claude: config.hasClaude, - openai: config.hasOpenAI, - gemini: config.hasGemini, - }, - opencodeZen: config.hasOpencodeZen, - copilot: config.hasCopilot, - zai: config.hasZaiCodingPlan, - kimiForCoding: config.hasKimiForCoding, - isMaxPlan: config.isMax20, - } -} -function isProviderAvailable(provider: string, avail: ProviderAvailability): boolean { - const mapping: Record = { - anthropic: avail.native.claude, - openai: avail.native.openai, - google: avail.native.gemini, - "github-copilot": avail.copilot, - opencode: avail.opencodeZen, - "zai-coding-plan": avail.zai, - "kimi-for-coding": avail.kimiForCoding, - } - return mapping[provider] ?? false -} - -function transformModelForProvider(provider: string, model: string): string { - if (provider === "github-copilot") { - return model - .replace("claude-opus-4-6", "claude-opus-4.6") - .replace("claude-sonnet-4-5", "claude-sonnet-4.5") - .replace("claude-haiku-4-5", "claude-haiku-4.5") - .replace("claude-sonnet-4", "claude-sonnet-4") - .replace("gemini-3-pro", "gemini-3-pro-preview") - .replace("gemini-3-flash", "gemini-3-flash-preview") - } - return model -} - -function resolveModelFromChain( - fallbackChain: FallbackEntry[], - avail: ProviderAvailability -): { model: string; variant?: string } | null { - for (const entry of fallbackChain) { - for (const provider of entry.providers) { - if (isProviderAvailable(provider, avail)) { - const transformedModel = transformModelForProvider(provider, entry.model) - return { - model: `${provider}/${transformedModel}`, - variant: entry.variant, - } - } - } - } - return null -} - -function getSisyphusFallbackChain(): FallbackEntry[] { - return AGENT_MODEL_REQUIREMENTS.sisyphus.fallbackChain -} - -function isAnyFallbackEntryAvailable( - fallbackChain: FallbackEntry[], - avail: ProviderAvailability -): boolean { - return fallbackChain.some((entry) => - entry.providers.some((provider) => isProviderAvailable(provider, avail)) - ) -} - -function isRequiredModelAvailable( - requiresModel: string, - fallbackChain: FallbackEntry[], - avail: ProviderAvailability -): boolean { - const matchingEntry = fallbackChain.find((entry) => entry.model === requiresModel) - if (!matchingEntry) return false - return matchingEntry.providers.some((provider) => isProviderAvailable(provider, avail)) -} - -function isRequiredProviderAvailable( - requiredProviders: string[], - avail: ProviderAvailability -): boolean { - return requiredProviders.some((provider) => isProviderAvailable(provider, avail)) -} export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig { const avail = toProviderAvailability(config) diff --git a/src/cli/provider-availability.ts b/src/cli/provider-availability.ts new file mode 100644 index 000000000..d0c76e45d --- /dev/null +++ b/src/cli/provider-availability.ts @@ -0,0 +1,30 @@ +import type { InstallConfig } from "./types" +import type { ProviderAvailability } from "./model-fallback-types" + +export function toProviderAvailability(config: InstallConfig): ProviderAvailability { + return { + native: { + claude: config.hasClaude, + openai: config.hasOpenAI, + gemini: config.hasGemini, + }, + opencodeZen: config.hasOpencodeZen, + copilot: config.hasCopilot, + zai: config.hasZaiCodingPlan, + kimiForCoding: config.hasKimiForCoding, + isMaxPlan: config.isMax20, + } +} + +export function isProviderAvailable(provider: string, availability: ProviderAvailability): boolean { + const mapping: Record = { + anthropic: availability.native.claude, + openai: availability.native.openai, + google: availability.native.gemini, + "github-copilot": availability.copilot, + opencode: availability.opencodeZen, + "zai-coding-plan": availability.zai, + "kimi-for-coding": availability.kimiForCoding, + } + return mapping[provider] ?? false +} diff --git a/src/cli/provider-model-id-transform.ts b/src/cli/provider-model-id-transform.ts new file mode 100644 index 000000000..5834247ee --- /dev/null +++ b/src/cli/provider-model-id-transform.ts @@ -0,0 +1,12 @@ +export function transformModelForProvider(provider: string, model: string): string { + if (provider === "github-copilot") { + return model + .replace("claude-opus-4-6", "claude-opus-4.6") + .replace("claude-sonnet-4-5", "claude-sonnet-4.5") + .replace("claude-haiku-4-5", "claude-haiku-4.5") + .replace("claude-sonnet-4", "claude-sonnet-4") + .replace("gemini-3-pro", "gemini-3-pro-preview") + .replace("gemini-3-flash", "gemini-3-flash-preview") + } + return model +} diff --git a/src/cli/tui-install-prompts.ts b/src/cli/tui-install-prompts.ts new file mode 100644 index 000000000..e817427cd --- /dev/null +++ b/src/cli/tui-install-prompts.ts @@ -0,0 +1,111 @@ +import * as p from "@clack/prompts" +import type { Option } from "@clack/prompts" +import type { + ClaudeSubscription, + DetectedConfig, + InstallConfig, +} from "./types" +import { detectedToInitialValues } from "./install-validators" + +async function selectOrCancel>(params: { + message: string + options: Option[] + initialValue: TValue +}): Promise { + const value = await p.select({ + message: params.message, + options: params.options, + initialValue: params.initialValue, + }) + if (p.isCancel(value)) { + p.cancel("Installation cancelled.") + return null + } + return value as TValue +} + +export async function promptInstallConfig(detected: DetectedConfig): Promise { + const initial = detectedToInitialValues(detected) + + const claude = await selectOrCancel({ + message: "Do you have a Claude Pro/Max subscription?", + options: [ + { value: "no", label: "No", hint: "Will use opencode/glm-4.7-free as fallback" }, + { value: "yes", label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" }, + { value: "max20", label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" }, + ], + initialValue: initial.claude, + }) + if (!claude) return null + + const openai = await selectOrCancel({ + message: "Do you have an OpenAI/ChatGPT Plus subscription?", + options: [ + { value: "no", label: "No", hint: "Oracle will use fallback models" }, + { value: "yes", label: "Yes", hint: "GPT-5.2 for Oracle (high-IQ debugging)" }, + ], + initialValue: initial.openai, + }) + if (!openai) return null + + const gemini = await selectOrCancel({ + message: "Will you integrate Google Gemini?", + options: [ + { value: "no", label: "No", hint: "Frontend/docs agents will use fallback" }, + { value: "yes", label: "Yes", hint: "Beautiful UI generation with Gemini 3 Pro" }, + ], + initialValue: initial.gemini, + }) + if (!gemini) return null + + const copilot = await selectOrCancel({ + message: "Do you have a GitHub Copilot subscription?", + options: [ + { value: "no", label: "No", hint: "Only native providers will be used" }, + { value: "yes", label: "Yes", hint: "Fallback option when native providers unavailable" }, + ], + initialValue: initial.copilot, + }) + if (!copilot) return null + + const opencodeZen = await selectOrCancel({ + message: "Do you have access to OpenCode Zen (opencode/ models)?", + options: [ + { value: "no", label: "No", hint: "Will use other configured providers" }, + { value: "yes", label: "Yes", hint: "opencode/claude-opus-4-6, opencode/gpt-5.2, etc." }, + ], + initialValue: initial.opencodeZen, + }) + if (!opencodeZen) return null + + const zaiCodingPlan = await selectOrCancel({ + message: "Do you have a Z.ai Coding Plan subscription?", + options: [ + { value: "no", label: "No", hint: "Will use other configured providers" }, + { value: "yes", label: "Yes", hint: "Fallback for Librarian and Multimodal Looker" }, + ], + initialValue: initial.zaiCodingPlan, + }) + if (!zaiCodingPlan) return null + + const kimiForCoding = await selectOrCancel({ + message: "Do you have a Kimi For Coding subscription?", + options: [ + { value: "no", label: "No", hint: "Will use other configured providers" }, + { value: "yes", label: "Yes", hint: "Kimi K2.5 for Sisyphus/Prometheus fallback" }, + ], + initialValue: initial.kimiForCoding, + }) + if (!kimiForCoding) return null + + return { + hasClaude: claude !== "no", + isMax20: claude === "max20", + hasOpenAI: openai === "yes", + hasGemini: gemini === "yes", + hasCopilot: copilot === "yes", + hasOpencodeZen: opencodeZen === "yes", + hasZaiCodingPlan: zaiCodingPlan === "yes", + hasKimiForCoding: kimiForCoding === "yes", + } +} diff --git a/src/cli/tui-installer.ts b/src/cli/tui-installer.ts new file mode 100644 index 000000000..d960769c2 --- /dev/null +++ b/src/cli/tui-installer.ts @@ -0,0 +1,135 @@ +import * as p from "@clack/prompts" +import color from "picocolors" +import type { InstallArgs } from "./types" +import { + addAuthPlugins, + addPluginToOpenCodeConfig, + addProviderConfig, + detectCurrentConfig, + getOpenCodeVersion, + isOpenCodeInstalled, + writeOmoConfig, +} from "./config-manager" +import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators" +import { promptInstallConfig } from "./tui-install-prompts" + +export async function runTuiInstaller(args: InstallArgs, version: string): Promise { + const detected = detectCurrentConfig() + const isUpdate = detected.isInstalled + + p.intro(color.bgMagenta(color.white(isUpdate ? " oMoMoMoMo... Update " : " oMoMoMoMo... "))) + + if (isUpdate) { + const initial = detectedToInitialValues(detected) + p.log.info(`Existing configuration detected: Claude=${initial.claude}, Gemini=${initial.gemini}`) + } + + const spinner = p.spinner() + spinner.start("Checking OpenCode installation") + + const installed = await isOpenCodeInstalled() + const openCodeVersion = await getOpenCodeVersion() + if (!installed) { + spinner.stop(`OpenCode binary not found ${color.yellow("[!]")}`) + p.log.warn("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.") + p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide") + } else { + spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`) + } + + const config = await promptInstallConfig(detected) + if (!config) return 1 + + spinner.start("Adding oh-my-opencode to OpenCode config") + const pluginResult = await addPluginToOpenCodeConfig(version) + if (!pluginResult.success) { + spinner.stop(`Failed to add plugin: ${pluginResult.error}`) + p.outro(color.red("Installation failed.")) + return 1 + } + spinner.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`) + + if (config.hasGemini) { + spinner.start("Adding auth plugins (fetching latest versions)") + const authResult = await addAuthPlugins(config) + if (!authResult.success) { + spinner.stop(`Failed to add auth plugins: ${authResult.error}`) + p.outro(color.red("Installation failed.")) + return 1 + } + spinner.stop(`Auth plugins added to ${color.cyan(authResult.configPath)}`) + + spinner.start("Adding provider configurations") + const providerResult = addProviderConfig(config) + if (!providerResult.success) { + spinner.stop(`Failed to add provider config: ${providerResult.error}`) + p.outro(color.red("Installation failed.")) + return 1 + } + spinner.stop(`Provider config added to ${color.cyan(providerResult.configPath)}`) + } + + spinner.start("Writing oh-my-opencode configuration") + const omoResult = writeOmoConfig(config) + if (!omoResult.success) { + spinner.stop(`Failed to write config: ${omoResult.error}`) + p.outro(color.red("Installation failed.")) + return 1 + } + spinner.stop(`Config written to ${color.cyan(omoResult.configPath)}`) + + if (!config.hasClaude) { + console.log() + console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING ")))) + console.log() + console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5."))) + console.log(color.red(" Without Claude, you may experience significantly degraded performance:")) + console.log(color.dim(" • Reduced orchestration quality")) + console.log(color.dim(" • Weaker tool selection and delegation")) + console.log(color.dim(" • Less reliable task completion")) + console.log() + console.log(color.yellow(" Consider subscribing to Claude Pro/Max for the best experience.")) + console.log() + } + + if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) { + p.log.warn("No model providers configured. Using opencode/glm-4.7-free as fallback.") + } + + p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete") + + p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!")) + p.log.message(`Run ${color.cyan("opencode")} to start!`) + + p.note( + `Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` + + `All features work like magic—parallel agents, background tasks,\n` + + `deep exploration, and relentless execution until completion.`, + "The Magic Word", + ) + + p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`) + p.log.message( + ` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`, + ) + + p.outro(color.green("oMoMoMoMo... Enjoy!")) + + if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) { + const providers: string[] = [] + if (config.hasClaude) providers.push(`Anthropic ${color.gray("→ Claude Pro/Max")}`) + if (config.hasGemini) providers.push(`Google ${color.gray("→ OAuth with Antigravity")}`) + if (config.hasCopilot) providers.push(`GitHub ${color.gray("→ Copilot")}`) + + console.log() + console.log(color.bold("Authenticate Your Providers")) + console.log() + console.log(` Run ${color.cyan("opencode auth login")} and select:`) + for (const provider of providers) { + console.log(` ${SYMBOLS.bullet} ${provider}`) + } + console.log() + } + + return 0 +} From 598a4389d14ba4cdd1eee516662c1e60d4cc8479 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:25:25 +0900 Subject: [PATCH 25/51] refactor(core): split index.ts and config-handler.ts into focused modules Main entry point: - create-hooks.ts, create-tools.ts, create-managers.ts - plugin-interface.ts: plugin interface types - plugin/ directory: plugin lifecycle modules Config handler: - agent-config-handler.ts, command-config-handler.ts - tool-config-handler.ts, mcp-config-handler.ts - provider-config-handler.ts, category-config-resolver.ts - agent-priority-order.ts, prometheus-agent-config-builder.ts - plugin-components-loader.ts --- src/create-hooks.ts | 61 ++ src/create-managers.ts | 79 ++ src/create-tools.ts | 53 + src/index.ts | 996 ++---------------- src/plugin-handlers/agent-config-handler.ts | 188 ++++ src/plugin-handlers/agent-priority-order.ts | 23 + .../category-config-resolver.ts | 9 + src/plugin-handlers/command-config-handler.ts | 62 ++ src/plugin-handlers/config-handler.ts | 532 +--------- src/plugin-handlers/index.ts | 9 + src/plugin-handlers/mcp-config-handler.ts | 21 + .../plugin-components-loader.ts | 70 ++ .../prometheus-agent-config-builder.ts | 98 ++ .../provider-config-handler.ts | 36 + src/plugin-handlers/tool-config-handler.ts | 91 ++ src/plugin-interface.ts | 65 ++ src/plugin/available-categories.ts | 29 + src/plugin/chat-message.ts | 139 +++ src/plugin/chat-params.ts | 71 ++ src/plugin/event.ts | 133 +++ src/plugin/hooks/create-continuation-hooks.ts | 104 ++ src/plugin/hooks/create-core-hooks.ts | 42 + src/plugin/hooks/create-session-hooks.ts | 181 ++++ src/plugin/hooks/create-skill-hooks.ts | 37 + src/plugin/hooks/create-tool-guard-hooks.ts | 98 ++ src/plugin/hooks/create-transform-hooks.ts | 65 ++ src/plugin/messages-transform.ts | 24 + src/plugin/skill-context.ts | 87 ++ src/plugin/tool-execute-after.ts | 47 + src/plugin/tool-execute-before.ts | 99 ++ src/plugin/tool-registry.ts | 143 +++ src/plugin/types.ts | 15 + src/plugin/unstable-agent-babysitter.ts | 41 + 33 files changed, 2305 insertions(+), 1443 deletions(-) create mode 100644 src/create-hooks.ts create mode 100644 src/create-managers.ts create mode 100644 src/create-tools.ts create mode 100644 src/plugin-handlers/agent-config-handler.ts create mode 100644 src/plugin-handlers/agent-priority-order.ts create mode 100644 src/plugin-handlers/category-config-resolver.ts create mode 100644 src/plugin-handlers/command-config-handler.ts create mode 100644 src/plugin-handlers/mcp-config-handler.ts create mode 100644 src/plugin-handlers/plugin-components-loader.ts create mode 100644 src/plugin-handlers/prometheus-agent-config-builder.ts create mode 100644 src/plugin-handlers/provider-config-handler.ts create mode 100644 src/plugin-handlers/tool-config-handler.ts create mode 100644 src/plugin-interface.ts create mode 100644 src/plugin/available-categories.ts create mode 100644 src/plugin/chat-message.ts create mode 100644 src/plugin/chat-params.ts create mode 100644 src/plugin/event.ts create mode 100644 src/plugin/hooks/create-continuation-hooks.ts create mode 100644 src/plugin/hooks/create-core-hooks.ts create mode 100644 src/plugin/hooks/create-session-hooks.ts create mode 100644 src/plugin/hooks/create-skill-hooks.ts create mode 100644 src/plugin/hooks/create-tool-guard-hooks.ts create mode 100644 src/plugin/hooks/create-transform-hooks.ts create mode 100644 src/plugin/messages-transform.ts create mode 100644 src/plugin/skill-context.ts create mode 100644 src/plugin/tool-execute-after.ts create mode 100644 src/plugin/tool-execute-before.ts create mode 100644 src/plugin/tool-registry.ts create mode 100644 src/plugin/types.ts create mode 100644 src/plugin/unstable-agent-babysitter.ts diff --git a/src/create-hooks.ts b/src/create-hooks.ts new file mode 100644 index 000000000..efa81fab8 --- /dev/null +++ b/src/create-hooks.ts @@ -0,0 +1,61 @@ +import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder" +import type { HookName, OhMyOpenCodeConfig } from "./config" +import type { LoadedSkill } from "./features/opencode-skill-loader/types" +import type { BackgroundManager } from "./features/background-agent" +import type { PluginContext } from "./plugin/types" + +import { createCoreHooks } from "./plugin/hooks/create-core-hooks" +import { createContinuationHooks } from "./plugin/hooks/create-continuation-hooks" +import { createSkillHooks } from "./plugin/hooks/create-skill-hooks" + +export type CreatedHooks = ReturnType + +export function createHooks(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + backgroundManager: BackgroundManager + isHookEnabled: (hookName: HookName) => boolean + safeHookEnabled: boolean + mergedSkills: LoadedSkill[] + availableSkills: AvailableSkill[] +}) { + const { + ctx, + pluginConfig, + backgroundManager, + isHookEnabled, + safeHookEnabled, + mergedSkills, + availableSkills, + } = args + + const core = createCoreHooks({ + ctx, + pluginConfig, + isHookEnabled, + safeHookEnabled, + }) + + const continuation = createContinuationHooks({ + ctx, + pluginConfig, + isHookEnabled, + safeHookEnabled, + backgroundManager, + sessionRecovery: core.sessionRecovery, + }) + + const skill = createSkillHooks({ + ctx, + isHookEnabled, + safeHookEnabled, + mergedSkills, + availableSkills, + }) + + return { + ...core, + ...continuation, + ...skill, + } +} diff --git a/src/create-managers.ts b/src/create-managers.ts new file mode 100644 index 000000000..fb8891d21 --- /dev/null +++ b/src/create-managers.ts @@ -0,0 +1,79 @@ +import type { OhMyOpenCodeConfig } from "./config" +import type { ModelCacheState } from "./plugin-state" +import type { PluginContext, TmuxConfig } from "./plugin/types" + +import type { SubagentSessionCreatedEvent } from "./features/background-agent" +import { BackgroundManager } from "./features/background-agent" +import { SkillMcpManager } from "./features/skill-mcp-manager" +import { initTaskToastManager } from "./features/task-toast-manager" +import { TmuxSessionManager } from "./features/tmux-subagent" +import { createConfigHandler } from "./plugin-handlers" +import { log } from "./shared" + +export type Managers = { + tmuxSessionManager: TmuxSessionManager + backgroundManager: BackgroundManager + skillMcpManager: SkillMcpManager + configHandler: ReturnType +} + +export function createManagers(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + tmuxConfig: TmuxConfig + modelCacheState: ModelCacheState +}): Managers { + const { ctx, pluginConfig, tmuxConfig, modelCacheState } = args + + const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig) + + const backgroundManager = new BackgroundManager( + ctx, + pluginConfig.background_task, + { + tmuxConfig, + onSubagentSessionCreated: async (event: SubagentSessionCreatedEvent) => { + log("[index] onSubagentSessionCreated callback received", { + sessionID: event.sessionID, + parentID: event.parentID, + title: event.title, + }) + + await tmuxSessionManager.onSessionCreated({ + type: "session.created", + properties: { + info: { + id: event.sessionID, + parentID: event.parentID, + title: event.title, + }, + }, + }) + + log("[index] onSubagentSessionCreated callback completed") + }, + onShutdown: () => { + tmuxSessionManager.cleanup().catch((error) => { + log("[index] tmux cleanup error during shutdown:", error) + }) + }, + }, + ) + + initTaskToastManager(ctx.client) + + const skillMcpManager = new SkillMcpManager() + + const configHandler = createConfigHandler({ + ctx: { directory: ctx.directory, client: ctx.client }, + pluginConfig, + modelCacheState, + }) + + return { + tmuxSessionManager, + backgroundManager, + skillMcpManager, + configHandler, + } +} diff --git a/src/create-tools.ts b/src/create-tools.ts new file mode 100644 index 000000000..880e0a427 --- /dev/null +++ b/src/create-tools.ts @@ -0,0 +1,53 @@ +import type { AvailableCategory, AvailableSkill } from "./agents/dynamic-agent-prompt-builder" +import type { OhMyOpenCodeConfig } from "./config" +import type { BrowserAutomationProvider } from "./config/schema/browser-automation" +import type { LoadedSkill } from "./features/opencode-skill-loader/types" +import type { PluginContext, ToolsRecord } from "./plugin/types" +import type { Managers } from "./create-managers" + +import { createAvailableCategories } from "./plugin/available-categories" +import { createSkillContext } from "./plugin/skill-context" +import { createToolRegistry } from "./plugin/tool-registry" + +export type CreateToolsResult = { + filteredTools: ToolsRecord + mergedSkills: LoadedSkill[] + availableSkills: AvailableSkill[] + availableCategories: AvailableCategory[] + browserProvider: BrowserAutomationProvider + disabledSkills: Set + taskSystemEnabled: boolean +} + +export async function createTools(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + managers: Pick +}): Promise { + const { ctx, pluginConfig, managers } = args + + const skillContext = await createSkillContext({ + directory: ctx.directory, + pluginConfig, + }) + + const availableCategories = createAvailableCategories(pluginConfig) + + const { filteredTools, taskSystemEnabled } = createToolRegistry({ + ctx, + pluginConfig, + managers, + skillContext, + availableCategories, + }) + + return { + filteredTools, + mergedSkills: skillContext.mergedSkills, + availableSkills: skillContext.availableSkills, + availableCategories, + browserProvider: skillContext.browserProvider, + disabledSkills: skillContext.disabledSkills, + taskSystemEnabled, + } +} diff --git a/src/index.ts b/src/index.ts index db49858c3..69cae8cd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,137 +1,33 @@ -import type { Plugin, ToolDefinition } from "@opencode-ai/plugin"; -import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder"; -import { - createTodoContinuationEnforcer, - createContextWindowMonitorHook, - createSessionRecoveryHook, - createSessionNotification, - createCommentCheckerHooks, - createToolOutputTruncatorHook, - createDirectoryAgentsInjectorHook, - createDirectoryReadmeInjectorHook, - createEmptyTaskResponseDetectorHook, - createThinkModeHook, - createClaudeCodeHooksHook, - createAnthropicContextWindowLimitRecoveryHook, - createRulesInjectorHook, - createBackgroundNotificationHook, - createAutoUpdateCheckerHook, - createKeywordDetectorHook, - createAgentUsageReminderHook, - createNonInteractiveEnvHook, - createInteractiveBashSessionHook, - createThinkingBlockValidatorHook, - createCategorySkillReminderHook, - createRalphLoopHook, - createAutoSlashCommandHook, - createEditErrorRecoveryHook, - createDelegateTaskRetryHook, - createTaskResumeInfoHook, - createStartWorkHook, - createAtlasHook, - createPrometheusMdOnlyHook, - createSisyphusJuniorNotepadHook, - createQuestionLabelTruncatorHook, - createSubagentQuestionBlockerHook, - createStopContinuationGuardHook, - createCompactionContextInjector, - createCompactionTodoPreserverHook, - createUnstableAgentBabysitterHook, - createPreemptiveCompactionHook, - createTasksTodowriteDisablerHook, - createWriteExistingFileGuardHook, -} from "./hooks"; -import { createAnthropicEffortHook } from "./hooks/anthropic-effort"; -import { - contextCollector, - createContextInjectorMessagesTransformHook, -} from "./features/context-injector"; -import { - applyAgentVariant, - resolveAgentVariant, - resolveVariantForModel, -} from "./shared/agent-variant"; -import { createFirstMessageVariantGate } from "./shared/first-message-variant"; -import { - discoverUserClaudeSkills, - discoverProjectClaudeSkills, - discoverOpencodeGlobalSkills, - discoverOpencodeProjectSkills, - mergeSkills, -} from "./features/opencode-skill-loader"; -import type { SkillScope } from "./features/opencode-skill-loader/types"; -import { createBuiltinSkills } from "./features/builtin-skills"; -import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader"; -import { - setMainSession, - getMainSessionID, - setSessionAgent, - updateSessionAgent, - clearSessionAgent, -} from "./features/claude-code-session-state"; -import { - builtinTools, - createCallOmoAgent, - createBackgroundTools, - createLookAt, - createSkillTool, - createSkillMcpTool, - createSlashcommandTool, - discoverCommandsSync, - sessionExists, - createDelegateTask, - interactive_bash, - startTmuxCheck, - lspManager, - createTaskCreateTool, - createTaskGetTool, - createTaskList, - createTaskUpdateTool, - createGrepTools, - createGlobTools, - createAstGrepTools, - createSessionManagerTools, -} from "./tools"; -import { - CATEGORY_DESCRIPTIONS, - DEFAULT_CATEGORIES, -} from "./tools/delegate-task/constants"; -import { BackgroundManager } from "./features/background-agent"; -import { SkillMcpManager } from "./features/skill-mcp-manager"; -import { initTaskToastManager } from "./features/task-toast-manager"; -import { TmuxSessionManager } from "./features/tmux-subagent"; -import { clearBoulderState } from "./features/boulder-state"; -import { type HookName } from "./config"; -import { - log, - detectExternalNotificationPlugin, - getNotificationConflictWarning, - resetMessageCursor, - hasConnectedProvidersCache, - getOpenCodeVersion, - isOpenCodeVersionAtLeast, - OPENCODE_NATIVE_AGENTS_INJECTION_VERSION, - injectServerAuthIntoClient, -} from "./shared"; -import { filterDisabledTools } from "./shared/disabled-tools"; -import { safeCreateHook } from "./shared/safe-create-hook"; -import { loadPluginConfig } from "./plugin-config"; -import { createModelCacheState } from "./plugin-state"; -import { createConfigHandler } from "./plugin-handlers"; -import { consumeToolMetadata } from "./features/tool-metadata-store"; +import type { Plugin } from "@opencode-ai/plugin" + +import type { HookName } from "./config" + +import { createHooks } from "./create-hooks" +import { createManagers } from "./create-managers" +import { createTools } from "./create-tools" +import { createPluginInterface } from "./plugin-interface" + +import { loadPluginConfig } from "./plugin-config" +import { createModelCacheState } from "./plugin-state" +import { createFirstMessageVariantGate } from "./shared/first-message-variant" +import { injectServerAuthIntoClient, log } from "./shared" +import { startTmuxCheck } from "./tools" const OhMyOpenCodePlugin: Plugin = async (ctx) => { log("[OhMyOpenCodePlugin] ENTRY - plugin loading", { directory: ctx.directory, - }); - injectServerAuthIntoClient(ctx.client); - // Start background tmux check immediately - startTmuxCheck(); + }) - const pluginConfig = loadPluginConfig(ctx.directory, ctx); - const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []); + injectServerAuthIntoClient(ctx.client) + startTmuxCheck() - const firstMessageVariantGate = createFirstMessageVariantGate(); + const pluginConfig = loadPluginConfig(ctx.directory, ctx) + const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []) + + const isHookEnabled = (hookName: HookName): boolean => !disabledHooks.has(hookName) + const safeHookEnabled = pluginConfig.experimental?.safe_hook_creation ?? true + + const firstMessageVariantGate = createFirstMessageVariantGate() const tmuxConfig = { enabled: pluginConfig.tmux?.enabled ?? false, @@ -139,831 +35,59 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { main_pane_size: pluginConfig.tmux?.main_pane_size ?? 60, main_pane_min_width: pluginConfig.tmux?.main_pane_min_width ?? 120, agent_pane_min_width: pluginConfig.tmux?.agent_pane_min_width ?? 40, - } as const; - const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName); - const safeHookEnabled = pluginConfig.experimental?.safe_hook_creation ?? true; - - const modelCacheState = createModelCacheState(); - - const contextWindowMonitor = isHookEnabled("context-window-monitor") - ? safeCreateHook("context-window-monitor", () => createContextWindowMonitorHook(ctx), { enabled: safeHookEnabled }) - : null; - const preemptiveCompaction = - isHookEnabled("preemptive-compaction") && - pluginConfig.experimental?.preemptive_compaction - ? safeCreateHook("preemptive-compaction", () => createPreemptiveCompactionHook(ctx), { enabled: safeHookEnabled }) - : null; - const sessionRecovery = isHookEnabled("session-recovery") - ? safeCreateHook("session-recovery", () => createSessionRecoveryHook(ctx, { - experimental: pluginConfig.experimental, - }), { enabled: safeHookEnabled }) - : null; - - // Check for conflicting notification plugins before creating session-notification - let sessionNotification = null; - if (isHookEnabled("session-notification")) { - const forceEnable = pluginConfig.notification?.force_enable ?? false; - const externalNotifier = detectExternalNotificationPlugin(ctx.directory); - - if (externalNotifier.detected && !forceEnable) { - // External notification plugin detected - skip our notification to avoid conflicts - log(getNotificationConflictWarning(externalNotifier.pluginName!)); - log("session-notification disabled due to external notifier conflict", { - detected: externalNotifier.pluginName, - allPlugins: externalNotifier.allPlugins, - }); - } else { - sessionNotification = safeCreateHook("session-notification", () => createSessionNotification(ctx), { enabled: safeHookEnabled }); - } } - const commentChecker = isHookEnabled("comment-checker") - ? safeCreateHook("comment-checker", () => createCommentCheckerHooks(pluginConfig.comment_checker), { enabled: safeHookEnabled }) - : null; - const toolOutputTruncator = isHookEnabled("tool-output-truncator") - ? safeCreateHook("tool-output-truncator", () => createToolOutputTruncatorHook(ctx, { - experimental: pluginConfig.experimental, - }), { enabled: safeHookEnabled }) - : null; - // Check for native OpenCode AGENTS.md injection support before creating hook - let directoryAgentsInjector = null; - if (isHookEnabled("directory-agents-injector")) { - const currentVersion = getOpenCodeVersion(); - const hasNativeSupport = - currentVersion !== null && - isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION); + const modelCacheState = createModelCacheState() - if (hasNativeSupport) { - log( - "directory-agents-injector auto-disabled due to native OpenCode support", - { - currentVersion, - nativeVersion: OPENCODE_NATIVE_AGENTS_INJECTION_VERSION, - }, - ); - } else { - directoryAgentsInjector = safeCreateHook("directory-agents-injector", () => createDirectoryAgentsInjectorHook(ctx), { enabled: safeHookEnabled }); - } - } - const directoryReadmeInjector = isHookEnabled("directory-readme-injector") - ? safeCreateHook("directory-readme-injector", () => createDirectoryReadmeInjectorHook(ctx), { enabled: safeHookEnabled }) - : null; - const emptyTaskResponseDetector = isHookEnabled( - "empty-task-response-detector", - ) - ? safeCreateHook("empty-task-response-detector", () => createEmptyTaskResponseDetectorHook(ctx), { enabled: safeHookEnabled }) - : null; - const thinkMode = isHookEnabled("think-mode") ? safeCreateHook("think-mode", () => createThinkModeHook(), { enabled: safeHookEnabled }) : null; - const claudeCodeHooks = createClaudeCodeHooksHook( + const managers = createManagers({ ctx, - { - disabledHooks: - (pluginConfig.claude_code?.hooks ?? true) ? undefined : true, - keywordDetectorDisabled: !isHookEnabled("keyword-detector"), - }, - contextCollector, - ); - const anthropicContextWindowLimitRecovery = isHookEnabled( - "anthropic-context-window-limit-recovery", - ) - ? safeCreateHook("anthropic-context-window-limit-recovery", () => createAnthropicContextWindowLimitRecoveryHook(ctx, { - experimental: pluginConfig.experimental, - }), { enabled: safeHookEnabled }) - : null; - const rulesInjector = isHookEnabled("rules-injector") - ? safeCreateHook("rules-injector", () => createRulesInjectorHook(ctx), { enabled: safeHookEnabled }) - : null; - const autoUpdateChecker = isHookEnabled("auto-update-checker") - ? safeCreateHook("auto-update-checker", () => createAutoUpdateCheckerHook(ctx, { - showStartupToast: isHookEnabled("startup-toast"), - isSisyphusEnabled: pluginConfig.sisyphus_agent?.disabled !== true, - autoUpdate: pluginConfig.auto_update ?? true, - }), { enabled: safeHookEnabled }) - : null; - const keywordDetector = isHookEnabled("keyword-detector") - ? safeCreateHook("keyword-detector", () => createKeywordDetectorHook(ctx, contextCollector), { enabled: safeHookEnabled }) - : null; - const contextInjectorMessagesTransform = - createContextInjectorMessagesTransformHook(contextCollector); - const agentUsageReminder = isHookEnabled("agent-usage-reminder") - ? safeCreateHook("agent-usage-reminder", () => createAgentUsageReminderHook(ctx), { enabled: safeHookEnabled }) - : null; - const nonInteractiveEnv = isHookEnabled("non-interactive-env") - ? safeCreateHook("non-interactive-env", () => createNonInteractiveEnvHook(ctx), { enabled: safeHookEnabled }) - : null; - const interactiveBashSession = isHookEnabled("interactive-bash-session") - ? safeCreateHook("interactive-bash-session", () => createInteractiveBashSessionHook(ctx), { enabled: safeHookEnabled }) - : null; - - const thinkingBlockValidator = isHookEnabled("thinking-block-validator") - ? safeCreateHook("thinking-block-validator", () => createThinkingBlockValidatorHook(), { enabled: safeHookEnabled }) - : null; - - let categorySkillReminder: ReturnType | null = null; - - const ralphLoop = isHookEnabled("ralph-loop") - ? safeCreateHook("ralph-loop", () => createRalphLoopHook(ctx, { - config: pluginConfig.ralph_loop, - checkSessionExists: async (sessionId) => sessionExists(sessionId), - }), { enabled: safeHookEnabled }) - : null; - - const editErrorRecovery = isHookEnabled("edit-error-recovery") - ? safeCreateHook("edit-error-recovery", () => createEditErrorRecoveryHook(ctx), { enabled: safeHookEnabled }) - : null; - - const delegateTaskRetry = isHookEnabled("delegate-task-retry") - ? safeCreateHook("delegate-task-retry", () => createDelegateTaskRetryHook(ctx), { enabled: safeHookEnabled }) - : null; - - const startWork = isHookEnabled("start-work") - ? safeCreateHook("start-work", () => createStartWorkHook(ctx), { enabled: safeHookEnabled }) - : null; - - const prometheusMdOnly = isHookEnabled("prometheus-md-only") - ? safeCreateHook("prometheus-md-only", () => createPrometheusMdOnlyHook(ctx), { enabled: safeHookEnabled }) - : null; - - const sisyphusJuniorNotepad = isHookEnabled("sisyphus-junior-notepad") - ? safeCreateHook("sisyphus-junior-notepad", () => createSisyphusJuniorNotepadHook(ctx), { enabled: safeHookEnabled }) - : null; - - const tasksTodowriteDisabler = isHookEnabled("tasks-todowrite-disabler") - ? safeCreateHook("tasks-todowrite-disabler", () => createTasksTodowriteDisablerHook({ - experimental: pluginConfig.experimental, - }), { enabled: safeHookEnabled }) - : null; - - const questionLabelTruncator = createQuestionLabelTruncatorHook(); - const subagentQuestionBlocker = createSubagentQuestionBlockerHook(); - const writeExistingFileGuard = isHookEnabled("write-existing-file-guard") - ? safeCreateHook("write-existing-file-guard", () => createWriteExistingFileGuardHook(ctx), { enabled: safeHookEnabled }) - : null; - - const taskResumeInfo = createTaskResumeInfoHook(); - - const anthropicEffort = isHookEnabled("anthropic-effort") - ? safeCreateHook("anthropic-effort", () => createAnthropicEffortHook(), { enabled: safeHookEnabled }) - : null; - - const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig); - - const backgroundManager = new BackgroundManager( - ctx, - pluginConfig.background_task, - { - tmuxConfig, - onSubagentSessionCreated: async (event) => { - log("[index] onSubagentSessionCreated callback received", { - sessionID: event.sessionID, - parentID: event.parentID, - title: event.title, - }); - await tmuxSessionManager.onSessionCreated({ - type: "session.created", - properties: { - info: { - id: event.sessionID, - parentID: event.parentID, - title: event.title, - }, - }, - }); - log("[index] onSubagentSessionCreated callback completed"); - }, - onShutdown: () => { - tmuxSessionManager.cleanup().catch((error) => { - log("[index] tmux cleanup error during shutdown:", error); - }); - }, - }, - ); - - const atlasHook = isHookEnabled("atlas") - ? safeCreateHook("atlas", () => createAtlasHook(ctx, { - directory: ctx.directory, - backgroundManager, - isContinuationStopped: (sessionID: string) => stopContinuationGuard?.isStopped(sessionID) ?? false, - }), { enabled: safeHookEnabled }) - : null; - - initTaskToastManager(ctx.client); - - const stopContinuationGuard = isHookEnabled("stop-continuation-guard") - ? safeCreateHook("stop-continuation-guard", () => createStopContinuationGuardHook(ctx), { enabled: safeHookEnabled }) - : null; - - const compactionContextInjector = isHookEnabled("compaction-context-injector") - ? safeCreateHook("compaction-context-injector", () => createCompactionContextInjector(), { enabled: safeHookEnabled }) - : null; - - const compactionTodoPreserver = isHookEnabled("compaction-todo-preserver") - ? safeCreateHook("compaction-todo-preserver", () => createCompactionTodoPreserverHook(ctx), { enabled: safeHookEnabled }) - : null; - - const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") - ? safeCreateHook("todo-continuation-enforcer", () => createTodoContinuationEnforcer(ctx, { - backgroundManager, - isContinuationStopped: stopContinuationGuard?.isStopped, - }), { enabled: safeHookEnabled }) - : null; - - const unstableAgentBabysitter = isHookEnabled("unstable-agent-babysitter") - ? safeCreateHook("unstable-agent-babysitter", () => createUnstableAgentBabysitterHook( - { - directory: ctx.directory, - client: { - session: { - messages: async (args) => { - const result = await ctx.client.session.messages(args); - if (Array.isArray(result)) return result; - if ( - typeof result === "object" && - result !== null && - "data" in result - ) { - const record = result as Record; - return { data: record.data }; - } - return []; - }, - prompt: async (args) => { - await ctx.client.session.promptAsync(args); - }, - promptAsync: async (args) => { - await ctx.client.session.promptAsync(args); - }, - }, - }, - }, - { - backgroundManager, - config: pluginConfig.babysitting, - }, - ), { enabled: safeHookEnabled }) - : null; - - if (sessionRecovery && todoContinuationEnforcer) { - sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering); - sessionRecovery.setOnRecoveryCompleteCallback( - todoContinuationEnforcer.markRecoveryComplete, - ); - } - - const backgroundNotificationHook = isHookEnabled("background-notification") - ? safeCreateHook("background-notification", () => createBackgroundNotificationHook(backgroundManager), { enabled: safeHookEnabled }) - : null; - const backgroundTools = createBackgroundTools(backgroundManager, ctx.client); - - const callOmoAgent = createCallOmoAgent(ctx, backgroundManager); - const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some( - (agent) => agent.toLowerCase() === "multimodal-looker", - ); - const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null; - const browserProvider = - pluginConfig.browser_automation_engine?.provider ?? "playwright"; - const disabledSkills = new Set(pluginConfig.disabled_skills ?? []); - const systemMcpNames = getSystemMcpServerNames(); - const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills }).filter((skill) => { - if (skill.mcpConfig) { - for (const mcpName of Object.keys(skill.mcpConfig)) { - if (systemMcpNames.has(mcpName)) return false; - } - } - return true; - }, - ); - const includeClaudeSkills = pluginConfig.claude_code?.skills !== false; - const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = - await Promise.all([ - includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]), - discoverOpencodeGlobalSkills(), - includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]), - discoverOpencodeProjectSkills(), - ]); - const mergedSkills = mergeSkills( - builtinSkills, - pluginConfig.skills, - userSkills, - globalSkills, - projectSkills, - opencodeProjectSkills, - ); - - function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] { - if (scope === "user" || scope === "opencode") return "user"; - if (scope === "project" || scope === "opencode-project") return "project"; - return "plugin"; - } - - const availableSkills: AvailableSkill[] = mergedSkills.map((skill) => ({ - name: skill.name, - description: skill.definition.description ?? "", - location: mapScopeToLocation(skill.scope), - })); - - const mergedCategories = pluginConfig.categories - ? { ...DEFAULT_CATEGORIES, ...pluginConfig.categories } - : DEFAULT_CATEGORIES; - - const availableCategories = Object.entries(mergedCategories).map( - ([name, categoryConfig]) => ({ - name, - description: - pluginConfig.categories?.[name]?.description - ?? CATEGORY_DESCRIPTIONS[name] - ?? "General tasks", - model: categoryConfig.model, - }), - ); - - const delegateTask = createDelegateTask({ - manager: backgroundManager, - client: ctx.client, - directory: ctx.directory, - userCategories: pluginConfig.categories, - gitMasterConfig: pluginConfig.git_master, - sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model, - browserProvider, - disabledSkills, - availableCategories, - availableSkills, - onSyncSessionCreated: async (event) => { - log("[index] onSyncSessionCreated callback", { - sessionID: event.sessionID, - parentID: event.parentID, - title: event.title, - }); - await tmuxSessionManager.onSessionCreated({ - type: "session.created", - properties: { - info: { - id: event.sessionID, - parentID: event.parentID, - title: event.title, - }, - }, - }); - }, - }); - - categorySkillReminder = isHookEnabled("category-skill-reminder") - ? safeCreateHook("category-skill-reminder", () => createCategorySkillReminderHook(ctx, availableSkills), { enabled: safeHookEnabled }) - : null; - - const skillMcpManager = new SkillMcpManager(); - const getSessionIDForMcp = () => getMainSessionID() || ""; - const skillTool = createSkillTool({ - skills: mergedSkills, - mcpManager: skillMcpManager, - getSessionID: getSessionIDForMcp, - gitMasterConfig: pluginConfig.git_master, - disabledSkills - }); - const skillMcpTool = createSkillMcpTool({ - manager: skillMcpManager, - getLoadedSkills: () => mergedSkills, - getSessionID: getSessionIDForMcp, - }); - - const commands = discoverCommandsSync(); - const slashcommandTool = createSlashcommandTool({ - commands, - skills: mergedSkills, - }); - - const autoSlashCommand = isHookEnabled("auto-slash-command") - ? safeCreateHook("auto-slash-command", () => createAutoSlashCommandHook({ skills: mergedSkills }), { enabled: safeHookEnabled }) - : null; - - const configHandler = createConfigHandler({ - ctx: { directory: ctx.directory, client: ctx.client }, pluginConfig, + tmuxConfig, modelCacheState, - }); + }) - const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false; - const taskToolsRecord: Record = taskSystemEnabled - ? { - task_create: createTaskCreateTool(pluginConfig, ctx), - task_get: createTaskGetTool(pluginConfig), - task_list: createTaskList(pluginConfig), - task_update: createTaskUpdateTool(pluginConfig, ctx), - } - : {}; + const toolsResult = await createTools({ + ctx, + pluginConfig, + managers, + }) - const allTools: Record = { - ...builtinTools, - ...createGrepTools(ctx), - ...createGlobTools(ctx), - ...createAstGrepTools(ctx), - ...createSessionManagerTools(ctx), - ...backgroundTools, - call_omo_agent: callOmoAgent, - ...(lookAt ? { look_at: lookAt } : {}), - task: delegateTask, - skill: skillTool, - skill_mcp: skillMcpTool, - slashcommand: slashcommandTool, - interactive_bash, - ...taskToolsRecord, - }; + const hooks = createHooks({ + ctx, + pluginConfig, + backgroundManager: managers.backgroundManager, + isHookEnabled, + safeHookEnabled, + mergedSkills: toolsResult.mergedSkills, + availableSkills: toolsResult.availableSkills, + }) - const filteredTools: Record = filterDisabledTools( - allTools, - pluginConfig.disabled_tools, - ); + const pluginInterface = createPluginInterface({ + ctx, + pluginConfig, + firstMessageVariantGate, + managers, + hooks, + tools: toolsResult.filteredTools, + }) return { - tool: filteredTools, - - "chat.params": async ( - input: { - sessionID: string - agent: string - model: Record - provider: Record - message: Record - }, - output: { - temperature: number - topP: number - topK: number - options: Record - }, - ) => { - const model = input.model as { providerID: string; modelID: string } - const message = input.message as { variant?: string } - await anthropicEffort?.["chat.params"]?.( - { ...input, agent: { name: input.agent }, model, provider: input.provider as { id: string }, message }, - output, - ); - }, - - "chat.message": async (input, output) => { - if (input.agent) { - setSessionAgent(input.sessionID, input.agent); - } - - const message = (output as { message: { variant?: string } }).message; - if (firstMessageVariantGate.shouldOverride(input.sessionID)) { - const variant = - input.model && input.agent - ? resolveVariantForModel(pluginConfig, input.agent, input.model) - : resolveAgentVariant(pluginConfig, input.agent); - if (variant !== undefined) { - message.variant = variant; - } - firstMessageVariantGate.markApplied(input.sessionID); - } else { - if (input.model && input.agent && message.variant === undefined) { - const variant = resolveVariantForModel( - pluginConfig, - input.agent, - input.model, - ); - if (variant !== undefined) { - message.variant = variant; - } - } else { - applyAgentVariant(pluginConfig, input.agent, message); - } - } - - await stopContinuationGuard?.["chat.message"]?.(input); - await keywordDetector?.["chat.message"]?.(input, output); - await claudeCodeHooks?.["chat.message"]?.(input, output); - await autoSlashCommand?.["chat.message"]?.(input, output); - await startWork?.["chat.message"]?.(input, output); - - if (!hasConnectedProvidersCache()) { - ctx.client.tui - .showToast({ - body: { - title: "⚠️ Provider Cache Missing", - message: - "Model filtering disabled. RESTART OpenCode to enable full functionality.", - variant: "warning" as const, - duration: 6000, - }, - }) - .catch(() => {}); - } - - if (ralphLoop) { - const parts = ( - output as { parts?: Array<{ type: string; text?: string }> } - ).parts; - const promptText = - parts - ?.filter((p) => p.type === "text" && p.text) - .map((p) => p.text) - .join("\n") - .trim() || ""; - - const isRalphLoopTemplate = - promptText.includes("You are starting a Ralph Loop") && - promptText.includes(""); - const isCancelRalphTemplate = promptText.includes( - "Cancel the currently active Ralph Loop", - ); - - if (isRalphLoopTemplate) { - const taskMatch = promptText.match( - /\s*([\s\S]*?)\s*<\/user-task>/i, - ); - const rawTask = taskMatch?.[1]?.trim() || ""; - - const quotedMatch = rawTask.match(/^["'](.+?)["']/); - const prompt = - quotedMatch?.[1] || - rawTask.split(/\s+--/)[0]?.trim() || - "Complete the task as instructed"; - - const maxIterMatch = rawTask.match(/--max-iterations=(\d+)/i); - const promiseMatch = rawTask.match( - /--completion-promise=["']?([^"'\s]+)["']?/i, - ); - - log("[ralph-loop] Starting loop from chat.message", { - sessionID: input.sessionID, - prompt, - }); - ralphLoop.startLoop(input.sessionID, prompt, { - maxIterations: maxIterMatch - ? parseInt(maxIterMatch[1], 10) - : undefined, - completionPromise: promiseMatch?.[1], - }); - } else if (isCancelRalphTemplate) { - log("[ralph-loop] Cancelling loop from chat.message", { - sessionID: input.sessionID, - }); - ralphLoop.cancelLoop(input.sessionID); - } - } - }, - - "experimental.chat.messages.transform": async ( - input: Record, - output: { messages: Array<{ info: unknown; parts: unknown[] }> }, - ) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await contextInjectorMessagesTransform?.[ - "experimental.chat.messages.transform" - ]?.(input, output as any); - await thinkingBlockValidator?.[ - "experimental.chat.messages.transform" - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ]?.(input, output as any); - }, - - config: configHandler, - - event: async (input) => { - await autoUpdateChecker?.event(input); - await claudeCodeHooks?.event?.(input); - await backgroundNotificationHook?.event(input); - await sessionNotification?.(input); - await todoContinuationEnforcer?.handler(input); - await unstableAgentBabysitter?.event(input); - await contextWindowMonitor?.event(input); - await directoryAgentsInjector?.event(input); - await directoryReadmeInjector?.event(input); - await rulesInjector?.event(input); - await thinkMode?.event(input); - await anthropicContextWindowLimitRecovery?.event(input); - await agentUsageReminder?.event(input); - await categorySkillReminder?.event(input); - await interactiveBashSession?.event(input); - await ralphLoop?.event(input); - await stopContinuationGuard?.event(input); - await compactionTodoPreserver?.event(input); - await atlasHook?.handler(input); - - const { event } = input; - const props = event.properties as Record | undefined; - - if (event.type === "session.created") { - const sessionInfo = props?.info as - | { id?: string; title?: string; parentID?: string } - | undefined; - log("[event] session.created", { sessionInfo, props }); - if (!sessionInfo?.parentID) { - setMainSession(sessionInfo?.id); - } - firstMessageVariantGate.markSessionCreated(sessionInfo); - await tmuxSessionManager.onSessionCreated( - event as { - type: string; - properties?: { - info?: { id?: string; parentID?: string; title?: string }; - }; - }, - ); - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id === getMainSessionID()) { - setMainSession(undefined); - } - if (sessionInfo?.id) { - clearSessionAgent(sessionInfo.id); - resetMessageCursor(sessionInfo.id); - firstMessageVariantGate.clear(sessionInfo.id); - await skillMcpManager.disconnectSession(sessionInfo.id); - await lspManager.cleanupTempDirectoryClients(); - await tmuxSessionManager.onSessionDeleted({ - sessionID: sessionInfo.id, - }); - } - } - - if (event.type === "message.updated") { - const info = props?.info as Record | undefined; - const sessionID = info?.sessionID as string | undefined; - const agent = info?.agent as string | undefined; - const role = info?.role as string | undefined; - if (sessionID && agent && role === "user") { - updateSessionAgent(sessionID, agent); - } - } - - if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined; - const error = props?.error; - - if (sessionRecovery?.isRecoverableError(error)) { - const messageInfo = { - id: props?.messageID as string | undefined, - role: "assistant" as const, - sessionID, - error, - }; - const recovered = - await sessionRecovery.handleSessionRecovery(messageInfo); - - if ( - recovered && - sessionID && - sessionID === getMainSessionID() && - !stopContinuationGuard?.isStopped(sessionID) - ) { - await ctx.client.session - .prompt({ - path: { id: sessionID }, - body: { parts: [{ type: "text", text: "continue" }] }, - query: { directory: ctx.directory }, - }) - .catch(() => {}); - } - } - } - }, - - "tool.execute.before": async (input, output) => { - await subagentQuestionBlocker?.["tool.execute.before"]?.(input, output); - await writeExistingFileGuard?.["tool.execute.before"]?.(input, output); - await questionLabelTruncator?.["tool.execute.before"]?.(input, output); - await claudeCodeHooks?.["tool.execute.before"]?.(input, output); - await nonInteractiveEnv?.["tool.execute.before"](input, output); - await commentChecker?.["tool.execute.before"]?.(input, output); - await directoryAgentsInjector?.["tool.execute.before"]?.(input, output); - await directoryReadmeInjector?.["tool.execute.before"]?.(input, output); - await rulesInjector?.["tool.execute.before"]?.(input, output); - await tasksTodowriteDisabler?.["tool.execute.before"]?.(input, output); - await prometheusMdOnly?.["tool.execute.before"]?.(input, output); - await sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output); - await atlasHook?.["tool.execute.before"]?.(input, output); - - if (input.tool === "task") { - const args = output.args as Record; - const category = typeof args.category === "string" ? args.category : undefined; - const subagentType = typeof args.subagent_type === "string" ? args.subagent_type : undefined; - if (category && !subagentType) { - args.subagent_type = "sisyphus-junior"; - } - } - - if (ralphLoop && input.tool === "slashcommand") { - const args = output.args as { command?: string } | undefined; - const command = args?.command?.replace(/^\//, "").toLowerCase(); - const sessionID = input.sessionID || getMainSessionID(); - - if (command === "ralph-loop" && sessionID) { - const rawArgs = - args?.command?.replace(/^\/?(ralph-loop)\s*/i, "") || ""; - const taskMatch = rawArgs.match(/^["'](.+?)["']/); - const prompt = - taskMatch?.[1] || - rawArgs.split(/\s+--/)[0]?.trim() || - "Complete the task as instructed"; - - const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i); - const promiseMatch = rawArgs.match( - /--completion-promise=["']?([^"'\s]+)["']?/i, - ); - - ralphLoop.startLoop(sessionID, prompt, { - maxIterations: maxIterMatch - ? parseInt(maxIterMatch[1], 10) - : undefined, - completionPromise: promiseMatch?.[1], - }); - } else if (command === "cancel-ralph" && sessionID) { - ralphLoop.cancelLoop(sessionID); - } else if (command === "ulw-loop" && sessionID) { - const rawArgs = - args?.command?.replace(/^\/?(ulw-loop)\s*/i, "") || ""; - const taskMatch = rawArgs.match(/^["'](.+?)["']/); - const prompt = - taskMatch?.[1] || - rawArgs.split(/\s+--/)[0]?.trim() || - "Complete the task as instructed"; - - const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i); - const promiseMatch = rawArgs.match( - /--completion-promise=["']?([^"'\s]+)["']?/i, - ); - - ralphLoop.startLoop(sessionID, prompt, { - ultrawork: true, - maxIterations: maxIterMatch - ? parseInt(maxIterMatch[1], 10) - : undefined, - completionPromise: promiseMatch?.[1], - }); - } - } - - if (input.tool === "slashcommand") { - const args = output.args as { command?: string } | undefined; - const command = args?.command?.replace(/^\//, "").toLowerCase(); - const sessionID = input.sessionID || getMainSessionID(); - - if (command === "stop-continuation" && sessionID) { - stopContinuationGuard?.stop(sessionID); - todoContinuationEnforcer?.cancelAllCountdowns(); - ralphLoop?.cancelLoop(sessionID); - clearBoulderState(ctx.directory); - log("[stop-continuation] All continuation mechanisms stopped", { - sessionID, - }); - } - } - }, - - "tool.execute.after": async (input, output) => { - // Guard against undefined output (e.g., from /review command - see issue #1035) - if (!output) { - return; - } - - // Restore metadata that fromPlugin() overwrites with { truncated, outputPath }. - // This must run FIRST, before any hook reads output.metadata. - const stored = consumeToolMetadata(input.sessionID, input.callID) - if (stored) { - if (stored.title) { - output.title = stored.title - } - if (stored.metadata) { - output.metadata = { ...output.metadata, ...stored.metadata } - } - } - - await claudeCodeHooks?.["tool.execute.after"]?.(input, output); - await toolOutputTruncator?.["tool.execute.after"](input, output); - await preemptiveCompaction?.["tool.execute.after"](input, output); - await contextWindowMonitor?.["tool.execute.after"](input, output); - await commentChecker?.["tool.execute.after"](input, output); - await directoryAgentsInjector?.["tool.execute.after"](input, output); - await directoryReadmeInjector?.["tool.execute.after"](input, output); - await rulesInjector?.["tool.execute.after"](input, output); - await emptyTaskResponseDetector?.["tool.execute.after"](input, output); - await agentUsageReminder?.["tool.execute.after"](input, output); - await categorySkillReminder?.["tool.execute.after"](input, output); - await interactiveBashSession?.["tool.execute.after"](input, output); - await editErrorRecovery?.["tool.execute.after"](input, output); - await delegateTaskRetry?.["tool.execute.after"](input, output); - await atlasHook?.["tool.execute.after"]?.(input, output); - await taskResumeInfo?.["tool.execute.after"]?.(input, output); - }, + ...pluginInterface, "experimental.session.compacting": async ( _input: { sessionID: string }, output: { context: string[] }, ): Promise => { - await compactionTodoPreserver?.capture(_input.sessionID); - if (!compactionContextInjector) { - return; + await hooks.compactionTodoPreserver?.capture(_input.sessionID) + if (!hooks.compactionContextInjector) { + return } - output.context.push(compactionContextInjector()); + output.context.push(hooks.compactionContextInjector()) }, - }; -}; + } +} -export default OhMyOpenCodePlugin; +export default OhMyOpenCodePlugin export type { OhMyOpenCodeConfig, @@ -973,9 +97,9 @@ export type { McpName, HookName, BuiltinCommandName, -} from "./config"; +} from "./config" // NOTE: Do NOT export functions from main index.ts! // OpenCode treats ALL exports as plugin instances and calls them. // Config error utilities are available via "./shared/config-errors" for internal use only. -export type { ConfigLoadError } from "./shared/config-errors"; +export type { ConfigLoadError } from "./shared/config-errors" diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts new file mode 100644 index 000000000..249216a6c --- /dev/null +++ b/src/plugin-handlers/agent-config-handler.ts @@ -0,0 +1,188 @@ +import { createBuiltinAgents } from "../agents"; +import { createSisyphusJuniorAgentWithOverrides } from "../agents/sisyphus-junior"; +import type { OhMyOpenCodeConfig } from "../config"; +import { log, migrateAgentConfig } from "../shared"; +import { AGENT_NAME_MAP } from "../shared/migration"; +import { + discoverOpencodeGlobalSkills, + discoverOpencodeProjectSkills, + discoverProjectClaudeSkills, + discoverUserClaudeSkills, +} from "../features/opencode-skill-loader"; +import { loadProjectAgents, loadUserAgents } from "../features/claude-code-agent-loader"; +import type { PluginComponents } from "./plugin-components-loader"; +import { reorderAgentsByPriority } from "./agent-priority-order"; +import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder"; + +type AgentConfigRecord = Record | undefined> & { + build?: Record; + plan?: Record; +}; + +export async function applyAgentConfig(params: { + config: Record; + pluginConfig: OhMyOpenCodeConfig; + ctx: { directory: string; client?: any }; + pluginComponents: PluginComponents; +}): Promise> { + const migratedDisabledAgents = (params.pluginConfig.disabled_agents ?? []).map( + (agent) => { + return AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent; + }, + ) as typeof params.pluginConfig.disabled_agents; + + const includeClaudeSkillsForAwareness = params.pluginConfig.claude_code?.skills ?? true; + const [ + discoveredUserSkills, + discoveredProjectSkills, + discoveredOpencodeGlobalSkills, + discoveredOpencodeProjectSkills, + ] = await Promise.all([ + includeClaudeSkillsForAwareness ? discoverUserClaudeSkills() : Promise.resolve([]), + includeClaudeSkillsForAwareness + ? discoverProjectClaudeSkills() + : Promise.resolve([]), + discoverOpencodeGlobalSkills(), + discoverOpencodeProjectSkills(), + ]); + + const allDiscoveredSkills = [ + ...discoveredOpencodeProjectSkills, + ...discoveredProjectSkills, + ...discoveredOpencodeGlobalSkills, + ...discoveredUserSkills, + ]; + + const browserProvider = + params.pluginConfig.browser_automation_engine?.provider ?? "playwright"; + const currentModel = params.config.model as string | undefined; + const disabledSkills = new Set(params.pluginConfig.disabled_skills ?? []); + + const builtinAgents = await createBuiltinAgents( + migratedDisabledAgents, + params.pluginConfig.agents, + params.ctx.directory, + undefined, + params.pluginConfig.categories, + params.pluginConfig.git_master, + allDiscoveredSkills, + params.ctx.client, + browserProvider, + currentModel, + disabledSkills, + ); + + const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true; + const userAgents = includeClaudeAgents ? loadUserAgents() : {}; + const projectAgents = includeClaudeAgents ? loadProjectAgents() : {}; + + const rawPluginAgents = params.pluginComponents.agents; + const pluginAgents = Object.fromEntries( + Object.entries(rawPluginAgents).map(([key, value]) => [ + key, + value ? migrateAgentConfig(value as Record) : value, + ]), + ); + + const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true; + const builderEnabled = + params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; + const plannerEnabled = params.pluginConfig.sisyphus_agent?.planner_enabled ?? true; + const replacePlan = params.pluginConfig.sisyphus_agent?.replace_plan ?? true; + const shouldDemotePlan = plannerEnabled && replacePlan; + + const configAgent = params.config.agent as AgentConfigRecord | undefined; + + if (isSisyphusEnabled && builtinAgents.sisyphus) { + (params.config as { default_agent?: string }).default_agent = "sisyphus"; + + const agentConfig: Record = { + sisyphus: builtinAgents.sisyphus, + }; + + agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides( + params.pluginConfig.agents?.["sisyphus-junior"], + undefined, + ); + + if (builderEnabled) { + const { name: _buildName, ...buildConfigWithoutName } = + configAgent?.build ?? {}; + const migratedBuildConfig = migrateAgentConfig( + buildConfigWithoutName as Record, + ); + const override = params.pluginConfig.agents?.["OpenCode-Builder"]; + const base = { + ...migratedBuildConfig, + description: `${(configAgent?.build?.description as string) ?? "Build agent"} (OpenCode default)`, + }; + agentConfig["OpenCode-Builder"] = override ? { ...base, ...override } : base; + } + + if (plannerEnabled) { + const prometheusOverride = params.pluginConfig.agents?.["prometheus"] as + | (Record & { prompt_append?: string }) + | undefined; + + agentConfig["prometheus"] = await buildPrometheusAgentConfig({ + configAgentPlan: configAgent?.plan, + pluginPrometheusOverride: prometheusOverride, + userCategories: params.pluginConfig.categories, + currentModel, + }); + } + + const filteredConfigAgents = configAgent + ? Object.fromEntries( + Object.entries(configAgent) + .filter(([key]) => { + if (key === "build") return false; + if (key === "plan" && shouldDemotePlan) return false; + if (key in builtinAgents) return false; + return true; + }) + .map(([key, value]) => [ + key, + value ? migrateAgentConfig(value as Record) : value, + ]), + ) + : {}; + + const migratedBuild = configAgent?.build + ? migrateAgentConfig(configAgent.build as Record) + : {}; + + const planDemoteConfig = shouldDemotePlan ? { mode: "subagent" as const } : undefined; + + params.config.agent = { + ...agentConfig, + ...Object.fromEntries( + Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"), + ), + ...userAgents, + ...projectAgents, + ...pluginAgents, + ...filteredConfigAgents, + build: { ...migratedBuild, mode: "subagent", hidden: true }, + ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), + }; + } else { + params.config.agent = { + ...builtinAgents, + ...userAgents, + ...projectAgents, + ...pluginAgents, + ...configAgent, + }; + } + + if (params.config.agent) { + params.config.agent = reorderAgentsByPriority( + params.config.agent as Record, + ); + } + + const agentResult = params.config.agent as Record; + log("[config-handler] agents loaded", { agentKeys: Object.keys(agentResult) }); + return agentResult; +} diff --git a/src/plugin-handlers/agent-priority-order.ts b/src/plugin-handlers/agent-priority-order.ts new file mode 100644 index 000000000..a87c0199a --- /dev/null +++ b/src/plugin-handlers/agent-priority-order.ts @@ -0,0 +1,23 @@ +const CORE_AGENT_ORDER = ["sisyphus", "hephaestus", "prometheus", "atlas"] as const; + +export function reorderAgentsByPriority( + agents: Record, +): Record { + const ordered: Record = {}; + const seen = new Set(); + + for (const key of CORE_AGENT_ORDER) { + if (Object.prototype.hasOwnProperty.call(agents, key)) { + ordered[key] = agents[key]; + seen.add(key); + } + } + + for (const [key, value] of Object.entries(agents)) { + if (!seen.has(key)) { + ordered[key] = value; + } + } + + return ordered; +} diff --git a/src/plugin-handlers/category-config-resolver.ts b/src/plugin-handlers/category-config-resolver.ts new file mode 100644 index 000000000..7a44ce9e6 --- /dev/null +++ b/src/plugin-handlers/category-config-resolver.ts @@ -0,0 +1,9 @@ +import type { CategoryConfig } from "../config/schema"; +import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"; + +export function resolveCategoryConfig( + categoryName: string, + userCategories?: Record, +): CategoryConfig | undefined { + return userCategories?.[categoryName] ?? DEFAULT_CATEGORIES[categoryName]; +} diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts new file mode 100644 index 000000000..1e2ca9402 --- /dev/null +++ b/src/plugin-handlers/command-config-handler.ts @@ -0,0 +1,62 @@ +import type { OhMyOpenCodeConfig } from "../config"; +import { + loadUserCommands, + loadProjectCommands, + loadOpencodeGlobalCommands, + loadOpencodeProjectCommands, +} from "../features/claude-code-command-loader"; +import { loadBuiltinCommands } from "../features/builtin-commands"; +import { + loadUserSkills, + loadProjectSkills, + loadOpencodeGlobalSkills, + loadOpencodeProjectSkills, +} from "../features/opencode-skill-loader"; +import type { PluginComponents } from "./plugin-components-loader"; + +export async function applyCommandConfig(params: { + config: Record; + pluginConfig: OhMyOpenCodeConfig; + pluginComponents: PluginComponents; +}): Promise { + const builtinCommands = loadBuiltinCommands(params.pluginConfig.disabled_commands); + const systemCommands = (params.config.command as Record) ?? {}; + + const includeClaudeCommands = params.pluginConfig.claude_code?.commands ?? true; + const includeClaudeSkills = params.pluginConfig.claude_code?.skills ?? true; + + const [ + userCommands, + projectCommands, + opencodeGlobalCommands, + opencodeProjectCommands, + userSkills, + projectSkills, + opencodeGlobalSkills, + opencodeProjectSkills, + ] = await Promise.all([ + includeClaudeCommands ? loadUserCommands() : Promise.resolve({}), + includeClaudeCommands ? loadProjectCommands() : Promise.resolve({}), + loadOpencodeGlobalCommands(), + loadOpencodeProjectCommands(), + includeClaudeSkills ? loadUserSkills() : Promise.resolve({}), + includeClaudeSkills ? loadProjectSkills() : Promise.resolve({}), + loadOpencodeGlobalSkills(), + loadOpencodeProjectSkills(), + ]); + + params.config.command = { + ...builtinCommands, + ...userCommands, + ...userSkills, + ...opencodeGlobalCommands, + ...opencodeGlobalSkills, + ...systemCommands, + ...projectCommands, + ...projectSkills, + ...opencodeProjectCommands, + ...opencodeProjectSkills, + ...params.pluginComponents.commands, + ...params.pluginComponents.skills, + }; +} diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 41adbaf20..5eb7f242b 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -1,39 +1,14 @@ -import { createBuiltinAgents } from "../agents"; -import { createSisyphusJuniorAgentWithOverrides } from "../agents/sisyphus-junior"; -import { - loadUserCommands, - loadProjectCommands, - loadOpencodeGlobalCommands, - loadOpencodeProjectCommands, -} from "../features/claude-code-command-loader"; -import { loadBuiltinCommands } from "../features/builtin-commands"; -import { - loadUserSkills, - loadProjectSkills, - loadOpencodeGlobalSkills, - loadOpencodeProjectSkills, - discoverUserClaudeSkills, - discoverProjectClaudeSkills, - discoverOpencodeGlobalSkills, - discoverOpencodeProjectSkills, -} from "../features/opencode-skill-loader"; -import { - loadUserAgents, - loadProjectAgents, -} from "../features/claude-code-agent-loader"; -import { loadMcpConfigs } from "../features/claude-code-mcp-loader"; -import { loadAllPluginComponents } from "../features/claude-code-plugin-loader"; -import { createBuiltinMcps } from "../mcp"; import type { OhMyOpenCodeConfig } from "../config"; -import { log, fetchAvailableModels, readConnectedProvidersCache, resolveModelPipeline, addConfigLoadError } from "../shared"; -import { getOpenCodeConfigPaths } from "../shared/opencode-config-dir"; -import { migrateAgentConfig } from "../shared/permission-compat"; -import { AGENT_NAME_MAP } from "../shared/migration"; -import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements"; -import { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "../agents/prometheus"; -import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"; import type { ModelCacheState } from "../plugin-state"; -import type { CategoryConfig } from "../config/schema"; +import { log } from "../shared"; +import { applyAgentConfig } from "./agent-config-handler"; +import { applyCommandConfig } from "./command-config-handler"; +import { applyMcpConfig } from "./mcp-config-handler"; +import { applyProviderConfig } from "./provider-config-handler"; +import { loadPluginComponents } from "./plugin-components-loader"; +import { applyToolConfig } from "./tool-config-handler"; + +export { resolveCategoryConfig } from "./category-config-resolver"; export interface ConfigHandlerDeps { ctx: { directory: string; client?: any }; @@ -41,486 +16,29 @@ export interface ConfigHandlerDeps { modelCacheState: ModelCacheState; } -export function resolveCategoryConfig( - categoryName: string, - userCategories?: Record -): CategoryConfig | undefined { - return userCategories?.[categoryName] ?? DEFAULT_CATEGORIES[categoryName]; -} - -const CORE_AGENT_ORDER = ["sisyphus", "hephaestus", "prometheus", "atlas"] as const; - -function reorderAgentsByPriority(agents: Record): Record { - const ordered: Record = {}; - const seen = new Set(); - - for (const key of CORE_AGENT_ORDER) { - if (Object.prototype.hasOwnProperty.call(agents, key)) { - ordered[key] = agents[key]; - seen.add(key); - } - } - - for (const [key, value] of Object.entries(agents)) { - if (!seen.has(key)) { - ordered[key] = value; - } - } - - return ordered; -} - export function createConfigHandler(deps: ConfigHandlerDeps) { const { ctx, pluginConfig, modelCacheState } = deps; return async (config: Record) => { - type ProviderConfig = { - options?: { headers?: Record }; - models?: Record; - }; - const providers = config.provider as - | Record - | undefined; + applyProviderConfig({ config, modelCacheState }); - const anthropicBeta = - providers?.anthropic?.options?.headers?.["anthropic-beta"]; - modelCacheState.anthropicContext1MEnabled = - anthropicBeta?.includes("context-1m") ?? false; + const pluginComponents = await loadPluginComponents({ pluginConfig }); - if (providers) { - for (const [providerID, providerConfig] of Object.entries(providers)) { - const models = providerConfig?.models; - if (models) { - for (const [modelID, modelConfig] of Object.entries(models)) { - const contextLimit = modelConfig?.limit?.context; - if (contextLimit) { - modelCacheState.modelContextLimitsCache.set( - `${providerID}/${modelID}`, - contextLimit - ); - } - } - } - } - } + const agentResult = await applyAgentConfig({ + config, + pluginConfig, + ctx, + pluginComponents, + }); - const emptyPluginDefaults = { - commands: {}, - skills: {}, - agents: {}, - mcpServers: {}, - hooksConfigs: [] as { hooks?: Record }[], - plugins: [] as { name: string; version: string }[], - errors: [] as { pluginKey: string; installPath: string; error: string }[], - }; + applyToolConfig({ config, pluginConfig, agentResult }); + await applyMcpConfig({ config, pluginConfig, pluginComponents }); + await applyCommandConfig({ config, pluginConfig, pluginComponents }); - let pluginComponents: typeof emptyPluginDefaults; - const pluginsEnabled = pluginConfig.claude_code?.plugins ?? true; - - if (pluginsEnabled) { - const timeoutMs = pluginConfig.experimental?.plugin_load_timeout_ms ?? 10000; - try { - let timeoutId: ReturnType; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout( - () => reject(new Error(`Plugin loading timed out after ${timeoutMs}ms`)), - timeoutMs, - ); - }); - pluginComponents = await Promise.race([ - loadAllPluginComponents({ - enabledPluginsOverride: pluginConfig.claude_code?.plugins_override, - }), - timeoutPromise, - ]).finally(() => clearTimeout(timeoutId)); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log("[config-handler] Plugin loading failed", { error: errorMessage }); - addConfigLoadError({ path: "plugin-loading", error: errorMessage }); - pluginComponents = emptyPluginDefaults; - } - } else { - pluginComponents = emptyPluginDefaults; - } - - if (pluginComponents.plugins.length > 0) { - log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, { - plugins: pluginComponents.plugins.map((p) => `${p.name}@${p.version}`), - }); - } - - if (pluginComponents.errors.length > 0) { - log(`Plugin load errors`, { errors: pluginComponents.errors }); - } - - // Migrate disabled_agents from old names to new names - const migratedDisabledAgents = (pluginConfig.disabled_agents ?? []).map(agent => { - return AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent - }) as typeof pluginConfig.disabled_agents - - const includeClaudeSkillsForAwareness = pluginConfig.claude_code?.skills ?? true; - const [ - discoveredUserSkills, - discoveredProjectSkills, - discoveredOpencodeGlobalSkills, - discoveredOpencodeProjectSkills, - ] = await Promise.all([ - includeClaudeSkillsForAwareness ? discoverUserClaudeSkills() : Promise.resolve([]), - includeClaudeSkillsForAwareness ? discoverProjectClaudeSkills() : Promise.resolve([]), - discoverOpencodeGlobalSkills(), - discoverOpencodeProjectSkills(), - ]); - - const allDiscoveredSkills = [ - ...discoveredOpencodeProjectSkills, - ...discoveredProjectSkills, - ...discoveredOpencodeGlobalSkills, - ...discoveredUserSkills, - ]; - - const browserProvider = pluginConfig.browser_automation_engine?.provider ?? "playwright"; - // config.model represents the currently active model in OpenCode (including UI selection) - // Pass it as uiSelectedModel so it takes highest priority in model resolution - const currentModel = config.model as string | undefined; - const disabledSkills = new Set(pluginConfig.disabled_skills ?? []); - const builtinAgents = await createBuiltinAgents( - migratedDisabledAgents, - pluginConfig.agents, - ctx.directory, - undefined, // systemDefaultModel - let fallback chain handle this - pluginConfig.categories, - pluginConfig.git_master, - allDiscoveredSkills, - ctx.client, - browserProvider, - currentModel, // uiSelectedModel - takes highest priority - disabledSkills - ); - - // Claude Code agents: Do NOT apply permission migration - // Claude Code uses whitelist-based tools format which is semantically different - // from OpenCode's denylist-based permission system - const userAgents = (pluginConfig.claude_code?.agents ?? true) - ? loadUserAgents() - : {}; - const projectAgents = (pluginConfig.claude_code?.agents ?? true) - ? loadProjectAgents() - : {}; - - // Plugin agents: Apply permission migration for compatibility - const rawPluginAgents = pluginComponents.agents; - const pluginAgents = Object.fromEntries( - Object.entries(rawPluginAgents).map(([k, v]) => [ - k, - v ? migrateAgentConfig(v as Record) : v, - ]) - ); - - const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true; - const builderEnabled = - pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; - const plannerEnabled = - pluginConfig.sisyphus_agent?.planner_enabled ?? true; - const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true; - const shouldDemotePlan = plannerEnabled && replacePlan; - - type AgentConfig = Record< - string, - Record | undefined - > & { - build?: Record; - plan?: Record; - explore?: { tools?: Record }; - librarian?: { tools?: Record }; - "multimodal-looker"?: { tools?: Record }; - atlas?: { tools?: Record }; - sisyphus?: { tools?: Record }; - }; - const configAgent = config.agent as AgentConfig | undefined; - - if (isSisyphusEnabled && builtinAgents.sisyphus) { - (config as { default_agent?: string }).default_agent = "sisyphus"; - - const agentConfig: Record = { - sisyphus: builtinAgents.sisyphus, - }; - - agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides( - pluginConfig.agents?.["sisyphus-junior"], - undefined - ); - - if (builderEnabled) { - const { name: _buildName, ...buildConfigWithoutName } = - configAgent?.build ?? {}; - const migratedBuildConfig = migrateAgentConfig( - buildConfigWithoutName as Record - ); - const openCodeBuilderOverride = - pluginConfig.agents?.["OpenCode-Builder"]; - const openCodeBuilderBase = { - ...migratedBuildConfig, - description: `${configAgent?.build?.description ?? "Build agent"} (OpenCode default)`, - }; - - agentConfig["OpenCode-Builder"] = openCodeBuilderOverride - ? { ...openCodeBuilderBase, ...openCodeBuilderOverride } - : openCodeBuilderBase; - } - - if (plannerEnabled) { - const prometheusOverride = - pluginConfig.agents?.["prometheus"] as - | (Record & { - category?: string - model?: string - variant?: string - reasoningEffort?: string - textVerbosity?: string - thinking?: { type: string; budgetTokens?: number } - temperature?: number - top_p?: number - maxTokens?: number - }) - | undefined; - - const categoryConfig = prometheusOverride?.category - ? resolveCategoryConfig( - prometheusOverride.category, - pluginConfig.categories - ) - : undefined; - - const prometheusRequirement = AGENT_MODEL_REQUIREMENTS["prometheus"]; - const connectedProviders = readConnectedProvidersCache(); - // IMPORTANT: Do NOT pass ctx.client to fetchAvailableModels during plugin initialization. - // Calling client API (e.g., client.provider.list()) from config handler causes deadlock: - // - Plugin init waits for server response - // - Server waits for plugin init to complete before handling requests - // Use cache-only mode instead. If cache is unavailable, fallback chain uses first model. - // See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301 - const availableModels = await fetchAvailableModels(undefined, { - connectedProviders: connectedProviders ?? undefined, - }); - - const modelResolution = resolveModelPipeline({ - intent: { - uiSelectedModel: currentModel, - userModel: prometheusOverride?.model ?? categoryConfig?.model, - }, - constraints: { availableModels }, - policy: { - fallbackChain: prometheusRequirement?.fallbackChain, - systemDefaultModel: undefined, - }, - }); - const resolvedModel = modelResolution?.model; - const resolvedVariant = modelResolution?.variant; - - const variantToUse = prometheusOverride?.variant ?? resolvedVariant; - const reasoningEffortToUse = prometheusOverride?.reasoningEffort ?? categoryConfig?.reasoningEffort; - const textVerbosityToUse = prometheusOverride?.textVerbosity ?? categoryConfig?.textVerbosity; - const thinkingToUse = prometheusOverride?.thinking ?? categoryConfig?.thinking; - const temperatureToUse = prometheusOverride?.temperature ?? categoryConfig?.temperature; - const topPToUse = prometheusOverride?.top_p ?? categoryConfig?.top_p; - const maxTokensToUse = prometheusOverride?.maxTokens ?? categoryConfig?.maxTokens; - const prometheusBase = { - name: "prometheus", - ...(resolvedModel ? { model: resolvedModel } : {}), - ...(variantToUse ? { variant: variantToUse } : {}), - mode: "all" as const, - prompt: PROMETHEUS_SYSTEM_PROMPT, - permission: PROMETHEUS_PERMISSION, - description: `${configAgent?.plan?.description ?? "Plan agent"} (Prometheus - OhMyOpenCode)`, - color: (configAgent?.plan?.color as string) ?? "#FF5722", // Deep Orange - Fire/Flame theme - ...(temperatureToUse !== undefined ? { temperature: temperatureToUse } : {}), - ...(topPToUse !== undefined ? { top_p: topPToUse } : {}), - ...(maxTokensToUse !== undefined ? { maxTokens: maxTokensToUse } : {}), - ...(categoryConfig?.tools ? { tools: categoryConfig.tools } : {}), - ...(thinkingToUse ? { thinking: thinkingToUse } : {}), - ...(reasoningEffortToUse !== undefined - ? { reasoningEffort: reasoningEffortToUse } - : {}), - ...(textVerbosityToUse !== undefined - ? { textVerbosity: textVerbosityToUse } - : {}), - }; - - // Properly handle prompt_append for Prometheus - // Extract prompt_append and append it to prompt instead of shallow spread - // Fixes: https://github.com/code-yeongyu/oh-my-opencode/issues/723 - if (prometheusOverride) { - const { prompt_append, ...restOverride } = prometheusOverride as Record & { prompt_append?: string }; - const merged = { ...prometheusBase, ...restOverride }; - if (prompt_append && merged.prompt) { - merged.prompt = merged.prompt + "\n" + prompt_append; - } - agentConfig["prometheus"] = merged; - } else { - agentConfig["prometheus"] = prometheusBase; - } - } - - const filteredConfigAgents = configAgent - ? Object.fromEntries( - Object.entries(configAgent) - .filter(([key]) => { - if (key === "build") return false; - if (key === "plan" && shouldDemotePlan) return false; - // Filter out agents that oh-my-opencode provides to prevent - // OpenCode defaults from overwriting user config in oh-my-opencode.json - // See: https://github.com/code-yeongyu/oh-my-opencode/issues/472 - if (key in builtinAgents) return false; - return true; - }) - .map(([key, value]) => [ - key, - value ? migrateAgentConfig(value as Record) : value, - ]) - ) - : {}; - - const migratedBuild = configAgent?.build - ? migrateAgentConfig(configAgent.build as Record) - : {}; - - const planDemoteConfig = shouldDemotePlan - ? { mode: "subagent" as const - } - : undefined; - - config.agent = { - ...agentConfig, - ...Object.fromEntries( - Object.entries(builtinAgents).filter(([k]) => k !== "sisyphus") - ), - ...userAgents, - ...projectAgents, - ...pluginAgents, - ...filteredConfigAgents, - build: { ...migratedBuild, mode: "subagent", hidden: true }, - ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), - }; - } else { - config.agent = { - ...builtinAgents, - ...userAgents, - ...projectAgents, - ...pluginAgents, - ...configAgent, - }; - } - - if (config.agent) { - config.agent = reorderAgentsByPriority(config.agent as Record); - } - - const agentResult = config.agent as AgentConfig; - - config.tools = { - ...(config.tools as Record), - "grep_app_*": false, - LspHover: false, - LspCodeActions: false, - LspCodeActionResolve: false, - "task_*": false, - teammate: false, - ...(pluginConfig.experimental?.task_system ? { todowrite: false, todoread: false } : {}), - }; - - type AgentWithPermission = { permission?: Record }; - - // In CLI run mode, deny Question tool for all agents (no TUI to answer questions) - const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true"; - const questionPermission = isCliRunMode ? "deny" : "allow"; - - if (agentResult.librarian) { - const agent = agentResult.librarian as AgentWithPermission; - agent.permission = { ...agent.permission, "grep_app_*": "allow" }; - } - if (agentResult["multimodal-looker"]) { - const agent = agentResult["multimodal-looker"] as AgentWithPermission; - agent.permission = { ...agent.permission, task: "deny", look_at: "deny" }; - } - if (agentResult["atlas"]) { - const agent = agentResult["atlas"] as AgentWithPermission; - agent.permission = { ...agent.permission, task: "allow", call_omo_agent: "deny", "task_*": "allow", teammate: "allow" }; - } - if (agentResult.sisyphus) { - const agent = agentResult.sisyphus as AgentWithPermission; - agent.permission = { ...agent.permission, call_omo_agent: "deny", task: "allow", question: questionPermission, "task_*": "allow", teammate: "allow" }; - } - if (agentResult.hephaestus) { - const agent = agentResult.hephaestus as AgentWithPermission; - agent.permission = { ...agent.permission, call_omo_agent: "deny", task: "allow", question: questionPermission }; - } - if (agentResult["prometheus"]) { - const agent = agentResult["prometheus"] as AgentWithPermission; - agent.permission = { ...agent.permission, call_omo_agent: "deny", task: "allow", question: questionPermission, "task_*": "allow", teammate: "allow" }; - } - if (agentResult["sisyphus-junior"]) { - const agent = agentResult["sisyphus-junior"] as AgentWithPermission; - agent.permission = { ...agent.permission, task: "allow", "task_*": "allow", teammate: "allow" }; - } - - config.permission = { - ...(config.permission as Record), - webfetch: "allow", - external_directory: "allow", - task: "deny", - }; - - const mcpResult = (pluginConfig.claude_code?.mcp ?? true) - ? await loadMcpConfigs() - : { servers: {} }; - - config.mcp = { - ...createBuiltinMcps(pluginConfig.disabled_mcps, pluginConfig), - ...(config.mcp as Record), - ...mcpResult.servers, - ...pluginComponents.mcpServers, - }; - - const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands); - const systemCommands = (config.command as Record) ?? {}; - - // Parallel loading of all commands and skills for faster startup - const includeClaudeCommands = pluginConfig.claude_code?.commands ?? true; - const includeClaudeSkills = pluginConfig.claude_code?.skills ?? true; - - const [ - userCommands, - projectCommands, - opencodeGlobalCommands, - opencodeProjectCommands, - userSkills, - projectSkills, - opencodeGlobalSkills, - opencodeProjectSkills, - ] = await Promise.all([ - includeClaudeCommands ? loadUserCommands() : Promise.resolve({}), - includeClaudeCommands ? loadProjectCommands() : Promise.resolve({}), - loadOpencodeGlobalCommands(), - loadOpencodeProjectCommands(), - includeClaudeSkills ? loadUserSkills() : Promise.resolve({}), - includeClaudeSkills ? loadProjectSkills() : Promise.resolve({}), - loadOpencodeGlobalSkills(), - loadOpencodeProjectSkills(), - ]); - - config.command = { - ...builtinCommands, - ...userCommands, - ...userSkills, - ...opencodeGlobalCommands, - ...opencodeGlobalSkills, - ...systemCommands, - ...projectCommands, - ...projectSkills, - ...opencodeProjectCommands, - ...opencodeProjectSkills, - ...pluginComponents.commands, - ...pluginComponents.skills, - }; + log("[config-handler] config handler applied", { + agentCount: Object.keys(agentResult).length, + commandCount: Object.keys((config.command as Record) ?? {}) + .length, + }); }; } diff --git a/src/plugin-handlers/index.ts b/src/plugin-handlers/index.ts index 8dd2e6b3a..fa9bde977 100644 --- a/src/plugin-handlers/index.ts +++ b/src/plugin-handlers/index.ts @@ -1 +1,10 @@ export { createConfigHandler, type ConfigHandlerDeps } from "./config-handler"; +export * from "./provider-config-handler"; +export * from "./agent-config-handler"; +export * from "./tool-config-handler"; +export * from "./mcp-config-handler"; +export * from "./command-config-handler"; +export * from "./plugin-components-loader"; +export * from "./category-config-resolver"; +export * from "./prometheus-agent-config-builder"; +export * from "./agent-priority-order"; diff --git a/src/plugin-handlers/mcp-config-handler.ts b/src/plugin-handlers/mcp-config-handler.ts new file mode 100644 index 000000000..677469be5 --- /dev/null +++ b/src/plugin-handlers/mcp-config-handler.ts @@ -0,0 +1,21 @@ +import type { OhMyOpenCodeConfig } from "../config"; +import { loadMcpConfigs } from "../features/claude-code-mcp-loader"; +import { createBuiltinMcps } from "../mcp"; +import type { PluginComponents } from "./plugin-components-loader"; + +export async function applyMcpConfig(params: { + config: Record; + pluginConfig: OhMyOpenCodeConfig; + pluginComponents: PluginComponents; +}): Promise { + const mcpResult = params.pluginConfig.claude_code?.mcp ?? true + ? await loadMcpConfigs() + : { servers: {} }; + + params.config.mcp = { + ...createBuiltinMcps(params.pluginConfig.disabled_mcps, params.pluginConfig), + ...(params.config.mcp as Record), + ...mcpResult.servers, + ...params.pluginComponents.mcpServers, + }; +} diff --git a/src/plugin-handlers/plugin-components-loader.ts b/src/plugin-handlers/plugin-components-loader.ts new file mode 100644 index 000000000..7d122a39e --- /dev/null +++ b/src/plugin-handlers/plugin-components-loader.ts @@ -0,0 +1,70 @@ +import type { OhMyOpenCodeConfig } from "../config"; +import { loadAllPluginComponents } from "../features/claude-code-plugin-loader"; +import { addConfigLoadError, log } from "../shared"; + +export type PluginComponents = { + commands: Record; + skills: Record; + agents: Record; + mcpServers: Record; + hooksConfigs: Array<{ hooks?: Record }>; + plugins: Array<{ name: string; version: string }>; + errors: Array<{ pluginKey: string; installPath: string; error: string }>; +}; + +const EMPTY_PLUGIN_COMPONENTS: PluginComponents = { + commands: {}, + skills: {}, + agents: {}, + mcpServers: {}, + hooksConfigs: [], + plugins: [], + errors: [], +}; + +export async function loadPluginComponents(params: { + pluginConfig: OhMyOpenCodeConfig; +}): Promise { + const pluginsEnabled = params.pluginConfig.claude_code?.plugins ?? true; + if (!pluginsEnabled) { + return EMPTY_PLUGIN_COMPONENTS; + } + + const timeoutMs = params.pluginConfig.experimental?.plugin_load_timeout_ms ?? 10000; + + try { + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`Plugin loading timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + + const pluginComponents = (await Promise.race([ + loadAllPluginComponents({ + enabledPluginsOverride: params.pluginConfig.claude_code?.plugins_override, + }), + timeoutPromise, + ]).finally(() => { + if (timeoutId) clearTimeout(timeoutId); + })) as PluginComponents; + + if (pluginComponents.plugins.length > 0) { + log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, { + plugins: pluginComponents.plugins.map((p) => `${p.name}@${p.version}`), + }); + } + + if (pluginComponents.errors.length > 0) { + log(`Plugin load errors`, { errors: pluginComponents.errors }); + } + + return pluginComponents; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log("[config-handler] Plugin loading failed", { error: errorMessage }); + addConfigLoadError({ path: "plugin-loading", error: errorMessage }); + return EMPTY_PLUGIN_COMPONENTS; + } +} diff --git a/src/plugin-handlers/prometheus-agent-config-builder.ts b/src/plugin-handlers/prometheus-agent-config-builder.ts new file mode 100644 index 000000000..6e3129add --- /dev/null +++ b/src/plugin-handlers/prometheus-agent-config-builder.ts @@ -0,0 +1,98 @@ +import type { CategoryConfig } from "../config/schema"; +import { PROMETHEUS_PERMISSION, PROMETHEUS_SYSTEM_PROMPT } from "../agents/prometheus"; +import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements"; +import { + fetchAvailableModels, + readConnectedProvidersCache, + resolveModelPipeline, +} from "../shared"; +import { resolveCategoryConfig } from "./category-config-resolver"; + +type PrometheusOverride = Record & { + category?: string; + model?: string; + variant?: string; + reasoningEffort?: string; + textVerbosity?: string; + thinking?: { type: string; budgetTokens?: number }; + temperature?: number; + top_p?: number; + maxTokens?: number; + prompt_append?: string; +}; + +export async function buildPrometheusAgentConfig(params: { + configAgentPlan: Record | undefined; + pluginPrometheusOverride: PrometheusOverride | undefined; + userCategories: Record | undefined; + currentModel: string | undefined; +}): Promise> { + const categoryConfig = params.pluginPrometheusOverride?.category + ? resolveCategoryConfig(params.pluginPrometheusOverride.category, params.userCategories) + : undefined; + + const requirement = AGENT_MODEL_REQUIREMENTS["prometheus"]; + const connectedProviders = readConnectedProvidersCache(); + const availableModels = await fetchAvailableModels(undefined, { + connectedProviders: connectedProviders ?? undefined, + }); + + const modelResolution = resolveModelPipeline({ + intent: { + uiSelectedModel: params.currentModel, + userModel: params.pluginPrometheusOverride?.model ?? categoryConfig?.model, + }, + constraints: { availableModels }, + policy: { + fallbackChain: requirement?.fallbackChain, + systemDefaultModel: undefined, + }, + }); + + const resolvedModel = modelResolution?.model; + const resolvedVariant = modelResolution?.variant; + + const variantToUse = params.pluginPrometheusOverride?.variant ?? resolvedVariant; + const reasoningEffortToUse = + params.pluginPrometheusOverride?.reasoningEffort ?? categoryConfig?.reasoningEffort; + const textVerbosityToUse = + params.pluginPrometheusOverride?.textVerbosity ?? categoryConfig?.textVerbosity; + const thinkingToUse = params.pluginPrometheusOverride?.thinking ?? categoryConfig?.thinking; + const temperatureToUse = + params.pluginPrometheusOverride?.temperature ?? categoryConfig?.temperature; + const topPToUse = params.pluginPrometheusOverride?.top_p ?? categoryConfig?.top_p; + const maxTokensToUse = + params.pluginPrometheusOverride?.maxTokens ?? categoryConfig?.maxTokens; + + const base: Record = { + name: "prometheus", + ...(resolvedModel ? { model: resolvedModel } : {}), + ...(variantToUse ? { variant: variantToUse } : {}), + mode: "all", + prompt: PROMETHEUS_SYSTEM_PROMPT, + permission: PROMETHEUS_PERMISSION, + description: `${(params.configAgentPlan?.description as string) ?? "Plan agent"} (Prometheus - OhMyOpenCode)`, + color: (params.configAgentPlan?.color as string) ?? "#FF5722", + ...(temperatureToUse !== undefined ? { temperature: temperatureToUse } : {}), + ...(topPToUse !== undefined ? { top_p: topPToUse } : {}), + ...(maxTokensToUse !== undefined ? { maxTokens: maxTokensToUse } : {}), + ...(categoryConfig?.tools ? { tools: categoryConfig.tools } : {}), + ...(thinkingToUse ? { thinking: thinkingToUse } : {}), + ...(reasoningEffortToUse !== undefined + ? { reasoningEffort: reasoningEffortToUse } + : {}), + ...(textVerbosityToUse !== undefined + ? { textVerbosity: textVerbosityToUse } + : {}), + }; + + const override = params.pluginPrometheusOverride; + if (!override) return base; + + const { prompt_append, ...restOverride } = override; + const merged = { ...base, ...restOverride }; + if (prompt_append && typeof merged.prompt === "string") { + merged.prompt = merged.prompt + "\n" + prompt_append; + } + return merged; +} diff --git a/src/plugin-handlers/provider-config-handler.ts b/src/plugin-handlers/provider-config-handler.ts new file mode 100644 index 000000000..75964d20b --- /dev/null +++ b/src/plugin-handlers/provider-config-handler.ts @@ -0,0 +1,36 @@ +import type { ModelCacheState } from "../plugin-state"; + +type ProviderConfig = { + options?: { headers?: Record }; + models?: Record; +}; + +export function applyProviderConfig(params: { + config: Record; + modelCacheState: ModelCacheState; +}): void { + const providers = params.config.provider as + | Record + | undefined; + + const anthropicBeta = providers?.anthropic?.options?.headers?.["anthropic-beta"]; + params.modelCacheState.anthropicContext1MEnabled = + anthropicBeta?.includes("context-1m") ?? false; + + if (!providers) return; + + for (const [providerID, providerConfig] of Object.entries(providers)) { + const models = providerConfig?.models; + if (!models) continue; + + for (const [modelID, modelConfig] of Object.entries(models)) { + const contextLimit = modelConfig?.limit?.context; + if (!contextLimit) continue; + + params.modelCacheState.modelContextLimitsCache.set( + `${providerID}/${modelID}`, + contextLimit, + ); + } + } +} diff --git a/src/plugin-handlers/tool-config-handler.ts b/src/plugin-handlers/tool-config-handler.ts new file mode 100644 index 000000000..f55044bd4 --- /dev/null +++ b/src/plugin-handlers/tool-config-handler.ts @@ -0,0 +1,91 @@ +import type { OhMyOpenCodeConfig } from "../config"; + +type AgentWithPermission = { permission?: Record }; + +export function applyToolConfig(params: { + config: Record; + pluginConfig: OhMyOpenCodeConfig; + agentResult: Record; +}): void { + params.config.tools = { + ...(params.config.tools as Record), + "grep_app_*": false, + LspHover: false, + LspCodeActions: false, + LspCodeActionResolve: false, + "task_*": false, + teammate: false, + ...(params.pluginConfig.experimental?.task_system + ? { todowrite: false, todoread: false } + : {}), + }; + + const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true"; + const questionPermission = isCliRunMode ? "deny" : "allow"; + + if (params.agentResult.librarian) { + const agent = params.agentResult.librarian as AgentWithPermission; + agent.permission = { ...agent.permission, "grep_app_*": "allow" }; + } + if (params.agentResult["multimodal-looker"]) { + const agent = params.agentResult["multimodal-looker"] as AgentWithPermission; + agent.permission = { ...agent.permission, task: "deny", look_at: "deny" }; + } + if (params.agentResult["atlas"]) { + const agent = params.agentResult["atlas"] as AgentWithPermission; + agent.permission = { + ...agent.permission, + task: "allow", + call_omo_agent: "deny", + "task_*": "allow", + teammate: "allow", + }; + } + if (params.agentResult.sisyphus) { + const agent = params.agentResult.sisyphus as AgentWithPermission; + agent.permission = { + ...agent.permission, + call_omo_agent: "deny", + task: "allow", + question: questionPermission, + "task_*": "allow", + teammate: "allow", + }; + } + if (params.agentResult.hephaestus) { + const agent = params.agentResult.hephaestus as AgentWithPermission; + agent.permission = { + ...agent.permission, + call_omo_agent: "deny", + task: "allow", + question: questionPermission, + }; + } + if (params.agentResult["prometheus"]) { + const agent = params.agentResult["prometheus"] as AgentWithPermission; + agent.permission = { + ...agent.permission, + call_omo_agent: "deny", + task: "allow", + question: questionPermission, + "task_*": "allow", + teammate: "allow", + }; + } + if (params.agentResult["sisyphus-junior"]) { + const agent = params.agentResult["sisyphus-junior"] as AgentWithPermission; + agent.permission = { + ...agent.permission, + task: "allow", + "task_*": "allow", + teammate: "allow", + }; + } + + params.config.permission = { + ...(params.config.permission as Record), + webfetch: "allow", + external_directory: "allow", + task: "deny", + }; +} diff --git a/src/plugin-interface.ts b/src/plugin-interface.ts new file mode 100644 index 000000000..f9b883a3f --- /dev/null +++ b/src/plugin-interface.ts @@ -0,0 +1,65 @@ +import type { PluginContext, PluginInterface, ToolsRecord } from "./plugin/types" +import type { OhMyOpenCodeConfig } from "./config" + +import { createChatParamsHandler } from "./plugin/chat-params" +import { createChatMessageHandler } from "./plugin/chat-message" +import { createMessagesTransformHandler } from "./plugin/messages-transform" +import { createEventHandler } from "./plugin/event" +import { createToolExecuteAfterHandler } from "./plugin/tool-execute-after" +import { createToolExecuteBeforeHandler } from "./plugin/tool-execute-before" + +import type { CreatedHooks } from "./create-hooks" +import type { Managers } from "./create-managers" + +export function createPluginInterface(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + firstMessageVariantGate: { + shouldOverride: (sessionID: string) => boolean + markApplied: (sessionID: string) => void + markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void + clear: (sessionID: string) => void + } + managers: Managers + hooks: CreatedHooks + tools: ToolsRecord +}): PluginInterface { + const { ctx, pluginConfig, firstMessageVariantGate, managers, hooks, tools } = + args + + return { + tool: tools, + + "chat.params": createChatParamsHandler({ anthropicEffort: hooks.anthropicEffort }), + + "chat.message": createChatMessageHandler({ + ctx, + pluginConfig, + firstMessageVariantGate, + hooks, + }), + + "experimental.chat.messages.transform": createMessagesTransformHandler({ + hooks, + }), + + config: managers.configHandler, + + event: createEventHandler({ + ctx, + pluginConfig, + firstMessageVariantGate, + managers, + hooks, + }), + + "tool.execute.before": createToolExecuteBeforeHandler({ + ctx, + hooks, + }), + + "tool.execute.after": createToolExecuteAfterHandler({ + hooks, + }), + } +} diff --git a/src/plugin/available-categories.ts b/src/plugin/available-categories.ts new file mode 100644 index 000000000..0cda43171 --- /dev/null +++ b/src/plugin/available-categories.ts @@ -0,0 +1,29 @@ +import type { AvailableCategory } from "../agents/dynamic-agent-prompt-builder" +import type { OhMyOpenCodeConfig } from "../config" + +import { + CATEGORY_DESCRIPTIONS, + DEFAULT_CATEGORIES, +} from "../tools/delegate-task/constants" + +export function createAvailableCategories( + pluginConfig: OhMyOpenCodeConfig, +): AvailableCategory[] { + const mergedCategories = pluginConfig.categories + ? { ...DEFAULT_CATEGORIES, ...pluginConfig.categories } + : DEFAULT_CATEGORIES + + return Object.entries(mergedCategories).map(([name, categoryConfig]) => { + const model = + typeof categoryConfig.model === "string" ? categoryConfig.model : undefined + + return { + name, + description: + pluginConfig.categories?.[name]?.description ?? + CATEGORY_DESCRIPTIONS[name] ?? + "General tasks", + model, + } + }) +} diff --git a/src/plugin/chat-message.ts b/src/plugin/chat-message.ts new file mode 100644 index 000000000..8cc1b394d --- /dev/null +++ b/src/plugin/chat-message.ts @@ -0,0 +1,139 @@ +import type { OhMyOpenCodeConfig } from "../config" +import type { PluginContext } from "./types" + +import { + applyAgentVariant, + resolveAgentVariant, + resolveVariantForModel, +} from "../shared/agent-variant" +import { hasConnectedProvidersCache } from "../shared" +import { + setSessionAgent, +} from "../features/claude-code-session-state" + +import type { CreatedHooks } from "../create-hooks" + +type FirstMessageVariantGate = { + shouldOverride: (sessionID: string) => boolean + markApplied: (sessionID: string) => void +} + +type ChatMessagePart = { type: string; text?: string; [key: string]: unknown } +type ChatMessageHandlerOutput = { message: Record; parts: ChatMessagePart[] } +type StartWorkHookOutput = { parts: Array<{ type: string; text?: string }> } + +function isStartWorkHookOutput(value: unknown): value is StartWorkHookOutput { + if (typeof value !== "object" || value === null) return false + const record = value as Record + const partsValue = record["parts"] + if (!Array.isArray(partsValue)) return false + return partsValue.every((part) => { + if (typeof part !== "object" || part === null) return false + const partRecord = part as Record + return typeof partRecord["type"] === "string" + }) +} + +export function createChatMessageHandler(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + firstMessageVariantGate: FirstMessageVariantGate + hooks: CreatedHooks +}): ( + input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string } }, + output: ChatMessageHandlerOutput +) => Promise { + const { ctx, pluginConfig, firstMessageVariantGate, hooks } = args + + return async ( + input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string } }, + output: ChatMessageHandlerOutput + ): Promise => { + if (input.agent) { + setSessionAgent(input.sessionID, input.agent) + } + + const message = output.message + + if (firstMessageVariantGate.shouldOverride(input.sessionID)) { + const variant = + input.model && input.agent + ? resolveVariantForModel(pluginConfig, input.agent, input.model) + : resolveAgentVariant(pluginConfig, input.agent) + if (variant !== undefined) { + message["variant"] = variant + } + firstMessageVariantGate.markApplied(input.sessionID) + } else { + if (input.model && input.agent && message["variant"] === undefined) { + const variant = resolveVariantForModel(pluginConfig, input.agent, input.model) + if (variant !== undefined) { + message["variant"] = variant + } + } else { + applyAgentVariant(pluginConfig, input.agent, message) + } + } + + await hooks.stopContinuationGuard?.["chat.message"]?.(input) + await hooks.keywordDetector?.["chat.message"]?.(input, output) + await hooks.claudeCodeHooks?.["chat.message"]?.(input, output) + await hooks.autoSlashCommand?.["chat.message"]?.(input, output) + if (hooks.startWork && isStartWorkHookOutput(output)) { + await hooks.startWork["chat.message"]?.(input, output) + } + + if (!hasConnectedProvidersCache()) { + ctx.client.tui + .showToast({ + body: { + title: "⚠️ Provider Cache Missing", + message: + "Model filtering disabled. RESTART OpenCode to enable full functionality.", + variant: "warning" as const, + duration: 6000, + }, + }) + .catch(() => {}) + } + + if (hooks.ralphLoop) { + const parts = output.parts + const promptText = + parts + ?.filter((p) => p.type === "text" && p.text) + .map((p) => p.text) + .join("\n") + .trim() || "" + + const isRalphLoopTemplate = + promptText.includes("You are starting a Ralph Loop") && + promptText.includes("") + const isCancelRalphTemplate = promptText.includes( + "Cancel the currently active Ralph Loop", + ) + + if (isRalphLoopTemplate) { + const taskMatch = promptText.match(/\s*([\s\S]*?)\s*<\/user-task>/i) + const rawTask = taskMatch?.[1]?.trim() || "" + const quotedMatch = rawTask.match(/^["'](.+?)["']/) + const prompt = + quotedMatch?.[1] || + rawTask.split(/\s+--/)[0]?.trim() || + "Complete the task as instructed" + + const maxIterMatch = rawTask.match(/--max-iterations=(\d+)/i) + const promiseMatch = rawTask.match( + /--completion-promise=["']?([^"'\s]+)["']?/i, + ) + + hooks.ralphLoop.startLoop(input.sessionID, prompt, { + maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined, + completionPromise: promiseMatch?.[1], + }) + } else if (isCancelRalphTemplate) { + hooks.ralphLoop.cancelLoop(input.sessionID) + } + } + } +} diff --git a/src/plugin/chat-params.ts b/src/plugin/chat-params.ts new file mode 100644 index 000000000..8f996a887 --- /dev/null +++ b/src/plugin/chat-params.ts @@ -0,0 +1,71 @@ +type ChatParamsInput = { + sessionID: string + agent: { name?: string } + model: { providerID: string; modelID: string } + provider: { id: string } + message: { variant?: string } +} + +type ChatParamsOutput = { + temperature?: number + topP?: number + topK?: number + options: Record +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function buildChatParamsInput(raw: unknown): ChatParamsInput | null { + if (!isRecord(raw)) return null + + const sessionID = raw.sessionID + const agent = raw.agent + const model = raw.model + const provider = raw.provider + const message = raw.message + + if (typeof sessionID !== "string") return null + if (typeof agent !== "string") return null + if (!isRecord(model)) return null + if (!isRecord(provider)) return null + if (!isRecord(message)) return null + + const providerID = model.providerID + const modelID = model.modelID + const providerId = provider.id + const variant = message.variant + + if (typeof providerID !== "string") return null + if (typeof modelID !== "string") return null + if (typeof providerId !== "string") return null + + return { + sessionID, + agent: { name: agent }, + model: { providerID, modelID }, + provider: { id: providerId }, + message: typeof variant === "string" ? { variant } : {}, + } +} + +function isChatParamsOutput(raw: unknown): raw is ChatParamsOutput { + if (!isRecord(raw)) return false + if (!isRecord(raw.options)) { + raw.options = {} + } + return isRecord(raw.options) +} + +export function createChatParamsHandler(args: { + anthropicEffort: { "chat.params"?: (input: ChatParamsInput, output: ChatParamsOutput) => Promise } | null +}): (input: unknown, output: unknown) => Promise { + return async (input, output): Promise => { + const normalizedInput = buildChatParamsInput(input) + if (!normalizedInput) return + if (!isChatParamsOutput(output)) return + + await args.anthropicEffort?.["chat.params"]?.(normalizedInput, output) + } +} diff --git a/src/plugin/event.ts b/src/plugin/event.ts new file mode 100644 index 000000000..bd05bce03 --- /dev/null +++ b/src/plugin/event.ts @@ -0,0 +1,133 @@ +import type { OhMyOpenCodeConfig } from "../config" +import type { PluginContext } from "./types" + +import { + clearSessionAgent, + getMainSessionID, + setMainSession, + updateSessionAgent, +} from "../features/claude-code-session-state" +import { resetMessageCursor } from "../shared" +import { lspManager } from "../tools" + +import type { CreatedHooks } from "../create-hooks" +import type { Managers } from "../create-managers" + +type FirstMessageVariantGate = { + markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void + clear: (sessionID: string) => void +} + +export function createEventHandler(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + firstMessageVariantGate: FirstMessageVariantGate + managers: Managers + hooks: CreatedHooks +}): (input: { event: { type: string; properties?: Record } }) => Promise { + const { ctx, firstMessageVariantGate, managers, hooks } = args + + return async (input): Promise => { + await hooks.autoUpdateChecker?.event?.(input) + await hooks.claudeCodeHooks?.event?.(input) + await hooks.backgroundNotificationHook?.event?.(input) + await hooks.sessionNotification?.(input) + await hooks.todoContinuationEnforcer?.handler?.(input) + await hooks.unstableAgentBabysitter?.event?.(input) + await hooks.contextWindowMonitor?.event?.(input) + await hooks.directoryAgentsInjector?.event?.(input) + await hooks.directoryReadmeInjector?.event?.(input) + await hooks.rulesInjector?.event?.(input) + await hooks.thinkMode?.event?.(input) + await hooks.anthropicContextWindowLimitRecovery?.event?.(input) + await hooks.agentUsageReminder?.event?.(input) + await hooks.categorySkillReminder?.event?.(input) + await hooks.interactiveBashSession?.event?.(input) + await hooks.ralphLoop?.event?.(input) + await hooks.stopContinuationGuard?.event?.(input) + await hooks.compactionTodoPreserver?.event?.(input) + await hooks.atlasHook?.handler?.(input) + + const { event } = input + const props = event.properties as Record | undefined + + if (event.type === "session.created") { + const sessionInfo = props?.info as + | { id?: string; title?: string; parentID?: string } + | undefined + + if (!sessionInfo?.parentID) { + setMainSession(sessionInfo?.id) + } + + firstMessageVariantGate.markSessionCreated(sessionInfo) + + await managers.tmuxSessionManager.onSessionCreated( + event as { + type: string + properties?: { + info?: { id?: string; parentID?: string; title?: string } + } + }, + ) + } + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id === getMainSessionID()) { + setMainSession(undefined) + } + + if (sessionInfo?.id) { + clearSessionAgent(sessionInfo.id) + resetMessageCursor(sessionInfo.id) + firstMessageVariantGate.clear(sessionInfo.id) + await managers.skillMcpManager.disconnectSession(sessionInfo.id) + await lspManager.cleanupTempDirectoryClients() + await managers.tmuxSessionManager.onSessionDeleted({ + sessionID: sessionInfo.id, + }) + } + } + + if (event.type === "message.updated") { + const info = props?.info as Record | undefined + const sessionID = info?.sessionID as string | undefined + const agent = info?.agent as string | undefined + const role = info?.role as string | undefined + if (sessionID && agent && role === "user") { + updateSessionAgent(sessionID, agent) + } + } + + if (event.type === "session.error") { + const sessionID = props?.sessionID as string | undefined + const error = props?.error + + if (hooks.sessionRecovery?.isRecoverableError(error)) { + const messageInfo = { + id: props?.messageID as string | undefined, + role: "assistant" as const, + sessionID, + error, + } + const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo) + + if ( + recovered && + sessionID && + sessionID === getMainSessionID() && + !hooks.stopContinuationGuard?.isStopped(sessionID) + ) { + await ctx.client.session + .prompt({ + path: { id: sessionID }, + body: { parts: [{ type: "text", text: "continue" }] }, + query: { directory: ctx.directory }, + }) + .catch(() => {}) + } + } + } + } +} diff --git a/src/plugin/hooks/create-continuation-hooks.ts b/src/plugin/hooks/create-continuation-hooks.ts new file mode 100644 index 000000000..90d17eebb --- /dev/null +++ b/src/plugin/hooks/create-continuation-hooks.ts @@ -0,0 +1,104 @@ +import type { HookName, OhMyOpenCodeConfig } from "../../config" +import type { BackgroundManager } from "../../features/background-agent" +import type { PluginContext } from "../types" + +import { + createTodoContinuationEnforcer, + createBackgroundNotificationHook, + createStopContinuationGuardHook, + createCompactionContextInjector, + createCompactionTodoPreserverHook, + createAtlasHook, +} from "../../hooks" +import { safeCreateHook } from "../../shared/safe-create-hook" +import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter" + +export type ContinuationHooks = { + stopContinuationGuard: ReturnType | null + compactionContextInjector: ReturnType | null + compactionTodoPreserver: ReturnType | null + todoContinuationEnforcer: ReturnType | null + unstableAgentBabysitter: ReturnType | null + backgroundNotificationHook: ReturnType | null + atlasHook: ReturnType | null +} + +type SessionRecovery = { + setOnAbortCallback: (callback: (sessionID: string) => void) => void + setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void +} | null + +export function createContinuationHooks(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + isHookEnabled: (hookName: HookName) => boolean + safeHookEnabled: boolean + backgroundManager: BackgroundManager + sessionRecovery: SessionRecovery +}): ContinuationHooks { + const { + ctx, + pluginConfig, + isHookEnabled, + safeHookEnabled, + backgroundManager, + sessionRecovery, + } = args + + const safeHook = (hookName: HookName, factory: () => T): T | null => + safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) + + const stopContinuationGuard = isHookEnabled("stop-continuation-guard") + ? safeHook("stop-continuation-guard", () => createStopContinuationGuardHook(ctx)) + : null + + const compactionContextInjector = isHookEnabled("compaction-context-injector") + ? safeHook("compaction-context-injector", () => createCompactionContextInjector()) + : null + + const compactionTodoPreserver = isHookEnabled("compaction-todo-preserver") + ? safeHook("compaction-todo-preserver", () => createCompactionTodoPreserverHook(ctx)) + : null + + const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") + ? safeHook("todo-continuation-enforcer", () => + createTodoContinuationEnforcer(ctx, { + backgroundManager, + isContinuationStopped: stopContinuationGuard?.isStopped, + })) + : null + + const unstableAgentBabysitter = isHookEnabled("unstable-agent-babysitter") + ? safeHook("unstable-agent-babysitter", () => + createUnstableAgentBabysitter({ ctx, backgroundManager, pluginConfig })) + : null + + if (sessionRecovery && todoContinuationEnforcer) { + sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering) + sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete) + } + + const backgroundNotificationHook = isHookEnabled("background-notification") + ? safeHook("background-notification", () => createBackgroundNotificationHook(backgroundManager)) + : null + + const atlasHook = isHookEnabled("atlas") + ? safeHook("atlas", () => + createAtlasHook(ctx, { + directory: ctx.directory, + backgroundManager, + isContinuationStopped: (sessionID: string) => + stopContinuationGuard?.isStopped(sessionID) ?? false, + })) + : null + + return { + stopContinuationGuard, + compactionContextInjector, + compactionTodoPreserver, + todoContinuationEnforcer, + unstableAgentBabysitter, + backgroundNotificationHook, + atlasHook, + } +} diff --git a/src/plugin/hooks/create-core-hooks.ts b/src/plugin/hooks/create-core-hooks.ts new file mode 100644 index 000000000..2bfac4ae4 --- /dev/null +++ b/src/plugin/hooks/create-core-hooks.ts @@ -0,0 +1,42 @@ +import type { HookName, OhMyOpenCodeConfig } from "../../config" +import type { PluginContext } from "../types" + +import { createSessionHooks } from "./create-session-hooks" +import { createToolGuardHooks } from "./create-tool-guard-hooks" +import { createTransformHooks } from "./create-transform-hooks" + +export function createCoreHooks(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + isHookEnabled: (hookName: HookName) => boolean + safeHookEnabled: boolean +}) { + const { ctx, pluginConfig, isHookEnabled, safeHookEnabled } = args + + const session = createSessionHooks({ + ctx, + pluginConfig, + isHookEnabled, + safeHookEnabled, + }) + + const tool = createToolGuardHooks({ + ctx, + pluginConfig, + isHookEnabled, + safeHookEnabled, + }) + + const transform = createTransformHooks({ + ctx, + pluginConfig, + isHookEnabled: (name) => isHookEnabled(name as HookName), + safeHookEnabled, + }) + + return { + ...session, + ...tool, + ...transform, + } +} diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts new file mode 100644 index 000000000..28a0ecc32 --- /dev/null +++ b/src/plugin/hooks/create-session-hooks.ts @@ -0,0 +1,181 @@ +import type { OhMyOpenCodeConfig, HookName } from "../../config" +import type { PluginContext } from "../types" + +import { + createContextWindowMonitorHook, + createSessionRecoveryHook, + createSessionNotification, + createThinkModeHook, + createAnthropicContextWindowLimitRecoveryHook, + createAutoUpdateCheckerHook, + createAgentUsageReminderHook, + createNonInteractiveEnvHook, + createInteractiveBashSessionHook, + createRalphLoopHook, + createEditErrorRecoveryHook, + createDelegateTaskRetryHook, + createTaskResumeInfoHook, + createStartWorkHook, + createPrometheusMdOnlyHook, + createSisyphusJuniorNotepadHook, + createQuestionLabelTruncatorHook, + createSubagentQuestionBlockerHook, + createPreemptiveCompactionHook, +} from "../../hooks" +import { createAnthropicEffortHook } from "../../hooks/anthropic-effort" +import { + detectExternalNotificationPlugin, + getNotificationConflictWarning, + log, +} from "../../shared" +import { safeCreateHook } from "../../shared/safe-create-hook" +import { sessionExists } from "../../tools" + +export type SessionHooks = { + contextWindowMonitor: ReturnType | null + preemptiveCompaction: ReturnType | null + sessionRecovery: ReturnType | null + sessionNotification: ReturnType | null + thinkMode: ReturnType | null + anthropicContextWindowLimitRecovery: ReturnType | null + autoUpdateChecker: ReturnType | null + agentUsageReminder: ReturnType | null + nonInteractiveEnv: ReturnType | null + interactiveBashSession: ReturnType | null + ralphLoop: ReturnType | null + editErrorRecovery: ReturnType | null + delegateTaskRetry: ReturnType | null + startWork: ReturnType | null + prometheusMdOnly: ReturnType | null + sisyphusJuniorNotepad: ReturnType | null + questionLabelTruncator: ReturnType + subagentQuestionBlocker: ReturnType + taskResumeInfo: ReturnType + anthropicEffort: ReturnType | null +} + +export function createSessionHooks(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + isHookEnabled: (hookName: HookName) => boolean + safeHookEnabled: boolean +}): SessionHooks { + const { ctx, pluginConfig, isHookEnabled, safeHookEnabled } = args + const safeHook = (hookName: HookName, factory: () => T): T | null => + safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) + + const contextWindowMonitor = isHookEnabled("context-window-monitor") + ? safeHook("context-window-monitor", () => createContextWindowMonitorHook(ctx)) + : null + + const preemptiveCompaction = + isHookEnabled("preemptive-compaction") && + pluginConfig.experimental?.preemptive_compaction + ? safeHook("preemptive-compaction", () => createPreemptiveCompactionHook(ctx)) + : null + + const sessionRecovery = isHookEnabled("session-recovery") + ? safeHook("session-recovery", () => + createSessionRecoveryHook(ctx, { experimental: pluginConfig.experimental })) + : null + + let sessionNotification: ReturnType | null = null + if (isHookEnabled("session-notification")) { + const forceEnable = pluginConfig.notification?.force_enable ?? false + const externalNotifier = detectExternalNotificationPlugin(ctx.directory) + if (externalNotifier.detected && !forceEnable) { + log(getNotificationConflictWarning(externalNotifier.pluginName!)) + } else { + sessionNotification = safeHook("session-notification", () => createSessionNotification(ctx)) + } + } + + const thinkMode = isHookEnabled("think-mode") + ? safeHook("think-mode", () => createThinkModeHook()) + : null + + const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery") + ? safeHook("anthropic-context-window-limit-recovery", () => + createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental })) + : null + + const autoUpdateChecker = isHookEnabled("auto-update-checker") + ? safeHook("auto-update-checker", () => + createAutoUpdateCheckerHook(ctx, { + showStartupToast: isHookEnabled("startup-toast"), + isSisyphusEnabled: pluginConfig.sisyphus_agent?.disabled !== true, + autoUpdate: pluginConfig.auto_update ?? true, + })) + : null + + const agentUsageReminder = isHookEnabled("agent-usage-reminder") + ? safeHook("agent-usage-reminder", () => createAgentUsageReminderHook(ctx)) + : null + + const nonInteractiveEnv = isHookEnabled("non-interactive-env") + ? safeHook("non-interactive-env", () => createNonInteractiveEnvHook(ctx)) + : null + + const interactiveBashSession = isHookEnabled("interactive-bash-session") + ? safeHook("interactive-bash-session", () => createInteractiveBashSessionHook(ctx)) + : null + + const ralphLoop = isHookEnabled("ralph-loop") + ? safeHook("ralph-loop", () => + createRalphLoopHook(ctx, { + config: pluginConfig.ralph_loop, + checkSessionExists: async (sessionId) => sessionExists(sessionId), + })) + : null + + const editErrorRecovery = isHookEnabled("edit-error-recovery") + ? safeHook("edit-error-recovery", () => createEditErrorRecoveryHook(ctx)) + : null + + const delegateTaskRetry = isHookEnabled("delegate-task-retry") + ? safeHook("delegate-task-retry", () => createDelegateTaskRetryHook(ctx)) + : null + + const startWork = isHookEnabled("start-work") + ? safeHook("start-work", () => createStartWorkHook(ctx)) + : null + + const prometheusMdOnly = isHookEnabled("prometheus-md-only") + ? safeHook("prometheus-md-only", () => createPrometheusMdOnlyHook(ctx)) + : null + + const sisyphusJuniorNotepad = isHookEnabled("sisyphus-junior-notepad") + ? safeHook("sisyphus-junior-notepad", () => createSisyphusJuniorNotepadHook(ctx)) + : null + + const questionLabelTruncator = createQuestionLabelTruncatorHook() + const subagentQuestionBlocker = createSubagentQuestionBlockerHook() + const taskResumeInfo = createTaskResumeInfoHook() + + const anthropicEffort = isHookEnabled("anthropic-effort") + ? safeHook("anthropic-effort", () => createAnthropicEffortHook()) + : null + + return { + contextWindowMonitor, + preemptiveCompaction, + sessionRecovery, + sessionNotification, + thinkMode, + anthropicContextWindowLimitRecovery, + autoUpdateChecker, + agentUsageReminder, + nonInteractiveEnv, + interactiveBashSession, + ralphLoop, + editErrorRecovery, + delegateTaskRetry, + startWork, + prometheusMdOnly, + sisyphusJuniorNotepad, + questionLabelTruncator, + subagentQuestionBlocker, + taskResumeInfo, + anthropicEffort, + } +} diff --git a/src/plugin/hooks/create-skill-hooks.ts b/src/plugin/hooks/create-skill-hooks.ts new file mode 100644 index 000000000..043a0bbbb --- /dev/null +++ b/src/plugin/hooks/create-skill-hooks.ts @@ -0,0 +1,37 @@ +import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" +import type { HookName } from "../../config" +import type { LoadedSkill } from "../../features/opencode-skill-loader/types" +import type { PluginContext } from "../types" + +import { createAutoSlashCommandHook, createCategorySkillReminderHook } from "../../hooks" +import { safeCreateHook } from "../../shared/safe-create-hook" + +export type SkillHooks = { + categorySkillReminder: ReturnType | null + autoSlashCommand: ReturnType | null +} + +export function createSkillHooks(args: { + ctx: PluginContext + isHookEnabled: (hookName: HookName) => boolean + safeHookEnabled: boolean + mergedSkills: LoadedSkill[] + availableSkills: AvailableSkill[] +}): SkillHooks { + const { ctx, isHookEnabled, safeHookEnabled, mergedSkills, availableSkills } = args + + const safeHook = (hookName: HookName, factory: () => T): T | null => + safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) + + const categorySkillReminder = isHookEnabled("category-skill-reminder") + ? safeHook("category-skill-reminder", () => + createCategorySkillReminderHook(ctx, availableSkills)) + : null + + const autoSlashCommand = isHookEnabled("auto-slash-command") + ? safeHook("auto-slash-command", () => + createAutoSlashCommandHook({ skills: mergedSkills })) + : null + + return { categorySkillReminder, autoSlashCommand } +} diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts new file mode 100644 index 000000000..ba0cb7f4b --- /dev/null +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -0,0 +1,98 @@ +import type { HookName, OhMyOpenCodeConfig } from "../../config" +import type { PluginContext } from "../types" + +import { + createCommentCheckerHooks, + createToolOutputTruncatorHook, + createDirectoryAgentsInjectorHook, + createDirectoryReadmeInjectorHook, + createEmptyTaskResponseDetectorHook, + createRulesInjectorHook, + createTasksTodowriteDisablerHook, + createWriteExistingFileGuardHook, +} from "../../hooks" +import { + getOpenCodeVersion, + isOpenCodeVersionAtLeast, + log, + OPENCODE_NATIVE_AGENTS_INJECTION_VERSION, +} from "../../shared" +import { safeCreateHook } from "../../shared/safe-create-hook" + +export type ToolGuardHooks = { + commentChecker: ReturnType | null + toolOutputTruncator: ReturnType | null + directoryAgentsInjector: ReturnType | null + directoryReadmeInjector: ReturnType | null + emptyTaskResponseDetector: ReturnType | null + rulesInjector: ReturnType | null + tasksTodowriteDisabler: ReturnType | null + writeExistingFileGuard: ReturnType | null +} + +export function createToolGuardHooks(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + isHookEnabled: (hookName: HookName) => boolean + safeHookEnabled: boolean +}): ToolGuardHooks { + const { ctx, pluginConfig, isHookEnabled, safeHookEnabled } = args + const safeHook = (hookName: HookName, factory: () => T): T | null => + safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) + + const commentChecker = isHookEnabled("comment-checker") + ? safeHook("comment-checker", () => createCommentCheckerHooks(pluginConfig.comment_checker)) + : null + + const toolOutputTruncator = isHookEnabled("tool-output-truncator") + ? safeHook("tool-output-truncator", () => + createToolOutputTruncatorHook(ctx, { experimental: pluginConfig.experimental })) + : null + + let directoryAgentsInjector: ReturnType | null = null + if (isHookEnabled("directory-agents-injector")) { + const currentVersion = getOpenCodeVersion() + const hasNativeSupport = + currentVersion !== null && isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION) + if (hasNativeSupport) { + log("directory-agents-injector auto-disabled due to native OpenCode support", { + currentVersion, + nativeVersion: OPENCODE_NATIVE_AGENTS_INJECTION_VERSION, + }) + } else { + directoryAgentsInjector = safeHook("directory-agents-injector", () => createDirectoryAgentsInjectorHook(ctx)) + } + } + + const directoryReadmeInjector = isHookEnabled("directory-readme-injector") + ? safeHook("directory-readme-injector", () => createDirectoryReadmeInjectorHook(ctx)) + : null + + const emptyTaskResponseDetector = isHookEnabled("empty-task-response-detector") + ? safeHook("empty-task-response-detector", () => createEmptyTaskResponseDetectorHook(ctx)) + : null + + const rulesInjector = isHookEnabled("rules-injector") + ? safeHook("rules-injector", () => createRulesInjectorHook(ctx)) + : null + + const tasksTodowriteDisabler = isHookEnabled("tasks-todowrite-disabler") + ? safeHook("tasks-todowrite-disabler", () => + createTasksTodowriteDisablerHook({ experimental: pluginConfig.experimental })) + : null + + const writeExistingFileGuard = isHookEnabled("write-existing-file-guard") + ? safeHook("write-existing-file-guard", () => createWriteExistingFileGuardHook(ctx)) + : null + + return { + commentChecker, + toolOutputTruncator, + directoryAgentsInjector, + directoryReadmeInjector, + emptyTaskResponseDetector, + rulesInjector, + tasksTodowriteDisabler, + writeExistingFileGuard, + } +} diff --git a/src/plugin/hooks/create-transform-hooks.ts b/src/plugin/hooks/create-transform-hooks.ts new file mode 100644 index 000000000..8001d0ab1 --- /dev/null +++ b/src/plugin/hooks/create-transform-hooks.ts @@ -0,0 +1,65 @@ +import type { OhMyOpenCodeConfig } from "../../config" +import type { PluginContext } from "../types" + +import { + createClaudeCodeHooksHook, + createKeywordDetectorHook, + createThinkingBlockValidatorHook, +} from "../../hooks" +import { + contextCollector, + createContextInjectorMessagesTransformHook, +} from "../../features/context-injector" +import { safeCreateHook } from "../../shared/safe-create-hook" + +export type TransformHooks = { + claudeCodeHooks: ReturnType + keywordDetector: ReturnType | null + contextInjectorMessagesTransform: ReturnType + thinkingBlockValidator: ReturnType | null +} + +export function createTransformHooks(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + isHookEnabled: (hookName: string) => boolean + safeHookEnabled?: boolean +}): TransformHooks { + const { ctx, pluginConfig, isHookEnabled } = args + const safeHookEnabled = args.safeHookEnabled ?? true + + const claudeCodeHooks = createClaudeCodeHooksHook( + ctx, + { + disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true, + keywordDetectorDisabled: !isHookEnabled("keyword-detector"), + }, + contextCollector, + ) + + const keywordDetector = isHookEnabled("keyword-detector") + ? safeCreateHook( + "keyword-detector", + () => createKeywordDetectorHook(ctx, contextCollector), + { enabled: safeHookEnabled }, + ) + : null + + const contextInjectorMessagesTransform = + createContextInjectorMessagesTransformHook(contextCollector) + + const thinkingBlockValidator = isHookEnabled("thinking-block-validator") + ? safeCreateHook( + "thinking-block-validator", + () => createThinkingBlockValidatorHook(), + { enabled: safeHookEnabled }, + ) + : null + + return { + claudeCodeHooks, + keywordDetector, + contextInjectorMessagesTransform, + thinkingBlockValidator, + } +} diff --git a/src/plugin/messages-transform.ts b/src/plugin/messages-transform.ts new file mode 100644 index 000000000..6ea674d8a --- /dev/null +++ b/src/plugin/messages-transform.ts @@ -0,0 +1,24 @@ +import type { Message, Part } from "@opencode-ai/sdk" + +import type { CreatedHooks } from "../create-hooks" + +type MessageWithParts = { + info: Message + parts: Part[] +} + +type MessagesTransformOutput = { messages: MessageWithParts[] } + +export function createMessagesTransformHandler(args: { + hooks: CreatedHooks +}): (input: Record, output: MessagesTransformOutput) => Promise { + return async (input, output): Promise => { + await args.hooks.contextInjectorMessagesTransform?.[ + "experimental.chat.messages.transform" + ]?.(input, output) + + await args.hooks.thinkingBlockValidator?.[ + "experimental.chat.messages.transform" + ]?.(input, output) + } +} diff --git a/src/plugin/skill-context.ts b/src/plugin/skill-context.ts new file mode 100644 index 000000000..634cbc594 --- /dev/null +++ b/src/plugin/skill-context.ts @@ -0,0 +1,87 @@ +import type { AvailableSkill } from "../agents/dynamic-agent-prompt-builder" +import type { OhMyOpenCodeConfig } from "../config" +import type { BrowserAutomationProvider } from "../config/schema/browser-automation" +import type { + LoadedSkill, + SkillScope, +} from "../features/opencode-skill-loader/types" + +import { + discoverUserClaudeSkills, + discoverProjectClaudeSkills, + discoverOpencodeGlobalSkills, + discoverOpencodeProjectSkills, + mergeSkills, +} from "../features/opencode-skill-loader" +import { createBuiltinSkills } from "../features/builtin-skills" +import { getSystemMcpServerNames } from "../features/claude-code-mcp-loader" + +export type SkillContext = { + mergedSkills: LoadedSkill[] + availableSkills: AvailableSkill[] + browserProvider: BrowserAutomationProvider + disabledSkills: Set +} + +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 createSkillContext(args: { + directory: string + pluginConfig: OhMyOpenCodeConfig +}): Promise { + const { directory, pluginConfig } = args + + const browserProvider: BrowserAutomationProvider = + pluginConfig.browser_automation_engine?.provider ?? "playwright" + + const disabledSkills = new Set(pluginConfig.disabled_skills ?? []) + const systemMcpNames = getSystemMcpServerNames() + + const builtinSkills = createBuiltinSkills({ + browserProvider, + disabledSkills, + }).filter((skill) => { + if (skill.mcpConfig) { + for (const mcpName of Object.keys(skill.mcpConfig)) { + if (systemMcpNames.has(mcpName)) return false + } + } + return true + }) + + const includeClaudeSkills = pluginConfig.claude_code?.skills !== false + const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = + await Promise.all([ + includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]), + discoverOpencodeGlobalSkills(), + includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]), + discoverOpencodeProjectSkills(), + ]) + + const mergedSkills = mergeSkills( + builtinSkills, + pluginConfig.skills, + userSkills, + globalSkills, + projectSkills, + opencodeProjectSkills, + { configDir: directory }, + ) + + const availableSkills: AvailableSkill[] = mergedSkills.map((skill) => ({ + name: skill.name, + description: skill.definition.description ?? "", + location: mapScopeToLocation(skill.scope), + })) + + return { + mergedSkills, + availableSkills, + browserProvider, + disabledSkills, + } +} diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts new file mode 100644 index 000000000..21282e3d3 --- /dev/null +++ b/src/plugin/tool-execute-after.ts @@ -0,0 +1,47 @@ +import { consumeToolMetadata } from "../features/tool-metadata-store" +import type { CreatedHooks } from "../create-hooks" + +export function createToolExecuteAfterHandler(args: { + hooks: CreatedHooks +}): ( + input: { tool: string; sessionID: string; callID: string }, + output: + | { title: string; output: string; metadata: Record } + | undefined, +) => Promise { + const { hooks } = args + + return async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: Record } | undefined, + ): Promise => { + if (!output) return + + const stored = consumeToolMetadata(input.sessionID, input.callID) + if (stored) { + if (stored.title) { + output.title = stored.title + } + if (stored.metadata) { + output.metadata = { ...output.metadata, ...stored.metadata } + } + } + + await hooks.claudeCodeHooks?.["tool.execute.after"]?.(input, output) + await hooks.toolOutputTruncator?.["tool.execute.after"]?.(input, output) + await hooks.preemptiveCompaction?.["tool.execute.after"]?.(input, output) + await hooks.contextWindowMonitor?.["tool.execute.after"]?.(input, output) + await hooks.commentChecker?.["tool.execute.after"]?.(input, output) + await hooks.directoryAgentsInjector?.["tool.execute.after"]?.(input, output) + await hooks.directoryReadmeInjector?.["tool.execute.after"]?.(input, output) + await hooks.rulesInjector?.["tool.execute.after"]?.(input, output) + await hooks.emptyTaskResponseDetector?.["tool.execute.after"]?.(input, output) + await hooks.agentUsageReminder?.["tool.execute.after"]?.(input, output) + await hooks.categorySkillReminder?.["tool.execute.after"]?.(input, output) + await hooks.interactiveBashSession?.["tool.execute.after"]?.(input, output) + await hooks.editErrorRecovery?.["tool.execute.after"]?.(input, output) + await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output) + await hooks.atlasHook?.["tool.execute.after"]?.(input, output) + await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output) + } +} diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts new file mode 100644 index 000000000..c7fefb0a6 --- /dev/null +++ b/src/plugin/tool-execute-before.ts @@ -0,0 +1,99 @@ +import type { PluginContext } from "./types" + +import { getMainSessionID } from "../features/claude-code-session-state" +import { clearBoulderState } from "../features/boulder-state" +import { log } from "../shared" + +import type { CreatedHooks } from "../create-hooks" + +export function createToolExecuteBeforeHandler(args: { + ctx: PluginContext + hooks: CreatedHooks +}): ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record }, +) => Promise { + const { ctx, hooks } = args + + return async (input, output): Promise => { + await hooks.subagentQuestionBlocker?.["tool.execute.before"]?.(input, output) + await hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output) + await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output) + await hooks.claudeCodeHooks?.["tool.execute.before"]?.(input, output) + await hooks.nonInteractiveEnv?.["tool.execute.before"]?.(input, output) + await hooks.commentChecker?.["tool.execute.before"]?.(input, output) + await hooks.directoryAgentsInjector?.["tool.execute.before"]?.(input, output) + await hooks.directoryReadmeInjector?.["tool.execute.before"]?.(input, output) + await hooks.rulesInjector?.["tool.execute.before"]?.(input, output) + await hooks.tasksTodowriteDisabler?.["tool.execute.before"]?.(input, output) + await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output) + await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output) + await hooks.atlasHook?.["tool.execute.before"]?.(input, output) + + if (input.tool === "task") { + const argsObject = output.args + const category = typeof argsObject.category === "string" ? argsObject.category : undefined + const subagentType = typeof argsObject.subagent_type === "string" ? argsObject.subagent_type : undefined + if (category && !subagentType) { + argsObject.subagent_type = "sisyphus-junior" + } + } + + if (hooks.ralphLoop && input.tool === "slashcommand") { + const rawCommand = typeof output.args.command === "string" ? output.args.command : undefined + const command = rawCommand?.replace(/^\//, "").toLowerCase() + const sessionID = input.sessionID || getMainSessionID() + + if (command === "ralph-loop" && sessionID) { + const rawArgs = rawCommand?.replace(/^\/?(ralph-loop)\s*/i, "") || "" + const taskMatch = rawArgs.match(/^["'](.+?)["']/) + const prompt = + taskMatch?.[1] || + rawArgs.split(/\s+--/)[0]?.trim() || + "Complete the task as instructed" + + const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i) + const promiseMatch = rawArgs.match(/--completion-promise=["']?([^"'\s]+)["']?/i) + + hooks.ralphLoop.startLoop(sessionID, prompt, { + maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined, + completionPromise: promiseMatch?.[1], + }) + } else if (command === "cancel-ralph" && sessionID) { + hooks.ralphLoop.cancelLoop(sessionID) + } else if (command === "ulw-loop" && sessionID) { + const rawArgs = rawCommand?.replace(/^\/?(ulw-loop)\s*/i, "") || "" + const taskMatch = rawArgs.match(/^["'](.+?)["']/) + const prompt = + taskMatch?.[1] || + rawArgs.split(/\s+--/)[0]?.trim() || + "Complete the task as instructed" + + const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i) + const promiseMatch = rawArgs.match(/--completion-promise=["']?([^"'\s]+)["']?/i) + + hooks.ralphLoop.startLoop(sessionID, prompt, { + ultrawork: true, + maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined, + completionPromise: promiseMatch?.[1], + }) + } + } + + if (input.tool === "slashcommand") { + const rawCommand = typeof output.args.command === "string" ? output.args.command : undefined + const command = rawCommand?.replace(/^\//, "").toLowerCase() + const sessionID = input.sessionID || getMainSessionID() + + if (command === "stop-continuation" && sessionID) { + hooks.stopContinuationGuard?.stop(sessionID) + hooks.todoContinuationEnforcer?.cancelAllCountdowns() + hooks.ralphLoop?.cancelLoop(sessionID) + clearBoulderState(ctx.directory) + log("[stop-continuation] All continuation mechanisms stopped", { + sessionID, + }) + } + } + } +} diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts new file mode 100644 index 000000000..7236ddc48 --- /dev/null +++ b/src/plugin/tool-registry.ts @@ -0,0 +1,143 @@ +import type { ToolDefinition } from "@opencode-ai/plugin" + +import type { + AvailableCategory, +} from "../agents/dynamic-agent-prompt-builder" +import type { OhMyOpenCodeConfig } from "../config" +import type { PluginContext, ToolsRecord } from "./types" + +import { + builtinTools, + createBackgroundTools, + createCallOmoAgent, + createLookAt, + createSkillTool, + createSkillMcpTool, + createSlashcommandTool, + createGrepTools, + createGlobTools, + createAstGrepTools, + createSessionManagerTools, + createDelegateTask, + discoverCommandsSync, + interactive_bash, + createTaskCreateTool, + createTaskGetTool, + createTaskList, + createTaskUpdateTool, +} from "../tools" +import { getMainSessionID } from "../features/claude-code-session-state" +import { filterDisabledTools } from "../shared/disabled-tools" +import { log } from "../shared" + +import type { Managers } from "../create-managers" +import type { SkillContext } from "./skill-context" + +export type ToolRegistryResult = { + filteredTools: ToolsRecord + taskSystemEnabled: boolean +} + +export function createToolRegistry(args: { + ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig + managers: Pick + skillContext: SkillContext + availableCategories: AvailableCategory[] +}): ToolRegistryResult { + const { ctx, pluginConfig, managers, skillContext, availableCategories } = args + + const backgroundTools = createBackgroundTools(managers.backgroundManager, ctx.client) + const callOmoAgent = createCallOmoAgent(ctx, managers.backgroundManager) + + const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some( + (agent) => agent.toLowerCase() === "multimodal-looker", + ) + const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null + + const delegateTask = createDelegateTask({ + manager: managers.backgroundManager, + client: ctx.client, + directory: ctx.directory, + userCategories: pluginConfig.categories, + gitMasterConfig: pluginConfig.git_master, + sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model, + browserProvider: skillContext.browserProvider, + disabledSkills: skillContext.disabledSkills, + availableCategories, + availableSkills: skillContext.availableSkills, + onSyncSessionCreated: async (event) => { + log("[index] onSyncSessionCreated callback", { + sessionID: event.sessionID, + parentID: event.parentID, + title: event.title, + }) + await managers.tmuxSessionManager.onSessionCreated({ + type: "session.created", + properties: { + info: { + id: event.sessionID, + parentID: event.parentID, + title: event.title, + }, + }, + }) + }, + }) + + const getSessionIDForMcp = (): string => getMainSessionID() || "" + + const skillTool = createSkillTool({ + skills: skillContext.mergedSkills, + mcpManager: managers.skillMcpManager, + getSessionID: getSessionIDForMcp, + gitMasterConfig: pluginConfig.git_master, + disabledSkills: skillContext.disabledSkills, + }) + + const skillMcpTool = createSkillMcpTool({ + manager: managers.skillMcpManager, + getLoadedSkills: () => skillContext.mergedSkills, + getSessionID: getSessionIDForMcp, + }) + + const commands = discoverCommandsSync() + const slashcommandTool = createSlashcommandTool({ + commands, + skills: skillContext.mergedSkills, + }) + + const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false + const taskToolsRecord: Record = taskSystemEnabled + ? { + task_create: createTaskCreateTool(pluginConfig, ctx), + task_get: createTaskGetTool(pluginConfig), + task_list: createTaskList(pluginConfig), + task_update: createTaskUpdateTool(pluginConfig, ctx), + } + : {} + + const allTools: Record = { + ...builtinTools, + ...createGrepTools(ctx), + ...createGlobTools(ctx), + ...createAstGrepTools(ctx), + ...createSessionManagerTools(ctx), + ...backgroundTools, + call_omo_agent: callOmoAgent, + ...(lookAt ? { look_at: lookAt } : {}), + task: delegateTask, + skill: skillTool, + skill_mcp: skillMcpTool, + slashcommand: slashcommandTool, + interactive_bash, + ...taskToolsRecord, + } + + const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools) + + return { + filteredTools, + taskSystemEnabled, + } +} diff --git a/src/plugin/types.ts b/src/plugin/types.ts new file mode 100644 index 000000000..583255052 --- /dev/null +++ b/src/plugin/types.ts @@ -0,0 +1,15 @@ +import type { Plugin, ToolDefinition } from "@opencode-ai/plugin" + +export type PluginContext = Parameters[0] +export type PluginInstance = Awaited> +export type PluginInterface = Omit + +export type ToolsRecord = Record + +export type TmuxConfig = { + enabled: boolean + layout: "main-horizontal" | "main-vertical" | "tiled" | "even-horizontal" | "even-vertical" + main_pane_size: number + main_pane_min_width: number + agent_pane_min_width: number +} diff --git a/src/plugin/unstable-agent-babysitter.ts b/src/plugin/unstable-agent-babysitter.ts new file mode 100644 index 000000000..6ab73bbd8 --- /dev/null +++ b/src/plugin/unstable-agent-babysitter.ts @@ -0,0 +1,41 @@ +import type { OhMyOpenCodeConfig } from "../config" +import type { PluginContext } from "./types" + +import { createUnstableAgentBabysitterHook } from "../hooks" +import type { BackgroundManager } from "../features/background-agent" + +export function createUnstableAgentBabysitter(args: { + ctx: PluginContext + backgroundManager: BackgroundManager + pluginConfig: OhMyOpenCodeConfig +}) { + const { ctx, backgroundManager, pluginConfig } = args + + return createUnstableAgentBabysitterHook( + { + directory: ctx.directory, + client: { + session: { + messages: async ({ path }) => { + const result = await ctx.client.session.messages({ path }) + if (Array.isArray(result)) return result + if (typeof result === "object" && result !== null) { + return result + } + return [] + }, + prompt: async (promptArgs) => { + await ctx.client.session.promptAsync(promptArgs) + }, + promptAsync: async (promptArgs) => { + await ctx.client.session.promptAsync(promptArgs) + }, + }, + }, + }, + { + backgroundManager, + config: pluginConfig.babysitting, + }, + ) +} From caf08af88b3f1f9c61a57622bee2e0f37b521d72 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 18:03:08 +0900 Subject: [PATCH 26/51] fix: resolve test isolation failures in task-continuation-enforcer and config-handler tests - Change BackgroundManager import to type-only to prevent global process listener pollution across parallel test files - Replace real BackgroundManager construction with createMockBackgroundManager - Fix nested spyOn in config-handler tests to reuse beforeEach spy via mockResolvedValue instead of re-spying inside test bodies --- src/hooks/task-continuation-enforcer.test.ts | 4 ++-- src/plugin-handlers/config-handler.test.ts | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/hooks/task-continuation-enforcer.test.ts b/src/hooks/task-continuation-enforcer.test.ts index 1a0cbc75d..d4d73ce71 100644 --- a/src/hooks/task-continuation-enforcer.test.ts +++ b/src/hooks/task-continuation-enforcer.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { BackgroundManager } from "../features/background-agent" +import type { BackgroundManager } from "../features/background-agent" import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state" import type { OhMyOpenCodeConfig } from "../config/schema" import { TaskObjectSchema } from "../tools/task/types" @@ -244,7 +244,7 @@ describe("task-continuation-enforcer", () => { }) const hook = createTaskContinuationEnforcer(createMockPluginInput(), createConfig(taskDir), { - backgroundManager: new BackgroundManager(createMockPluginInput()), + backgroundManager: createMockBackgroundManager(false), }) // when - session goes idle diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 08c58f6f0..62b5140ad 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test" import { resolveCategoryConfig, createConfigHandler } from "./config-handler" import type { CategoryConfig } from "../config/schema" @@ -949,7 +951,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { test("denies todowrite and todoread for primary agents when task_system is enabled", async () => { //#given - spyOn(agents, "createBuiltinAgents" as any).mockResolvedValue({ + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mockResolvedValue: (value: Record) => void + } + createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" }, atlas: { name: "atlas", prompt: "test", mode: "primary" }, @@ -987,7 +992,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { test("does not deny todowrite/todoread when task_system is disabled", async () => { //#given - spyOn(agents, "createBuiltinAgents" as any).mockResolvedValue({ + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mockResolvedValue: (value: Record) => void + } + createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, hephaestus: { name: "hephaestus", prompt: "test", mode: "primary" }, }) @@ -1021,7 +1029,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { test("does not deny todowrite/todoread when task_system is undefined", async () => { //#given - spyOn(agents, "createBuiltinAgents" as any).mockResolvedValue({ + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mockResolvedValue: (value: Record) => void + } + createBuiltinAgentsMock.mockResolvedValue({ sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, }) From c9be2e1696437354ba4e65773fb5331682b005d0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 18:03:15 +0900 Subject: [PATCH 27/51] refactor: extract model selection logic from delegate-task into focused modules - Create available-models.ts for model availability checking - Create model-selection.ts for category-to-model resolution logic - Update category-resolver, subagent-resolver, and sync modules to import from new focused modules instead of monolithic sources --- src/tools/delegate-task/available-models.ts | 64 ++++++++++++++++++ src/tools/delegate-task/categories.ts | 4 +- src/tools/delegate-task/category-resolver.ts | 55 +++++++-------- src/tools/delegate-task/model-selection.ts | 67 +++++++++++++++++++ .../delegate-task/parent-context-resolver.ts | 3 +- src/tools/delegate-task/subagent-resolver.ts | 32 ++++----- src/tools/delegate-task/sync-continuation.ts | 4 +- src/tools/delegate-task/sync-prompt-sender.ts | 2 +- .../delegate-task/sync-session-poller.ts | 2 +- src/tools/delegate-task/sync-task.ts | 2 +- src/tools/delegate-task/tools.ts | 2 +- 11 files changed, 178 insertions(+), 59 deletions(-) create mode 100644 src/tools/delegate-task/available-models.ts create mode 100644 src/tools/delegate-task/model-selection.ts diff --git a/src/tools/delegate-task/available-models.ts b/src/tools/delegate-task/available-models.ts new file mode 100644 index 000000000..711ac1920 --- /dev/null +++ b/src/tools/delegate-task/available-models.ts @@ -0,0 +1,64 @@ +import type { OpencodeClient } from "./types" +import { log } from "../../shared/logger" +import { readConnectedProvidersCache, readProviderModelsCache } from "../../shared/connected-providers-cache" + +function addFromProviderModels( + out: Set, + providerID: string, + models: Array | undefined +): void { + if (!models) return + for (const item of models) { + const modelID = typeof item === "string" ? item : item?.id + if (!modelID) continue + out.add(`${providerID}/${modelID}`) + } +} + +export async function getAvailableModelsForDelegateTask(client: OpencodeClient): Promise> { + const providerModelsCache = readProviderModelsCache() + + if (providerModelsCache?.models) { + const connected = new Set(providerModelsCache.connected) + + const out = new Set() + for (const [providerID, models] of Object.entries(providerModelsCache.models)) { + if (!connected.has(providerID)) continue + addFromProviderModels(out, providerID, models as Array | undefined) + } + return out + } + + const connectedProviders = readConnectedProvidersCache() + + if (!connectedProviders || connectedProviders.length === 0) { + return new Set() + } + + const modelList = (client as unknown as { model?: { list?: () => Promise } }) + ?.model + ?.list + + if (!modelList) { + return new Set() + } + + try { + const result = await modelList() + const rows = Array.isArray(result) + ? result + : ((result as { data?: unknown }).data as Array<{ provider?: string; id?: string }> | undefined) ?? [] + + const connected = new Set(connectedProviders) + const out = new Set() + for (const row of rows) { + if (!row?.provider || !row?.id) continue + if (!connected.has(row.provider)) continue + out.add(`${row.provider}/${row.id}`) + } + return out + } catch (err) { + log("[delegate-task] client.model.list failed", { error: String(err) }) + return new Set() + } +} diff --git a/src/tools/delegate-task/categories.ts b/src/tools/delegate-task/categories.ts index 1ee544d27..a13c21854 100644 --- a/src/tools/delegate-task/categories.ts +++ b/src/tools/delegate-task/categories.ts @@ -1,9 +1,9 @@ import type { CategoryConfig, CategoriesConfig } from "../../config/schema" import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants" -import { resolveModel } from "../../shared" +import { resolveModel } from "../../shared/model-resolver" import { isModelAvailable } from "../../shared/model-availability" import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" -import { log } from "../../shared" +import { log } from "../../shared/logger" export interface ResolveCategoryConfigOptions { userCategories?: CategoriesConfig diff --git a/src/tools/delegate-task/category-resolver.ts b/src/tools/delegate-task/category-resolver.ts index 3eba5c24f..84df498aa 100644 --- a/src/tools/delegate-task/category-resolver.ts +++ b/src/tools/delegate-task/category-resolver.ts @@ -5,10 +5,9 @@ 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" +import { getAvailableModelsForDelegateTask } from "./available-models" +import { resolveModelForDelegateTask } from "./model-selection" export interface CategoryResolutionResult { agentToUse: string @@ -28,10 +27,7 @@ export async function resolveCategoryExecution( ): Promise { const { client, userCategories, sisyphusJuniorModel } = executorCtx - const connectedProviders = readConnectedProvidersCache() - const availableModels = await fetchAvailableModels(client, { - connectedProviders: connectedProviders ?? undefined, - }) + const availableModels = await getAvailableModelsForDelegateTask(client) const resolved = resolveCategoryConfig(args.category!, { userCategories, @@ -71,20 +67,16 @@ export async function resolveCategoryExecution( : { 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, - }, + const resolution = resolveModelForDelegateTask({ + userModel: explicitCategoryModel ?? overrideModel, + categoryDefaultModel: resolved.model, + fallbackChain: requirement.fallbackChain, + availableModels, + systemDefaultModel, }) if (resolution) { - const { model: resolvedModel, provenance, variant: resolvedVariant } = resolution + const { model: resolvedModel, variant: resolvedVariant } = resolution actualModel = resolvedModel if (!parseModelString(actualModel)) { @@ -99,20 +91,19 @@ export async function resolveCategoryExecution( } } - 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 - } + const type: "user-defined" | "inherited" | "category-default" | "system-default" = + (explicitCategoryModel || overrideModel) + ? "user-defined" + : (systemDefaultModel && actualModel === systemDefaultModel) + ? "system-default" + : "category-default" + + const source: "override" | "category-default" | "system-default" = + type === "user-defined" + ? "override" + : type === "system-default" + ? "system-default" + : "category-default" modelInfo = { model: actualModel, type, source } diff --git a/src/tools/delegate-task/model-selection.ts b/src/tools/delegate-task/model-selection.ts new file mode 100644 index 000000000..12987421e --- /dev/null +++ b/src/tools/delegate-task/model-selection.ts @@ -0,0 +1,67 @@ +import type { FallbackEntry } from "../../shared/model-requirements" +import { fuzzyMatchModel } from "../../shared/model-availability" + +function normalizeModel(model?: string): string | undefined { + const trimmed = model?.trim() + return trimmed || undefined +} + +export function resolveModelForDelegateTask(input: { + userModel?: string + categoryDefaultModel?: string + fallbackChain?: FallbackEntry[] + availableModels: Set + systemDefaultModel?: string +}): { model: string; variant?: string } | undefined { + const userModel = normalizeModel(input.userModel) + if (userModel) { + return { model: userModel } + } + + const categoryDefault = normalizeModel(input.categoryDefaultModel) + if (categoryDefault) { + if (input.availableModels.size === 0) { + return { model: categoryDefault } + } + + const parts = categoryDefault.split("/") + const providerHint = parts.length >= 2 ? [parts[0]] : undefined + const match = fuzzyMatchModel(categoryDefault, input.availableModels, providerHint) + if (match) { + return { model: match } + } + } + + const fallbackChain = input.fallbackChain + if (fallbackChain && fallbackChain.length > 0) { + if (input.availableModels.size === 0) { + const first = fallbackChain[0] + const provider = first?.providers?.[0] + if (provider) { + return { model: `${provider}/${first.model}`, variant: first.variant } + } + } else { + for (const entry of fallbackChain) { + for (const provider of entry.providers) { + const fullModel = `${provider}/${entry.model}` + const match = fuzzyMatchModel(fullModel, input.availableModels, [provider]) + if (match) { + return { model: match, variant: entry.variant } + } + } + + const crossProviderMatch = fuzzyMatchModel(entry.model, input.availableModels) + if (crossProviderMatch) { + return { model: crossProviderMatch, variant: entry.variant } + } + } + } + } + + const systemDefaultModel = normalizeModel(input.systemDefaultModel) + if (systemDefaultModel) { + return { model: systemDefaultModel } + } + + return undefined +} diff --git a/src/tools/delegate-task/parent-context-resolver.ts b/src/tools/delegate-task/parent-context-resolver.ts index 664cb8d95..cf2317839 100644 --- a/src/tools/delegate-task/parent-context-resolver.ts +++ b/src/tools/delegate-task/parent-context-resolver.ts @@ -2,7 +2,8 @@ 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" +import { log } from "../../shared/logger" +import { getMessageDir } from "../../shared/session-utils" export function resolveParentContext(ctx: ToolContextWithMetadata): ParentContext { const messageDir = getMessageDir(ctx.sessionID) diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 2ee6af355..0447416d9 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -3,10 +3,9 @@ import type { ExecutorContext } from "./executor-types" import { isPlanFamily } from "./constants" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" import { parseModelString } from "./model-string-parser" -import { resolveModelPipeline } from "../../shared" -import { fetchAvailableModels } from "../../shared/model-availability" -import { readConnectedProvidersCache } from "../../shared/connected-providers-cache" import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements" +import { getAvailableModelsForDelegateTask } from "./available-models" +import { resolveModelForDelegateTask } from "./model-selection" export async function resolveSubagentExecution( args: DelegateTaskArgs, @@ -86,26 +85,19 @@ Create the work plan directly - that's your job as the planning agent.`, ?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentNameLower)?.[1] : undefined) const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentNameLower] - if (agentOverride?.model || agentRequirement) { - const connectedProviders = readConnectedProvidersCache() - const availableModels = await fetchAvailableModels(client, { - connectedProviders: connectedProviders ?? undefined, - }) + if (agentOverride?.model || agentRequirement || matchedAgent.model) { + const availableModels = await getAvailableModelsForDelegateTask(client) const matchedAgentModelStr = matchedAgent.model ? `${matchedAgent.model.providerID}/${matchedAgent.model.modelID}` : undefined - const resolution = resolveModelPipeline({ - intent: { - userModel: agentOverride?.model, - categoryDefaultModel: matchedAgentModelStr, - }, - constraints: { availableModels }, - policy: { - fallbackChain: agentRequirement?.fallbackChain, - systemDefaultModel: undefined, - }, + const resolution = resolveModelForDelegateTask({ + userModel: agentOverride?.model, + categoryDefaultModel: matchedAgentModelStr, + fallbackChain: agentRequirement?.fallbackChain, + availableModels, + systemDefaultModel: undefined, }) if (resolution) { @@ -115,7 +107,9 @@ Create the work plan directly - that's your job as the planning agent.`, categoryModel = variantToUse ? { ...parsed, variant: variantToUse } : parsed } } - } else if (matchedAgent.model) { + } + + if (!categoryModel && matchedAgent.model) { categoryModel = matchedAgent.model } } catch { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 653df39e0..8852a3553 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -3,7 +3,9 @@ import type { ExecutorContext, SessionMessage } from "./executor-types" import { isPlanFamily } from "./constants" import { storeToolMetadata } from "../../features/tool-metadata-store" import { getTaskToastManager } from "../../features/task-toast-manager" -import { getAgentToolRestrictions, getMessageDir, promptSyncWithModelSuggestionRetry } from "../../shared" +import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions" +import { getMessageDir } from "../../shared/session-utils" +import { promptSyncWithModelSuggestionRetry } from "../../shared/model-suggestion-retry" import { findNearestMessageWithFields } from "../../features/hook-message-injector" import { formatDuration } from "./time-formatter" diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index d0829878a..083e9df22 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -1,6 +1,6 @@ import type { DelegateTaskArgs, OpencodeClient } from "./types" import { isPlanFamily } from "./constants" -import { promptSyncWithModelSuggestionRetry } from "../../shared" +import { promptSyncWithModelSuggestionRetry } from "../../shared/model-suggestion-retry" import { formatDetailedError } from "./error-formatting" export async function sendSyncPrompt( diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts index 42832d7e1..a9060e68a 100644 --- a/src/tools/delegate-task/sync-session-poller.ts +++ b/src/tools/delegate-task/sync-session-poller.ts @@ -1,6 +1,6 @@ import type { ToolContextWithMetadata, OpencodeClient } from "./types" import { getTimingConfig } from "./timing" -import { log } from "../../shared" +import { log } from "../../shared/logger" export async function pollSyncSession( ctx: ToolContextWithMetadata, diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 4d621d50d..2838053d7 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -4,7 +4,7 @@ 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 { log } from "../../shared/logger" import { formatDuration } from "./time-formatter" import { formatDetailedError } from "./error-formatting" import { createSyncSession } from "./sync-session-creator" diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index 1db72408c..c668443b9 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -1,7 +1,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin" import type { DelegateTaskArgs, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types" import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "./constants" -import { log } from "../../shared" +import { log } from "../../shared/logger" import { buildSystemContent } from "./prompt-builder" import type { AvailableCategory, From d5f0e75b7d88a90af049451004e58c049ab762b6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 18:39:36 +0900 Subject: [PATCH 28/51] fix: restore permission config in background session creation Add permission: [{ permission: 'question', action: 'deny', pattern: '*' }] to client.session.create() call to prevent background sessions from asking questions that go unanswered, causing hangs. --- src/features/background-agent/task-starter.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/background-agent/task-starter.ts b/src/features/background-agent/task-starter.ts index 6083d15a0..2a84dbcab 100644 --- a/src/features/background-agent/task-starter.ts +++ b/src/features/background-agent/task-starter.ts @@ -69,7 +69,8 @@ export async function startQueuedTask(args: { body: { parentID: input.parentSessionID, title: `${input.description} (@${input.agent} subagent)`, - }, + permission: [{ permission: "question", action: "deny" as const, pattern: "*" }], + } as any, query: { directory: parentDirectory, }, From cbb77715252053e91e3db296e33939c71765f445 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 18:39:36 +0900 Subject: [PATCH 29/51] fix: prevent command injection in git diff stats collection Replace execSync with string commands with execFileSync using argument arrays to avoid shell interpretation of file paths with special chars. --- src/shared/git-worktree/collect-git-diff-stats.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/git-worktree/collect-git-diff-stats.ts b/src/shared/git-worktree/collect-git-diff-stats.ts index 158a09d82..49a98fe2f 100644 --- a/src/shared/git-worktree/collect-git-diff-stats.ts +++ b/src/shared/git-worktree/collect-git-diff-stats.ts @@ -1,11 +1,11 @@ -import { execSync } from "node:child_process" +import { execFileSync } from "node:child_process" import { parseGitStatusPorcelain } from "./parse-status-porcelain" import { parseGitDiffNumstat } from "./parse-diff-numstat" import type { GitFileStat } from "./types" export function collectGitDiffStats(directory: string): GitFileStat[] { try { - const diffOutput = execSync("git diff --numstat HEAD", { + const diffOutput = execFileSync("git", ["diff", "--numstat", "HEAD"], { cwd: directory, encoding: "utf-8", timeout: 5000, @@ -14,7 +14,7 @@ export function collectGitDiffStats(directory: string): GitFileStat[] { if (!diffOutput) return [] - const statusOutput = execSync("git status --porcelain", { + const statusOutput = execFileSync("git", ["status", "--porcelain"], { cwd: directory, encoding: "utf-8", timeout: 5000, From fecc6b860555e71c1388726bc6721e3838404316 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 21:11:07 +0900 Subject: [PATCH 30/51] fix: remove task-continuation-enforcer references after dev merge Dev removed task-continuation-enforcer entirely. Remove all remaining references from plugin hooks, event handler, tool-execute-before, and config schema to align with origin/dev. --- src/config/schema/hooks.ts | 1 - src/hooks/interactive-bash-session/index.ts | 2 +- src/plugin-handlers/config-handler.test.ts | 2 +- src/plugin/event.ts | 1 - src/plugin/hooks/create-continuation-hooks.ts | 16 +------------- src/plugin/tool-execute-before.ts | 1 - src/tools/call-omo-agent/tools.ts | 21 +++++++++++-------- src/tools/delegate-task/executor.ts | 2 +- 8 files changed, 16 insertions(+), 30 deletions(-) diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index f4b5a987b..bb5f6bdb0 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -2,7 +2,6 @@ import { z } from "zod" export const HookNameSchema = z.enum([ "todo-continuation-enforcer", - "task-continuation-enforcer", "context-window-monitor", "session-recovery", "session-notification", diff --git a/src/hooks/interactive-bash-session/index.ts b/src/hooks/interactive-bash-session/index.ts index 22b925ff4..50c5cebcc 100644 --- a/src/hooks/interactive-bash-session/index.ts +++ b/src/hooks/interactive-bash-session/index.ts @@ -1,3 +1,3 @@ -export { createInteractiveBashSessionHook } from "./interactive-bash-session-hook" +export { createInteractiveBashSessionHook } from "./hook" export { createInteractiveBashSessionTracker } from "./interactive-bash-session-tracker" export { parseTmuxCommand } from "./tmux-command-parser" diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 62b5140ad..bca4ce4d1 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -1,4 +1,4 @@ -/// +/// import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test" import { resolveCategoryConfig, createConfigHandler } from "./config-handler" diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 21f458f17..4c068503b 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -33,7 +33,6 @@ export function createEventHandler(args: { await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input)) await Promise.resolve(hooks.sessionNotification?.(input)) await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input)) - await Promise.resolve(hooks.taskContinuationEnforcer?.handler?.(input)) await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input)) await Promise.resolve(hooks.contextWindowMonitor?.event?.(input)) await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input)) diff --git a/src/plugin/hooks/create-continuation-hooks.ts b/src/plugin/hooks/create-continuation-hooks.ts index 4db6d33b9..dbc2d3d07 100644 --- a/src/plugin/hooks/create-continuation-hooks.ts +++ b/src/plugin/hooks/create-continuation-hooks.ts @@ -4,7 +4,6 @@ import type { PluginContext } from "../types" import { createTodoContinuationEnforcer, - createTaskContinuationEnforcer, createBackgroundNotificationHook, createStopContinuationGuardHook, createCompactionContextInjector, @@ -19,7 +18,6 @@ export type ContinuationHooks = { compactionContextInjector: ReturnType | null compactionTodoPreserver: ReturnType | null todoContinuationEnforcer: ReturnType | null - taskContinuationEnforcer: ReturnType | null unstableAgentBabysitter: ReturnType | null backgroundNotificationHook: ReturnType | null atlasHook: ReturnType | null @@ -70,14 +68,6 @@ export function createContinuationHooks(args: { })) : null - const taskContinuationEnforcer = isHookEnabled("task-continuation-enforcer") - ? safeHook("task-continuation-enforcer", () => - createTaskContinuationEnforcer(ctx, pluginConfig, { - backgroundManager, - isContinuationStopped: stopContinuationGuard?.isStopped, - })) - : null - const unstableAgentBabysitter = isHookEnabled("unstable-agent-babysitter") ? safeHook("unstable-agent-babysitter", () => createUnstableAgentBabysitter({ ctx, backgroundManager, pluginConfig })) @@ -91,10 +81,7 @@ export function createContinuationHooks(args: { onAbortCallbacks.push(todoContinuationEnforcer.markRecovering) onRecoveryCompleteCallbacks.push(todoContinuationEnforcer.markRecoveryComplete) } - if (taskContinuationEnforcer) { - onAbortCallbacks.push(taskContinuationEnforcer.markRecovering) - onRecoveryCompleteCallbacks.push(taskContinuationEnforcer.markRecoveryComplete) - } + if (onAbortCallbacks.length > 0) { sessionRecovery.setOnAbortCallback((sessionID: string) => { @@ -129,7 +116,6 @@ export function createContinuationHooks(args: { compactionContextInjector, compactionTodoPreserver, todoContinuationEnforcer, - taskContinuationEnforcer, unstableAgentBabysitter, backgroundNotificationHook, atlasHook, diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index 0af9605aa..c7fefb0a6 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -88,7 +88,6 @@ export function createToolExecuteBeforeHandler(args: { if (command === "stop-continuation" && sessionID) { hooks.stopContinuationGuard?.stop(sessionID) hooks.todoContinuationEnforcer?.cancelAllCountdowns() - hooks.taskContinuationEnforcer?.cancelAllCountdowns() hooks.ralphLoop?.cancelLoop(sessionID) clearBoulderState(ctx.directory) log("[stop-continuation] All continuation mechanisms stopped", { diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index 242b4d5c4..dbcfcf970 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -1,12 +1,10 @@ import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants" -import type { CallOmoAgentArgs } from "./types" +import type { AllowedAgentType, CallOmoAgentArgs, ToolContextWithMetadata } from "./types" import type { BackgroundManager } from "../../features/background-agent" import { log } from "../../shared" -import { normalizeAgentType } from "./agent-type-normalizer" -import { executeBackgroundAgent } from "./background-agent-executor" -import { executeSyncAgent } from "./sync-agent-executor" -import type { ToolContextWithMetadata } from "./tool-context-with-metadata" +import { executeBackground } from "./background-executor" +import { executeSync } from "./sync-executor" export function createCallOmoAgent( ctx: PluginInput, @@ -34,21 +32,26 @@ export function createCallOmoAgent( const toolCtx = toolContext as ToolContextWithMetadata log(`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`) - const normalizedAgent = normalizeAgentType(args.subagent_type) - if (!normalizedAgent) { + // Case-insensitive agent validation - allows "Explore", "EXPLORE", "explore" etc. + if ( + !ALLOWED_AGENTS.some( + (name) => name.toLowerCase() === args.subagent_type.toLowerCase(), + ) + ) { return `Error: Invalid agent type "${args.subagent_type}". Only ${ALLOWED_AGENTS.join(", ")} are allowed.` } + const normalizedAgent = args.subagent_type.toLowerCase() as AllowedAgentType args = { ...args, subagent_type: normalizedAgent } if (args.run_in_background) { if (args.session_id) { return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.` } - return await executeBackgroundAgent(args, toolCtx, backgroundManager) + return await executeBackground(args, toolCtx, backgroundManager) } - return await executeSyncAgent(args, toolCtx, ctx) + return await executeSync(args, toolCtx, ctx) }, }) } diff --git a/src/tools/delegate-task/executor.ts b/src/tools/delegate-task/executor.ts index ec63771cb..927ea41bb 100644 --- a/src/tools/delegate-task/executor.ts +++ b/src/tools/delegate-task/executor.ts @@ -1,6 +1,6 @@ export type { ExecutorContext, ParentContext } from "./executor-types" -export { resolveSkillContent } from "./skill-content-resolver" +export { resolveSkillContent } from "./skill-resolver" export { resolveParentContext } from "./parent-context-resolver" export { executeBackgroundContinuation } from "./background-continuation" From 9353ac5b9dc70e15bdf2beb5239ac3197de163ca Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 21:23:21 +0900 Subject: [PATCH 31/51] fix: integrate dev CLAUDE_CODE_TASK_LIST_ID env var support --- src/features/claude-tasks/storage.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/features/claude-tasks/storage.ts b/src/features/claude-tasks/storage.ts index b8916d4ec..698a9a7ca 100644 --- a/src/features/claude-tasks/storage.ts +++ b/src/features/claude-tasks/storage.ts @@ -26,6 +26,9 @@ export function resolveTaskListId(config: Partial = {}): str const envId = process.env.ULTRAWORK_TASK_LIST_ID?.trim() if (envId) return sanitizePathSegment(envId) + const claudeEnvId = process.env.CLAUDE_CODE_TASK_LIST_ID?.trim() + if (claudeEnvId) return sanitizePathSegment(claudeEnvId) + const configId = config.sisyphus?.tasks?.task_list_id?.trim() if (configId) return sanitizePathSegment(configId) From f67a4df07e16aa5defe87d0194d7ff1a218d088f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 21:24:08 +0900 Subject: [PATCH 32/51] fix: integrate dev background_output task_id title resolution --- .../create-background-output.ts | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/tools/background-task/create-background-output.ts b/src/tools/background-task/create-background-output.ts index c498ddbf1..0999aa274 100644 --- a/src/tools/background-task/create-background-output.ts +++ b/src/tools/background-task/create-background-output.ts @@ -1,4 +1,6 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundTask } from "../../features/background-agent" +import { storeToolMetadata } from "../../features/tool-metadata-store" import type { BackgroundOutputArgs } from "./types" import type { BackgroundOutputClient, BackgroundOutputManager } from "./clients" import { BACKGROUND_OUTPUT_DESCRIPTION } from "./constants" @@ -7,6 +9,28 @@ import { formatFullSession } from "./full-session-format" import { formatTaskResult } from "./task-result-format" import { formatTaskStatus } from "./task-status-format" +const SISYPHUS_JUNIOR_AGENT = "sisyphus-junior" + +type ToolContextWithMetadata = { + sessionID: string + metadata?: (input: { title?: string; metadata?: Record }) => void + callID?: string + callId?: string + call_id?: string +} + +function resolveToolCallID(ctx: ToolContextWithMetadata): string | undefined { + if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID + if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId + if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id + return undefined +} + +function formatResolvedTitle(task: BackgroundTask): string { + const label = task.agent === SISYPHUS_JUNIOR_AGENT && task.category ? task.category : task.agent + return `${label} - ${task.description}` +} + export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition { return tool({ description: BACKGROUND_OUTPUT_DESCRIPTION, @@ -26,13 +50,31 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client: include_tool_results: tool.schema.boolean().optional().describe("Include tool results in full_session output (default: false)"), thinking_max_chars: tool.schema.number().optional().describe("Max characters for thinking content (default: 2000)"), }, - async execute(args: BackgroundOutputArgs) { + async execute(args: BackgroundOutputArgs, toolContext) { try { + const ctx = toolContext as ToolContextWithMetadata const task = manager.getTask(args.task_id) if (!task) { return `Task not found: ${args.task_id}` } + const meta = { + title: formatResolvedTitle(task), + metadata: { + task_id: task.id, + agent: task.agent, + category: task.category, + description: task.description, + sessionId: task.sessionID ?? "pending", + } as Record, + } + ctx.metadata?.(meta) + + const callID = resolveToolCallID(ctx) + if (callID) { + storeToolMetadata(ctx.sessionID, callID, meta) + } + if (args.full_session === true) { return await formatFullSession(task, client, { includeThinking: args.include_thinking === true, From 71728e15467fcb827773ea117894369bd617d763 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 21:32:52 +0900 Subject: [PATCH 33/51] fix: integrate dev model-availability changes lost during merge --- src/shared/model-availability.test.ts | 524 ++++++++++++-------------- 1 file changed, 235 insertions(+), 289 deletions(-) diff --git a/src/shared/model-availability.test.ts b/src/shared/model-availability.test.ts index cdd41b032..df60e8e32 100644 --- a/src/shared/model-availability.test.ts +++ b/src/shared/model-availability.test.ts @@ -1,173 +1,202 @@ -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, mock } from "bun:test" +declare const require: (name: string) => any +const { describe, it, expect, beforeEach, afterEach, beforeAll } = require("bun:test") import { mkdtempSync, writeFileSync, rmSync } from "fs" import { tmpdir } from "os" import { join } from "path" -import { fuzzyMatchModel, isModelAvailable } from "./model-name-matcher" -let activeCacheHomeDir: string | null = null -const DEFAULT_CACHE_HOME_DIR = join(tmpdir(), "opencode-test-default-cache") +let __resetModelCache: () => void +let fetchAvailableModels: ( + client?: unknown, + options?: { connectedProviders?: string[] | null }, +) => Promise> +let fuzzyMatchModel: (target: string, available: Set, providers?: string[]) => string | null +let isModelAvailable: (targetModel: string, availableModels: Set) => boolean +let getConnectedProviders: (client: unknown) => Promise -mock.module("./data-path", () => ({ - getDataDir: () => activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, - getOpenCodeStorageDir: () => join(activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, "opencode", "storage"), - getCacheDir: () => activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, - getOmoOpenCodeCacheDir: () => join(activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, "oh-my-opencode"), - getOpenCodeCacheDir: () => join(activeCacheHomeDir ?? DEFAULT_CACHE_HOME_DIR, "opencode"), -})) +beforeAll(async () => { + ;({ + __resetModelCache, + fetchAvailableModels, + fuzzyMatchModel, + isModelAvailable, + getConnectedProviders, + } = await import("./model-availability")) +}) describe("fetchAvailableModels", () => { - let tempDir: string - let fetchAvailableModels: (client?: unknown, options?: { connectedProviders?: string[] | null }) => Promise> - let __resetModelCache: () => void + let tempDir: string + let originalXdgCache: string | undefined - beforeAll(async () => { - ;({ fetchAvailableModels } = await import("./available-models-fetcher")) - ;({ __resetModelCache } = await import("./model-cache-availability")) - }) + beforeEach(() => { + __resetModelCache() + tempDir = mkdtempSync(join(tmpdir(), "opencode-test-")) + originalXdgCache = process.env.XDG_CACHE_HOME + process.env.XDG_CACHE_HOME = tempDir + }) - beforeEach(() => { - __resetModelCache() - tempDir = mkdtempSync(join(tmpdir(), "opencode-test-")) - activeCacheHomeDir = tempDir - }) + afterEach(() => { + if (originalXdgCache !== undefined) { + process.env.XDG_CACHE_HOME = originalXdgCache + } else { + delete process.env.XDG_CACHE_HOME + } + rmSync(tempDir, { recursive: true, force: true }) + }) - afterEach(() => { - activeCacheHomeDir = null - rmSync(tempDir, { recursive: true, force: true }) - }) + function writeModelsCache(data: Record) { + const cacheDir = join(tempDir, "opencode") + require("fs").mkdirSync(cacheDir, { recursive: true }) + writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data)) + } - function writeModelsCache(data: Record) { - const cacheDir = join(tempDir, "opencode") - require("fs").mkdirSync(cacheDir, { recursive: true }) - writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data)) - } + it("#given cache file with models #when fetchAvailableModels called with connectedProviders #then returns Set of model IDs", async () => { + writeModelsCache({ + openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } }, + anthropic: { + id: "anthropic", + models: { "claude-opus-4-6": { id: "claude-opus-4-6" } }, + }, + google: { id: "google", models: { "gemini-3-pro": { id: "gemini-3-pro" } } }, + }) - it("#given cache file with models #when fetchAvailableModels called with connectedProviders #then returns Set of model IDs", async () => { - writeModelsCache({ - openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } }, - anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } }, - google: { id: "google", models: { "gemini-3-pro": { id: "gemini-3-pro" } } }, - }) + const result = await fetchAvailableModels(undefined, { + connectedProviders: ["openai", "anthropic", "google"], + }) - const result = await fetchAvailableModels(undefined, { - connectedProviders: ["openai", "anthropic", "google"] - }) + expect(result).toBeInstanceOf(Set) + expect(result.size).toBe(3) + expect(result.has("openai/gpt-5.2")).toBe(true) + expect(result.has("anthropic/claude-opus-4-6")).toBe(true) + expect(result.has("google/gemini-3-pro")).toBe(true) + }) - expect(result).toBeInstanceOf(Set) - expect(result.size).toBe(3) - expect(result.has("openai/gpt-5.2")).toBe(true) - expect(result.has("anthropic/claude-opus-4-6")).toBe(true) - expect(result.has("google/gemini-3-pro")).toBe(true) - }) + it("#given connectedProviders unknown #when fetchAvailableModels called without options #then returns empty Set", async () => { + writeModelsCache({ + openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } }, + }) - it("#given connectedProviders unknown #when fetchAvailableModels called without options #then returns empty Set", async () => { - writeModelsCache({ - openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } }, - }) + const result = await fetchAvailableModels() - const result = await fetchAvailableModels() + expect(result).toBeInstanceOf(Set) + expect(result.size).toBe(0) + }) - expect(result).toBeInstanceOf(Set) - expect(result.size).toBe(0) - }) + it("#given connectedProviders unknown but client can list #when fetchAvailableModels called with client #then returns models from API filtered by connected providers", async () => { + const client = { + provider: { + list: async () => ({ data: { connected: ["openai"] } }), + }, + model: { + list: async () => ({ + data: [ + { id: "gpt-5.3-codex", provider: "openai" }, + { id: "gemini-3-pro", provider: "google" }, + ], + }), + }, + } - it("#given connectedProviders unknown but client can list #when fetchAvailableModels called with client #then returns models from API filtered by connected providers", async () => { - const client = { - provider: { - list: async () => ({ data: { connected: ["openai"] } }), - }, - model: { - list: async () => ({ - data: [ - { id: "gpt-5.3-codex", provider: "openai" }, - { id: "gemini-3-pro", provider: "google" }, - ], - }), - }, - } + const result = await fetchAvailableModels(client) - const result = await fetchAvailableModels(client) + expect(result).toBeInstanceOf(Set) + expect(result.has("openai/gpt-5.3-codex")).toBe(true) + expect(result.has("google/gemini-3-pro")).toBe(false) + }) - expect(result).toBeInstanceOf(Set) - expect(result.has("openai/gpt-5.3-codex")).toBe(true) - expect(result.has("google/gemini-3-pro")).toBe(false) - }) + it("#given cache file not found #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => { + const result = await fetchAvailableModels(undefined, { + connectedProviders: ["openai"], + }) - it("#given cache file not found #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => { - const result = await fetchAvailableModels(undefined, { connectedProviders: ["openai"] }) + expect(result).toBeInstanceOf(Set) + expect(result.size).toBe(0) + }) - expect(result).toBeInstanceOf(Set) - expect(result.size).toBe(0) - }) + it("#given cache missing but client can list #when fetchAvailableModels called with connectedProviders #then returns models from API", async () => { + const client = { + provider: { + list: async () => ({ data: { connected: ["openai", "google"] } }), + }, + model: { + list: async () => ({ + data: [ + { id: "gpt-5.3-codex", provider: "openai" }, + { id: "gemini-3-pro", provider: "google" }, + ], + }), + }, + } - it("#given cache missing but client can list #when fetchAvailableModels called with connectedProviders #then returns models from API", async () => { - const client = { - provider: { - list: async () => ({ data: { connected: ["openai", "google"] } }), - }, - model: { - list: async () => ({ - data: [ - { id: "gpt-5.3-codex", provider: "openai" }, - { id: "gemini-3-pro", provider: "google" }, - ], - }), - }, - } + const result = await fetchAvailableModels(client, { + connectedProviders: ["openai", "google"], + }) - const result = await fetchAvailableModels(client, { connectedProviders: ["openai", "google"] }) + expect(result).toBeInstanceOf(Set) + expect(result.has("openai/gpt-5.3-codex")).toBe(true) + expect(result.has("google/gemini-3-pro")).toBe(true) + }) - expect(result).toBeInstanceOf(Set) - expect(result.has("openai/gpt-5.3-codex")).toBe(true) - expect(result.has("google/gemini-3-pro")).toBe(true) - }) + it("#given cache read twice #when second call made with same providers #then reads fresh each time", async () => { + writeModelsCache({ + openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } }, + anthropic: { + id: "anthropic", + models: { "claude-opus-4-6": { id: "claude-opus-4-6" } }, + }, + }) - it("#given cache read twice #when second call made with same providers #then reads fresh each time", async () => { - writeModelsCache({ - openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } }, - anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } }, - }) + const result1 = await fetchAvailableModels(undefined, { + connectedProviders: ["openai"], + }) + const result2 = await fetchAvailableModels(undefined, { + connectedProviders: ["openai"], + }) - const result1 = await fetchAvailableModels(undefined, { connectedProviders: ["openai"] }) - const result2 = await fetchAvailableModels(undefined, { connectedProviders: ["openai"] }) + expect(result1.size).toBe(result2.size) + expect(result1.has("openai/gpt-5.2")).toBe(true) + }) - expect(result1.size).toBe(result2.size) - expect(result1.has("openai/gpt-5.2")).toBe(true) - }) + it("#given empty providers in cache #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => { + writeModelsCache({}) - it("#given empty providers in cache #when fetchAvailableModels called with connectedProviders #then returns empty Set", async () => { - writeModelsCache({}) + const result = await fetchAvailableModels(undefined, { + connectedProviders: ["openai"], + }) - const result = await fetchAvailableModels(undefined, { connectedProviders: ["openai"] }) + expect(result).toBeInstanceOf(Set) + expect(result.size).toBe(0) + }) - expect(result).toBeInstanceOf(Set) - expect(result.size).toBe(0) - }) + it("#given cache file with various providers #when fetchAvailableModels called with all providers #then extracts all IDs correctly", async () => { + writeModelsCache({ + openai: { + id: "openai", + models: { "gpt-5.3-codex": { id: "gpt-5.3-codex" } }, + }, + anthropic: { + id: "anthropic", + models: { "claude-sonnet-4-5": { id: "claude-sonnet-4-5" } }, + }, + google: { + id: "google", + models: { "gemini-3-flash": { id: "gemini-3-flash" } }, + }, + opencode: { id: "opencode", models: { "gpt-5-nano": { id: "gpt-5-nano" } } }, + }) - it("#given cache file with various providers #when fetchAvailableModels called with all providers #then extracts all IDs correctly", async () => { - writeModelsCache({ - openai: { id: "openai", models: { "gpt-5.3-codex": { id: "gpt-5.3-codex" } } }, - anthropic: { id: "anthropic", models: { "claude-sonnet-4-5": { id: "claude-sonnet-4-5" } } }, - google: { id: "google", models: { "gemini-3-flash": { id: "gemini-3-flash" } } }, - opencode: { id: "opencode", models: { "gpt-5-nano": { id: "gpt-5-nano" } } }, - }) + const result = await fetchAvailableModels(undefined, { + connectedProviders: ["openai", "anthropic", "google", "opencode"], + }) - const result = await fetchAvailableModels(undefined, { - connectedProviders: ["openai", "anthropic", "google", "opencode"] - }) - - expect(result.size).toBe(4) - expect(result.has("openai/gpt-5.3-codex")).toBe(true) - expect(result.has("anthropic/claude-sonnet-4-5")).toBe(true) - expect(result.has("google/gemini-3-flash")).toBe(true) - expect(result.has("opencode/gpt-5-nano")).toBe(true) - }) + expect(result.size).toBe(4) + expect(result.has("openai/gpt-5.3-codex")).toBe(true) + expect(result.has("anthropic/claude-sonnet-4-5")).toBe(true) + expect(result.has("google/gemini-3-flash")).toBe(true) + expect(result.has("opencode/gpt-5-nano")).toBe(true) + }) }) describe("fuzzyMatchModel", () => { - // given available models from multiple providers - // when searching for a substring match - // then return the matching model it("should match substring in model name", () => { const available = new Set([ "openai/gpt-5.2", @@ -178,9 +207,6 @@ describe("fuzzyMatchModel", () => { expect(result).toBe("openai/gpt-5.2") }) - // given available model with preview suffix - // when searching with provider-prefixed base model - // then return preview model it("should match preview suffix for gemini-3-flash", () => { const available = new Set(["google/gemini-3-flash-preview"]) const result = fuzzyMatchModel( @@ -191,9 +217,6 @@ describe("fuzzyMatchModel", () => { expect(result).toBe("google/gemini-3-flash-preview") }) - // given available models with partial matches - // when searching for a substring - // then return exact match if it exists it("should prefer exact match over substring match", () => { const available = new Set([ "openai/gpt-5.2", @@ -204,9 +227,6 @@ describe("fuzzyMatchModel", () => { expect(result).toBe("openai/gpt-5.2") }) - // given available models with multiple substring matches - // when searching for a substring - // then return the shorter model name (more specific) it("should prefer shorter model name when multiple matches exist", () => { const available = new Set([ "openai/gpt-5.2-ultra", @@ -216,9 +236,6 @@ describe("fuzzyMatchModel", () => { expect(result).toBe("openai/gpt-5.2-ultra") }) - // given available models with claude variants - // when searching for claude-opus - // then return matching claude-opus model it("should match claude-opus to claude-opus-4-6", () => { const available = new Set([ "anthropic/claude-opus-4-6", @@ -228,9 +245,6 @@ describe("fuzzyMatchModel", () => { expect(result).toBe("anthropic/claude-opus-4-6") }) - // given available models from multiple providers - // when providers filter is specified - // then only search models from specified providers it("should filter by provider when providers array is given", () => { const available = new Set([ "openai/gpt-5.2", @@ -241,9 +255,6 @@ describe("fuzzyMatchModel", () => { expect(result).toBe("openai/gpt-5.2") }) - // given available models from multiple providers - // when providers filter excludes matching models - // then return null it("should return null when provider filter excludes all matches", () => { const available = new Set([ "openai/gpt-5.2", @@ -253,9 +264,6 @@ describe("fuzzyMatchModel", () => { expect(result).toBeNull() }) - // given available models - // when no substring match exists - // then return null it("should return null when no match found", () => { const available = new Set([ "openai/gpt-5.2", @@ -265,9 +273,6 @@ describe("fuzzyMatchModel", () => { expect(result).toBeNull() }) - // given available models with different cases - // when searching with different case - // then match case-insensitively it("should match case-insensitively", () => { const available = new Set([ "openai/gpt-5.2", @@ -277,9 +282,6 @@ describe("fuzzyMatchModel", () => { expect(result).toBe("openai/gpt-5.2") }) - // given available models with exact match and longer variants - // when searching for exact match - // then return exact match first it("should prioritize exact match over longer variants", () => { const available = new Set([ "anthropic/claude-opus-4-6", @@ -289,9 +291,6 @@ describe("fuzzyMatchModel", () => { expect(result).toBe("anthropic/claude-opus-4-6") }) - // given available models with similar model IDs (e.g., glm-4.7 and glm-4.7-free) - // when searching for the longer variant (glm-4.7-free) - // then return exact model ID match, not the shorter one it("should prefer exact model ID match over shorter substring match", () => { const available = new Set([ "zai-coding-plan/glm-4.7", @@ -301,9 +300,6 @@ describe("fuzzyMatchModel", () => { expect(result).toBe("zai-coding-plan/glm-4.7-free") }) - // given available models with similar model IDs - // when searching for the shorter variant - // then return the shorter match (existing behavior preserved) it("should still prefer shorter match when searching for shorter variant", () => { const available = new Set([ "zai-coding-plan/glm-4.7", @@ -313,21 +309,12 @@ describe("fuzzyMatchModel", () => { expect(result).toBe("zai-coding-plan/glm-4.7") }) - // given same model ID from multiple providers - // when searching for exact model ID - // then return shortest full string (preserves tie-break behavior) it("should use shortest tie-break when multiple providers have same model ID", () => { - const available = new Set([ - "opencode/gpt-5.2", - "openai/gpt-5.2", - ]) + const available = new Set(["opencode/gpt-5.2", "openai/gpt-5.2"]) const result = fuzzyMatchModel("gpt-5.2", available) expect(result).toBe("openai/gpt-5.2") }) - // given available models with multiple providers - // when multiple providers are specified - // then search all specified providers it("should search all specified providers", () => { const available = new Set([ "openai/gpt-5.2", @@ -338,21 +325,12 @@ describe("fuzzyMatchModel", () => { expect(result).toBe("openai/gpt-5.2") }) - // given available models with provider prefix - // when searching with provider filter - // then only match models with correct provider prefix it("should only match models with correct provider prefix", () => { - const available = new Set([ - "openai/gpt-5.2", - "anthropic/gpt-something", - ]) + const available = new Set(["openai/gpt-5.2", "anthropic/gpt-something"]) const result = fuzzyMatchModel("gpt", available, ["openai"]) expect(result).toBe("openai/gpt-5.2") }) - // given empty available set - // when searching - // then return null it("should return null for empty available set", () => { const available = new Set() const result = fuzzyMatchModel("gpt", available) @@ -361,16 +339,13 @@ describe("fuzzyMatchModel", () => { }) describe("getConnectedProviders", () => { - // given SDK client with connected providers - // when provider.list returns data - // then returns connected array it("should return connected providers from SDK", async () => { const mockClient = { provider: { list: async () => ({ - data: { connected: ["anthropic", "opencode", "google"] } - }) - } + data: { connected: ["anthropic", "opencode", "google"] }, + }), + }, } const result = await getConnectedProviders(mockClient) @@ -378,14 +353,13 @@ describe("getConnectedProviders", () => { expect(result).toEqual(["anthropic", "opencode", "google"]) }) - // given SDK client - // when provider.list throws error - // then returns empty array it("should return empty array on SDK error", async () => { const mockClient = { provider: { - list: async () => { throw new Error("Network error") } - } + list: async () => { + throw new Error("Network error") + }, + }, } const result = await getConnectedProviders(mockClient) @@ -393,14 +367,11 @@ describe("getConnectedProviders", () => { expect(result).toEqual([]) }) - // given SDK client with empty connected array - // when provider.list returns empty - // then returns empty array it("should return empty array when no providers connected", async () => { const mockClient = { provider: { - list: async () => ({ data: { connected: [] } }) - } + list: async () => ({ data: { connected: [] } }), + }, } const result = await getConnectedProviders(mockClient) @@ -408,9 +379,6 @@ describe("getConnectedProviders", () => { expect(result).toEqual([]) }) - // given SDK client without provider.list method - // when getConnectedProviders called - // then returns empty array it("should return empty array when client.provider.list not available", async () => { const mockClient = {} @@ -419,23 +387,17 @@ describe("getConnectedProviders", () => { expect(result).toEqual([]) }) - // given null client - // when getConnectedProviders called - // then returns empty array it("should return empty array for null client", async () => { const result = await getConnectedProviders(null) expect(result).toEqual([]) }) - // given SDK client with missing data.connected - // when provider.list returns without connected field - // then returns empty array it("should return empty array when data.connected is undefined", async () => { const mockClient = { provider: { - list: async () => ({ data: {} }) - } + list: async () => ({ data: {} }), + }, } const result = await getConnectedProviders(mockClient) @@ -470,9 +432,6 @@ describe("fetchAvailableModels with connected providers filtering", () => { writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data)) } - // given cache with multiple providers - // when connectedProviders specifies one provider - // then only returns models from that provider it("should filter models by connected providers", async () => { writeModelsCache({ openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } }, @@ -481,7 +440,7 @@ describe("fetchAvailableModels with connected providers filtering", () => { }) const result = await fetchAvailableModels(undefined, { - connectedProviders: ["anthropic"] + connectedProviders: ["anthropic"], }) expect(result.size).toBe(1) @@ -490,9 +449,6 @@ describe("fetchAvailableModels with connected providers filtering", () => { expect(result.has("google/gemini-3-pro")).toBe(false) }) - // given cache with multiple providers - // when connectedProviders specifies multiple providers - // then returns models from all specified providers it("should filter models by multiple connected providers", async () => { writeModelsCache({ openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } }, @@ -501,7 +457,7 @@ describe("fetchAvailableModels with connected providers filtering", () => { }) const result = await fetchAvailableModels(undefined, { - connectedProviders: ["anthropic", "google"] + connectedProviders: ["anthropic", "google"], }) expect(result.size).toBe(2) @@ -510,9 +466,6 @@ describe("fetchAvailableModels with connected providers filtering", () => { expect(result.has("openai/gpt-5.2")).toBe(false) }) - // given cache with models - // when connectedProviders is empty array - // then returns empty set it("should return empty set when connectedProviders is empty", async () => { writeModelsCache({ openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } }, @@ -520,15 +473,12 @@ describe("fetchAvailableModels with connected providers filtering", () => { }) const result = await fetchAvailableModels(undefined, { - connectedProviders: [] + connectedProviders: [], }) expect(result.size).toBe(0) }) - // given cache with models - // when connectedProviders is undefined (no options) - // then returns empty set (triggers fallback in resolver) it("should return empty set when connectedProviders not specified", async () => { writeModelsCache({ openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } }, @@ -540,24 +490,18 @@ describe("fetchAvailableModels with connected providers filtering", () => { expect(result.size).toBe(0) }) - // given cache with models - // when connectedProviders contains provider not in cache - // then returns empty set for that provider it("should handle provider not in cache gracefully", async () => { writeModelsCache({ openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } }, }) const result = await fetchAvailableModels(undefined, { - connectedProviders: ["azure"] + connectedProviders: ["azure"], }) expect(result.size).toBe(0) }) - // given cache with models and mixed connected providers - // when some providers exist in cache and some don't - // then returns models only from matching providers it("should return models from providers that exist in both cache and connected list", async () => { writeModelsCache({ openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } }, @@ -565,39 +509,31 @@ describe("fetchAvailableModels with connected providers filtering", () => { }) const result = await fetchAvailableModels(undefined, { - connectedProviders: ["anthropic", "azure", "unknown"] + connectedProviders: ["anthropic", "azure", "unknown"], }) expect(result.size).toBe(1) expect(result.has("anthropic/claude-opus-4-6")).toBe(true) }) - // given filtered fetch - // when called twice with different filters - // then does NOT use cache (dynamic per-session) it("should not cache filtered results", async () => { writeModelsCache({ openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } }, anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } }, }) - // First call with anthropic const result1 = await fetchAvailableModels(undefined, { - connectedProviders: ["anthropic"] + connectedProviders: ["anthropic"], }) expect(result1.size).toBe(1) - // Second call with openai - should work, not cached const result2 = await fetchAvailableModels(undefined, { - connectedProviders: ["openai"] + connectedProviders: ["openai"], }) expect(result2.size).toBe(1) expect(result2.has("openai/gpt-5.2")).toBe(true) }) - // given connectedProviders unknown - // when called twice without connectedProviders - // then always returns empty set (triggers fallback) it("should return empty set when connectedProviders unknown", async () => { writeModelsCache({ openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } }, @@ -631,13 +567,19 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)", rmSync(tempDir, { recursive: true, force: true }) }) - function writeProviderModelsCache(data: { models: Record; connected: string[] }) { + function writeProviderModelsCache(data: { + models: Record + connected: string[] + }) { const cacheDir = join(tempDir, "oh-my-opencode") require("fs").mkdirSync(cacheDir, { recursive: true }) - writeFileSync(join(cacheDir, "provider-models.json"), JSON.stringify({ - ...data, - updatedAt: new Date().toISOString() - })) + writeFileSync( + join(cacheDir, "provider-models.json"), + JSON.stringify({ + ...data, + updatedAt: new Date().toISOString(), + }), + ) } function writeModelsCache(data: Record) { @@ -646,24 +588,21 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)", writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data)) } - // given provider-models cache exists (whitelist-filtered) - // when fetchAvailableModels called - // then uses provider-models cache instead of models.json it("should prefer provider-models cache over models.json", async () => { writeProviderModelsCache({ models: { opencode: ["glm-4.7-free", "gpt-5-nano"], - anthropic: ["claude-opus-4-6"] + anthropic: ["claude-opus-4-6"], }, - connected: ["opencode", "anthropic"] + connected: ["opencode", "anthropic"], }) writeModelsCache({ opencode: { models: { "glm-4.7-free": {}, "gpt-5-nano": {}, "gpt-5.2": {} } }, - anthropic: { models: { "claude-opus-4-6": {}, "claude-sonnet-4-5": {} } } + anthropic: { models: { "claude-opus-4-6": {}, "claude-sonnet-4-5": {} } }, }) const result = await fetchAvailableModels(undefined, { - connectedProviders: ["opencode", "anthropic"] + connectedProviders: ["opencode", "anthropic"], }) expect(result.size).toBe(3) @@ -674,13 +613,9 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)", expect(result.has("anthropic/claude-sonnet-4-5")).toBe(false) }) - // given provider-models cache exists but has no models (API failure) - // when fetchAvailableModels called - // then falls back to models.json so fuzzy matching can still work it("should fall back to models.json when provider-models cache is empty", async () => { writeProviderModelsCache({ - models: { - }, + models: {}, connected: ["google"], }) writeModelsCache({ @@ -690,21 +625,22 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)", const availableModels = await fetchAvailableModels(undefined, { connectedProviders: ["google"], }) - const match = fuzzyMatchModel("google/gemini-3-flash", availableModels, ["google"]) + const match = fuzzyMatchModel( + "google/gemini-3-flash", + availableModels, + ["google"], + ) expect(match).toBe("google/gemini-3-flash-preview") }) - // given only models.json exists (no provider-models cache) - // when fetchAvailableModels called - // then falls back to models.json (no whitelist filtering) it("should fallback to models.json when provider-models cache not found", async () => { writeModelsCache({ opencode: { models: { "glm-4.7-free": {}, "gpt-5-nano": {}, "gpt-5.2": {} } }, }) const result = await fetchAvailableModels(undefined, { - connectedProviders: ["opencode"] + connectedProviders: ["opencode"], }) expect(result.size).toBe(3) @@ -713,21 +649,18 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)", expect(result.has("opencode/gpt-5.2")).toBe(true) }) - // given provider-models cache with whitelist - // when connectedProviders filters to subset - // then only returns models from connected providers it("should filter by connectedProviders even with provider-models cache", async () => { writeProviderModelsCache({ models: { opencode: ["glm-4.7-free"], anthropic: ["claude-opus-4-6"], - google: ["gemini-3-pro"] + google: ["gemini-3-pro"], }, - connected: ["opencode", "anthropic", "google"] + connected: ["opencode", "anthropic", "google"], }) const result = await fetchAvailableModels(undefined, { - connectedProviders: ["opencode"] + connectedProviders: ["opencode"], }) expect(result.size).toBe(1) @@ -740,15 +673,25 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)", writeProviderModelsCache({ models: { ollama: [ - { id: "ministral-3:14b-32k-agent", provider: "ollama", context: 32768, output: 8192 }, - { id: "qwen3-coder:32k-agent", provider: "ollama", context: 32768, output: 8192 } - ] + { + id: "ministral-3:14b-32k-agent", + provider: "ollama", + context: 32768, + output: 8192, + }, + { + id: "qwen3-coder:32k-agent", + provider: "ollama", + context: 32768, + output: 8192, + }, + ], }, - connected: ["ollama"] + connected: ["ollama"], }) const result = await fetchAvailableModels(undefined, { - connectedProviders: ["ollama"] + connectedProviders: ["ollama"], }) expect(result.size).toBe(2) @@ -762,14 +705,14 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)", anthropic: ["claude-opus-4-6", "claude-sonnet-4-5"], ollama: [ { id: "ministral-3:14b-32k-agent", provider: "ollama" }, - { id: "qwen3-coder:32k-agent", provider: "ollama" } - ] + { id: "qwen3-coder:32k-agent", provider: "ollama" }, + ], }, - connected: ["anthropic", "ollama"] + connected: ["anthropic", "ollama"], }) const result = await fetchAvailableModels(undefined, { - connectedProviders: ["anthropic", "ollama"] + connectedProviders: ["anthropic", "ollama"], }) expect(result.size).toBe(4) @@ -787,14 +730,14 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)", { provider: "ollama" }, { id: "", provider: "ollama" }, null, - "string-model" - ] + "string-model", + ], }, - connected: ["ollama"] + connected: ["ollama"], }) const result = await fetchAvailableModels(undefined, { - connectedProviders: ["ollama"] + connectedProviders: ["ollama"], }) expect(result.size).toBe(2) @@ -806,7 +749,10 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)", describe("isModelAvailable", () => { it("returns true when model exists via fuzzy match", () => { // given - const available = new Set(["openai/gpt-5.3-codex", "anthropic/claude-opus-4-6"]) + const available = new Set([ + "openai/gpt-5.3-codex", + "anthropic/claude-opus-4-6", + ]) // when const result = isModelAvailable("gpt-5.3-codex", available) From babcb0050a85adb4029926b288e8a8bc6579b7cc Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 21:57:34 +0900 Subject: [PATCH 34/51] fix: address Cubic P2 issues in CLI modules --- src/cli/config-manager/auth-plugins.ts | 24 ++++++++++++++++++++++-- src/cli/config-manager/bun-install.ts | 7 +++---- src/cli/run/event-formatting.ts | 14 ++++++++++++-- src/cli/tui-install-prompts.ts | 2 ++ 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/cli/config-manager/auth-plugins.ts b/src/cli/config-manager/auth-plugins.ts index 7bbc8b819..577d80a20 100644 --- a/src/cli/config-manager/auth-plugins.ts +++ b/src/cli/config-manager/auth-plugins.ts @@ -1,4 +1,4 @@ -import { writeFileSync } from "node:fs" +import { readFileSync, writeFileSync } from "node:fs" import type { ConfigMergeResult, InstallConfig } from "../types" import { getConfigDir } from "./config-context" import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" @@ -55,7 +55,27 @@ export async function addAuthPlugins(config: InstallConfig): Promise `"${p}"`).join(",\n ") + const newContent = content.replace( + pluginArrayRegex, + `"plugin": [\n ${formattedPlugins}\n ]` + ) + writeFileSync(path, newContent) + } else { + const inlinePlugins = plugins.map((p) => `"${p}"`).join(", ") + const newContent = content.replace(/(\{)/, `$1\n "plugin": [${inlinePlugins}],`) + writeFileSync(path, newContent) + } + } else { + writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") + } return { success: true, configPath: path } } catch (err) { return { diff --git a/src/cli/config-manager/bun-install.ts b/src/cli/config-manager/bun-install.ts index daf56fc02..052617e49 100644 --- a/src/cli/config-manager/bun-install.ts +++ b/src/cli/config-manager/bun-install.ts @@ -18,8 +18,8 @@ export async function runBunInstallWithDetails(): Promise { try { const proc = Bun.spawn(["bun", "install"], { cwd: getConfigDir(), - stdout: "pipe", - stderr: "pipe", + stdout: "inherit", + stderr: "inherit", }) let timeoutId: ReturnType @@ -44,10 +44,9 @@ export async function runBunInstallWithDetails(): Promise { } if (proc.exitCode !== 0) { - const stderr = await new Response(proc.stderr).text() return { success: false, - error: stderr.trim() || `bun install failed with exit code ${proc.exitCode}`, + error: `bun install failed with exit code ${proc.exitCode}`, } } diff --git a/src/cli/run/event-formatting.ts b/src/cli/run/event-formatting.ts index f9056641e..2e6e2e083 100644 --- a/src/cli/run/event-formatting.ts +++ b/src/cli/run/event-formatting.ts @@ -103,9 +103,19 @@ export function logEventVerbose(ctx: RunContext, payload: EventPayload): void { const toolProps = props as ToolExecuteProps | undefined const toolName = toolProps?.name ?? "unknown" const input = toolProps?.input ?? {} - const inputStr = JSON.stringify(input).slice(0, 150) + let inputStr: string + try { + inputStr = JSON.stringify(input) + } catch { + try { + inputStr = String(input) + } catch { + inputStr = "[unserializable]" + } + } + const inputPreview = inputStr.slice(0, 150) console.error(pc.cyan(`${sessionTag} TOOL.EXECUTE: ${pc.bold(toolName)}`)) - console.error(pc.dim(` input: ${inputStr}${inputStr.length >= 150 ? "..." : ""}`)) + console.error(pc.dim(` input: ${inputPreview}${inputStr.length >= 150 ? "..." : ""}`)) break } diff --git a/src/cli/tui-install-prompts.ts b/src/cli/tui-install-prompts.ts index e817427cd..7b6d03b5b 100644 --- a/src/cli/tui-install-prompts.ts +++ b/src/cli/tui-install-prompts.ts @@ -12,6 +12,8 @@ async function selectOrCancel options: Option[] initialValue: TValue }): Promise { + if (!process.stdin.isTTY) return null + const value = await p.select({ message: params.message, options: params.options, From 7331cbdea28824a38e8a1e4142280ee555d3f413 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 22:03:58 +0900 Subject: [PATCH 35/51] fix: address Cubic P2 issues in doctor checks and agent overrides --- src/agents/builtin-agents/agent-overrides.ts | 4 ++++ src/cli/doctor/checks/model-resolution-cache.ts | 3 ++- src/cli/doctor/checks/model-resolution-config.ts | 9 +++++---- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/agents/builtin-agents/agent-overrides.ts b/src/agents/builtin-agents/agent-overrides.ts index 5e705b025..ad80e8d6f 100644 --- a/src/agents/builtin-agents/agent-overrides.ts +++ b/src/agents/builtin-agents/agent-overrides.ts @@ -27,6 +27,10 @@ export function applyCategoryOverride( if (categoryConfig.top_p !== undefined) result.top_p = categoryConfig.top_p if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens + if (categoryConfig.prompt_append && typeof result.prompt === "string") { + result.prompt = result.prompt + "\n" + categoryConfig.prompt_append + } + return result as AgentConfig } diff --git a/src/cli/doctor/checks/model-resolution-cache.ts b/src/cli/doctor/checks/model-resolution-cache.ts index 9628db9e5..7c1b75233 100644 --- a/src/cli/doctor/checks/model-resolution-cache.ts +++ b/src/cli/doctor/checks/model-resolution-cache.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" +import { parseJsonc } from "../../../shared" import type { AvailableModelsInfo } from "./model-resolution-types" function getOpenCodeCacheDir(): string { @@ -18,7 +19,7 @@ export function loadAvailableModelsFromCache(): AvailableModelsInfo { try { const content = readFileSync(cacheFile, "utf-8") - const data = JSON.parse(content) as Record }> + const data = parseJsonc }>>(content) const providers = Object.keys(data) let modelCount = 0 diff --git a/src/cli/doctor/checks/model-resolution-config.ts b/src/cli/doctor/checks/model-resolution-config.ts index e84853ee4..db01cc4e5 100644 --- a/src/cli/doctor/checks/model-resolution-config.ts +++ b/src/cli/doctor/checks/model-resolution-config.ts @@ -1,12 +1,13 @@ import { readFileSync } from "node:fs" -import { homedir } from "node:os" import { join } from "node:path" -import { detectConfigFile, parseJsonc } from "../../../shared" +import { detectConfigFile, getOpenCodeConfigPaths, parseJsonc } from "../../../shared" import type { OmoConfig } from "./model-resolution-types" const PACKAGE_NAME = "oh-my-opencode" -const USER_CONFIG_DIR = join(homedir(), ".config", "opencode") -const USER_CONFIG_BASE = join(USER_CONFIG_DIR, PACKAGE_NAME) +const USER_CONFIG_BASE = join( + getOpenCodeConfigPaths({ binary: "opencode", version: null }).configDir, + PACKAGE_NAME +) const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME) export function loadOmoConfig(): OmoConfig | null { From 2834445067b3001921684ca9bcc66f3980b303c9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 22:09:12 +0900 Subject: [PATCH 36/51] fix: guard interactive prompts on both stdin and stdout TTY --- src/cli/tui-install-prompts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/tui-install-prompts.ts b/src/cli/tui-install-prompts.ts index 7b6d03b5b..1a39dc702 100644 --- a/src/cli/tui-install-prompts.ts +++ b/src/cli/tui-install-prompts.ts @@ -12,7 +12,7 @@ async function selectOrCancel options: Option[] initialValue: TValue }): Promise { - if (!process.stdin.isTTY) return null + if (!process.stdin.isTTY || !process.stdout.isTTY) return null const value = await p.select({ message: params.message, From be03e27faf7aeb19d6c6f6f09effb9964770bd1c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 22:14:39 +0900 Subject: [PATCH 37/51] chore: trigger re-review From 8a2c3cc98df4cc456f5917984bf0f3ff0607cf38 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 22:35:16 +0900 Subject: [PATCH 38/51] =?UTF-8?q?fix:=20address=20Cubic=20round=205=20issu?= =?UTF-8?q?es=20=E2=80=94=20prototype-pollution=20guard,=20URL-encode,=20J?= =?UTF-8?q?SONC=20preservation,=20config-context=20warning,=20dynamic=20co?= =?UTF-8?q?nfig=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/config-manager/add-provider-config.ts | 20 +++++++++++++++++-- src/cli/config-manager/auth-plugins.ts | 2 +- src/cli/config-manager/bun-install.ts | 2 +- src/cli/config-manager/config-context.ts | 3 +++ src/cli/config-manager/deep-merge-record.ts | 1 + 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/cli/config-manager/add-provider-config.ts b/src/cli/config-manager/add-provider-config.ts index 833c9882f..b2a400a43 100644 --- a/src/cli/config-manager/add-provider-config.ts +++ b/src/cli/config-manager/add-provider-config.ts @@ -1,4 +1,4 @@ -import { writeFileSync } from "node:fs" +import { readFileSync, writeFileSync } from "node:fs" import type { ConfigMergeResult, InstallConfig } from "../types" import { getConfigDir } from "./config-context" import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" @@ -45,7 +45,23 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult { newConfig.provider = providers } - writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") + if (format === "jsonc") { + const content = readFileSync(path, "utf-8") + const providerRegex = /"provider"\s*:\s*\{[\s\S]*?\n \}/ + const providerJson = JSON.stringify(newConfig.provider, null, 2) + .split("\n") + .map((line, i) => (i === 0 ? line : ` ${line}`)) + .join("\n") + if (providerRegex.test(content)) { + const newContent = content.replace(providerRegex, `"provider": ${providerJson}`) + writeFileSync(path, newContent) + } else { + const newContent = content.replace(/(\{)/, `$1\n "provider": ${providerJson},`) + writeFileSync(path, newContent) + } + } else { + writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") + } return { success: true, configPath: path } } catch (err) { return { diff --git a/src/cli/config-manager/auth-plugins.ts b/src/cli/config-manager/auth-plugins.ts index 577d80a20..4e4d5718f 100644 --- a/src/cli/config-manager/auth-plugins.ts +++ b/src/cli/config-manager/auth-plugins.ts @@ -8,7 +8,7 @@ import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-o export async function fetchLatestVersion(packageName: string): Promise { try { - const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`) + const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`) if (!res.ok) return null const data = (await res.json()) as { version: string } return data.version diff --git a/src/cli/config-manager/bun-install.ts b/src/cli/config-manager/bun-install.ts index 052617e49..f24e77fa2 100644 --- a/src/cli/config-manager/bun-install.ts +++ b/src/cli/config-manager/bun-install.ts @@ -39,7 +39,7 @@ export async function runBunInstallWithDetails(): Promise { return { success: false, timedOut: true, - error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ~/.config/opencode && bun i`, + error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${getConfigDir()} && bun i`, } } diff --git a/src/cli/config-manager/config-context.ts b/src/cli/config-manager/config-context.ts index 78eb88d77..67448f29a 100644 --- a/src/cli/config-manager/config-context.ts +++ b/src/cli/config-manager/config-context.ts @@ -19,6 +19,9 @@ export function initConfigContext(binary: OpenCodeBinaryType, version: string | export function getConfigContext(): ConfigContext { if (!configContext) { + if (process.env.NODE_ENV !== "production") { + console.warn("[config-context] getConfigContext() called before initConfigContext(); defaulting to CLI paths.") + } const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) configContext = { binary: "opencode", version: null, paths } } diff --git a/src/cli/config-manager/deep-merge-record.ts b/src/cli/config-manager/deep-merge-record.ts index 54c0daa57..b38c26633 100644 --- a/src/cli/config-manager/deep-merge-record.ts +++ b/src/cli/config-manager/deep-merge-record.ts @@ -5,6 +5,7 @@ export function deepMergeRecord>( const result: TTarget = { ...target } for (const key of Object.keys(source) as Array) { + if (key === "__proto__" || key === "constructor" || key === "prototype") continue const sourceValue = source[key] const targetValue = result[key] From 06d265c1de28bfc923af31f7a536d293d859f2d3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 22:38:51 +0900 Subject: [PATCH 39/51] fix: use brace-depth matching for JSONC provider replacement instead of fragile regex --- src/cli/config-manager/add-provider-config.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/cli/config-manager/add-provider-config.ts b/src/cli/config-manager/add-provider-config.ts index b2a400a43..c911310a5 100644 --- a/src/cli/config-manager/add-provider-config.ts +++ b/src/cli/config-manager/add-provider-config.ts @@ -47,13 +47,31 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult { if (format === "jsonc") { const content = readFileSync(path, "utf-8") - const providerRegex = /"provider"\s*:\s*\{[\s\S]*?\n \}/ const providerJson = JSON.stringify(newConfig.provider, null, 2) .split("\n") .map((line, i) => (i === 0 ? line : ` ${line}`)) .join("\n") - if (providerRegex.test(content)) { - const newContent = content.replace(providerRegex, `"provider": ${providerJson}`) + // Match "provider" key with any indentation and nested brace depth + const providerIdx = content.indexOf('"provider"') + if (providerIdx !== -1) { + const colonIdx = content.indexOf(":", providerIdx + '"provider"'.length) + const braceStart = content.indexOf("{", colonIdx) + let depth = 0 + let braceEnd = braceStart + for (let i = braceStart; i < content.length; i++) { + if (content[i] === "{") depth++ + else if (content[i] === "}") { + depth-- + if (depth === 0) { + braceEnd = i + break + } + } + } + const newContent = + content.slice(0, providerIdx) + + `"provider": ${providerJson}` + + content.slice(braceEnd + 1) writeFileSync(path, newContent) } else { const newContent = content.replace(/(\{)/, `$1\n "provider": ${providerJson},`) From a1d7f9e82298f646b623c35867e8c3d7fde23754 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 22:43:02 +0900 Subject: [PATCH 40/51] fix: guard against missing brace in JSONC provider replacement --- src/cli/config-manager/add-provider-config.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/cli/config-manager/add-provider-config.ts b/src/cli/config-manager/add-provider-config.ts index c911310a5..a7a195f41 100644 --- a/src/cli/config-manager/add-provider-config.ts +++ b/src/cli/config-manager/add-provider-config.ts @@ -55,24 +55,28 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult { const providerIdx = content.indexOf('"provider"') if (providerIdx !== -1) { const colonIdx = content.indexOf(":", providerIdx + '"provider"'.length) - const braceStart = content.indexOf("{", colonIdx) - let depth = 0 - let braceEnd = braceStart - for (let i = braceStart; i < content.length; i++) { - if (content[i] === "{") depth++ - else if (content[i] === "}") { - depth-- - if (depth === 0) { - braceEnd = i - break + const braceStart = colonIdx !== -1 ? content.indexOf("{", colonIdx) : -1 + if (braceStart === -1) { + writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") + } else { + let depth = 0 + let braceEnd = braceStart + for (let i = braceStart; i < content.length; i++) { + if (content[i] === "{") depth++ + else if (content[i] === "}") { + depth-- + if (depth === 0) { + braceEnd = i + break + } } } + const newContent = + content.slice(0, providerIdx) + + `"provider": ${providerJson}` + + content.slice(braceEnd + 1) + writeFileSync(path, newContent) } - const newContent = - content.slice(0, providerIdx) + - `"provider": ${providerJson}` + - content.slice(braceEnd + 1) - writeFileSync(path, newContent) } else { const newContent = content.replace(/(\{)/, `$1\n "provider": ${providerJson},`) writeFileSync(path, newContent) From 5ca3d9c4893e119445815111e4cc7cae0ec5924d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Feb 2026 11:01:38 +0900 Subject: [PATCH 41/51] =?UTF-8?q?fix:=20address=20remaining=20Cubic=20issu?= =?UTF-8?q?es=20=E2=80=94=20reset=20lastPartText=20on=20new=20message,=20T?= =?UTF-8?q?TY=20guard=20for=20installer,=20filter=20disabled=20skills,=20l?= =?UTF-8?q?ocal-dev=20version=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/builtin-agents/available-skills.ts | 2 +- src/cli/get-local-version/get-local-version.ts | 3 ++- src/cli/run/event-handlers.ts | 1 + src/cli/tui-installer.ts | 5 +++++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/agents/builtin-agents/available-skills.ts b/src/agents/builtin-agents/available-skills.ts index 38a44801b..27ed5d698 100644 --- a/src/agents/builtin-agents/available-skills.ts +++ b/src/agents/builtin-agents/available-skills.ts @@ -24,7 +24,7 @@ export function buildAvailableSkills( })) const discoveredAvailable: AvailableSkill[] = discoveredSkills - .filter(s => !builtinSkillNames.has(s.name)) + .filter(s => !builtinSkillNames.has(s.name) && !disabledSkills?.has(s.name)) .map((skill) => ({ name: skill.name, description: skill.definition.description ?? "", diff --git a/src/cli/get-local-version/get-local-version.ts b/src/cli/get-local-version/get-local-version.ts index 4ce30e688..c46f90531 100644 --- a/src/cli/get-local-version/get-local-version.ts +++ b/src/cli/get-local-version/get-local-version.ts @@ -2,6 +2,7 @@ import { findPluginEntry, getCachedVersion, getLatestVersion, + getLocalDevVersion, isLocalDevMode, } from "../../hooks/auto-update-checker/checker" @@ -15,7 +16,7 @@ export async function getLocalVersion( try { if (isLocalDevMode(directory)) { - const currentVersion = getCachedVersion() + const currentVersion = getLocalDevVersion(directory) ?? getCachedVersion() const info: VersionInfo = { currentVersion, latestVersion: null, diff --git a/src/cli/run/event-handlers.ts b/src/cli/run/event-handlers.ts index 9f1dcabd4..50390095b 100644 --- a/src/cli/run/event-handlers.ts +++ b/src/cli/run/event-handlers.ts @@ -71,6 +71,7 @@ export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, sta state.hasReceivedMeaningfulWork = true state.messageCount++ + state.lastPartText = "" } export function handleToolExecute(ctx: RunContext, payload: EventPayload, state: EventState): void { diff --git a/src/cli/tui-installer.ts b/src/cli/tui-installer.ts index d960769c2..dac9e6125 100644 --- a/src/cli/tui-installer.ts +++ b/src/cli/tui-installer.ts @@ -14,6 +14,11 @@ import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install import { promptInstallConfig } from "./tui-install-prompts" export async function runTuiInstaller(args: InstallArgs, version: string): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.error("Error: Interactive installer requires a TTY. Use --non-interactive or set environment variables directly.") + return 1 + } + const detected = detectCurrentConfig() const isUpdate = detected.isInstalled From d6fbe7bd8d8137694dda6d804068c01823f1217a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Feb 2026 11:17:51 +0900 Subject: [PATCH 42/51] =?UTF-8?q?fix:=20address=20Cubic=20CLI=20and=20agen?= =?UTF-8?q?t=20issues=20=E2=80=94=20URL=20encode,=20JSONC=20leading=20comm?= =?UTF-8?q?ents,=20config=20clone,=20untracked=20files,=20parse=20error=20?= =?UTF-8?q?handling,=20cache=20path,=20message-dir=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../doctor/checks/model-resolution-details.ts | 6 ++++- src/features/background-agent/message-dir.ts | 19 +-------------- .../collect-git-diff-stats.test.ts | 21 +++++++++++++++-- .../git-worktree/collect-git-diff-stats.ts | 23 ++++++++++++++++--- 4 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/cli/doctor/checks/model-resolution-details.ts b/src/cli/doctor/checks/model-resolution-details.ts index 7489a2c24..e96655476 100644 --- a/src/cli/doctor/checks/model-resolution-details.ts +++ b/src/cli/doctor/checks/model-resolution-details.ts @@ -1,3 +1,6 @@ +import { join } from "node:path" + +import { getOpenCodeCacheDir } from "../../../shared" import type { AvailableModelsInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types" import { formatModelWithVariant, getCategoryEffectiveVariant, getEffectiveVariant } from "./model-resolution-variant" @@ -7,6 +10,7 @@ export function buildModelResolutionDetails(options: { config: OmoConfig }): string[] { const details: string[] = [] + const cacheFile = join(getOpenCodeCacheDir(), "models.json") details.push("═══ Available Models (from cache) ═══") details.push("") @@ -16,7 +20,7 @@ export function buildModelResolutionDetails(options: { ` Sample: ${options.available.providers.slice(0, 6).join(", ")}${options.available.providers.length > 6 ? "..." : ""}` ) details.push(` Total models: ${options.available.modelCount}`) - details.push(` Cache: ~/.cache/opencode/models.json`) + details.push(` Cache: ${cacheFile}`) details.push(` ℹ Runtime: only connected providers used`) details.push(` Refresh: opencode models --refresh`) } else { diff --git a/src/features/background-agent/message-dir.ts b/src/features/background-agent/message-dir.ts index 3e8f56a47..138f5dab8 100644 --- a/src/features/background-agent/message-dir.ts +++ b/src/features/background-agent/message-dir.ts @@ -1,18 +1 @@ -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" - -import { MESSAGE_STORAGE } from "../hook-message-injector" - -export 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 -} +export { getMessageDir } from "./message-storage-locator" diff --git a/src/shared/git-worktree/collect-git-diff-stats.test.ts b/src/shared/git-worktree/collect-git-diff-stats.test.ts index 678d2f67a..5aab6e255 100644 --- a/src/shared/git-worktree/collect-git-diff-stats.test.ts +++ b/src/shared/git-worktree/collect-git-diff-stats.test.ts @@ -15,7 +15,11 @@ const execFileSyncMock = mock((file: string, args: string[], _opts: { cwd?: stri } if (subcommand === "status") { - return " M file.ts\n" + return " M file.ts\n?? new-file.ts\n" + } + + if (subcommand === "ls-files") { + return "new-file.ts\n" } throw new Error(`unexpected args: ${args.join(" ")}`) @@ -38,7 +42,7 @@ describe("collectGitDiffStats", () => { //#then expect(execSyncMock).not.toHaveBeenCalled() - expect(execFileSyncMock).toHaveBeenCalledTimes(2) + expect(execFileSyncMock).toHaveBeenCalledTimes(3) const [firstCallFile, firstCallArgs, firstCallOpts] = execFileSyncMock.mock .calls[0]! as unknown as [string, string[], { cwd?: string }] @@ -54,6 +58,13 @@ describe("collectGitDiffStats", () => { expect(secondCallOpts.cwd).toBe(directory) expect(secondCallArgs.join(" ")).not.toContain(directory) + const [thirdCallFile, thirdCallArgs, thirdCallOpts] = execFileSyncMock.mock + .calls[2]! as unknown as [string, string[], { cwd?: string }] + expect(thirdCallFile).toBe("git") + expect(thirdCallArgs).toEqual(["ls-files", "--others", "--exclude-standard"]) + expect(thirdCallOpts.cwd).toBe(directory) + expect(thirdCallArgs.join(" ")).not.toContain(directory) + expect(result).toEqual([ { path: "file.ts", @@ -61,6 +72,12 @@ describe("collectGitDiffStats", () => { removed: 2, status: "modified", }, + { + path: "new-file.ts", + added: 0, + removed: 0, + status: "added", + }, ]) }) }) diff --git a/src/shared/git-worktree/collect-git-diff-stats.ts b/src/shared/git-worktree/collect-git-diff-stats.ts index 49a98fe2f..546e8bfa9 100644 --- a/src/shared/git-worktree/collect-git-diff-stats.ts +++ b/src/shared/git-worktree/collect-git-diff-stats.ts @@ -12,8 +12,6 @@ export function collectGitDiffStats(directory: string): GitFileStat[] { stdio: ["pipe", "pipe", "pipe"], }).trim() - if (!diffOutput) return [] - const statusOutput = execFileSync("git", ["status", "--porcelain"], { cwd: directory, encoding: "utf-8", @@ -21,8 +19,27 @@ export function collectGitDiffStats(directory: string): GitFileStat[] { stdio: ["pipe", "pipe", "pipe"], }).trim() + const untrackedOutput = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { + cwd: directory, + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim() + + const untrackedNumstat = untrackedOutput + ? untrackedOutput + .split("\n") + .filter(Boolean) + .map((filePath) => `0\t0\t${filePath}`) + .join("\n") + : "" + + const combinedNumstat = [diffOutput, untrackedNumstat].filter(Boolean).join("\n").trim() + + if (!combinedNumstat) return [] + const statusMap = parseGitStatusPorcelain(statusOutput) - return parseGitDiffNumstat(diffOutput, statusMap) + return parseGitDiffNumstat(combinedNumstat, statusMap) } catch { return [] } From 247940bf02c52c305a87d08fadac5b3e7f3789bb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Feb 2026 11:15:22 +0900 Subject: [PATCH 43/51] =?UTF-8?q?fix:=20address=20Cubic=20background-agent?= =?UTF-8?q?=20issues=20=E2=80=94=20task=20status=20filter,=20array=20respo?= =?UTF-8?q?nse=20handling,=20error=20mapping,=20concurrency=20key,=20durat?= =?UTF-8?q?ion=20fallback,=20output=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/background-agent/notification-builder.ts | 3 ++- src/features/background-agent/parent-session-notifier.ts | 2 +- src/features/background-agent/poll-running-tasks.ts | 5 ++++- src/features/background-agent/session-output-validator.ts | 3 ++- src/features/background-agent/task-queries.ts | 2 +- src/features/background-agent/task-resumer.ts | 6 +++++- 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/features/background-agent/notification-builder.ts b/src/features/background-agent/notification-builder.ts index e16d2b4e5..66e9c5a87 100644 --- a/src/features/background-agent/notification-builder.ts +++ b/src/features/background-agent/notification-builder.ts @@ -8,7 +8,8 @@ export function buildBackgroundTaskNotificationText(args: { completedTasks: BackgroundTask[] }): string { const { task, duration, allComplete, remainingCount, completedTasks } = args - const statusText = task.status === "completed" ? "COMPLETED" : "CANCELLED" + const statusText = + task.status === "completed" ? "COMPLETED" : task.status === "error" ? "ERROR" : "CANCELLED" const errorInfo = task.error ? `\n**Error:** ${task.error}` : "" if (allComplete) { diff --git a/src/features/background-agent/parent-session-notifier.ts b/src/features/background-agent/parent-session-notifier.ts index 9d4c1ac08..2c2ff05ae 100644 --- a/src/features/background-agent/parent-session-notifier.ts +++ b/src/features/background-agent/parent-session-notifier.ts @@ -13,7 +13,7 @@ export async function notifyParentSession( ): Promise { const { client, state } = ctx - const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) + const duration = formatDuration(task.startedAt ?? task.completedAt ?? new Date(), task.completedAt) log("[background-agent] notifyParentSession called for task:", task.id) const toastManager = getTaskToastManager() diff --git a/src/features/background-agent/poll-running-tasks.ts b/src/features/background-agent/poll-running-tasks.ts index 688bba6f8..6c5a4461d 100644 --- a/src/features/background-agent/poll-running-tasks.ts +++ b/src/features/background-agent/poll-running-tasks.ts @@ -94,7 +94,10 @@ export async function pollRunningTasks(args: { continue } - const messages = asSessionMessages((messagesResult as { data?: unknown }).data) + const messagesPayload = Array.isArray(messagesResult) + ? messagesResult + : (messagesResult as { data?: unknown }).data + const messages = asSessionMessages(messagesPayload) const assistantMsgs = messages.filter((m) => m.info?.role === "assistant") let toolCalls = 0 diff --git a/src/features/background-agent/session-output-validator.ts b/src/features/background-agent/session-output-validator.ts index 136bcc41c..8e14a21c8 100644 --- a/src/features/background-agent/session-output-validator.ts +++ b/src/features/background-agent/session-output-validator.ts @@ -55,7 +55,8 @@ export async function validateSessionHasOutput( path: { id: sessionID }, }) - const messagesRaw = "data" in response ? response.data : [] + const messagesRaw = + isObject(response) && "data" in response ? (response as { data?: unknown }).data : response const messages = Array.isArray(messagesRaw) ? messagesRaw : [] const hasAssistantOrToolMessage = messages.some((message) => { diff --git a/src/features/background-agent/task-queries.ts b/src/features/background-agent/task-queries.ts index d53c6f901..641f0e41d 100644 --- a/src/features/background-agent/task-queries.ts +++ b/src/features/background-agent/task-queries.ts @@ -45,7 +45,7 @@ export function getRunningTasks(tasks: Iterable): BackgroundTask } export function getCompletedTasks(tasks: Iterable): BackgroundTask[] { - return Array.from(tasks).filter((t) => t.status !== "running") + return Array.from(tasks).filter((t) => t.status === "completed") } export function hasRunningTasks(tasks: Iterable): boolean { diff --git a/src/features/background-agent/task-resumer.ts b/src/features/background-agent/task-resumer.ts index e09b12768..632081c3a 100644 --- a/src/features/background-agent/task-resumer.ts +++ b/src/features/background-agent/task-resumer.ts @@ -48,7 +48,11 @@ export async function resumeBackgroundTask(args: { return existingTask } - const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent + const concurrencyKey = + existingTask.concurrencyGroup ?? + (existingTask.model + ? `${existingTask.model.providerID}/${existingTask.model.modelID}` + : existingTask.agent) await concurrencyManager.acquire(concurrencyKey) existingTask.concurrencyKey = concurrencyKey existingTask.concurrencyGroup = concurrencyKey From 7fdba56d8f123234992d51698b7194253f96c942 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Feb 2026 11:36:29 +0900 Subject: [PATCH 44/51] fix(background-agent): align getCompletedTasks filter with state manager semantics --- src/features/background-agent/task-queries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/background-agent/task-queries.ts b/src/features/background-agent/task-queries.ts index 641f0e41d..d53c6f901 100644 --- a/src/features/background-agent/task-queries.ts +++ b/src/features/background-agent/task-queries.ts @@ -45,7 +45,7 @@ export function getRunningTasks(tasks: Iterable): BackgroundTask } export function getCompletedTasks(tasks: Iterable): BackgroundTask[] { - return Array.from(tasks).filter((t) => t.status === "completed") + return Array.from(tasks).filter((t) => t.status !== "running") } export function hasRunningTasks(tasks: Iterable): boolean { From edc3317e37e0bcbdfd15500c01a138474eb654f1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Feb 2026 11:36:35 +0900 Subject: [PATCH 45/51] fix(git-worktree): compute real line counts for untracked files in diff stats --- .../collect-git-diff-stats.test.ts | 13 +++++++++++-- .../git-worktree/collect-git-diff-stats.ts | 19 ++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/shared/git-worktree/collect-git-diff-stats.test.ts b/src/shared/git-worktree/collect-git-diff-stats.test.ts index 5aab6e255..cc59bfbfa 100644 --- a/src/shared/git-worktree/collect-git-diff-stats.test.ts +++ b/src/shared/git-worktree/collect-git-diff-stats.test.ts @@ -7,6 +7,9 @@ const execSyncMock = mock(() => { }) const execFileSyncMock = mock((file: string, args: string[], _opts: { cwd?: string }) => { + if (file === "wc") { + return " 10 new-file.ts\n" + } if (file !== "git") throw new Error(`unexpected file: ${file}`) const subcommand = args[0] @@ -42,7 +45,7 @@ describe("collectGitDiffStats", () => { //#then expect(execSyncMock).not.toHaveBeenCalled() - expect(execFileSyncMock).toHaveBeenCalledTimes(3) + expect(execFileSyncMock).toHaveBeenCalledTimes(4) const [firstCallFile, firstCallArgs, firstCallOpts] = execFileSyncMock.mock .calls[0]! as unknown as [string, string[], { cwd?: string }] @@ -65,6 +68,12 @@ describe("collectGitDiffStats", () => { expect(thirdCallOpts.cwd).toBe(directory) expect(thirdCallArgs.join(" ")).not.toContain(directory) + const [fourthCallFile, fourthCallArgs, fourthCallOpts] = execFileSyncMock.mock + .calls[3]! as unknown as [string, string[], { cwd?: string }] + expect(fourthCallFile).toBe("wc") + expect(fourthCallArgs).toEqual(["-l", "--", "new-file.ts"]) + expect(fourthCallOpts.cwd).toBe(directory) + expect(result).toEqual([ { path: "file.ts", @@ -74,7 +83,7 @@ describe("collectGitDiffStats", () => { }, { path: "new-file.ts", - added: 0, + added: 10, removed: 0, status: "added", }, diff --git a/src/shared/git-worktree/collect-git-diff-stats.ts b/src/shared/git-worktree/collect-git-diff-stats.ts index 546e8bfa9..728bd3a09 100644 --- a/src/shared/git-worktree/collect-git-diff-stats.ts +++ b/src/shared/git-worktree/collect-git-diff-stats.ts @@ -30,7 +30,24 @@ export function collectGitDiffStats(directory: string): GitFileStat[] { ? untrackedOutput .split("\n") .filter(Boolean) - .map((filePath) => `0\t0\t${filePath}`) + .map((filePath) => { + try { + const wcOutput = execFileSync("wc", ["-l", "--", filePath], { + cwd: directory, + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim() + + const [lineCountToken] = wcOutput.split(/\s+/) + const lineCount = Number(lineCountToken) + if (!Number.isFinite(lineCount)) return `0\t0\t${filePath}` + + return `${lineCount}\t0\t${filePath}` + } catch { + return `0\t0\t${filePath}` + } + }) .join("\n") : "" From 0e49214ee779527795b41b488c943c0a963edaaf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Feb 2026 11:45:20 +0900 Subject: [PATCH 46/51] fix(background-agent): rename getCompletedTasks to getNonRunningTasks for semantic accuracy --- src/features/background-agent/manager.ts | 4 ++-- src/features/background-agent/state.ts | 2 +- src/features/background-agent/task-queries.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index cfe8808a6..c13c0cebe 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1044,9 +1044,9 @@ export class BackgroundManager { } /** - * Get all completed tasks still in memory (for compaction hook) + * Get all non-running tasks still in memory (for compaction hook) */ - getCompletedTasks(): BackgroundTask[] { + getNonRunningTasks(): BackgroundTask[] { return Array.from(this.tasks.values()).filter(t => t.status !== "running") } diff --git a/src/features/background-agent/state.ts b/src/features/background-agent/state.ts index e3a1cd57b..074ece38d 100644 --- a/src/features/background-agent/state.ts +++ b/src/features/background-agent/state.ts @@ -48,7 +48,7 @@ export class TaskStateManager { getRunningTasks(): BackgroundTask[] { return Array.from(this.tasks.values()).filter(t => t.status === "running") } - getCompletedTasks(): BackgroundTask[] { + getNonRunningTasks(): BackgroundTask[] { return Array.from(this.tasks.values()).filter(t => t.status !== "running") } diff --git a/src/features/background-agent/task-queries.ts b/src/features/background-agent/task-queries.ts index d53c6f901..e4301ca5d 100644 --- a/src/features/background-agent/task-queries.ts +++ b/src/features/background-agent/task-queries.ts @@ -44,7 +44,7 @@ export function getRunningTasks(tasks: Iterable): BackgroundTask return Array.from(tasks).filter((t) => t.status === "running") } -export function getCompletedTasks(tasks: Iterable): BackgroundTask[] { +export function getNonRunningTasks(tasks: Iterable): BackgroundTask[] { return Array.from(tasks).filter((t) => t.status !== "running") } From 554926209da6339d011cdeba652a136f88deecc5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Feb 2026 11:45:29 +0900 Subject: [PATCH 47/51] fix(git-worktree): use Node readFileSync for cross-platform untracked file line counts --- .../collect-git-diff-stats.test.ts | 19 ++++++++++--------- .../git-worktree/collect-git-diff-stats.ts | 15 ++++----------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/shared/git-worktree/collect-git-diff-stats.test.ts b/src/shared/git-worktree/collect-git-diff-stats.test.ts index cc59bfbfa..334d15f4a 100644 --- a/src/shared/git-worktree/collect-git-diff-stats.test.ts +++ b/src/shared/git-worktree/collect-git-diff-stats.test.ts @@ -7,9 +7,6 @@ const execSyncMock = mock(() => { }) const execFileSyncMock = mock((file: string, args: string[], _opts: { cwd?: string }) => { - if (file === "wc") { - return " 10 new-file.ts\n" - } if (file !== "git") throw new Error(`unexpected file: ${file}`) const subcommand = args[0] @@ -28,11 +25,19 @@ const execFileSyncMock = mock((file: string, args: string[], _opts: { cwd?: stri throw new Error(`unexpected args: ${args.join(" ")}`) }) +const readFileSyncMock = mock((_path: string, _encoding: string) => { + return "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n" +}) + mock.module("node:child_process", () => ({ execSync: execSyncMock, execFileSync: execFileSyncMock, })) +mock.module("node:fs", () => ({ + readFileSync: readFileSyncMock, +})) + const { collectGitDiffStats } = await import("./collect-git-diff-stats") describe("collectGitDiffStats", () => { @@ -45,7 +50,7 @@ describe("collectGitDiffStats", () => { //#then expect(execSyncMock).not.toHaveBeenCalled() - expect(execFileSyncMock).toHaveBeenCalledTimes(4) + expect(execFileSyncMock).toHaveBeenCalledTimes(3) const [firstCallFile, firstCallArgs, firstCallOpts] = execFileSyncMock.mock .calls[0]! as unknown as [string, string[], { cwd?: string }] @@ -68,11 +73,7 @@ describe("collectGitDiffStats", () => { expect(thirdCallOpts.cwd).toBe(directory) expect(thirdCallArgs.join(" ")).not.toContain(directory) - const [fourthCallFile, fourthCallArgs, fourthCallOpts] = execFileSyncMock.mock - .calls[3]! as unknown as [string, string[], { cwd?: string }] - expect(fourthCallFile).toBe("wc") - expect(fourthCallArgs).toEqual(["-l", "--", "new-file.ts"]) - expect(fourthCallOpts.cwd).toBe(directory) + expect(readFileSyncMock).toHaveBeenCalled() expect(result).toEqual([ { diff --git a/src/shared/git-worktree/collect-git-diff-stats.ts b/src/shared/git-worktree/collect-git-diff-stats.ts index 728bd3a09..b16d5ebae 100644 --- a/src/shared/git-worktree/collect-git-diff-stats.ts +++ b/src/shared/git-worktree/collect-git-diff-stats.ts @@ -1,4 +1,6 @@ import { execFileSync } from "node:child_process" +import { readFileSync } from "node:fs" +import { join } from "node:path" import { parseGitStatusPorcelain } from "./parse-status-porcelain" import { parseGitDiffNumstat } from "./parse-diff-numstat" import type { GitFileStat } from "./types" @@ -32,17 +34,8 @@ export function collectGitDiffStats(directory: string): GitFileStat[] { .filter(Boolean) .map((filePath) => { try { - const wcOutput = execFileSync("wc", ["-l", "--", filePath], { - cwd: directory, - encoding: "utf-8", - timeout: 5000, - stdio: ["pipe", "pipe", "pipe"], - }).trim() - - const [lineCountToken] = wcOutput.split(/\s+/) - const lineCount = Number(lineCountToken) - if (!Number.isFinite(lineCount)) return `0\t0\t${filePath}` - + const content = readFileSync(join(directory, filePath), "utf-8") + const lineCount = content.split("\n").length - (content.endsWith("\n") ? 1 : 0) return `${lineCount}\t0\t${filePath}` } catch { return `0\t0\t${filePath}` From c4572a25fb8202a02f405c30e6dac0b0bb3163d1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Feb 2026 11:59:50 +0900 Subject: [PATCH 48/51] fix(config-manager): skip string literals when counting braces in JSONC provider replacement --- src/cli/config-manager/add-provider-config.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/cli/config-manager/add-provider-config.ts b/src/cli/config-manager/add-provider-config.ts index a7a195f41..1e49ab49f 100644 --- a/src/cli/config-manager/add-provider-config.ts +++ b/src/cli/config-manager/add-provider-config.ts @@ -61,9 +61,25 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult { } else { let depth = 0 let braceEnd = braceStart + let inString = false + let escape = false for (let i = braceStart; i < content.length; i++) { - if (content[i] === "{") depth++ - else if (content[i] === "}") { + const ch = content[i] + if (escape) { + escape = false + continue + } + if (ch === "\\") { + escape = true + continue + } + if (ch === '"') { + inString = !inString + continue + } + if (inString) continue + if (ch === "{") depth++ + else if (ch === "}") { depth-- if (depth === 0) { braceEnd = i From b0202e23f7459c56cf49aab4b4350737241097a0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Feb 2026 12:00:01 +0900 Subject: [PATCH 49/51] fix(agents): sanitize custom agent names for markdown table safety --- src/agents/custom-agent-summaries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/custom-agent-summaries.ts b/src/agents/custom-agent-summaries.ts index bdcb7b68d..42418b013 100644 --- a/src/agents/custom-agent-summaries.ts +++ b/src/agents/custom-agent-summaries.ts @@ -38,7 +38,7 @@ export function parseRegisteredAgentSummaries(input: unknown): RegisteredAgentSu if (enabled === false) continue const description = typeof item.description === "string" ? item.description : "" - result.push({ name, description: sanitizeMarkdownTableCell(description) }) + result.push({ name: sanitizeMarkdownTableCell(name), description: sanitizeMarkdownTableCell(description) }) } return result From 6a91d72a7213c5828a06a27b257075e104ffd72c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Feb 2026 12:00:11 +0900 Subject: [PATCH 50/51] fix(agents): remove duplicate category override application in general-agents --- src/agents/builtin-agents/general-agents.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/agents/builtin-agents/general-agents.ts b/src/agents/builtin-agents/general-agents.ts index 0c88d56b3..7de6dafa0 100644 --- a/src/agents/builtin-agents/general-agents.ts +++ b/src/agents/builtin-agents/general-agents.ts @@ -5,7 +5,7 @@ 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 { applyOverrides } from "./agent-overrides" import { applyEnvironmentContext } from "./environment-context" import { applyModelResolution } from "./model-resolution" @@ -79,12 +79,6 @@ export function collectPendingBuiltinAgents(input: { 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) } From 133da2624a63bb2ff0de1047c0a21d2ce7268270 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Feb 2026 12:00:24 +0900 Subject: [PATCH 51/51] fix(config-manager): guard against non-array plugin values in auth-plugins --- src/cli/config-manager/auth-plugins.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/config-manager/auth-plugins.ts b/src/cli/config-manager/auth-plugins.ts index 4e4d5718f..127d99c65 100644 --- a/src/cli/config-manager/auth-plugins.ts +++ b/src/cli/config-manager/auth-plugins.ts @@ -44,7 +44,8 @@ export async function addAuthPlugins(config: InstallConfig): Promise